railiance-apps/tools/cnpg-logical-backup.sh

159 lines
4.7 KiB
Bash
Raw Normal View History

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