From 3e3061fe5b3d04fc8d3527f5ab598172ace0106d Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Fri, 16 Jan 2026 23:52:56 -0300 Subject: [PATCH] finance: add actual budget and firefly --- .../applications/finance/kustomization.yaml | 30 ++++ .../applications/kustomization.yaml | 1 + .../bstein-dev-home/backend-deployment.yaml | 6 + services/finance/actual-budget-data-pvc.yaml | 12 ++ .../finance/actual-budget-deployment.yaml | 156 +++++++++++++++++ services/finance/actual-budget-ingress.yaml | 26 +++ services/finance/actual-budget-service.yaml | 15 ++ services/finance/firefly-cronjob.yaml | 55 ++++++ services/finance/firefly-deployment.yaml | 164 ++++++++++++++++++ services/finance/firefly-ingress.yaml | 26 +++ services/finance/firefly-service.yaml | 15 ++ services/finance/firefly-storage-pvc.yaml | 12 ++ .../finance/firefly-user-sync-cronjob.yaml | 90 ++++++++++ services/finance/kustomization.yaml | 27 +++ services/finance/namespace.yaml | 5 + services/finance/portal-rbac.yaml | 31 ++++ .../scripts/actual_openid_bootstrap.mjs | 70 ++++++++ .../finance/scripts/firefly_user_sync.php | 107 ++++++++++++ services/finance/serviceaccount.yaml | 6 + .../actual-oidc-secret-ensure-job.yaml | 48 +++++ services/keycloak/kustomization.yaml | 4 + services/keycloak/realm-settings-job.yaml | 16 ++ .../scripts/actual_oidc_secret_ensure.sh | 78 +++++++++ .../vault/scripts/vault_k8s_auth_configure.sh | 4 +- 24 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 clusters/atlas/flux-system/applications/finance/kustomization.yaml create mode 100644 services/finance/actual-budget-data-pvc.yaml create mode 100644 services/finance/actual-budget-deployment.yaml create mode 100644 services/finance/actual-budget-ingress.yaml create mode 100644 services/finance/actual-budget-service.yaml create mode 100644 services/finance/firefly-cronjob.yaml create mode 100644 services/finance/firefly-deployment.yaml create mode 100644 services/finance/firefly-ingress.yaml create mode 100644 services/finance/firefly-service.yaml create mode 100644 services/finance/firefly-storage-pvc.yaml create mode 100644 services/finance/firefly-user-sync-cronjob.yaml create mode 100644 services/finance/kustomization.yaml create mode 100644 services/finance/namespace.yaml create mode 100644 services/finance/portal-rbac.yaml create mode 100644 services/finance/scripts/actual_openid_bootstrap.mjs create mode 100644 services/finance/scripts/firefly_user_sync.php create mode 100644 services/finance/serviceaccount.yaml create mode 100644 services/keycloak/actual-oidc-secret-ensure-job.yaml create mode 100644 services/keycloak/scripts/actual_oidc_secret_ensure.sh diff --git a/clusters/atlas/flux-system/applications/finance/kustomization.yaml b/clusters/atlas/flux-system/applications/finance/kustomization.yaml new file mode 100644 index 0000000..a28b711 --- /dev/null +++ b/clusters/atlas/flux-system/applications/finance/kustomization.yaml @@ -0,0 +1,30 @@ +# clusters/atlas/flux-system/applications/finance/kustomization.yaml +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: finance + namespace: flux-system +spec: + interval: 10m + path: ./services/finance + prune: true + sourceRef: + kind: GitRepository + name: flux-system + targetNamespace: finance + dependsOn: + - name: keycloak + - name: postgres + - name: traefik + - name: vault + - name: mailu + healthChecks: + - apiVersion: apps/v1 + kind: Deployment + name: actual-budget + namespace: finance + - apiVersion: apps/v1 + kind: Deployment + name: firefly + namespace: finance + wait: false diff --git a/clusters/atlas/flux-system/applications/kustomization.yaml b/clusters/atlas/flux-system/applications/kustomization.yaml index c73906e..417a3ec 100644 --- a/clusters/atlas/flux-system/applications/kustomization.yaml +++ b/clusters/atlas/flux-system/applications/kustomization.yaml @@ -28,4 +28,5 @@ resources: - nextcloud-mail-sync/kustomization.yaml - outline/kustomization.yaml - planka/kustomization.yaml + - finance/kustomization.yaml - health/kustomization.yaml diff --git a/services/bstein-dev-home/backend-deployment.yaml b/services/bstein-dev-home/backend-deployment.yaml index 6e7b40f..7ccca82 100644 --- a/services/bstein-dev-home/backend-deployment.yaml +++ b/services/bstein-dev-home/backend-deployment.yaml @@ -102,6 +102,12 @@ spec: value: wger-user-sync - name: WGER_USER_SYNC_WAIT_TIMEOUT_SEC value: "90" + - name: FIREFLY_NAMESPACE + value: finance + - name: FIREFLY_USER_SYNC_CRONJOB + value: firefly-user-sync + - name: FIREFLY_USER_SYNC_WAIT_TIMEOUT_SEC + value: "90" ports: - name: http containerPort: 8080 diff --git a/services/finance/actual-budget-data-pvc.yaml b/services/finance/actual-budget-data-pvc.yaml new file mode 100644 index 0000000..7016cda --- /dev/null +++ b/services/finance/actual-budget-data-pvc.yaml @@ -0,0 +1,12 @@ +# services/finance/actual-budget-data-pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: actual-budget-data + namespace: finance +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: asteria + resources: + requests: + storage: 10Gi diff --git a/services/finance/actual-budget-deployment.yaml b/services/finance/actual-budget-deployment.yaml new file mode 100644 index 0000000..11b7e5c --- /dev/null +++ b/services/finance/actual-budget-deployment.yaml @@ -0,0 +1,156 @@ +# services/finance/actual-budget-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: actual-budget + namespace: finance + labels: + app: actual-budget +spec: + replicas: 1 + selector: + matchLabels: + app: actual-budget + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 0 + maxUnavailable: 1 + template: + metadata: + labels: + app: actual-budget + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "finance" + vault.hashicorp.com/agent-inject-secret-actual-env.sh: "kv/data/atlas/finance/actual-oidc" + vault.hashicorp.com/agent-inject-template-actual-env.sh: | + {{ with secret "kv/data/atlas/finance/actual-oidc" }} + export ACTUAL_OPENID_CLIENT_ID="{{ .Data.data.ACTUAL_OPENID_CLIENT_ID }}" + export ACTUAL_OPENID_CLIENT_SECRET="{{ .Data.data.ACTUAL_OPENID_CLIENT_SECRET }}" + {{ end }} + spec: + serviceAccountName: finance-vault + nodeSelector: + kubernetes.io/arch: arm64 + node-role.kubernetes.io/worker: "true" + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + preference: + matchExpressions: + - key: hardware + operator: In + values: ["rpi5"] + - weight: 70 + preference: + matchExpressions: + - key: hardware + operator: In + values: ["rpi4"] + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + initContainers: + - name: init-data-permissions + image: docker.io/alpine:3.20 + command: ["/bin/sh", "-c"] + args: + - | + set -e + mkdir -p /data + chown -R 1000:1000 /data + securityContext: + runAsUser: 0 + runAsGroup: 0 + volumeMounts: + - name: actual-data + mountPath: /data + - name: init-openid + image: actualbudget/actual-server:sha-b6452f9-alpine + command: ["/bin/sh", "-c"] + args: + - | + set -eu + . /vault/secrets/actual-env.sh + node /scripts/actual_openid_bootstrap.mjs + env: + - name: ACTUAL_DATA_DIR + value: /data + - name: ACTUAL_LOGIN_METHOD + value: openid + - name: ACTUAL_ALLOWED_LOGIN_METHODS + value: openid + - name: ACTUAL_MULTIUSER + value: "true" + - name: ACTUAL_OPENID_DISCOVERY_URL + value: https://sso.bstein.dev/realms/atlas + - name: ACTUAL_OPENID_SERVER_HOSTNAME + value: https://budget.bstein.dev + volumeMounts: + - name: actual-data + mountPath: /data + - name: actual-openid-bootstrap-script + mountPath: /scripts + readOnly: true + containers: + - name: actual-budget + image: actualbudget/actual-server:sha-b6452f9-alpine + command: ["/bin/sh", "-c"] + args: + - | + . /vault/secrets/actual-env.sh + exec node app + ports: + - name: http + containerPort: 5006 + env: + - name: ACTUAL_DATA_DIR + value: /data + - name: ACTUAL_LOGIN_METHOD + value: openid + - name: ACTUAL_ALLOWED_LOGIN_METHODS + value: openid + - name: ACTUAL_MULTIUSER + value: "true" + - name: ACTUAL_OPENID_DISCOVERY_URL + value: https://sso.bstein.dev/realms/atlas + - name: ACTUAL_OPENID_SERVER_HOSTNAME + value: https://budget.bstein.dev + volumeMounts: + - name: actual-data + mountPath: /data + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 6 + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + volumes: + - name: actual-data + persistentVolumeClaim: + claimName: actual-budget-data + - name: actual-openid-bootstrap-script + configMap: + name: actual-openid-bootstrap-script + defaultMode: 0555 diff --git a/services/finance/actual-budget-ingress.yaml b/services/finance/actual-budget-ingress.yaml new file mode 100644 index 0000000..4cbc9e6 --- /dev/null +++ b/services/finance/actual-budget-ingress.yaml @@ -0,0 +1,26 @@ +# services/finance/actual-budget-ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: actual-budget + namespace: finance + annotations: + kubernetes.io/ingress.class: traefik + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + cert-manager.io/cluster-issuer: letsencrypt +spec: + tls: + - hosts: ["budget.bstein.dev"] + secretName: actual-budget-tls + rules: + - host: budget.bstein.dev + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: actual-budget + port: + number: 80 diff --git a/services/finance/actual-budget-service.yaml b/services/finance/actual-budget-service.yaml new file mode 100644 index 0000000..05213c4 --- /dev/null +++ b/services/finance/actual-budget-service.yaml @@ -0,0 +1,15 @@ +# services/finance/actual-budget-service.yaml +apiVersion: v1 +kind: Service +metadata: + name: actual-budget + namespace: finance + labels: + app: actual-budget +spec: + selector: + app: actual-budget + ports: + - name: http + port: 80 + targetPort: 5006 diff --git a/services/finance/firefly-cronjob.yaml b/services/finance/firefly-cronjob.yaml new file mode 100644 index 0000000..6c4d507 --- /dev/null +++ b/services/finance/firefly-cronjob.yaml @@ -0,0 +1,55 @@ +# services/finance/firefly-cronjob.yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: firefly-cron + namespace: finance +spec: + schedule: "0 3 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 1 + template: + metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/role: "finance" + vault.hashicorp.com/agent-inject-secret-firefly-cron-token: "kv/data/atlas/finance/firefly-secrets" + vault.hashicorp.com/agent-inject-template-firefly-cron-token: | + {{- with secret "kv/data/atlas/finance/firefly-secrets" -}} + {{ .Data.data.STATIC_CRON_TOKEN }} + {{- end -}} + spec: + serviceAccountName: finance-vault + restartPolicy: Never + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + preference: + matchExpressions: + - key: hardware + operator: In + values: ["rpi5"] + - weight: 70 + preference: + matchExpressions: + - key: hardware + operator: In + values: ["rpi4"] + nodeSelector: + kubernetes.io/arch: arm64 + node-role.kubernetes.io/worker: "true" + containers: + - name: cron + image: curlimages/curl:8.5.0 + command: ["/bin/sh", "-c"] + args: + - | + set -eu + token="$(cat /vault/secrets/firefly-cron-token)" + curl -fsS "http://firefly.finance.svc.cluster.local/api/v1/cron/${token}" diff --git a/services/finance/firefly-deployment.yaml b/services/finance/firefly-deployment.yaml new file mode 100644 index 0000000..1b51a07 --- /dev/null +++ b/services/finance/firefly-deployment.yaml @@ -0,0 +1,164 @@ +# services/finance/firefly-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: firefly + namespace: finance + labels: + app: firefly +spec: + replicas: 1 + selector: + matchLabels: + app: firefly + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 0 + maxUnavailable: 1 + template: + metadata: + labels: + app: firefly + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "finance" + vault.hashicorp.com/agent-inject-secret-firefly-env.sh: "kv/data/atlas/finance/firefly-db" + vault.hashicorp.com/agent-inject-template-firefly-env.sh: | + {{ with secret "kv/data/atlas/finance/firefly-db" }} + export DB_CONNECTION="pgsql" + export DB_HOST="{{ .Data.data.DB_HOST }}" + export DB_PORT="{{ .Data.data.DB_PORT }}" + export DB_DATABASE="{{ .Data.data.DB_DATABASE }}" + export DB_USERNAME="{{ .Data.data.DB_USERNAME }}" + export DB_PASSWORD="$(cat /vault/secrets/firefly-db-password)" + {{ end }} + {{ with secret "kv/data/atlas/finance/firefly-secrets" }} + export APP_KEY="$(cat /vault/secrets/firefly-app-key)" + export STATIC_CRON_TOKEN="$(cat /vault/secrets/firefly-cron-token)" + {{ end }} + {{ with secret "kv/data/atlas/shared/postmark-relay" }} + export MAIL_USERNAME="{{ index .Data.data "relay-username" }}" + export MAIL_PASSWORD="{{ index .Data.data "relay-password" }}" + {{ end }} + vault.hashicorp.com/agent-inject-secret-firefly-db-password: "kv/data/atlas/finance/firefly-db" + vault.hashicorp.com/agent-inject-template-firefly-db-password: | + {{- with secret "kv/data/atlas/finance/firefly-db" -}} + {{ .Data.data.DB_PASSWORD }} + {{- end -}} + vault.hashicorp.com/agent-inject-secret-firefly-app-key: "kv/data/atlas/finance/firefly-secrets" + vault.hashicorp.com/agent-inject-template-firefly-app-key: | + {{- with secret "kv/data/atlas/finance/firefly-secrets" -}} + {{ .Data.data.APP_KEY }} + {{- end -}} + vault.hashicorp.com/agent-inject-secret-firefly-cron-token: "kv/data/atlas/finance/firefly-secrets" + vault.hashicorp.com/agent-inject-template-firefly-cron-token: | + {{- with secret "kv/data/atlas/finance/firefly-secrets" -}} + {{ .Data.data.STATIC_CRON_TOKEN }} + {{- end -}} + spec: + serviceAccountName: finance-vault + nodeSelector: + kubernetes.io/arch: arm64 + node-role.kubernetes.io/worker: "true" + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + preference: + matchExpressions: + - key: hardware + operator: In + values: ["rpi5"] + - weight: 70 + preference: + matchExpressions: + - key: hardware + operator: In + values: ["rpi4"] + securityContext: + runAsUser: 33 + runAsGroup: 33 + fsGroup: 33 + fsGroupChangePolicy: OnRootMismatch + initContainers: + - name: init-storage-permissions + image: docker.io/alpine:3.20 + command: ["/bin/sh", "-c"] + args: + - | + set -e + mkdir -p /var/www/html/storage + chown -R 33:33 /var/www/html/storage + securityContext: + runAsUser: 0 + runAsGroup: 0 + volumeMounts: + - name: firefly-storage + mountPath: /var/www/html/storage + containers: + - name: firefly + image: fireflyiii/core:version-6.4.15 + args: ["/bin/sh", "-c", ". /vault/secrets/firefly-env.sh && exec /init"] + env: + - name: APP_ENV + value: production + - name: APP_DEBUG + value: "false" + - name: APP_URL + value: https://money.bstein.dev + - name: SITE_OWNER + value: brad@bstein.dev + - name: TZ + value: Etc/UTC + - name: TRUSTED_PROXIES + value: "**" + - name: AUTHENTICATION_GUARD + value: web + - name: MAIL_MAILER + value: smtp + - name: MAIL_HOST + value: mail.bstein.dev + - name: MAIL_PORT + value: "587" + - name: MAIL_ENCRYPTION + value: tls + - name: MAIL_FROM + value: no-reply-firefly@bstein.dev + - name: CACHE_DRIVER + value: file + - name: SESSION_DRIVER + value: file + ports: + - name: http + containerPort: 8080 + volumeMounts: + - name: firefly-storage + mountPath: /var/www/html/storage + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + volumes: + - name: firefly-storage + persistentVolumeClaim: + claimName: firefly-storage diff --git a/services/finance/firefly-ingress.yaml b/services/finance/firefly-ingress.yaml new file mode 100644 index 0000000..bd01661 --- /dev/null +++ b/services/finance/firefly-ingress.yaml @@ -0,0 +1,26 @@ +# services/finance/firefly-ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: firefly + namespace: finance + annotations: + kubernetes.io/ingress.class: traefik + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + cert-manager.io/cluster-issuer: letsencrypt +spec: + tls: + - hosts: ["money.bstein.dev"] + secretName: firefly-tls + rules: + - host: money.bstein.dev + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: firefly + port: + number: 80 diff --git a/services/finance/firefly-service.yaml b/services/finance/firefly-service.yaml new file mode 100644 index 0000000..a66980b --- /dev/null +++ b/services/finance/firefly-service.yaml @@ -0,0 +1,15 @@ +# services/finance/firefly-service.yaml +apiVersion: v1 +kind: Service +metadata: + name: firefly + namespace: finance + labels: + app: firefly +spec: + selector: + app: firefly + ports: + - name: http + port: 80 + targetPort: 8080 diff --git a/services/finance/firefly-storage-pvc.yaml b/services/finance/firefly-storage-pvc.yaml new file mode 100644 index 0000000..835f827 --- /dev/null +++ b/services/finance/firefly-storage-pvc.yaml @@ -0,0 +1,12 @@ +# services/finance/firefly-storage-pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: firefly-storage + namespace: finance +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: asteria + resources: + requests: + storage: 10Gi diff --git a/services/finance/firefly-user-sync-cronjob.yaml b/services/finance/firefly-user-sync-cronjob.yaml new file mode 100644 index 0000000..dab7f31 --- /dev/null +++ b/services/finance/firefly-user-sync-cronjob.yaml @@ -0,0 +1,90 @@ +# services/finance/firefly-user-sync-cronjob.yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: firefly-user-sync + namespace: finance +spec: + schedule: "0 6 * * *" + suspend: true + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 0 + template: + metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/role: "finance" + vault.hashicorp.com/agent-inject-secret-firefly-env.sh: "kv/data/atlas/finance/firefly-db" + vault.hashicorp.com/agent-inject-template-firefly-env.sh: | + {{ with secret "kv/data/atlas/finance/firefly-db" }} + export DB_CONNECTION="pgsql" + export DB_HOST="{{ .Data.data.DB_HOST }}" + export DB_PORT="{{ .Data.data.DB_PORT }}" + export DB_DATABASE="{{ .Data.data.DB_DATABASE }}" + export DB_USERNAME="{{ .Data.data.DB_USERNAME }}" + export DB_PASSWORD="$(cat /vault/secrets/firefly-db-password)" + {{ end }} + {{ with secret "kv/data/atlas/finance/firefly-secrets" }} + export APP_KEY="$(cat /vault/secrets/firefly-app-key)" + {{ end }} + vault.hashicorp.com/agent-inject-secret-firefly-db-password: "kv/data/atlas/finance/firefly-db" + vault.hashicorp.com/agent-inject-template-firefly-db-password: | + {{- with secret "kv/data/atlas/finance/firefly-db" -}} + {{ .Data.data.DB_PASSWORD }} + {{- end -}} + vault.hashicorp.com/agent-inject-secret-firefly-app-key: "kv/data/atlas/finance/firefly-secrets" + vault.hashicorp.com/agent-inject-template-firefly-app-key: | + {{- with secret "kv/data/atlas/finance/firefly-secrets" -}} + {{ .Data.data.APP_KEY }} + {{- end -}} + spec: + serviceAccountName: finance-vault + restartPolicy: Never + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + preference: + matchExpressions: + - key: hardware + operator: In + values: ["rpi5"] + - weight: 70 + preference: + matchExpressions: + - key: hardware + operator: In + values: ["rpi4"] + nodeSelector: + kubernetes.io/arch: arm64 + node-role.kubernetes.io/worker: "true" + containers: + - name: sync + image: fireflyiii/core:version-6.4.15 + command: ["/bin/sh", "-c"] + args: + - | + set -eu + . /vault/secrets/firefly-env.sh + exec php /scripts/firefly_user_sync.php + env: + - name: APP_ENV + value: production + - name: APP_DEBUG + value: "false" + - name: TZ + value: Etc/UTC + volumeMounts: + - name: firefly-user-sync-script + mountPath: /scripts + readOnly: true + volumes: + - name: firefly-user-sync-script + configMap: + name: firefly-user-sync-script + defaultMode: 0555 diff --git a/services/finance/kustomization.yaml b/services/finance/kustomization.yaml new file mode 100644 index 0000000..8cde8ba --- /dev/null +++ b/services/finance/kustomization.yaml @@ -0,0 +1,27 @@ +# services/finance/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: finance +resources: + - namespace.yaml + - serviceaccount.yaml + - portal-rbac.yaml + - actual-budget-data-pvc.yaml + - firefly-storage-pvc.yaml + - actual-budget-deployment.yaml + - firefly-deployment.yaml + - firefly-user-sync-cronjob.yaml + - firefly-cronjob.yaml + - actual-budget-service.yaml + - firefly-service.yaml + - actual-budget-ingress.yaml + - firefly-ingress.yaml +generatorOptions: + disableNameSuffixHash: true +configMapGenerator: + - name: actual-openid-bootstrap-script + files: + - actual_openid_bootstrap.mjs=scripts/actual_openid_bootstrap.mjs + - name: firefly-user-sync-script + files: + - firefly_user_sync.php=scripts/firefly_user_sync.php diff --git a/services/finance/namespace.yaml b/services/finance/namespace.yaml new file mode 100644 index 0000000..e262026 --- /dev/null +++ b/services/finance/namespace.yaml @@ -0,0 +1,5 @@ +# services/finance/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: finance diff --git a/services/finance/portal-rbac.yaml b/services/finance/portal-rbac.yaml new file mode 100644 index 0000000..2fb7ede --- /dev/null +++ b/services/finance/portal-rbac.yaml @@ -0,0 +1,31 @@ +# services/finance/portal-rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: bstein-dev-home-firefly-user-sync + namespace: finance +rules: + - apiGroups: ["batch"] + resources: ["cronjobs"] + verbs: ["get"] + resourceNames: ["firefly-user-sync"] + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["create", "get", "list", "watch"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: bstein-dev-home-firefly-user-sync + namespace: finance +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: bstein-dev-home-firefly-user-sync +subjects: + - kind: ServiceAccount + name: bstein-dev-home + namespace: bstein-dev-home diff --git a/services/finance/scripts/actual_openid_bootstrap.mjs b/services/finance/scripts/actual_openid_bootstrap.mjs new file mode 100644 index 0000000..af14524 --- /dev/null +++ b/services/finance/scripts/actual_openid_bootstrap.mjs @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +function findRoot() { + const candidates = []; + if (process.env.ACTUAL_SERVER_ROOT) { + candidates.push(process.env.ACTUAL_SERVER_ROOT); + } + candidates.push('/app'); + candidates.push('/usr/src/app'); + candidates.push('/srv/app'); + candidates.push('/opt/actual-server'); + + for (const base of candidates) { + if (!base) { + continue; + } + const accountDb = path.join(base, 'src', 'account-db.js'); + if (fs.existsSync(accountDb)) { + return base; + } + } + return ''; +} + +const root = findRoot(); +if (!root) { + console.error('actual server root not found'); + process.exit(1); +} + +const accountDbUrl = pathToFileURL(path.join(root, 'src', 'account-db.js')).href; +const loadConfigUrl = pathToFileURL(path.join(root, 'src', 'load-config.js')).href; + +const accountDb = await import(accountDbUrl); +const { default: finalConfig } = await import(loadConfigUrl); + +const openId = finalConfig?.openId; +if (!openId) { + console.error('missing openid configuration'); + process.exit(1); +} + +const active = accountDb.getActiveLoginMethod(); +if (active === 'openid') { + console.log('openid already enabled'); + process.exit(0); +} + +try { + if (accountDb.needsBootstrap()) { + const result = await accountDb.bootstrap({ openId }); + if (result?.error && result.error !== 'already-bootstrapped') { + console.error(`bootstrap failed: ${result.error}`); + process.exit(1); + } + } else { + const result = await accountDb.enableOpenID({ openId }); + if (result?.error) { + console.error(`enable openid failed: ${result.error}`); + process.exit(1); + } + } + + console.log('openid bootstrap complete'); +} catch (err) { + console.error('openid bootstrap error:', err); + process.exit(1); +} diff --git a/services/finance/scripts/firefly_user_sync.php b/services/finance/scripts/firefly_user_sync.php new file mode 100644 index 0000000..dcb78ea --- /dev/null +++ b/services/finance/scripts/firefly_user_sync.php @@ -0,0 +1,107 @@ +#!/usr/bin/env php +make(ConsoleKernel::class); +$kernel->bootstrap(); + +$repository = $app->make(UserRepositoryInterface::class); + +$existing_user = User::where('email', $email)->first(); +$first_user = User::count() == 0; + +if (!$existing_user) { + $existing_user = User::create( + [ + 'email' => $email, + 'password' => bcrypt($password), + 'blocked' => false, + 'blocked_code' => null, + ] + ); + + if ($first_user) { + $role = Role::where('name', 'owner')->first(); + if ($role) { + $existing_user->roles()->attach($role); + } + } + + log_line(sprintf('created firefly user %s', $email)); +} else { + log_line(sprintf('updating firefly user %s', $email)); +} + +$existing_user->blocked = false; +$existing_user->blocked_code = null; +$existing_user->save(); + +$repository->changePassword($existing_user, $password); +CreatesGroupMemberships::createGroupMembership($existing_user); + +log_line('firefly user sync complete'); diff --git a/services/finance/serviceaccount.yaml b/services/finance/serviceaccount.yaml new file mode 100644 index 0000000..d57a3d2 --- /dev/null +++ b/services/finance/serviceaccount.yaml @@ -0,0 +1,6 @@ +# services/finance/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: finance-vault + namespace: finance diff --git a/services/keycloak/actual-oidc-secret-ensure-job.yaml b/services/keycloak/actual-oidc-secret-ensure-job.yaml new file mode 100644 index 0000000..0cb8aa8 --- /dev/null +++ b/services/keycloak/actual-oidc-secret-ensure-job.yaml @@ -0,0 +1,48 @@ +# services/keycloak/actual-oidc-secret-ensure-job.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: actual-oidc-secret-ensure-1 + namespace: sso +spec: + backoffLimit: 0 + ttlSecondsAfterFinished: 3600 + template: + metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/role: "sso-secrets" + vault.hashicorp.com/agent-inject-secret-keycloak-admin-env.sh: "kv/data/atlas/shared/keycloak-admin" + vault.hashicorp.com/agent-inject-template-keycloak-admin-env.sh: | + {{ with secret "kv/data/atlas/shared/keycloak-admin" }} + export KEYCLOAK_ADMIN="{{ .Data.data.username }}" + export KEYCLOAK_ADMIN_USER="{{ .Data.data.username }}" + export KEYCLOAK_ADMIN_PASSWORD="{{ .Data.data.password }}" + {{ end }} + spec: + serviceAccountName: mas-secrets-ensure + restartPolicy: Never + volumes: + - name: actual-oidc-secret-ensure-script + configMap: + name: actual-oidc-secret-ensure-script + defaultMode: 0555 + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: ["arm64"] + - key: node-role.kubernetes.io/worker + operator: Exists + containers: + - name: apply + image: alpine:3.20 + command: ["/scripts/actual_oidc_secret_ensure.sh"] + volumeMounts: + - name: actual-oidc-secret-ensure-script + mountPath: /scripts + readOnly: true diff --git a/services/keycloak/kustomization.yaml b/services/keycloak/kustomization.yaml index 316f447..6030a82 100644 --- a/services/keycloak/kustomization.yaml +++ b/services/keycloak/kustomization.yaml @@ -24,6 +24,7 @@ resources: - logs-oidc-secret-ensure-job.yaml - harbor-oidc-secret-ensure-job.yaml - vault-oidc-secret-ensure-job.yaml + - actual-oidc-secret-ensure-job.yaml - service.yaml - ingress.yaml generatorOptions: @@ -39,3 +40,6 @@ configMapGenerator: - name: vault-oidc-secret-ensure-script files: - vault_oidc_secret_ensure.sh=scripts/vault_oidc_secret_ensure.sh + - name: actual-oidc-secret-ensure-script + files: + - actual_oidc_secret_ensure.sh=scripts/actual_oidc_secret_ensure.sh diff --git a/services/keycloak/realm-settings-job.yaml b/services/keycloak/realm-settings-job.yaml index 0def763..3bf726a 100644 --- a/services/keycloak/realm-settings-job.yaml +++ b/services/keycloak/realm-settings-job.yaml @@ -250,6 +250,22 @@ spec: "permissions": {"view": ["admin"], "edit": ["admin"]}, "validations": {"length": {"max": 64}}, }, + { + "name": "firefly_password", + "displayName": "Firefly Password", + "multivalued": False, + "annotations": {"group": "user-metadata"}, + "permissions": {"view": ["admin"], "edit": ["admin"]}, + "validations": {"length": {"max": 255}}, + }, + { + "name": "firefly_password_updated_at", + "displayName": "Firefly Password Updated At", + "multivalued": False, + "annotations": {"group": "user-metadata"}, + "permissions": {"view": ["admin"], "edit": ["admin"]}, + "validations": {"length": {"max": 64}}, + }, ] def has_attr(name: str) -> bool: diff --git a/services/keycloak/scripts/actual_oidc_secret_ensure.sh b/services/keycloak/scripts/actual_oidc_secret_ensure.sh new file mode 100644 index 0000000..c686c24 --- /dev/null +++ b/services/keycloak/scripts/actual_oidc_secret_ensure.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env sh +set -euo pipefail + +apk add --no-cache curl jq >/dev/null + +. /vault/secrets/keycloak-admin-env.sh + +KC_URL="http://keycloak.sso.svc.cluster.local" +ACCESS_TOKEN="" +for attempt in 1 2 3 4 5; do + TOKEN_JSON="$(curl -sS -X POST "$KC_URL/realms/master/protocol/openid-connect/token" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=${KEYCLOAK_ADMIN}" \ + -d "password=${KEYCLOAK_ADMIN_PASSWORD}" || true)" + ACCESS_TOKEN="$(echo "$TOKEN_JSON" | jq -r '.access_token' 2>/dev/null || true)" + if [ -n "$ACCESS_TOKEN" ] && [ "$ACCESS_TOKEN" != "null" ]; then + break + fi + echo "Keycloak token request failed (attempt ${attempt})" >&2 + sleep $((attempt * 2)) +done +if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then + echo "Failed to fetch Keycloak admin token" >&2 + exit 1 +fi + +CLIENT_QUERY="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/clients?clientId=actual-budget" || true)" +CLIENT_ID="$(echo "$CLIENT_QUERY" | jq -r '.[0].id' 2>/dev/null || true)" + +if [ -z "$CLIENT_ID" ] || [ "$CLIENT_ID" = "null" ]; then + create_payload='{"clientId":"actual-budget","enabled":true,"protocol":"openid-connect","publicClient":false,"standardFlowEnabled":true,"implicitFlowEnabled":false,"directAccessGrantsEnabled":false,"serviceAccountsEnabled":false,"redirectUris":["https://budget.bstein.dev/openid/callback"],"webOrigins":["https://budget.bstein.dev"],"rootUrl":"https://budget.bstein.dev","baseUrl":"/"}' + status="$(curl -sS -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "${create_payload}" \ + "$KC_URL/admin/realms/atlas/clients")" + if [ "$status" != "201" ] && [ "$status" != "204" ]; then + echo "Keycloak client create failed (status ${status})" >&2 + exit 1 + fi + CLIENT_QUERY="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/clients?clientId=actual-budget" || true)" + CLIENT_ID="$(echo "$CLIENT_QUERY" | jq -r '.[0].id' 2>/dev/null || true)" +fi + +if [ -z "$CLIENT_ID" ] || [ "$CLIENT_ID" = "null" ]; then + echo "Keycloak client actual-budget not found" >&2 + exit 1 +fi + +CLIENT_SECRET="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/clients/${CLIENT_ID}/client-secret" | jq -r '.value' 2>/dev/null || true)" +if [ -z "$CLIENT_SECRET" ] || [ "$CLIENT_SECRET" = "null" ]; then + echo "Keycloak client secret not found" >&2 + exit 1 +fi + +vault_addr="${VAULT_ADDR:-http://vault.vault.svc.cluster.local:8200}" +vault_role="${VAULT_ROLE:-sso-secrets}" +jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" +login_payload="$(jq -nc --arg jwt "${jwt}" --arg role "${vault_role}" '{jwt:$jwt, role:$role}')" +vault_token="$(curl -sS --request POST --data "${login_payload}" \ + "${vault_addr}/v1/auth/kubernetes/login" | jq -r '.auth.client_token')" +if [ -z "${vault_token}" ] || [ "${vault_token}" = "null" ]; then + echo "vault login failed" >&2 + exit 1 +fi + +payload="$(jq -nc \ + --arg client_id "actual-budget" \ + --arg client_secret "${CLIENT_SECRET}" \ + '{data:{ACTUAL_OPENID_CLIENT_ID:$client_id, ACTUAL_OPENID_CLIENT_SECRET:$client_secret}}')" + +curl -sS -X POST -H "X-Vault-Token: ${vault_token}" \ + -d "${payload}" "${vault_addr}/v1/kv/data/atlas/finance/actual-oidc" >/dev/null diff --git a/services/vault/scripts/vault_k8s_auth_configure.sh b/services/vault/scripts/vault_k8s_auth_configure.sh index 2d2d4ba..04114fc 100644 --- a/services/vault/scripts/vault_k8s_auth_configure.sh +++ b/services/vault/scripts/vault_k8s_auth_configure.sh @@ -214,6 +214,8 @@ write_policy_and_role "crypto" "crypto" "crypto-vault-sync" \ "crypto/* harbor-pull/crypto" "" write_policy_and_role "health" "health" "health-vault-sync" \ "health/*" "" +write_policy_and_role "finance" "finance" "finance-vault" \ + "finance/* shared/postmark-relay" "" write_policy_and_role "longhorn" "longhorn-system" "longhorn-vault,longhorn-vault-sync" \ "longhorn/* harbor-pull/longhorn" "" write_policy_and_role "postgres" "postgres" "postgres-vault" \ @@ -223,7 +225,7 @@ write_policy_and_role "vault" "vault" "vault" \ write_policy_and_role "sso-secrets" "sso" "mas-secrets-ensure" \ "shared/keycloak-admin" \ - "harbor/harbor-oidc vault/vault-oidc-config comms/synapse-oidc logging/oauth2-proxy-logs-oidc" + "harbor/harbor-oidc vault/vault-oidc-config comms/synapse-oidc logging/oauth2-proxy-logs-oidc finance/actual-oidc" write_policy_and_role "crypto-secrets" "crypto" "crypto-secrets-ensure" \ "" \ "crypto/wallet-monero-temp-rpc-auth"