RAILIANCE-WP-0015: Option A CNPG logical backup coverage healthy
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:
parent
374ebed349
commit
6635fdc976
11 changed files with 929 additions and 69 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue