#!/bin/bash # ============================================================================= # WOOO AIOps - AWOOOI 高頻備份(每 6 小時) # 2026-04-05 Claude Code: awoooi_prod 為核心生產 DB,每日一次不夠 # 部署位置: /backup/scripts/backup-awoooi-frequent.sh (on 192.168.0.110) # cron: 0 */6 * * * /backup/scripts/backup-awoooi-frequent.sh >> /backup/logs/cron.log 2>&1 # 說明: 每 6 小時備份 awoooi_prod,最多損失 6 小時資料 # 完整備份(含 Gitea/Harbor)仍由 backup-all.sh 每日 02:00 執行 # ============================================================================= set -euo pipefail source "$(dirname "$0")/common.sh" SERVICE="awoooi-frequent" AWOOOI_HOST="192.168.0.188" AWOOOI_DB_USER="awoooi" AWOOOI_DB_PASS="${AWOOOI_DB_PASS:-}" AWOOOI_DB_HOST="localhost" AWOOOI_DB_PORT="5432" LOCAL_REPO="${BACKUP_BASE}/awoooi" DUMP_DIR="/tmp/awoooi-freq-backup-$$" AWOOOI_K8S_HOST="${AWOOOI_K8S_HOST:-192.168.0.120}" AWOOOI_K8S_HOSTS="${AWOOOI_K8S_HOSTS:-${AWOOOI_K8S_HOST} 192.168.0.121 192.168.0.125}" AWOOOI_K8S_SECRET_NAME="${AWOOOI_K8S_SECRET_NAME:-awoooi-secrets}" AWOOOI_K8S_NAMESPACE="${AWOOOI_K8S_NAMESPACE:-awoooi-prod}" AWOOOI_K8S_DATABASE_URL_KEYS="${AWOOOI_K8S_DATABASE_URL_KEYS:-AWOOOI_BACKUP_DATABASE_URL BACKUP_DATABASE_URL DATABASE_URL}" FORCE_RLS_RESTORE_SQL="" FORCE_RLS_RESTORE_DB="" # 高頻備份保留策略 # 2026-05-19 ogt + Codex: 保留策略統一交給 common.sh。 # 預設 latest-only keep-last=1,避免高頻 DB snapshot 堆積。 resolve_database_url() { if [ -n "${AWOOOI_DATABASE_URL:-}" ]; then printf '%s\n' "${AWOOOI_DATABASE_URL}" return 0 fi if [ -n "${DATABASE_URL:-}" ]; then printf '%s\n' "${DATABASE_URL}" return 0 fi # 2026-07-01 ogt + Codex: 優先使用專用備份 DB URL;不存在時才退回 # runtime DATABASE_URL。只在遠端流程內解碼,不把 secret value 寫入 log。 local k8s_host key encoded decoded for k8s_host in ${AWOOOI_K8S_HOSTS}; do for key in ${AWOOOI_K8S_DATABASE_URL_KEYS}; do encoded="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 "wooo@${k8s_host}" \ "sudo -n kubectl get secret ${AWOOOI_K8S_SECRET_NAME} -n ${AWOOOI_K8S_NAMESPACE} -o jsonpath='{.data.${key}}' 2>/dev/null || kubectl get secret ${AWOOOI_K8S_SECRET_NAME} -n ${AWOOOI_K8S_NAMESPACE} -o jsonpath='{.data.${key}}'" \ 2>/dev/null || true)" decoded="$(printf '%s' "${encoded}" | base64 -d 2>/dev/null || true)" if [ -n "${decoded}" ]; then printf '%s\n' "${decoded}" return 0 fi done done return 1 } load_database_config() { local database_url database_url="$(resolve_database_url || true)" if [ -z "${database_url}" ]; then log_error "無法解析 AWOOOI DATABASE_URL;拒絕使用舊硬編密碼" return 1 fi eval "$( python3 - 3<<< "${database_url}" <<'PY' import shlex from urllib.parse import unquote, urlparse with open(3) as source: url = source.read().strip() parsed = urlparse(url) values = { "AWOOOI_DB_USER": unquote(parsed.username or "awoooi"), "AWOOOI_DB_PASS": unquote(parsed.password or ""), "AWOOOI_DB_HOST": parsed.hostname or "localhost", "AWOOOI_DB_PORT": str(parsed.port or 5432), } for key, value in values.items(): print(f"{key}={shlex.quote(value)}") PY )" } quote_remote() { printf "%q" "$1" } pgpass_escape() { local value="$1" value="${value//\\/\\\\}" value="${value//:/\\:}" printf '%s' "${value}" } pgpass_line() { local database="$1" printf '%s:%s:%s:%s:%s\n' \ "$(pgpass_escape "${AWOOOI_DB_HOST}")" \ "$(pgpass_escape "${AWOOOI_DB_PORT}")" \ "$(pgpass_escape "${database}")" \ "$(pgpass_escape "${AWOOOI_DB_USER}")" \ "$(pgpass_escape "${AWOOOI_DB_PASS}")" } remote_psql_command() { local database="$1" printf "psql --no-password -U %s -h %s -p %s -d %s -v ON_ERROR_STOP=1" \ "$(quote_remote "${AWOOOI_DB_USER}")" \ "$(quote_remote "${AWOOOI_DB_HOST}")" \ "$(quote_remote "${AWOOOI_DB_PORT}")" \ "$(quote_remote "${database}")" } remote_pgpass_wrapper() { local command="$1" printf 'umask 077; pgpass=$(mktemp "${TMPDIR:-/tmp}/awoooi-pgpass.XXXXXX") || exit 1; cleanup() { rm -f "$pgpass"; }; trap cleanup EXIT HUP INT TERM; cat > "$pgpass"; PGOPTIONS="-c statement_timeout=0 -c max_parallel_workers_per_gather=0" PGPASSFILE="$pgpass" %s' "${command}" } run_remote_pgpass_command() { local database="$1" local command="$2" pgpass_line "${database}" | ssh "ollama@${AWOOOI_HOST}" "$(remote_pgpass_wrapper "${command}")" } latest_restic_snapshot_id() { restic -r "${LOCAL_REPO}" snapshots --latest 1 --json \ --password-file "${RESTIC_PASSWORD_FILE}" 2>/dev/null | \ python3 -c 'import json,sys; rows=json.load(sys.stdin); row=max(rows,key=lambda r: r.get("time","")) if rows else {}; print(row.get("short_id","unknown"))' \ 2>/dev/null || echo "unknown" } collect_force_rls_sql() { local database="$1" local mode="$2" local query query=" select format('ALTER TABLE %I.%I ${mode} ROW LEVEL SECURITY;', n.nspname, c.relname) from pg_class c join pg_namespace n on n.oid = c.relnamespace where c.relkind in ('r', 'p') and c.relforcerowsecurity and pg_get_userbyid(c.relowner) = current_user order by 1; " run_remote_pgpass_command "${database}" "$(remote_psql_command "${database}") -At -c $(quote_remote "${query}")" } apply_remote_sql() { local database="$1" local sql="$2" [ -n "${sql}" ] || return 0 run_remote_pgpass_command "${database}" "$(remote_psql_command "${database}") -c $(quote_remote "${sql}") >/dev/null" } restore_force_rls() { if [ -n "${FORCE_RLS_RESTORE_DB}" ] && [ -n "${FORCE_RLS_RESTORE_SQL}" ]; then if apply_remote_sql "${FORCE_RLS_RESTORE_DB}" "${FORCE_RLS_RESTORE_SQL}"; then log_info "FORCE ROW LEVEL SECURITY 已恢復 (${FORCE_RLS_RESTORE_DB})" else log_error "FORCE ROW LEVEL SECURITY 恢復失敗 (${FORCE_RLS_RESTORE_DB})" return 1 fi FORCE_RLS_RESTORE_DB="" FORCE_RLS_RESTORE_SQL="" fi } trap restore_force_rls EXIT dump_database_with_rls_guard() { local database="$1" local output_file="$2" local stderr_file="${output_file}.stderr" local noforce_sql force_sql dump_rc noforce_sql="$(collect_force_rls_sql "${database}" "NO FORCE")" force_sql="$(printf '%s\n' "${noforce_sql}" | sed 's/NO FORCE/FORCE/')" if [ -n "${noforce_sql}" ]; then FORCE_RLS_RESTORE_DB="${database}" FORCE_RLS_RESTORE_SQL="${force_sql}" log_info "暫時解除 FORCE RLS 以完成完整 pg_dump (${database}, tables=$(printf '%s\n' "${noforce_sql}" | awk 'NF {count++} END {print count+0}'))" apply_remote_sql "${database}" "${noforce_sql}" fi set +e run_remote_pgpass_command "${database}" "pg_dump --no-password \ -U $(quote_remote "${AWOOOI_DB_USER}") -h $(quote_remote "${AWOOOI_DB_HOST}") -p $(quote_remote "${AWOOOI_DB_PORT}") \ $(quote_remote "${database}")" > "${output_file}" 2>"${stderr_file}" dump_rc=$? set -e restore_force_rls if [ "${dump_rc}" -ne 0 ]; then log_error "${database} dump 失敗,pg_dump stderr 尾端如下(已避免輸出 credential):" tail -40 "${stderr_file}" | sed -E 's/(password=)[^ ]+/\1REDACTED/g' || true return "${dump_rc}" fi rm -f "${stderr_file}" } main() { local start_time=$(date +%s) log_info "========== AWOOOI 高頻備份 ($(date '+%H:%M')) ==========" mkdir -p "${DUMP_DIR}" load_database_config || { notify_clawbot "failed" "${SERVICE}" "AWOOOI 高頻備份失敗:DATABASE_URL 不可用" rm -rf "${DUMP_DIR}" exit 1 } local timestamp=$(date "+%Y%m%d_%H%M%S") # 只備份 awoooi_prod(高頻核心) if dump_database_with_rls_guard "awoooi_prod" "${DUMP_DIR}/awoooi_prod_${timestamp}.sql"; then local size=$(du -h "${DUMP_DIR}/awoooi_prod_${timestamp}.sql" | cut -f1) log_success "awoooi_prod dump 完成 (${size})" else local status=$? log_error "awoooi_prod dump 失敗" notify_clawbot "failed" "${SERVICE}" "AWOOOI 高頻備份失敗" rm -rf "${DUMP_DIR}" exit "${status}" fi # Restic 備份(同一倉庫,頻率不同) restic -r "${LOCAL_REPO}" backup "${DUMP_DIR}" \ --password-file "${RESTIC_PASSWORD_FILE}" \ --tag "service:awoooi" --tag "freq:6h" \ --tag "timestamp:${timestamp}" 2>&1 local snapshot_id snapshot_id="$(latest_restic_snapshot_id)" log_success "快照: ${snapshot_id}" cleanup_old_backups "${LOCAL_REPO}" rm -rf "${DUMP_DIR}" local end_time=$(date +%s) log_success "========== AWOOOI 高頻備份完成 ($((end_time-start_time))s) ==========" } main "$@"