#!/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})"