# Option A — age-encrypted logical dumps → Nextcloud WebDAV (RAILIANCE-WP-0015). # # Decided approach (not barman ScheduledBackup). Applies to CoulombCore # databases namespace clusters: apps-pg, gitea-db, net-kingdom-pg, state-hub-db. # # Unattended path (primary): workstation cron (same pattern as forgejo-backup). # CoulombCore pods cannot reach github.com / Alpine CDN to fetch `age`, so # in-cluster CronJobs are shipped **suspended**. Enable only after an image # with kubectl+age+curl is available in-cluster. # # Prereqs: # 1. OpenBao lane CCR-2026-0004 resolvable # 2. make cnpg-backup-offsite-secret-apply # Secret cnpg-backup-offsite # 3. kubectl apply -f manifests/cnpg-option-a-backup.yaml # 4. Install workstation cron (see schedule ConfigMap / docs) # # Schedule: daily 02:30 UTC (RPO 24h). Retention target 14 daily + 4 weekly # is enforced on the Nextcloud side / operator prune. # # Health: make cnpg-backup-status --- apiVersion: v1 kind: ConfigMap metadata: name: cnpg-option-a-schedule namespace: databases labels: app.kubernetes.io/part-of: railiance-apps railiance.apps/backup-lane: option-a data: mode: workstation-cron schedule: "30 2 * * *" command: "cd $HOME/railiance-apps && make cnpg-logical-backup" clusters: "apps-pg,gitea-db,net-kingdom-pg,state-hub-db" rpo_hours: "24" rto_hours: "4" retention: "14 daily + 4 weekly (Nextcloud operator prune)" note: "In-cluster CronJobs suspended until image with age is available without egress installs" --- apiVersion: v1 kind: ServiceAccount metadata: name: cnpg-logical-backup namespace: databases labels: app.kubernetes.io/part-of: railiance-apps railiance.apps/backup-lane: option-a --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: cnpg-logical-backup namespace: databases labels: app.kubernetes.io/part-of: railiance-apps railiance.apps/backup-lane: option-a rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list"] - apiGroups: [""] resources: ["pods/exec"] verbs: ["create"] - apiGroups: [""] resources: ["configmaps"] verbs: ["get", "create", "update", "patch"] - apiGroups: [""] resources: ["secrets"] resourceNames: ["cnpg-backup-offsite"] verbs: ["get"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: cnpg-logical-backup namespace: databases labels: app.kubernetes.io/part-of: railiance-apps railiance.apps/backup-lane: option-a roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: cnpg-logical-backup subjects: - kind: ServiceAccount name: cnpg-logical-backup namespace: databases --- apiVersion: v1 kind: ConfigMap metadata: name: cnpg-option-a-runner namespace: databases labels: app.kubernetes.io/part-of: railiance-apps railiance.apps/backup-lane: option-a data: run-backup.sh: | #!/bin/sh # In-cluster Option A runner. Env: CLUSTER, DATABASES (comma-separated). set -eu CLUSTER="${CLUSTER:?CLUSTER required}" DATABASES="${DATABASES:?DATABASES required}" NAMESPACE="${NAMESPACE:-databases}" STATUS_CM="${STATUS_CM:-cnpg-option-a-status}" WORK=/tmp/backup-work mkdir -p "$WORK" # Prefer distro package (cluster often cannot reach github.com). if ! command -v age >/dev/null 2>&1; then echo "install age via apk" apk add --no-cache age >/dev/null fi if ! command -v age >/dev/null 2>&1; then echo "ERROR: age not available (apk install failed; github fallback disabled)" >&2 exit 1 fi AGE_BIN="$(command -v age)" NC_TOKEN="$(cat /secrets/offsite/NC_WEBDAV_TOKEN)" NC_URL="$(cat /secrets/offsite/NC_WEBDAV_URL)" AGE_PUB="$(cat /secrets/offsite/AGE_PUBLIC_KEY)" # filesdrop shares often need token-only URL form case "$NC_URL" in */) ;; *) NC_URL="${NC_URL}/" ;; esac POD="$(kubectl get pods -n "$NAMESPACE" \ -l "cnpg.io/cluster=${CLUSTER},role=primary" \ -o jsonpath='{.items[0].metadata.name}')" if [ -z "$POD" ]; then echo "ERROR: primary pod not found for ${CLUSTER}" >&2 exit 1 fi echo "cluster=${CLUSTER} pod=${POD}" TS="$(date -u +%Y%m%dT%H%M%SZ)" OLD_IFS=$IFS IFS=, for db in $DATABASES; do IFS=$OLD_IFS plain="${WORK}/${CLUSTER}-${db}-${TS}.dump" aged="${plain}.age" echo "dump ${db}" kubectl exec -n "$NAMESPACE" "$POD" -c postgres -- \ pg_dump -U postgres -d "$db" -Fc --no-owner --no-acl >"$plain" "$AGE_BIN" -r "$AGE_PUB" -o "$aged" "$plain" rm -f "$plain" remote="${CLUSTER}/${CLUSTER}-${db}-${TS}.dump.age" dest="${NC_URL}${remote}" echo "upload ${remote}" curl -sf -u "${NC_TOKEN}:" -T "$aged" "$dest" # weekly on Sunday UTC if [ "$(date -u +%u)" = "7" ]; then curl -sf -u "${NC_TOKEN}:" -T "$aged" \ "${NC_URL}${CLUSTER}/${CLUSTER}-${db}-weekly-${TS}.dump.age" || true fi rm -f "$aged" done IFS=$OLD_IFS # Record last success for cnpg-backup-status if kubectl get configmap "$STATUS_CM" -n "$NAMESPACE" >/dev/null 2>&1; then kubectl patch configmap "$STATUS_CM" -n "$NAMESPACE" --type merge \ -p "{\"data\":{\"${CLUSTER}\":\"${TS}\",\"updated\":\"${TS}\",\"lane\":\"option-a\"}}" else kubectl create configmap "$STATUS_CM" -n "$NAMESPACE" \ --from-literal="${CLUSTER}=${TS}" \ --from-literal=updated="${TS}" \ --from-literal=lane=option-a fi kubectl label configmap "$STATUS_CM" -n "$NAMESPACE" \ railiance.apps/backup-lane=option-a --overwrite >/dev/null echo "ok: ${CLUSTER} backup ${TS}" --- # Shared pod template fields are repeated per CronJob for readability (no Kustomize). apiVersion: batch/v1 kind: CronJob metadata: name: cnpg-logical-backup-apps-pg namespace: databases labels: app.kubernetes.io/part-of: railiance-apps railiance.apps/backup-lane: option-a railiance.apps/backup-cluster: apps-pg spec: schedule: "30 2 * * *" suspend: true concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 3 jobTemplate: spec: backoffLimit: 1 activeDeadlineSeconds: 7200 template: metadata: labels: railiance.apps/backup-lane: option-a railiance.apps/backup-cluster: apps-pg spec: serviceAccountName: cnpg-logical-backup restartPolicy: Never containers: - name: backup image: alpine/k8s:1.31.12 imagePullPolicy: IfNotPresent env: - name: CLUSTER value: apps-pg - name: DATABASES value: apps_meta,core_hub,core_hub_staging,vergabe_db - name: NAMESPACE value: databases command: ["/bin/sh", "/runner/run-backup.sh"] volumeMounts: - name: runner mountPath: /runner readOnly: true - name: offsite mountPath: /secrets/offsite readOnly: true resources: requests: cpu: 100m memory: 256Mi limits: cpu: "1" memory: 1Gi volumes: - name: runner configMap: name: cnpg-option-a-runner defaultMode: 0755 - name: offsite secret: secretName: cnpg-backup-offsite --- apiVersion: batch/v1 kind: CronJob metadata: name: cnpg-logical-backup-gitea-db namespace: databases labels: app.kubernetes.io/part-of: railiance-apps railiance.apps/backup-lane: option-a railiance.apps/backup-cluster: gitea-db spec: schedule: "35 2 * * *" suspend: true concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 3 jobTemplate: spec: backoffLimit: 1 activeDeadlineSeconds: 7200 template: metadata: labels: railiance.apps/backup-lane: option-a railiance.apps/backup-cluster: gitea-db spec: serviceAccountName: cnpg-logical-backup restartPolicy: Never containers: - name: backup image: alpine/k8s:1.31.12 imagePullPolicy: IfNotPresent env: - name: CLUSTER value: gitea-db - name: DATABASES value: gitea - name: NAMESPACE value: databases command: ["/bin/sh", "/runner/run-backup.sh"] volumeMounts: - name: runner mountPath: /runner readOnly: true - name: offsite mountPath: /secrets/offsite readOnly: true resources: requests: cpu: 100m memory: 256Mi limits: cpu: "1" memory: 1Gi volumes: - name: runner configMap: name: cnpg-option-a-runner defaultMode: 0755 - name: offsite secret: secretName: cnpg-backup-offsite --- apiVersion: batch/v1 kind: CronJob metadata: name: cnpg-logical-backup-net-kingdom-pg namespace: databases labels: app.kubernetes.io/part-of: railiance-apps railiance.apps/backup-lane: option-a railiance.apps/backup-cluster: net-kingdom-pg spec: schedule: "40 2 * * *" suspend: true concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 3 jobTemplate: spec: backoffLimit: 1 activeDeadlineSeconds: 7200 template: metadata: labels: railiance.apps/backup-lane: option-a railiance.apps/backup-cluster: net-kingdom-pg spec: serviceAccountName: cnpg-logical-backup restartPolicy: Never containers: - name: backup image: alpine/k8s:1.31.12 imagePullPolicy: IfNotPresent env: - name: CLUSTER value: net-kingdom-pg - name: DATABASES value: interhub,privacyidea_db - name: NAMESPACE value: databases command: ["/bin/sh", "/runner/run-backup.sh"] volumeMounts: - name: runner mountPath: /runner readOnly: true - name: offsite mountPath: /secrets/offsite readOnly: true resources: requests: cpu: 100m memory: 256Mi limits: cpu: "1" memory: 1Gi volumes: - name: runner configMap: name: cnpg-option-a-runner defaultMode: 0755 - name: offsite secret: secretName: cnpg-backup-offsite --- apiVersion: batch/v1 kind: CronJob metadata: name: cnpg-logical-backup-state-hub-db namespace: databases labels: app.kubernetes.io/part-of: railiance-apps railiance.apps/backup-lane: option-a railiance.apps/backup-cluster: state-hub-db spec: schedule: "45 2 * * *" suspend: true concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 3 jobTemplate: spec: backoffLimit: 1 activeDeadlineSeconds: 7200 template: metadata: labels: railiance.apps/backup-lane: option-a railiance.apps/backup-cluster: state-hub-db spec: serviceAccountName: cnpg-logical-backup restartPolicy: Never containers: - name: backup image: alpine/k8s:1.31.12 imagePullPolicy: IfNotPresent env: - name: CLUSTER value: state-hub-db - name: DATABASES value: state_hub - name: NAMESPACE value: databases command: ["/bin/sh", "/runner/run-backup.sh"] volumeMounts: - name: runner mountPath: /runner readOnly: true - name: offsite mountPath: /secrets/offsite readOnly: true resources: requests: cpu: 100m memory: 256Mi limits: cpu: "1" memory: 1Gi volumes: - name: runner configMap: name: cnpg-option-a-runner defaultMode: 0755 - name: offsite secret: secretName: cnpg-backup-offsite