commit 2ee46ceb4a444fd6622d373cc9950403825e1b23 Author: SmokyZone Date: Sun Sep 20 18:46:19 2026 +0200 Add Helm chart for the expense tracker (app, Postgres, ingress, backups) - Deployment/Service for the app, StatefulSet/Service for Postgres 17 - Secrets (DB password, session secret, Google client, allowed e-mails) via ExternalSecret from OpenBao - Ingress with a Let's Encrypt certificate, NetworkPolicy for Postgres - Nightly pg_dump CronJob - Optional OpenBao OIDC provider setup script Co-Authored-By: Claude Sonnet 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1c155d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.tgz +.DS_Store diff --git a/Chart.yaml b/Chart.yaml new file mode 100644 index 0000000..7de2153 --- /dev/null +++ b/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: expense-tracker +description: Helm chart to run the Budget / expense tracker (web app + Postgres) on Kubernetes +type: application +version: 0.1.0 +appVersion: "b6da854" +home: https://git.smokyzone.de/SmokyZone/expense_tracker-gitops +sources: + - https://git.smokyzone.de/SmokyZone/expense_tracker-gitops + - https://git.smokyzone.de/SmokyZone/budget +keywords: + - budget + - expense-tracker + - postgres +maintainers: + - name: SmokyZone diff --git a/README.md b/README.md new file mode 100644 index 0000000..c02b411 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# expense_tracker-gitops + +Helm chart that deploys the **Budget / expense tracker** +([`SmokyZone/budget`](https://git.smokyzone.de/SmokyZone/budget)) on the home cluster, +following the same pattern as `valheim-gitops` (ArgoCD app-of-apps, secrets from OpenBao via +External Secrets Operator). + +## What's included + +- **Deployment + Service** for the app (non-root, read-only root filesystem, probes) +- **StatefulSet + Service** for **Postgres 17** with a persistent volume +- **NetworkPolicy** so only the app and the backup job can reach Postgres +- **Ingress** `budget.smokyzone.de` with a Let's Encrypt certificate (cert-manager, DNS-01) +- **ExternalSecrets** that pull the DB password, session secret, Google client and the list of + allowed e-mail addresses from OpenBao +- **CronJob** with a nightly `pg_dump` (14 days retention) into its own volume + +Reachable at **https://budget.smokyzone.de:30444** from the home network (30444 is the +ingress-nginx NodePort, same as for the other apps). + +## First-time setup + +### 1. DNS (Cloudflare, you) + +Add two `A` records, **DNS only** (grey cloud, *not* proxied), pointing at the node's LAN IP: + +| Name | Type | Content | Proxy | +|---|---|---|---| +| `budget` | A | `192.168.2.218` | DNS only | +| `argocd` | A | `192.168.2.218` | DNS only | + +`argocd.smokyzone.de` needs no ingress: ArgoCD already serves TLS on NodePort 30443, so it is +`https://argocd.smokyzone.de:30443` (self-signed certificate, expect a browser warning). + +Private IPs in public DNS only work as long as your router doesn't apply DNS-rebind protection. +If names don't resolve on the LAN, allow `smokyzone.de` in the router's rebind exceptions (Fritz!Box: +*Home Network → Network → Network Settings → DNS Rebind Protection → Host name exceptions*) or add +local DNS overrides instead. + +The certificate does not depend on these records (DNS-01 challenge via the existing Cloudflare +issuer). + +### 2. Google OAuth client (you) + +Google Cloud Console → *APIs & Services* → *Credentials* → *Create credentials* → *OAuth client ID*, +type **Web application**: + +- Authorized redirect URI: `https://budget.smokyzone.de:30444/auth/callback/google` +- Consent screen: *External*, publishing status *Testing* is fine - add your Google account(s) as + **test users** (up to 100; no Google verification needed). + +Keep the client ID and secret for the next step. + +### 3. Secrets in OpenBao (you) + +KV v2 mount `secret/` (same as valheim): + +```sh +bao kv put secret/expense-tracker/db password="$(openssl rand -base64 32 | tr -d '/+=')" + +bao kv put secret/expense-tracker/app \ + session_secret="$(openssl rand -base64 48 | tr -d '\n')" \ + allowed_emails="you@gmail.com" \ + google_client_id=".apps.googleusercontent.com" \ + google_client_secret="" +``` + +`allowed_emails` is a comma-separated allow-list: **only these Google accounts can sign in**, even +though anyone can *authenticate* with Google. Removing an address locks that user out immediately, +including existing sessions (after the next ESO refresh, at most 1 h, or restart the pod). + +### 4. Deploy + +Register the app in `apps-in-apps` (already done in this change) and push - ArgoCD creates the +`expense-tracker` Application. Check: + +```sh +kubectl -n argocd get app expense-tracker +kubectl -n expense-tracker get pods,externalsecret,certificate,ingress +``` + +Until step 3 is done the ExternalSecrets report an error and the pods wait in +`CreateContainerConfigError` - that is expected; they start by themselves once the secrets exist. + +### 5. Move the existing data in + +Your old JSON files go into Postgres for **your** account (matched by e-mail, so your first Google +login lands directly on the imported data): + +```sh +kubectl -n expense-tracker port-forward svc/expense-tracker-postgres 5432:5432 & + +cd ~/dev/cluster/budget # the app repository, with data/*.json in ./data +export PGHOST=127.0.0.1 PGUSER=budget PGDATABASE=budget +export PGPASSWORD="$(kubectl -n expense-tracker get secret expense-tracker-db -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d)" +npm run import:json -- --email you@gmail.com --dir data +``` + +It prints the imported row counts and refuses to overwrite existing data without `--replace`. +Afterwards stop the old local container (`docker compose down` in the `budget` directory) so you +don't keep editing the JSON copy. + +## Everyday operations + +**Ship a new version** - in the `budget` repo: commit, run `scripts/release.sh`, then set `image.tag` +in `values.yaml` to the printed tag and push this repo. ArgoCD rolls it out (database migrations +run automatically on start). + +**Add another user** - append the address to `allowed_emails` in OpenBao. They get their own, +empty account on first login; nobody sees anyone else's data. + +**Backups** - the CronJob writes `budget-.dump` (`pg_dump -Fc`) to the +`expense-tracker-backups` volume every night at 03:15. Run one now with +`kubectl -n expense-tracker create job --from=cronjob/expense-tracker-backup manual-$(date +%s)`. +These dumps live on the same disk as the database: they protect against mistakes, **not** against +losing the machine. Copy them elsewhere from time to time, e.g. via a throw-away pod: + +```sh +kubectl -n expense-tracker run bk --restart=Never --image=alpine --overrides='{"spec":{"containers":[{"name":"bk","image":"alpine","command":["sleep","600"],"volumeMounts":[{"name":"b","mountPath":"/backup"}]}],"volumes":[{"name":"b","persistentVolumeClaim":{"claimName":"expense-tracker-backups"}}]}}' +kubectl -n expense-tracker cp bk:/backup ./budget-backups +kubectl -n expense-tracker delete pod bk +``` + +**Restore** - copy a dump into the Postgres pod and restore it over the running database: + +```sh +kubectl -n expense-tracker cp ./budget-backups/budget-.dump expense-tracker-postgres-0:/tmp/restore.dump +kubectl -n expense-tracker exec expense-tracker-postgres-0 -- \ + pg_restore -U budget -d budget --clean --if-exists --no-owner /tmp/restore.dump +``` + +**Rotating the DB password** - `POSTGRES_PASSWORD` is only read when the data directory is first +created, so changing it in OpenBao alone breaks the app. Change it inside Postgres first +(`ALTER USER budget PASSWORD '...'`), then update OpenBao. + +## OpenBao as a second SSO provider (optional) + +OpenBao can act as an OIDC provider, so you can sign in with an OpenBao identity as well as with +Google (both map to the same account when the e-mail matches). + +1. `export BAO_ADDR=https://192.168.2.218:30200 BAO_SKIP_VERIFY=true BAO_TOKEN=` +2. `scripts/openbao-oidc.sh` - creates the key, scope, client and provider, and stores the client + ID/secret and OpenBao's CA certificate in `secret/expense-tracker/app`. + (The script follows the OpenBao docs but has not been run against your instance yet; check each + step's output.) +3. Give the entity you log in with an `email` metadata value (the script prints the commands). +4. Set `auth.openbao.enabled: true` in `values.yaml`, commit, push. + +The issuer is `https://192.168.2.218:30200/v1/identity/oidc/provider/expense-tracker`. It must be +reachable from the **browser** (it will warn about OpenBao's self-signed certificate once) and from +the pod, which is why it uses the node address rather than the in-cluster service name. + +## Security notes + +- **LAN-only is enforced by DNS, not by the cluster.** ingress-nginx runs with + `externalTrafficPolicy: Cluster`, so the app sees every request as coming from the node + (`192.168.2.218`) - an IP allow-list on the ingress would be meaningless. The hostname points to a + private IP, but anyone who reaches the ingress with that `Host` header (e.g. through the router's + port-forward) hits the login page. The protection is Google sign-in plus `allowed_emails`. Note that + Let's Encrypt certificates are public (Certificate Transparency), so the hostname is discoverable. + For real network isolation set `externalTrafficPolicy: Local` on ingress-nginx and add a + `whitelist-source-range` annotation. +- The container image is pullable without credentials (Gitea packages follow the visibility of the + `SmokyZone` account). It contains only application code, never secrets or data. +- Sessions live 30 days (rolling), in Postgres; cookies are `HttpOnly`, `SameSite=Lax`, `Secure`. +- Deleting the namespace deletes the database volume (StorageClass `local-path` reclaim policy is + `Delete`). Keep backups outside the cluster. + +## Structure + +``` +expense_tracker-gitops/ +├── Chart.yaml +├── values.yaml # image tag, host, sizes, feature toggles +├── scripts/openbao-oidc.sh # optional: OpenBao as OIDC provider +└── templates/ + ├── namespace.yaml + ├── externalsecret.yaml # DB + app secrets from OpenBao + ├── postgres.yaml # StatefulSet + Service + ├── deployment.yaml # app Deployment + Service + ├── ingress.yaml + ├── networkpolicy.yaml + └── backup.yaml # nightly pg_dump CronJob + PVC +``` diff --git a/scripts/openbao-oidc.sh b/scripts/openbao-oidc.sh new file mode 100755 index 0000000..cecebcf --- /dev/null +++ b/scripts/openbao-oidc.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Configure OpenBao as an OpenID Connect provider for the expense tracker and store the +# resulting client credentials + CA certificate in the KV secret the chart reads. +# +# export BAO_ADDR=https://192.168.2.218:30200 BAO_SKIP_VERIFY=true BAO_TOKEN= +# scripts/openbao-oidc.sh +# +# Prerequisites: `bao` and `kubectl` on PATH; the KV secret secret/expense-tracker/app +# already exists (see README step 1). Afterwards enable it: `auth.openbao.enabled: true`. +# +# NOTE: written against the OpenBao OIDC provider API docs; not run against a live server +# from the machine that authored it - check the output of each step. +set -euo pipefail + +NAME="${NAME:-expense-tracker}" +# scheme://host:port that both browser and pod can reach; the effective issuer becomes +# /v1/identity/oidc/provider/ and must equal auth.openbao.issuer in values.yaml +ISSUER_ADDR="${ISSUER_ADDR:-https://192.168.2.218:30200}" +REDIRECT_URI="${REDIRECT_URI:-https://budget.smokyzone.de:30444/auth/callback/openbao}" +KV_MOUNT="${KV_MOUNT:-secret}" +KV_PATH="${KV_PATH:-expense-tracker/app}" + +echo "1/5 signing key" +bao write "identity/oidc/key/$NAME" allowed_client_ids="*" algorithm=RS256 rotation_period=24h verification_ttl=24h + +echo "2/5 scope 'email' (claim comes from the entity's metadata)" +bao write identity/oidc/scope/email template='{"email": {{identity.entity.metadata.email}}}' + +echo "3/5 client" +bao write "identity/oidc/client/$NAME" redirect_uris="$REDIRECT_URI" assignments="allow_all" \ + key="$NAME" id_token_ttl=30m access_token_ttl=1h +CLIENT_ID="$(bao read -field=client_id "identity/oidc/client/$NAME")" +CLIENT_SECRET="$(bao read -field=client_secret "identity/oidc/client/$NAME")" + +echo "4/5 provider" +bao write "identity/oidc/provider/$NAME" issuer="$ISSUER_ADDR" allowed_client_ids="$CLIENT_ID" scopes_supported="email" + +echo "5/5 store credentials + CA in $KV_MOUNT/$KV_PATH" +CA_FILE="$(mktemp)"; trap 'rm -f "$CA_FILE"' EXIT +kubectl -n openbao get secret openbao-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > "$CA_FILE" +[ -s "$CA_FILE" ] || { echo "could not read the CA from secret openbao/openbao-tls" >&2; exit 1; } +bao kv patch -mount="$KV_MOUNT" "$KV_PATH" \ + openbao_client_id="$CLIENT_ID" openbao_client_secret="$CLIENT_SECRET" openbao_ca_cert=@"$CA_FILE" + +echo +echo "Issuer: $ISSUER_ADDR/v1/identity/oidc/provider/$NAME" +curl -sk "$ISSUER_ADDR/v1/identity/oidc/provider/$NAME/.well-known/openid-configuration" | head -c 300 || true +echo +cat < metadata=email=
+ bao write identity/entity-alias name= canonical_id= mount_accessor= + (or add metadata to the existing entity of the userpass/OIDC user you log in with). + * Set auth.openbao.enabled=true in values.yaml, commit and push. + * The address must also be listed in allowed_emails. +MSG diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl new file mode 100644 index 0000000..fd05e80 --- /dev/null +++ b/templates/_helpers.tpl @@ -0,0 +1,21 @@ +{{- define "expense-tracker.labels" -}} +app.kubernetes.io/part-of: expense-tracker +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* Standard PG* connection variables; the libpq/pg client reads these directly. */}} +{{- define "expense-tracker.pgenv" -}} +- name: PGHOST + value: expense-tracker-postgres +- name: PGPORT + value: "5432" +- name: PGDATABASE + value: {{ .Values.postgres.database | quote }} +- name: PGUSER + value: {{ .Values.postgres.user | quote }} +- name: PGPASSWORD + valueFrom: + secretKeyRef: + name: expense-tracker-db + key: POSTGRES_PASSWORD +{{- end }} diff --git a/templates/backup.yaml b/templates/backup.yaml new file mode 100644 index 0000000..022e223 --- /dev/null +++ b/templates/backup.yaml @@ -0,0 +1,82 @@ +{{- if .Values.backup.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: expense-tracker-backups + namespace: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker-backup + {{- include "expense-tracker.labels" . | nindent 4 }} +spec: + accessModes: ["ReadWriteOnce"] + {{- if .Values.backup.persistence.storageClassName }} + storageClassName: {{ .Values.backup.persistence.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.backup.persistence.size }} +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: expense-tracker-backup + namespace: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker-backup + {{- include "expense-tracker.labels" . | nindent 4 }} +spec: + schedule: {{ .Values.backup.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 2 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 2 + template: + metadata: + labels: + app.kubernetes.io/name: expense-tracker-backup + spec: + restartPolicy: OnFailure + securityContext: + runAsUser: 70 + runAsGroup: 70 + fsGroup: 70 + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: pg-dump + image: {{ .Values.postgres.image | quote }} + command: + - sh + - -ec + - | + f="/backup/{{ .Values.postgres.database }}-$(date +%Y%m%d-%H%M%S).dump" + # Write to a temp name first so a failed dump never looks like a good backup. + pg_dump --format=custom --no-owner -f "$f.partial" + mv "$f.partial" "$f" + echo "wrote $f ($(du -h "$f" | cut -f1))" + find /backup -name '*.dump' -mtime +{{ .Values.backup.retentionDays }} -print -delete + find /backup -name '*.partial' -mmin +60 -delete + ls -lh /backup + env: + {{- include "expense-tracker.pgenv" . | nindent 16 }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 256Mi + volumeMounts: + - name: backups + mountPath: /backup + volumes: + - name: backups + persistentVolumeClaim: + claimName: expense-tracker-backups +{{- end }} diff --git a/templates/deployment.yaml b/templates/deployment.yaml new file mode 100644 index 0000000..114317b --- /dev/null +++ b/templates/deployment.yaml @@ -0,0 +1,153 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: expense-tracker + namespace: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker + {{- include "expense-tracker.labels" . | nindent 4 }} + annotations: + # Stakater Reloader: rolling restart when ESO refreshes a secret from OpenBao + # (env vars are only read at container start). Harmless if Reloader is not installed. + secret.reloader.stakater.com/reload: "expense-tracker-app,expense-tracker-db" +spec: + replicas: {{ .Values.app.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: expense-tracker + template: + metadata: + labels: + app.kubernetes.io/name: expense-tracker + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: app + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 5174 + env: + - name: PORT + value: "5174" + - name: PUBLIC_URL + value: {{ .Values.app.publicUrl | quote }} + {{- include "expense-tracker.pgenv" . | nindent 12 }} + - name: SESSION_SECRET + valueFrom: + secretKeyRef: + name: expense-tracker-app + key: SESSION_SECRET + - name: ALLOWED_EMAILS + valueFrom: + secretKeyRef: + name: expense-tracker-app + key: ALLOWED_EMAILS + {{- if .Values.auth.google.enabled }} + - name: GOOGLE_CLIENT_ID + valueFrom: + secretKeyRef: + name: expense-tracker-app + key: GOOGLE_CLIENT_ID + - name: GOOGLE_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: expense-tracker-app + key: GOOGLE_CLIENT_SECRET + {{- end }} + {{- if .Values.auth.openbao.enabled }} + - name: OPENBAO_ISSUER + value: {{ .Values.auth.openbao.issuer | quote }} + - name: OPENBAO_CLIENT_ID + valueFrom: + secretKeyRef: + name: expense-tracker-app + key: OPENBAO_CLIENT_ID + - name: OPENBAO_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: expense-tracker-app + key: OPENBAO_CLIENT_SECRET + # OpenBao's certificate is self-signed (cert-manager), so trust its CA. + - name: NODE_EXTRA_CA_CERTS + value: /etc/openbao-ca/openbao-ca.crt + {{- end }} + # The server starts listening only after Postgres answered and migrations ran. + startupProbe: + httpGet: + path: /livez + port: http + periodSeconds: 3 + failureThreshold: 40 + readinessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 10 + timeoutSeconds: 3 + livenessProbe: + httpGet: + path: /livez + port: http + periodSeconds: 20 + timeoutSeconds: 3 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + {{- toYaml .Values.app.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + {{- if .Values.auth.openbao.enabled }} + - name: openbao-ca + mountPath: /etc/openbao-ca + readOnly: true + {{- end }} + volumes: + - name: tmp + emptyDir: {} + {{- if .Values.auth.openbao.enabled }} + - name: openbao-ca + secret: + secretName: expense-tracker-app + items: + - key: openbao-ca.crt + path: openbao-ca.crt + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: expense-tracker + namespace: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker + {{- include "expense-tracker.labels" . | nindent 4 }} +spec: + selector: + app.kubernetes.io/name: expense-tracker + ports: + - name: http + port: 80 + targetPort: http diff --git a/templates/externalsecret.yaml b/templates/externalsecret.yaml new file mode 100644 index 0000000..a468791 --- /dev/null +++ b/templates/externalsecret.yaml @@ -0,0 +1,74 @@ +# Both Secrets are created by External Secrets Operator from OpenBao (KV v2). +# If a referenced key is missing in OpenBao the ExternalSecret reports an error and the +# Secret is not created - so pods wait in CreateContainerConfigError until it exists. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: expense-tracker-db + namespace: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker + {{- include "expense-tracker.labels" . | nindent 4 }} +spec: + refreshInterval: {{ .Values.externalSecret.refreshInterval }} + secretStoreRef: + kind: {{ .Values.externalSecret.storeKind }} + name: {{ .Values.externalSecret.storeName }} + target: + name: expense-tracker-db + creationPolicy: Owner + data: + - secretKey: POSTGRES_PASSWORD + remoteRef: + key: {{ .Values.externalSecret.db.remoteKey }} + property: {{ .Values.externalSecret.db.properties.password }} +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: expense-tracker-app + namespace: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker + {{- include "expense-tracker.labels" . | nindent 4 }} +spec: + refreshInterval: {{ .Values.externalSecret.refreshInterval }} + secretStoreRef: + kind: {{ .Values.externalSecret.storeKind }} + name: {{ .Values.externalSecret.storeName }} + target: + name: expense-tracker-app + creationPolicy: Owner + data: + - secretKey: SESSION_SECRET + remoteRef: + key: {{ .Values.externalSecret.app.remoteKey }} + property: {{ .Values.externalSecret.app.properties.sessionSecret }} + - secretKey: ALLOWED_EMAILS + remoteRef: + key: {{ .Values.externalSecret.app.remoteKey }} + property: {{ .Values.externalSecret.app.properties.allowedEmails }} + {{- if .Values.auth.google.enabled }} + - secretKey: GOOGLE_CLIENT_ID + remoteRef: + key: {{ .Values.externalSecret.app.remoteKey }} + property: {{ .Values.externalSecret.app.properties.googleClientId }} + - secretKey: GOOGLE_CLIENT_SECRET + remoteRef: + key: {{ .Values.externalSecret.app.remoteKey }} + property: {{ .Values.externalSecret.app.properties.googleClientSecret }} + {{- end }} + {{- if .Values.auth.openbao.enabled }} + - secretKey: OPENBAO_CLIENT_ID + remoteRef: + key: {{ .Values.externalSecret.app.remoteKey }} + property: {{ .Values.externalSecret.app.properties.openbaoClientId }} + - secretKey: OPENBAO_CLIENT_SECRET + remoteRef: + key: {{ .Values.externalSecret.app.remoteKey }} + property: {{ .Values.externalSecret.app.properties.openbaoClientSecret }} + - secretKey: openbao-ca.crt + remoteRef: + key: {{ .Values.externalSecret.app.remoteKey }} + property: {{ .Values.externalSecret.app.properties.openbaoCaCert }} + {{- end }} diff --git a/templates/ingress.yaml b/templates/ingress.yaml new file mode 100644 index 0000000..d5df705 --- /dev/null +++ b/templates/ingress.yaml @@ -0,0 +1,33 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: expense-tracker + namespace: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker + {{- include "expense-tracker.labels" . | nindent 4 }} + annotations: + cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer }} + nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.ingress.proxyBodySize | quote }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + tls: + - hosts: + - {{ .Values.ingress.host }} + secretName: {{ .Values.ingress.tlsSecretName }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: expense-tracker + port: + name: http +{{- end }} diff --git a/templates/namespace.yaml b/templates/namespace.yaml new file mode 100644 index 0000000..416662f --- /dev/null +++ b/templates/namespace.yaml @@ -0,0 +1,9 @@ +{{- if .Values.namespace.create }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker + {{- include "expense-tracker.labels" . | nindent 4 }} +{{- end }} diff --git a/templates/networkpolicy.yaml b/templates/networkpolicy.yaml new file mode 100644 index 0000000..c8c86b5 --- /dev/null +++ b/templates/networkpolicy.yaml @@ -0,0 +1,28 @@ +{{- if .Values.networkPolicy.enabled }} +# Postgres accepts connections only from the app and the backup job of this release. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: expense-tracker-postgres + namespace: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker-postgres + {{- include "expense-tracker.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: expense-tracker-postgres + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: expense-tracker + - podSelector: + matchLabels: + app.kubernetes.io/name: expense-tracker-backup + ports: + - protocol: TCP + port: 5432 +{{- end }} diff --git a/templates/postgres.yaml b/templates/postgres.yaml new file mode 100644 index 0000000..a1f68eb --- /dev/null +++ b/templates/postgres.yaml @@ -0,0 +1,118 @@ +apiVersion: v1 +kind: Service +metadata: + name: expense-tracker-postgres + namespace: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker-postgres + {{- include "expense-tracker.labels" . | nindent 4 }} +spec: + selector: + app.kubernetes.io/name: expense-tracker-postgres + ports: + - name: postgres + port: 5432 + targetPort: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: expense-tracker-postgres + namespace: {{ .Values.namespace.name }} + labels: + app.kubernetes.io/name: expense-tracker-postgres + {{- include "expense-tracker.labels" . | nindent 4 }} +spec: + serviceName: expense-tracker-postgres + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: expense-tracker-postgres + template: + metadata: + labels: + app.kubernetes.io/name: expense-tracker-postgres + spec: + securityContext: + # uid/gid of the `postgres` user in the alpine image + runAsUser: 70 + runAsGroup: 70 + fsGroup: 70 + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: postgres + image: {{ .Values.postgres.image | quote }} + ports: + - name: postgres + containerPort: 5432 + env: + - name: POSTGRES_DB + value: {{ .Values.postgres.database | quote }} + - name: POSTGRES_USER + value: {{ .Values.postgres.user | quote }} + # Only used when the data directory is first initialised; changing it later in + # OpenBao does NOT change the database user's password (see README). + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: expense-tracker-db + key: POSTGRES_PASSWORD + # A sub-directory, because the volume root may contain lost+found. + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + readinessProbe: + exec: + command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""] + periodSeconds: 5 + timeoutSeconds: 3 + livenessProbe: + exec: + command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""] + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 6 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + resources: + {{- toYaml .Values.postgres.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + # Unix socket dir + scratch space (the image writes there as the postgres user) + - name: run + mountPath: /var/run/postgresql + - name: tmp + mountPath: /tmp + volumes: + - name: run + emptyDir: {} + - name: tmp + emptyDir: {} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- if .Values.postgres.persistence.storageClassName }} + storageClassName: {{ .Values.postgres.persistence.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgres.persistence.size }} diff --git a/values.yaml b/values.yaml new file mode 100644 index 0000000..dc218dd --- /dev/null +++ b/values.yaml @@ -0,0 +1,103 @@ +## Default values for the expense-tracker chart + +namespace: + # If true, the chart creates the namespace itself. + create: true + name: expense-tracker + +image: + # Built and pushed by `scripts/release.sh` in the `budget` repository. + repository: git.smokyzone.de/smokyzone/budget + tag: "b6da854" + pullPolicy: IfNotPresent + +app: + replicas: 1 + # Externally visible URL, INCLUDING the ingress-nginx NodePort. Used to build the + # OIDC redirect URIs: /auth/callback/google (and /openbao). + publicUrl: https://budget.smokyzone.de:30444 + resources: + requests: + cpu: 50m + memory: 96Mi + limits: + cpu: 500m + memory: 256Mi + +# Secrets are NOT stored in git - they are read from OpenBao via External Secrets Operator. +externalSecret: + # ClusterSecretStore (deployed by the openbao chart) to read from. + storeKind: ClusterSecretStore + storeName: openbao + refreshInterval: 1h + # Path of the secrets inside the KV v2 mount configured on the store (`secret/`). + app: + remoteKey: expense-tracker/app + properties: + sessionSecret: session_secret # >= 32 random characters + allowedEmails: allowed_emails # comma separated: only these addresses may sign in + googleClientId: google_client_id + googleClientSecret: google_client_secret + openbaoClientId: openbao_client_id + openbaoClientSecret: openbao_client_secret + openbaoCaCert: openbao_ca_cert # PEM of the CA that signed OpenBao's TLS certificate + db: + remoteKey: expense-tracker/db + properties: + password: password + +auth: + google: + enabled: true + # OpenBao as a second OIDC provider (see README, "OpenBao as SSO provider"). + openbao: + enabled: false + # Must be reachable from the browser AND from the pod, and must equal the `issuer` + # configured on the OpenBao OIDC provider. OpenBao's UI NodePort serves this address. + issuer: https://192.168.2.218:30200/v1/identity/oidc/provider/expense-tracker + +ingress: + enabled: true + className: nginx + host: budget.smokyzone.de + # cert-manager ClusterIssuer that issues the certificate. The DNS-01 issuer works for a + # hostname that only resolves to a private IP. + clusterIssuer: letsencrypt-dns-cloudflare + tlsSecretName: expense-tracker-tls + # ingress-nginx defaults to 1m; the API accepts JSON bodies up to 5 MB. + proxyBodySize: 6m + annotations: {} + +postgres: + image: postgres:17-alpine + database: budget + user: budget + persistence: + size: 5Gi + # Leave empty to use the cluster default StorageClass. + storageClassName: "" + resources: + requests: + cpu: 100m + memory: 192Mi + limits: + cpu: "1" + memory: 512Mi + +# Nightly pg_dump into its own volume. This protects against mistakes (a bad import, a +# deleted row), NOT against losing the node's disk - copy the dumps off the machine too. +backup: + enabled: true + schedule: "15 3 * * *" + retentionDays: 14 + persistence: + size: 5Gi + storageClassName: "" + +# Only the app and the backup job may talk to Postgres. +networkPolicy: + enabled: true + +nodeSelector: {} +tolerations: [] +affinity: {}