RAILIANCE-WP-0015: Option A CNPG logical backup coverage healthy
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Has been cancelled

Materialize offsite Secret from OpenBao, deploy per-cluster CronJobs,
generalize multi-cluster logical backup + status health for Option A,
seed encrypted uploads and restore-drill evidence; workplan finished.
This commit is contained in:
tegwick 2026-07-22 18:00:48 +02:00
parent 374ebed349
commit 6635fdc976
11 changed files with 929 additions and 69 deletions

View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Materialize databases/cnpg-backup-offsite from OpenBao (RAILIANCE-WP-0015-T02).
# Never prints secret values. Does not place AGE_PRIVATE_KEY in-cluster.
set -euo pipefail
NAMESPACE="${CNPG_NAMESPACE:-databases}"
SECRET_NAME="${CNPG_BACKUP_OFFSITE_SECRET:-cnpg-backup-offsite}"
BAO_PATH="${RAILIANCE_BACKUP_BAO_PATH:-platform/workloads/railiance/backup/offsite-lane}"
AGE_PUBLIC_KEY="${RAILIANCE_BACKUP_AGE_PUBLIC_KEY:-age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz}"
DRY_RUN="${DRY_RUN:-0}"
if ! command -v bao >/dev/null 2>&1; then
echo "ERROR: bao CLI required" >&2
exit 1
fi
if ! command -v kubectl >/dev/null 2>&1; then
echo "ERROR: kubectl required" >&2
exit 1
fi
if ! bao token lookup >/dev/null 2>&1; then
echo "ERROR: no valid OpenBao token" >&2
echo "Run: bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read" >&2
exit 1
fi
if ! bao kv metadata get "$BAO_PATH" >/dev/null 2>&1; then
echo "ERROR: cannot read metadata for ${BAO_PATH}" >&2
exit 1
fi
# Fetch into env without echoing
NC_WEBDAV_TOKEN="$(bao kv get -field=NC_WEBDAV_TOKEN "$BAO_PATH")"
NC_WEBDAV_URL="$(bao kv get -field=NC_WEBDAV_URL "$BAO_PATH")"
if [[ -z "${NC_WEBDAV_TOKEN}" || -z "${NC_WEBDAV_URL}" ]]; then
echo "ERROR: NC_WEBDAV_TOKEN or NC_WEBDAV_URL empty at ${BAO_PATH}" >&2
exit 1
fi
echo "cnpg-backup-offsite secret apply"
echo " namespace: ${NAMESPACE}"
echo " secret: ${SECRET_NAME}"
echo " source: ${BAO_PATH}"
echo " keys: NC_WEBDAV_TOKEN, NC_WEBDAV_URL, AGE_PUBLIC_KEY"
echo " dry-run: ${DRY_RUN}"
if [[ "$DRY_RUN" == "1" ]]; then
echo "ok: dry-run — would create/update Secret ${NAMESPACE}/${SECRET_NAME}"
exit 0
fi
kubectl create secret generic "$SECRET_NAME" \
-n "$NAMESPACE" \
--from-literal=NC_WEBDAV_TOKEN="$NC_WEBDAV_TOKEN" \
--from-literal=NC_WEBDAV_URL="$NC_WEBDAV_URL" \
--from-literal=AGE_PUBLIC_KEY="$AGE_PUBLIC_KEY" \
--dry-run=client -o yaml \
| kubectl apply -f -
# Clear locals
unset NC_WEBDAV_TOKEN NC_WEBDAV_URL
# Non-secret verification
keys="$(kubectl get secret "$SECRET_NAME" -n "$NAMESPACE" -o jsonpath='{.data}' \
| python3 -c 'import sys,json; print(",".join(sorted(json.load(sys.stdin).keys())))')"
echo "ok: Secret ${NAMESPACE}/${SECRET_NAME} applied (keys: ${keys})"

View file

@ -1,7 +1,12 @@
#!/usr/bin/env bash
# CNPG backup posture for Option A (logical CronJobs) and legacy barman ScheduledBackup.
set -euo pipefail
namespace="${CNPG_NAMESPACE:-databases}"
# RPO target is 24h; allow 36h before last-success is considered stale.
rpo_hours="${CNPG_BACKUP_RPO_HOURS:-36}"
status_cm="${CNPG_OPTION_A_STATUS_CM:-cnpg-option-a-status}"
expected_clusters="${CNPG_EXPECTED_CLUSTERS:-apps-pg,gitea-db,net-kingdom-pg,state-hub-db}"
if ! kubectl get ns "$namespace" >/dev/null 2>&1; then
echo "ERROR: namespace $namespace is not reachable from the current kubeconfig" >&2
@ -13,6 +18,8 @@ context="$(kubectl config current-context 2>/dev/null || echo unknown)"
server="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}' 2>/dev/null || echo unknown)"
echo "kube-context: ${context}"
echo "api-server: ${server}"
echo "lane: Option A (logical dump → age → Nextcloud) preferred; barman ScheduledBackup also accepted"
echo "RPO window: ${rpo_hours}h (last-success freshness)"
echo
mapfile -t clusters < <(
@ -24,8 +31,52 @@ if ((${#clusters[@]} == 0)); then
exit 1
fi
# Load status ConfigMap timestamps (cluster -> ISO-ish stamp YYYYMMDDTHHMMSSZ)
declare -A last_success=()
if kubectl get configmap "$status_cm" -n "$namespace" >/dev/null 2>&1; then
while IFS= read -r line; do
[[ -z "$line" ]] && continue
key="${line%%=*}"
val="${line#*=}"
case "$key" in
lane|updated) continue ;;
*) last_success["$key"]="$val" ;;
esac
done < <(
kubectl get configmap "$status_cm" -n "$namespace" -o json 2>/dev/null \
| python3 -c 'import sys,json
d=json.load(sys.stdin).get("data") or {}
for k,v in sorted(d.items()):
print(f"{k}={v}")' || true
)
echo "status ConfigMap: ${status_cm} (present)"
else
echo "status ConfigMap: ${status_cm} (missing)"
fi
echo
fresh_enough() {
local ts="$1"
[[ -z "$ts" ]] && return 1
# Accept YYYYMMDDTHHMMSSZ
if [[ ! "$ts" =~ ^[0-9]{8}T[0-9]{6}Z$ ]]; then
return 1
fi
local epoch now delta max_delta
epoch="$(date -u -d "${ts:0:4}-${ts:4:2}-${ts:6:2} ${ts:9:2}:${ts:11:2}:${ts:13:2}" +%s 2>/dev/null || true)"
if [[ -z "$epoch" ]]; then
return 1
fi
now="$(date -u +%s)"
delta=$((now - epoch))
max_delta=$((rpo_hours * 3600))
((delta >= 0 && delta <= max_delta))
}
missing=0
logical_backup_note=0
degraded_detail=()
IFS=',' read -r -a expected <<<"$expected_clusters"
for cluster in "${clusters[@]}"; do
if ! kubectl get cluster "$cluster" -n "$namespace" >/dev/null 2>&1; then
@ -38,45 +89,104 @@ for cluster in "${clusters[@]}"; do
backup_spec="$(kubectl get cluster "$cluster" -n "$namespace" \
-o jsonpath='{.spec.backup}' 2>/dev/null || true)"
if [[ -z "$backup_spec" || "$backup_spec" == "null" ]]; then
echo "${cluster}: backup spec = null"
echo "${cluster}: backup spec = null (expected for Option A)"
else
echo "${cluster}: backup spec configured"
echo "${cluster}: backup spec configured (barman path)"
fi
scheduled="$(kubectl get scheduledbackup -n "$namespace" \
-o jsonpath="{range .items[?(@.spec.cluster.name=='${cluster}')]}{.metadata.name}{' '}{end}" 2>/dev/null || true)"
scheduled="${scheduled%" "}"
if [[ -z "$scheduled" ]]; then
echo "${cluster}: ScheduledBackup = none"
missing=$((missing + 1))
if [[ "$cluster" == "apps-pg" || "$cluster" == "gitea-db" || "$cluster" == "forgejo-db" ]]; then
echo "${cluster}: Phase 1 fallback = logical pg_dump (see make apps-pg-backup-dry-run or platform forgejo-backup)"
logical_backup_note=1
option_a_cron="$(kubectl get cronjob -n "$namespace" \
-l "railiance.apps/backup-lane=option-a,railiance.apps/backup-cluster=${cluster}" \
-o jsonpath='{range .items[*]}{.metadata.name}{" "}{.spec.suspend}{" "}{end}' 2>/dev/null || true)"
covered=0
schedule_note=""
if [[ -n "$scheduled" ]]; then
covered=1
schedule_note="ScheduledBackup=${scheduled}"
fi
if [[ -n "$option_a_cron" ]]; then
# format: name suspend name suspend ...
read -r cj_name cj_suspend _ <<<"$option_a_cron"
if [[ "${cj_suspend}" == "true" ]]; then
echo "${cluster}: Option A CronJob = ${cj_name} (SUSPENDED)"
schedule_note="${schedule_note:+$schedule_note; }OptionA=${cj_name}:suspended"
else
covered=1
schedule_note="${schedule_note:+$schedule_note; }OptionA=${cj_name}"
echo "${cluster}: Option A CronJob = ${cj_name}"
fi
else
echo "${cluster}: Option A CronJob = none"
fi
if [[ -n "$scheduled" ]]; then
echo "${cluster}: ScheduledBackup = ${scheduled}"
fi
if command -v kubectl >/dev/null 2>&1 && kubectl cnpg status "$cluster" -n "$namespace" >/dev/null 2>&1; then
echo "${cluster}: kubectl cnpg status available"
kubectl cnpg status "$cluster" -n "$namespace" | sed -n '1,8p'
else
phase="$(kubectl get cluster "$cluster" -n "$namespace" -o jsonpath='{.status.phase}' 2>/dev/null || true)"
instances="$(kubectl get cluster "$cluster" -n "$namespace" -o jsonpath='{.status.instances}' 2>/dev/null || true)"
echo "${cluster}: phase=${phase:-unknown} instances=${instances:-unknown}"
echo "${cluster}: ScheduledBackup = none"
fi
ts="${last_success[$cluster]:-}"
if [[ -n "$ts" ]]; then
if fresh_enough "$ts"; then
echo "${cluster}: last-success = ${ts} (fresh ≤${rpo_hours}h)"
else
echo "${cluster}: last-success = ${ts} (STALE >${rpo_hours}h)"
if ((covered)); then
degraded_detail+=("${cluster}:stale-success")
# scheduled but stale still counts as coverage with warning; mark degraded
covered=0
fi
fi
else
echo "${cluster}: last-success = none"
# Schedule present but never succeeded: still incomplete for healthy
if ((covered)); then
echo "${cluster}: note = schedule present but no recorded success yet"
covered=0
degraded_detail+=("${cluster}:no-success-yet")
fi
fi
phase="$(kubectl get cluster "$cluster" -n "$namespace" -o jsonpath='{.status.phase}' 2>/dev/null || true)"
instances="$(kubectl get cluster "$cluster" -n "$namespace" -o jsonpath='{.status.instances}' 2>/dev/null || true)"
echo "${cluster}: phase=${phase:-unknown} instances=${instances:-unknown}"
if ((covered == 0)); then
missing=$((missing + 1))
echo "${cluster}: coverage = MISSING"
else
echo "${cluster}: coverage = ok (${schedule_note})"
fi
echo
done
if (( missing > 0 )); then
echo "RESULT: degraded — ${missing} cluster(s) lack ScheduledBackup coverage"
echo "See docs/app-data-backup-restore-handoff.md and manifests/cnpg-backup-readiness.yaml"
if (( logical_backup_note )); then
echo "Phase 1: logical pg_dump lane may still run when OpenBao offsite-lane is reachable"
echo "See docs/credential-routing-railiance-apps.md"
# Warn if expected clusters are absent from the cluster list
for exp in "${expected[@]}"; do
found=0
for c in "${clusters[@]}"; do
[[ "$c" == "$exp" ]] && found=1 && break
done
if ((found == 0)); then
echo "WARN: expected cluster ${exp} not found in ${namespace}"
missing=$((missing + 1))
fi
done
if ((missing > 0)); then
echo "RESULT: degraded — ${missing} cluster(s) lack healthy Option A / ScheduledBackup coverage"
if ((${#degraded_detail[@]} > 0)); then
echo "detail: ${degraded_detail[*]}"
fi
echo "See docs/app-data-backup-restore-handoff.md and manifests/cnpg-option-a-backup.yaml"
echo "Operator: make cnpg-backup-offsite-secret-apply && make cnpg-option-a-apply"
echo "Manual run: make cnpg-logical-backup (or DRY_RUN=1 make cnpg-logical-backup-dry-run)"
exit 1
fi
echo "RESULT: ok — all tracked clusters have ScheduledBackup resources"
echo "RESULT: ok — all tracked clusters have healthy Option A (or barman) backup coverage"

158
tools/cnpg-logical-backup.sh Executable file
View file

@ -0,0 +1,158 @@
#!/usr/bin/env bash
# Option A logical backup for CoulombCore CNPG clusters (RAILIANCE-WP-0015-T03).
# pg_dump -Fc → age → Nextcloud WebDAV; records last-success ConfigMap.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PLATFORM_REPO="${PLATFORM_REPO:-$HOME/railiance-platform}"
COMMON_SH="${PLATFORM_REPO}/lib/railiance-backup-common.sh"
NAMESPACE="${CNPG_NAMESPACE:-databases}"
BACKUP_ROOT="${BACKUP_DIR:-$HOME/.cache/railiance/backups/cnpg}"
DRY_RUN="${RAILIANCE_BACKUP_DRY_RUN:-1}"
STATUS_CM="${CNPG_OPTION_A_STATUS_CM:-cnpg-option-a-status}"
CLUSTERS_FILTER="${CNPG_BACKUP_CLUSTERS:-}"
UPDATE_STATUS="${CNPG_UPDATE_STATUS_CM:-1}"
# cluster|db1,db2,... (skip template/postgres system DB)
DEFAULT_TARGETS=(
"apps-pg|apps_meta,core_hub,core_hub_staging,vergabe_db"
"gitea-db|gitea"
"net-kingdom-pg|interhub,privacyidea_db"
"state-hub-db|state_hub"
)
if [[ ! -f "$COMMON_SH" ]]; then
echo "ERROR: missing ${COMMON_SH}" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$COMMON_SH"
railiance_backup_require_tools
if [[ "$DRY_RUN" != "1" ]]; then
railiance_backup_require_openbao_lane
fi
primary_pod() {
local cluster="$1"
kubectl get pods -n "$NAMESPACE" \
-l "cnpg.io/cluster=${cluster},role=primary" \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true
}
dump_db() {
local cluster="$1" pod="$2" db="$3" out="$4"
kubectl exec -n "$NAMESPACE" "$pod" -c postgres -- \
pg_dump -U postgres -d "$db" -Fc --no-owner --no-acl >"$out"
}
record_status_cm() {
local cluster="$1" ts="$2"
[[ "$UPDATE_STATUS" == "1" ]] || return 0
if ! kubectl get ns "$NAMESPACE" >/dev/null 2>&1; then
echo "WARN: skip status ConfigMap — namespace unreachable" >&2
return 0
fi
if ! kubectl get configmap "$STATUS_CM" -n "$NAMESPACE" >/dev/null 2>&1; then
kubectl create configmap "$STATUS_CM" -n "$NAMESPACE" \
--from-literal="${cluster}=${ts}" \
--from-literal=lane=option-a \
--from-literal=updated="${ts}" \
>/dev/null
else
kubectl patch configmap "$STATUS_CM" -n "$NAMESPACE" --type merge \
-p "{\"data\":{\"${cluster}\":\"${ts}\",\"updated\":\"${ts}\",\"lane\":\"option-a\"}}" \
>/dev/null
fi
# label for discovery by cnpg-backup-status
kubectl label configmap "$STATUS_CM" -n "$NAMESPACE" \
railiance.apps/backup-lane=option-a --overwrite >/dev/null
}
should_run_cluster() {
local cluster="$1"
if [[ -z "$CLUSTERS_FILTER" ]]; then
return 0
fi
[[ ",${CLUSTERS_FILTER}," == *",${cluster},"* ]]
}
TS="$(date -u +%Y%m%dT%H%M%SZ)"
echo "cnpg logical backup (Option A)"
echo " namespace: ${NAMESPACE}"
echo " dry-run: ${DRY_RUN}"
echo " ts: ${TS}"
echo
failed=0
succeeded_clusters=()
for entry in "${DEFAULT_TARGETS[@]}"; do
cluster="${entry%%|*}"
dbs_csv="${entry#*|}"
should_run_cluster "$cluster" || continue
pod="$(primary_pod "$cluster")"
if [[ -z "$pod" ]]; then
echo "ERROR: ${cluster}: primary pod not found" >&2
failed=$((failed + 1))
continue
fi
cluster_dir="${BACKUP_ROOT}/${cluster}"
mkdir -p "$cluster_dir"
cluster_ok=1
echo "${cluster}: primary=${pod}"
IFS=',' read -r -a dbs <<<"$dbs_csv"
for db in "${dbs[@]}"; do
plain="${cluster_dir}/${cluster}-${db}-${TS}.dump"
aged="${plain}.age"
echo " dump ${db}$(basename "$plain")"
if ! dump_db "$cluster" "$pod" "$db" "$plain"; then
echo " ERROR: pg_dump failed for ${cluster}/${db}" >&2
cluster_ok=0
failed=$((failed + 1))
continue
fi
railiance_backup_encrypt "$plain" "$aged"
echo " ok: age $(basename "$aged") ($(du -h "$aged" | awk '{print $1}'))"
# drop plaintext after encrypt
rm -f "$plain"
if [[ "$DRY_RUN" == "1" ]]; then
echo " dry-run: skip upload"
else
RAILIANCE_BACKUP_NC_PREFIX="${cluster}"
railiance_backup_nc_upload "$aged" "${cluster}-${db}-${TS}.dump.age"
# weekly copy on Sundays (UTC)
if [[ "$(date -u +%u)" == "7" ]]; then
railiance_backup_nc_upload "$aged" "${cluster}-${db}-weekly-${TS}.dump.age" || true
fi
echo " ok: uploaded ${cluster}/${cluster}-${db}-${TS}.dump.age"
fi
railiance_backup_prune_local "$cluster_dir" "${cluster}-${db}-*.dump.age" 7
done
if ((cluster_ok)); then
railiance_backup_record_success "$cluster_dir" "$TS"
record_status_cm "$cluster" "$TS"
succeeded_clusters+=("$cluster")
echo " ok: ${cluster} complete"
fi
echo
done
if ((failed > 0)); then
echo "RESULT: failed — ${failed} dump error(s); succeeded: ${succeeded_clusters[*]:-none}"
exit 1
fi
if ((${#succeeded_clusters[@]} == 0)); then
echo "RESULT: failed — no clusters selected/ran"
exit 1
fi
echo "RESULT: ok — clusters: ${succeeded_clusters[*]} (dry-run=${DRY_RUN})"