#!/usr/bin/env bash # Verify a Gitea full-server backup dump with a non-production, metadata-only # structural drill. This never restores into Gitea, never contacts production, # never reads tokens, and never prints archive member names or file contents. set -euo pipefail DUMP_ZIP="${AIOPS_GITEA_FULL_BACKUP_DRILL_DUMP_ZIP:-}" HOST_LABEL="${AIOPS_HOST_LABEL:-110}" TEXTFILE_PATH="${AIOPS_GITEA_FULL_BACKUP_RESTORE_DRILL_TEXTFILE:-/home/wooo/node_exporter_textfiles/gitea_full_backup_restore_drill.prom}" WRITE_TEXTFILE=0 TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-60}" DRILL_SCOPE="structural_metadata_only" REQUIRED_COMPONENTS="database repositories app_settings lfs_objects package_registry attachments hooks_custom_assets" usage() { cat <<'USAGE' Usage: scripts/backup/gitea-full-backup-restore-drill.sh [options] Options: --dump-zip PATH Gitea dump zip to verify structurally. --write-textfile Write node-exporter textfile metric after verification. --textfile PATH Textfile metric path. --host LABEL Host label for metrics. Default: 110. -h, --help Show this help. This verifier inspects only the Gitea dump zip central directory and component coverage. It never restores to production, never contacts Gitea, never reads credentials, never prints archive member names, and never extracts contents. USAGE } while [ "$#" -gt 0 ]; do case "$1" in --dump-zip) shift DUMP_ZIP="${1:?--dump-zip requires PATH}" ;; --write-textfile) WRITE_TEXTFILE=1 ;; --textfile) shift TEXTFILE_PATH="${1:?--textfile requires PATH}" ;; --host) shift HOST_LABEL="${1:?--host requires LABEL}" ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 64 ;; esac shift done if [ -z "$DUMP_ZIP" ]; then echo "ERROR=dump_zip_required" >&2 usage >&2 exit 64 fi ok=0 entry_count=0 missing_components="" error_reason="" if [ ! -f "$DUMP_ZIP" ]; then error_reason="dump_zip_not_found" else set +e drill_result="$( timeout "$TIMEOUT_SECONDS" python3 - "$DUMP_ZIP" $REQUIRED_COMPONENTS <<'PY' from __future__ import annotations import sys import zipfile from pathlib import Path dump_zip = Path(sys.argv[1]) required = sys.argv[2:] try: with zipfile.ZipFile(dump_zip) as archive: names = archive.namelist() except zipfile.BadZipFile: print("ERROR=bad_zip") sys.exit(1) except OSError: print("ERROR=zip_open_failed") sys.exit(1) lowered = [name.lower() for name in names] patterns = { "database": ("gitea-db", "database", ".sql"), "repositories": ("repo", "repos", "repositories", ".git"), "app_settings": ("app.ini", "app.example.ini", "conf/"), "lfs_objects": ("lfs",), "package_registry": ("package", "packages"), "attachments": ("attachment", "attachments"), "hooks_custom_assets": ("hook", "custom", "public/"), } missing = [] for component in required: needles = patterns.get(component, (component,)) if not any(any(needle in name for needle in needles) for name in lowered): missing.append(component) print(f"ENTRY_COUNT={len(names)}") print(f"MISSING_COMPONENTS={','.join(missing)}") print("OK=1" if not missing and names else "OK=0") PY )" drill_status=$? set -e entry_count="$(printf '%s\n' "$drill_result" | awk -F= '$1 == "ENTRY_COUNT" {print $2; exit}')" missing_components="$(printf '%s\n' "$drill_result" | awk -F= '$1 == "MISSING_COMPONENTS" {print $2; exit}')" if [ "$drill_status" -ne 0 ]; then error_reason="$(printf '%s\n' "$drill_result" | awk -F= '$1 == "ERROR" {print $2; exit}')" [ -n "$error_reason" ] || error_reason="drill_inspection_failed" elif printf '%s\n' "$drill_result" | grep -q '^OK=1$'; then ok=1 else error_reason="component_coverage_gap" fi fi timestamp="$(date +%s)" dump_label="$(basename "${DUMP_ZIP:-missing}")" host_label="${HOST_LABEL//\\/\\\\}" host_label="${host_label//\"/\\\"}" dump_label="${dump_label//\\/\\\\}" dump_label="${dump_label//\"/\\\"}" error_label="${error_reason//\\/\\\\}" error_label="${error_label//\"/\\\"}" component_present() { local component="$1" if [ "$ok" -eq 1 ]; then printf '1' elif printf ',%s,' "$missing_components" | grep -q ",${component},"; then printf '0' else printf '0' fi } if [ "$WRITE_TEXTFILE" -eq 1 ]; then mkdir -p "$(dirname "$TEXTFILE_PATH")" tmp_textfile="${TEXTFILE_PATH}.$$" { echo '# HELP awoooi_gitea_full_backup_restore_drill_performed Whether a non-production Gitea full-backup structural restore drill succeeded.' echo '# TYPE awoooi_gitea_full_backup_restore_drill_performed gauge' printf 'awoooi_gitea_full_backup_restore_drill_performed{host="%s",service="gitea",drill_scope="%s",production_restore="false",dump="%s",error="%s"} %s\n' "$host_label" "$DRILL_SCOPE" "$dump_label" "$error_label" "$ok" printf 'awoooi_gitea_full_backup_restore_drill_timestamp{host="%s",service="gitea",drill_scope="%s"} %s\n' "$host_label" "$DRILL_SCOPE" "$timestamp" printf 'awoooi_gitea_full_backup_restore_drill_entry_count{host="%s",service="gitea",drill_scope="%s"} %s\n' "$host_label" "$DRILL_SCOPE" "${entry_count:-0}" for component in $REQUIRED_COMPONENTS; do printf 'awoooi_gitea_full_backup_restore_drill_component_receipt{host="%s",service="gitea",component="%s",drill_scope="%s"} %s\n' "$host_label" "$component" "$DRILL_SCOPE" "$(component_present "$component")" done printf 'awoooi_gitea_full_backup_secret_value_collected{host="%s",service="gitea",drill_scope="%s"} 0\n' "$host_label" "$DRILL_SCOPE" printf 'awoooi_gitea_full_backup_production_restore_performed{host="%s",service="gitea",drill_scope="%s"} 0\n' "$host_label" "$DRILL_SCOPE" } > "$tmp_textfile" mv "$tmp_textfile" "$TEXTFILE_PATH" fi printf 'GITEA_FULL_BACKUP_RESTORE_DRILL_OK=%s\n' "$ok" printf 'ENTRY_COUNT=%s\n' "${entry_count:-0}" printf 'TEXTFILE_WRITTEN=%s\n' "$WRITE_TEXTFILE" printf 'SECRET_VALUES_COLLECTED=0\n' printf 'PRODUCTION_RESTORE_PERFORMED=0\n' if [ -n "$error_reason" ]; then printf 'ERROR=%s\n' "$error_reason" exit 1 fi