265 lines
6.8 KiB
Bash
265 lines
6.8 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
SOURCE_BACKUP_DIR=""
|
|
VERIFY_DIR=""
|
|
SKIP_SMOKE="0"
|
|
ADMIN_DB="${GAOKAO_DB_PATH:-${ROOT_DIR}/data/orders/admin.db}"
|
|
ORDERS_DB="${GAOKAO_ORDERS_DB_PATH:-${ROOT_DIR}/data/orders.db}"
|
|
SHARE_DB="${GAOKAO_SHARE_DB_PATH:-${ROOT_DIR}/data/share/short_links.db}"
|
|
SHARE_REPORT_DIR="${GAOKAO_SHARE_REPORT_DIR:-${ROOT_DIR}/data/share/reports}"
|
|
EXAMPLE_REPORT_DIR="${ROOT_DIR}/data/examples"
|
|
PORTAL_UPLOAD_DIR="${GAOKAO_PORTAL_UPLOAD_DIR:-${ROOT_DIR}/data/portal_uploads}"
|
|
ORDER_ARTIFACT_HELPER="${ROOT_DIR}/scripts/backup_collect_order_artifacts.py"
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
bash scripts/backup_verify.sh [verify_dir]
|
|
bash scripts/backup_verify.sh --from-backup /path/to/backup-dir [--skip-smoke]
|
|
|
|
Default mode copies live SQLite/files into a temporary verify dir, then validates:
|
|
- SQLite files are readable
|
|
- manifest checksums (if manifest.json exists)
|
|
- restore smoke via FastAPI TestClient (unless --skip-smoke)
|
|
EOF
|
|
}
|
|
|
|
log() {
|
|
printf '[backup-verify] %s\n' "$1"
|
|
}
|
|
|
|
copy_if_exists() {
|
|
local src="$1"
|
|
local dest_dir="$2"
|
|
if [[ -f "$src" ]]; then
|
|
mkdir -p "$dest_dir"
|
|
cp "$src" "$dest_dir/"
|
|
log "copied file: $src"
|
|
else
|
|
log "skip missing file: $src"
|
|
fi
|
|
}
|
|
|
|
copy_sqlite_to() {
|
|
local src="$1"
|
|
local dest_dir="$2"
|
|
if [[ -f "$src" ]]; then
|
|
mkdir -p "$dest_dir"
|
|
local dest_path="$dest_dir/$(basename "$src")"
|
|
python3 - "$src" "$dest_path" <<'PY'
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
src_path = Path(sys.argv[1]).resolve()
|
|
dest_path = Path(sys.argv[2]).resolve()
|
|
if dest_path.exists():
|
|
dest_path.unlink()
|
|
|
|
with sqlite3.connect(f"{src_path.as_uri()}?mode=ro", uri=True) as source_conn:
|
|
with sqlite3.connect(dest_path) as dest_conn:
|
|
source_conn.backup(dest_conn)
|
|
dest_conn.commit()
|
|
PY
|
|
log "copied sqlite snapshot: $src -> $dest_path"
|
|
else
|
|
log "skip missing sqlite file: $src"
|
|
fi
|
|
}
|
|
|
|
copy_dir_if_exists() {
|
|
local src="$1"
|
|
local dest_dir="$2"
|
|
if [[ -d "$src" ]]; then
|
|
mkdir -p "$dest_dir"
|
|
cp -R "$src/." "$dest_dir/"
|
|
log "copied directory: $src"
|
|
else
|
|
log "skip missing directory: $src"
|
|
fi
|
|
}
|
|
|
|
copy_order_artifacts_if_possible() {
|
|
local orders_db="$1"
|
|
local dest_dir="$2"
|
|
if [[ ! -f "$orders_db" ]]; then
|
|
log "skip order artifacts, missing orders db: $orders_db"
|
|
return
|
|
fi
|
|
mkdir -p "$dest_dir"
|
|
local summary
|
|
summary="$(python3 "$ORDER_ARTIFACT_HELPER" --orders-db "$orders_db" --output-dir "$dest_dir")"
|
|
log "copied order artifacts: $summary"
|
|
}
|
|
|
|
stage_live_db_sources() {
|
|
log "staging live sqlite files into: $VERIFY_DIR/db"
|
|
copy_sqlite_to "$ADMIN_DB" "$VERIFY_DIR/db"
|
|
copy_sqlite_to "$ORDERS_DB" "$VERIFY_DIR/db"
|
|
copy_sqlite_to "$SHARE_DB" "$VERIFY_DIR/db"
|
|
}
|
|
|
|
stage_live_file_sources() {
|
|
log "staging live artifact files into: $VERIFY_DIR/files"
|
|
copy_dir_if_exists "$SHARE_REPORT_DIR" "$VERIFY_DIR/files/reports"
|
|
copy_dir_if_exists "$EXAMPLE_REPORT_DIR" "$VERIFY_DIR/files/examples"
|
|
copy_dir_if_exists "$PORTAL_UPLOAD_DIR" "$VERIFY_DIR/files/portal_uploads"
|
|
copy_order_artifacts_if_possible "$ORDERS_DB" "$VERIFY_DIR/files/order_artifacts"
|
|
}
|
|
|
|
stage_live_sources() {
|
|
log "verify dir: $VERIFY_DIR"
|
|
stage_live_db_sources
|
|
stage_live_file_sources
|
|
}
|
|
|
|
stage_existing_backup() {
|
|
if [[ ! -d "$SOURCE_BACKUP_DIR" ]]; then
|
|
printf 'backup dir not found: %s\n' "$SOURCE_BACKUP_DIR" >&2
|
|
exit 1
|
|
fi
|
|
log "staging backup dir: $SOURCE_BACKUP_DIR -> $VERIFY_DIR"
|
|
mkdir -p "$VERIFY_DIR"
|
|
cp -R "$SOURCE_BACKUP_DIR/." "$VERIFY_DIR/"
|
|
}
|
|
|
|
verify_manifest_if_present() {
|
|
local manifest_path="$1"
|
|
if [[ ! -f "$manifest_path" ]]; then
|
|
log "skip missing manifest: $manifest_path"
|
|
return
|
|
fi
|
|
|
|
log "verifying manifest checksums"
|
|
python3 - "$manifest_path" <<'PY'
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
manifest_path = Path(sys.argv[1]).resolve()
|
|
root = manifest_path.parent
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
errors: list[str] = []
|
|
for item in manifest.get("files", []):
|
|
rel = item["path"]
|
|
target = root / rel
|
|
if not target.is_file():
|
|
errors.append(f"missing file: {rel}")
|
|
continue
|
|
digest = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
if digest != item["sha256"]:
|
|
errors.append(f"sha256 mismatch: {rel}")
|
|
if errors:
|
|
for err in errors:
|
|
print(err)
|
|
raise SystemExit(1)
|
|
print(f"manifest_ok files={len(manifest.get('files', []))}")
|
|
PY
|
|
}
|
|
|
|
verify_sqlite_file() {
|
|
local db_path="$1"
|
|
if [[ ! -f "$db_path" ]]; then
|
|
return 0
|
|
fi
|
|
python3 - <<'PY' "$db_path"
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
p = Path(sys.argv[1])
|
|
conn = sqlite3.connect(p)
|
|
try:
|
|
cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
tables = [row[0] for row in cur.fetchall()]
|
|
print(f"sqlite_ok path={p} tables={','.join(tables) if tables else 'none'}")
|
|
finally:
|
|
conn.close()
|
|
PY
|
|
}
|
|
|
|
verify_artifacts() {
|
|
log "verifying copied sqlite files"
|
|
if [[ -d "$VERIFY_DIR/db" ]]; then
|
|
while IFS= read -r -d '' db_file; do
|
|
verify_sqlite_file "$db_file"
|
|
done < <(find "$VERIFY_DIR/db" -type f -name '*.db' -print0 | sort -z)
|
|
fi
|
|
|
|
log "verifying copied report artifacts"
|
|
if [[ -d "$VERIFY_DIR/files" ]]; then
|
|
find "$VERIFY_DIR/files" -type f | sort | sed 's#^#[backup-verify] artifact: #'
|
|
fi
|
|
}
|
|
|
|
run_restore_smoke() {
|
|
if [[ "$SKIP_SMOKE" == "1" ]]; then
|
|
log "skip restore smoke"
|
|
return
|
|
fi
|
|
|
|
log "running restore smoke"
|
|
# Prefer the project virtualenv if present so the smoke step uses
|
|
# the same dependency set as the rest of the project. Falling back
|
|
# to ``python3`` is fine for dev environments where the user has
|
|
# already installed admin / test requirements globally.
|
|
local python_bin="${ROOT_DIR}/.venv/bin/python"
|
|
if [[ ! -x "$python_bin" ]]; then
|
|
python_bin="python3"
|
|
fi
|
|
"$python_bin" "$ROOT_DIR/scripts/backup_restore_smoke.py" --backup-dir "$VERIFY_DIR"
|
|
}
|
|
|
|
parse_args() {
|
|
while (( $# > 0 )); do
|
|
case "$1" in
|
|
--from-backup)
|
|
SOURCE_BACKUP_DIR="$2"
|
|
shift 2
|
|
;;
|
|
--skip-smoke)
|
|
SKIP_SMOKE="1"
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
if [[ -n "$VERIFY_DIR" ]]; then
|
|
printf 'unexpected argument: %s\n' "$1" >&2
|
|
exit 1
|
|
fi
|
|
VERIFY_DIR="$1"
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
main() {
|
|
parse_args "$@"
|
|
VERIFY_DIR="${VERIFY_DIR:-$(mktemp -d /tmp/gaokao-backup-verify-XXXXXX)}"
|
|
|
|
if [[ -n "$SOURCE_BACKUP_DIR" ]]; then
|
|
stage_existing_backup
|
|
else
|
|
mkdir -p "$VERIFY_DIR"
|
|
stage_live_sources
|
|
fi
|
|
|
|
verify_manifest_if_present "$VERIFY_DIR/manifest.json"
|
|
verify_artifacts
|
|
run_restore_smoke
|
|
log "backup verification finished"
|
|
}
|
|
|
|
main "$@"
|