# ============================================================================= # AI Software Factory — PostgreSQL daily base backup (CronJob, 05:00 UTC) # # Part of the TASK-0135 backup/DR deliverable. Pairs with: # - postgres/statefulset.yaml (the PITR StatefulSet; WAL archiving runs # continuously via Postgres archive_command — see postgres/configmap.yaml) # - postgres/walg-iam-policy.yaml (least-priv S3 identity) # # What this Job does each run (0 5 * * * UTC): # 1. Stage the pinned wal-g v3.0.7 binary into an emptyDir (so the command # is stable across image bumps — the binary is NOT baked into the # postgres:16-alpine image). # 2. Wait for the live DB (pg_isready). # 3. `wal-g backup-push` in ONLINE STREAMING mode (PGHOST=factory-db, no # local data dir — verified against wal-g v3 PostgreSQL.md: "To stream # the backup data, leave out the data directory … use PGHOST or # --pghost"). This is a consistent physical base backup taken over # streaming replication; it needs no pg_basebackup and no maintenance # window. # 4. `wal-g wal-verify integrity timeline` — the "is the archive # actually restorable" sanity the runbook's drill relies on. VERIFIED # against cmd/pg/wal_verify.go: at least one check is REQUIRED # (integrity = segment hash check, timeline = continuity check); a # bare `wal-verify` exits with an argument error. # 5. Row-count sanity: a "successful" base backup of an empty DB must not # pass silently (pg_class count > 0). # # No marker object is written (TASK-0135 design): scripts/backup-health.ts # derives "last base backup < 26 h" from the newest object LastModified # under pg/basebackups_005/ — the base backup directory wal-g writes # (finishing with its *_backup_stop_sentinel.json) IS the marker. A # synthetic marker could diverge from the artifact and be a false signal # (or false alarm) either way. # # Retention: this Job does NOT delete old backups. `wal-g delete retain # FULL 7` (7 daily fulls, or `--keep-forever` on a marked one) is a # MANUAL / monthly operation in the runbook (§8.6) so an accidental # automation cannot destroy the PITR chain. # # wal-g v3 env (verified, see statefulset.yaml header): WALG_S3_PREFIX # (s3:///pg) + AWS_ENDPOINT/AWS_REGION/AWS_S3_FORCE_PATH_STYLE + # AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY. This Job sets them from the SAME # app config/secret sources the StatefulSet uses, so the base backup and the # WAL archive land in the one storage root PITR restore requires. # ============================================================================= apiVersion: batch/v1 kind: CronJob metadata: name: pg-backup labels: app.kubernetes.io/name: postgres app.kubernetes.io/component: backup app.kubernetes.io/part-of: ai-software-factory spec: schedule: "0 5 * * *" # 05:00 UTC daily (the release-pipeline # backup-health gate expects < 26 h since # the last success — one failed day still # leaves ~24 h of margin). timeZone: "UTC" concurrencyPolicy: Forbid # never overlap a long backup with the next successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 5 jobTemplate: spec: backoffLimit: 2 activeDeadlineSeconds: 3600 # 1 h budget; a base backup should be far # faster (RTO budget is 1 h total) template: metadata: labels: app.kubernetes.io/name: postgres-backup app.kubernetes.io/component: backup app.kubernetes.io/part-of: ai-software-factory spec: restartPolicy: Never initContainers: # Stage the pinned wal-g binary (v3.0.7) into the shared emptyDir. # Pinned image (verified 2026-10): Ubuntu 24.04 base, wal-g # v3.0.7 PostgreSQL build, binary at /usr/local/bin/wal-g. - name: walg-stager image: chekkan/wal-g:v3.0.7 command: - sh - -c - | set -e mkdir -p /opt/walg cp /usr/local/bin/wal-g /opt/walg/wal-g chmod 0755 /opt/walg/wal-g /opt/walg/wal-g --version volumeMounts: - name: walg-tools mountPath: /opt/walg containers: - name: backup # postgres:16-alpine so pg_isready/psql exist for the readiness # wait + post-backup row-count sanity; the wal-g binary is # staged from the pinned image by the initContainer above. image: postgres:16-alpine command: - sh - -c - | set -eu # ---- env: spec names -> wal-g v3 names (verified) ------- export WALG_S3_PREFIX="s3://${WAL_S3_BUCKET}/${WAL_S3_PREFIX:-pg}" # prefix env, default "pg" (same mapping as the archive_command wrapper) export AWS_ENDPOINT="${S3_ENDPOINT}" export AWS_REGION="${S3_REGION:-us-east-1}" export AWS_S3_FORCE_PATH_STYLE="true" # libpq target for ONLINE streaming backup-push (no data dir): export PGHOST="${DATABASE_HOST:-factory-db}" export PGPORT="${DATABASE_PORT:-5432}" # 1) wait for the live DB (pg_isready, up to ~10 min) echo "[pg-backup] waiting for postgres at ${PGHOST}:${PGPORT} …" i=0 until pg_isready -q -h "$PGHOST" -p "$PGPORT" -U "$PGUSER"; do i=$((i+1)); [ "$i" -ge 120 ] && { echo "DB not ready after 600s"; exit 1; } sleep 5 done # 2) online base backup (streaming; consistent, no window) echo "[pg-backup] wal-g backup-push (online streaming) …" /opt/walg/wal-g backup-push # 3) verify the WAL archive (integrity + timeline; VERIFIED: # at least one check name is required — a bare wal-verify # exits with an argument error). The timeline check # connects to the live DB (QueryCurrentWalSegment) to # find the current segment — PGHOST/PGUSER are set above. echo "[pg-backup] wal-g wal-verify integrity timeline …" /opt/walg/wal-g wal-verify integrity timeline # 4) row-count sanity (cheap; a base backup that "succeeded" # but produced an empty DB must not pass silently) cnt="$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -tAc 'select count(*) from pg_catalog.pg_class')" echo "[pg-backup] pg_class rows in backup: ${cnt}" [ "${cnt:-0}" -gt 0 ] || { echo "backup appears EMPTY (pg_class=0)"; exit 1; } # 5) done. The base-backup directory in pg/basebackups_005/ # (finished by its *_backup_stop_sentinel.json) is the # artifact the backup-health gate (< 26 h) reads — no # synthetic marker (TASK-0135 design note, header). echo "[pg-backup] OK — base backup + wal-verify + row-count sanity" env: # --- wal-g / S3 (same sources as the StatefulSet) --- - name: WAL_S3_BUCKET value: "factory-backups" - name: WAL_S3_PREFIX value: "pg" - name: S3_ENDPOINT valueFrom: configMapKeyRef: { name: factory-config, key: S3_ENDPOINT } - name: S3_REGION valueFrom: configMapKeyRef: { name: factory-config, key: S3_REGION } - name: AWS_ACCESS_KEY_ID valueFrom: { secretKeyRef: { name: factory-secrets, key: S3_ACCESS_KEY } } - name: AWS_SECRET_ACCESS_KEY valueFrom: { secretKeyRef: { name: factory-secrets, key: S3_SECRET_KEY } } # --- libpq (online backup-push connects as the factory user) --- - name: DATABASE_HOST valueFrom: { configMapKeyRef: { name: factory-config, key: DATABASE_HOST } } - name: DATABASE_PORT valueFrom: { configMapKeyRef: { name: factory-config, key: DATABASE_PORT } } - name: PGUSER valueFrom: { secretKeyRef: { name: factory-db, key: POSTGRES_USER } } - name: PGPASSWORD valueFrom: { secretKeyRef: { name: factory-db, key: POSTGRES_PASSWORD } } - name: PGDATABASE valueFrom: { secretKeyRef: { name: factory-db, key: POSTGRES_DB } } volumeMounts: - name: walg-tools mountPath: /opt/walg volumes: - name: walg-tools emptyDir: {}