platform: restore cert-manager and encrypt budget storage

This commit is contained in:
Brad Stein 2026-01-17 07:38:38 -03:00
parent 71bab17665
commit 8192dfeebe
12 changed files with 171 additions and 13 deletions

View File

@ -15,6 +15,5 @@ spec:
namespace: flux-system namespace: flux-system
targetNamespace: cert-manager targetNamespace: cert-manager
dependsOn: dependsOn:
- name: cert-manager-cleanup
- name: helm - name: helm
wait: true wait: true

View File

@ -4,7 +4,6 @@ kind: Kustomization
resources: resources:
- core/kustomization.yaml - core/kustomization.yaml
- helm/kustomization.yaml - helm/kustomization.yaml
- cert-manager-cleanup/kustomization.yaml
- cert-manager/kustomization.yaml - cert-manager/kustomization.yaml
- metallb/kustomization.yaml - metallb/kustomization.yaml
- traefik/kustomization.yaml - traefik/kustomization.yaml

View File

@ -0,0 +1,24 @@
# infrastructure/modules/base/storageclass/asteria-encrypted.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: asteria-encrypted
parameters:
diskSelector: asteria
fromBackup: ""
numberOfReplicas: "2"
staleReplicaTimeout: "30"
fsType: "ext4"
replicaAutoBalance: "least-effort"
dataLocality: "disabled"
encrypted: "true"
csi.storage.k8s.io/provisioner-secret-name: ${pvc.name}
csi.storage.k8s.io/provisioner-secret-namespace: ${pvc.namespace}
csi.storage.k8s.io/node-publish-secret-name: ${pvc.name}
csi.storage.k8s.io/node-publish-secret-namespace: ${pvc.namespace}
csi.storage.k8s.io/node-stage-secret-name: ${pvc.name}
csi.storage.k8s.io/node-stage-secret-namespace: ${pvc.namespace}
provisioner: driver.longhorn.io
reclaimPolicy: Retain
allowVolumeExpansion: true
volumeBindingMode: Immediate

View File

@ -3,4 +3,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization kind: Kustomization
resources: resources:
- asteria.yaml - asteria.yaml
- asteria-encrypted.yaml
- astreae.yaml - astreae.yaml

View File

@ -2,11 +2,11 @@
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: actual-budget-data name: actual-budget-data-encrypted
namespace: finance namespace: finance
spec: spec:
accessModes: ["ReadWriteOnce"] accessModes: ["ReadWriteOnce"]
storageClassName: asteria storageClassName: asteria-encrypted
resources: resources:
requests: requests:
storage: 10Gi storage: 10Gi

View File

@ -169,7 +169,7 @@ spec:
volumes: volumes:
- name: actual-data - name: actual-data
persistentVolumeClaim: persistentVolumeClaim:
claimName: actual-budget-data claimName: actual-budget-data-encrypted
- name: actual-openid-bootstrap-script - name: actual-openid-bootstrap-script
configMap: configMap:
name: actual-openid-bootstrap-script name: actual-openid-bootstrap-script

View File

@ -6,7 +6,7 @@ metadata:
namespace: finance namespace: finance
annotations: annotations:
kubernetes.io/ingress.class: traefik kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.entrypoints: websecure traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.tls: "true" traefik.ingress.kubernetes.io/router.tls: "true"
cert-manager.io/cluster-issuer: letsencrypt cert-manager.io/cluster-issuer: letsencrypt
spec: spec:

View File

@ -2,7 +2,7 @@
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: metadata:
name: finance-secrets-ensure-4 name: finance-secrets-ensure-5
namespace: finance namespace: finance
spec: spec:
backoffLimit: 1 backoffLimit: 1

View File

@ -0,0 +1,24 @@
# services/finance/finance-secrets-ensure-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: finance-secrets-ensure
namespace: finance
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: finance-secrets-ensure
namespace: finance
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: finance-secrets-ensure
subjects:
- kind: ServiceAccount
name: finance-secrets-ensure
namespace: finance

View File

@ -6,7 +6,7 @@ metadata:
namespace: finance namespace: finance
annotations: annotations:
kubernetes.io/ingress.class: traefik kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.entrypoints: websecure traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.tls: "true" traefik.ingress.kubernetes.io/router.tls: "true"
cert-manager.io/cluster-issuer: letsencrypt cert-manager.io/cluster-issuer: letsencrypt
spec: spec:

View File

@ -6,6 +6,7 @@ resources:
- namespace.yaml - namespace.yaml
- serviceaccount.yaml - serviceaccount.yaml
- portal-rbac.yaml - portal-rbac.yaml
- finance-secrets-ensure-rbac.yaml
- actual-budget-data-pvc.yaml - actual-budget-data-pvc.yaml
- firefly-storage-pvc.yaml - firefly-storage-pvc.yaml
- finance-secrets-ensure-job.yaml - finance-secrets-ensure-job.yaml

View File

@ -3,6 +3,7 @@ import base64
import json import json
import os import os
import secrets import secrets
import ssl
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
@ -20,18 +21,81 @@ def require_value(label: str, value: str) -> None:
raise RuntimeError(f"missing {label}") raise RuntimeError(f"missing {label}")
def http_json(method: str, url: str, headers=None, payload=None): def http_json(method: str, url: str, headers=None, payload=None, context=None):
data = None data = None
if payload is not None: if payload is not None:
data = json.dumps(payload).encode() data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers=headers or {}, method=method) req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=15, context=context) as resp:
body = resp.read() body = resp.read()
if not body: if not body:
return resp.status, None return resp.status, None
return resp.status, json.loads(body.decode()) return resp.status, json.loads(body.decode())
def k8s_context() -> ssl.SSLContext:
ca_path = Path("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
if ca_path.exists():
return ssl.create_default_context(cafile=str(ca_path))
return ssl.create_default_context()
def k8s_api_url(path: str) -> str:
host = os.environ.get("KUBERNETES_SERVICE_HOST")
port = os.environ.get("KUBERNETES_SERVICE_PORT", "443")
if not host:
raise RuntimeError("missing kubernetes service host")
return f"https://{host}:{port}{path}"
def k8s_get_secret(namespace: str, name: str, token: str):
try:
_, body = http_json(
"GET",
k8s_api_url(f"/api/v1/namespaces/{namespace}/secrets/{name}"),
headers={"Authorization": f"Bearer {token}"},
context=k8s_context(),
)
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise
return body
def k8s_create_secret(namespace: str, name: str, token: str, string_data: dict):
payload = {
"apiVersion": "v1",
"kind": "Secret",
"metadata": {"name": name, "namespace": namespace},
"type": "Opaque",
"stringData": string_data,
}
try:
status, _ = http_json(
"POST",
k8s_api_url(f"/api/v1/namespaces/{namespace}/secrets"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
payload=payload,
context=k8s_context(),
)
except urllib.error.HTTPError as exc:
if exc.code == 409:
return
raise
if status not in (200, 201):
raise RuntimeError(f"k8s secret create failed for {name} (status {status})")
def decode_secret_value(value: str) -> str:
if not value:
return ""
return base64.b64decode(value.encode()).decode("utf-8")
def vault_login(vault_addr: str, role: str, jwt: str) -> str: def vault_login(vault_addr: str, role: str, jwt: str) -> str:
status, body = http_json( status, body = http_json(
"POST", "POST",
@ -152,17 +216,63 @@ def ensure_actual_db(vault_addr: str, token: str):
) )
def ensure_actual_encryption(vault_addr: str, token: str, sa_token: str):
namespace = os.environ.get("FINANCE_NAMESPACE", "finance")
secret_name = os.environ.get("ACTUAL_BUDGET_PVC_NAME", "actual-budget-data-encrypted")
if not sa_token:
raise RuntimeError("missing service account token for k8s")
vault_data = vault_read(vault_addr, token, "finance/actual-encryption")
vault_key = vault_data.get("CRYPTO_KEY_VALUE", "")
k8s_secret = k8s_get_secret(namespace, secret_name, sa_token)
k8s_key = ""
if k8s_secret:
data = k8s_secret.get("data", {}) or {}
k8s_key = decode_secret_value(data.get("CRYPTO_KEY_VALUE", ""))
if vault_key and k8s_key and vault_key != k8s_key:
raise RuntimeError("actual encryption key mismatch between vault and k8s")
key = vault_key or k8s_key
provider = "secret"
if not key:
key = secrets.token_urlsafe(48)
vault_write(
vault_addr,
token,
"finance/actual-encryption",
{"CRYPTO_KEY_VALUE": key, "CRYPTO_KEY_PROVIDER": provider},
)
elif not vault_key:
vault_write(
vault_addr,
token,
"finance/actual-encryption",
{"CRYPTO_KEY_VALUE": key, "CRYPTO_KEY_PROVIDER": provider},
)
if not k8s_secret:
k8s_create_secret(
namespace,
secret_name,
sa_token,
{"CRYPTO_KEY_VALUE": key, "CRYPTO_KEY_PROVIDER": provider},
)
def main() -> int: def main() -> int:
vault_addr = os.environ.get("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200") vault_addr = os.environ.get("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200")
vault_role = os.environ.get("VAULT_ROLE", "finance-secrets") vault_role = os.environ.get("VAULT_ROLE", "finance-secrets")
jwt = read_file(Path("/var/run/secrets/kubernetes.io/serviceaccount/token")) sa_token = read_file(Path("/var/run/secrets/kubernetes.io/serviceaccount/token"))
if not jwt: if not sa_token:
raise RuntimeError("missing service account token") raise RuntimeError("missing service account token")
token = vault_login(vault_addr, vault_role, jwt) token = vault_login(vault_addr, vault_role, sa_token)
ensure_firefly_db(vault_addr, token) ensure_firefly_db(vault_addr, token)
ensure_firefly_secrets(vault_addr, token) ensure_firefly_secrets(vault_addr, token)
ensure_actual_db(vault_addr, token) ensure_actual_db(vault_addr, token)
ensure_actual_encryption(vault_addr, token, sa_token)
print("finance secrets ensured") print("finance secrets ensured")
return 0 return 0