Start autonomous Belgium and North Sea RC
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-18 00:12:34 +02:00
parent 9513f8613e
commit 9c402e0df2
36 changed files with 2235 additions and 115 deletions
+38
View File
@@ -1938,3 +1938,41 @@ docker exec geointel python /app/scripts/probe_mdk_bathymetry.py
This performs only `GetCapabilities`, keeps strict TLS verification enabled
and returns exit code `2` for an honest non-ready source.
# Release backup and restore proof
The release-candidate safety path is host-operated against the running
all-in-one container:
```bash
bash scripts/backup_release_state.sh \
--container geointel \
--output-root /mnt/user/appdata/geointel/backups \
--storage-path /mnt/user/appdata/geointel/storage \
--models-path /mnt/user/appdata/geointel/models \
--inventory-mode sha256
```
The backup is written atomically and contains a PostgreSQL custom-format dump,
archive listing, Alembic/PostGIS metadata, critical table counts, optional
storage/model inventories and SHA-256 checksums. An empty or known-default
database password leaves the release gate failed. For an emergency backup
before rotating that password, add `--allow-insecure-password`; the manifest
still records the insecure state.
Verify without changing any database:
```bash
bash scripts/verify_release_backup.sh \
--backup-dir /mnt/user/appdata/geointel/backups/<release-id>
```
Prove restoration only in a generated temporary database:
```bash
bash scripts/restore_release_backup_smoke.sh \
--backup-dir /mnt/user/appdata/geointel/backups/<release-id> \
--confirm-isolated-restore
```
The restore smoke rejects the production database name, compares PostGIS,
Alembic and retained table counts, and removes its temporary database.
+2
View File
@@ -11,6 +11,8 @@ DOCS = ROOT / "docs" / "API_CONTRACTS.md" # docs/API_CONTRACTS.md
ALLOWED_NON_ENVELOPE_ENDPOINTS = {
("GET", "/health"),
("GET", "/health/live"),
("GET", "/health/ready"),
("GET", "/api/v1/exports/{export_id}/download"),
("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/image"),
}
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
set -euo pipefail
CONTAINER="geointel"
OUTPUT_ROOT="backups"
RELEASE_ID="rc-$(date -u +%Y%m%dT%H%M%SZ)"
STORAGE_PATH=""
MODELS_PATH=""
INVENTORY_MODE="metadata"
ALLOW_INSECURE_PASSWORD="false"
usage() {
cat <<'EOF'
Usage: bash scripts/backup_release_state.sh [options]
Creates an atomic, read-only release backup from the running all-in-one
GeoIntel container. It never deletes or restores application data.
Options:
--container NAME Docker container (default: geointel)
--output-root PATH Host backup root (default: backups)
--release-id ID Safe backup directory name
--storage-path PATH Optional host storage path to inventory
--models-path PATH Optional host model path to inventory
--inventory-mode metadata|sha256 Hash all inventoried files only with sha256
--allow-insecure-password Complete emergency backup despite an
empty/default production DB password
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--container) CONTAINER="$2"; shift 2 ;;
--output-root) OUTPUT_ROOT="$2"; shift 2 ;;
--release-id) RELEASE_ID="$2"; shift 2 ;;
--storage-path) STORAGE_PATH="$2"; shift 2 ;;
--models-path) MODELS_PATH="$2"; shift 2 ;;
--inventory-mode) INVENTORY_MODE="$2"; shift 2 ;;
--allow-insecure-password) ALLOW_INSECURE_PASSWORD="true"; shift ;;
--help|-h) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if ! [[ "$RELEASE_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then
echo "Unsafe release id: $RELEASE_ID" >&2
exit 2
fi
if [ "$INVENTORY_MODE" != "metadata" ] && [ "$INVENTORY_MODE" != "sha256" ]; then
echo "--inventory-mode must be metadata or sha256" >&2
exit 2
fi
for required in docker python3 sha256sum git; do
if ! command -v "$required" >/dev/null 2>&1; then
echo "Missing required command: $required" >&2
exit 2
fi
done
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" != "true" ]; then
echo "Container '$CONTAINER' is not running." >&2
exit 3
fi
OUTPUT_ROOT="$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve())' "$OUTPUT_ROOT")"
mkdir -p "$OUTPUT_ROOT"
PARTIAL="$OUTPUT_ROOT/.${RELEASE_ID}.partial"
FINAL="$OUTPUT_ROOT/$RELEASE_ID"
if [ -e "$PARTIAL" ] || [ -e "$FINAL" ]; then
echo "Backup target already exists: $FINAL" >&2
exit 3
fi
mkdir -p "$PARTIAL"
cleanup_partial() {
if [ -d "$PARTIAL" ]; then
rm -rf -- "$PARTIAL"
fi
}
trap cleanup_partial EXIT
DB_NAME="$(docker exec "$CONTAINER" sh -c 'printf %s "${POSTGRES_DB:-${GEOINTEL_POSTGRES_DB:-geointel}}"' )"
DB_USER="$(docker exec "$CONTAINER" sh -c 'printf %s "${POSTGRES_USER:-${GEOINTEL_POSTGRES_USER:-geointel}}"' )"
if [ -z "$DB_NAME" ] || [ -z "$DB_USER" ]; then
echo "Could not resolve database identity from the container." >&2
exit 3
fi
PASSWORD_SECURE="true"
if ! docker exec "$CONTAINER" sh -c '
password="${POSTGRES_PASSWORD:-${GEOINTEL_POSTGRES_PASSWORD:-}}"
test -n "$password" &&
test "$password" != "geointel" &&
test "$password" != "postgres" &&
test "$password" != "password"
'; then
PASSWORD_SECURE="false"
fi
echo "Creating PostgreSQL custom-format dump..."
docker exec "$CONTAINER" pg_dump \
-U "$DB_USER" \
-d "$DB_NAME" \
-Fc \
--no-owner \
--no-privileges > "$PARTIAL/database.dump"
test -s "$PARTIAL/database.dump"
docker exec -i "$CONTAINER" pg_restore --list \
< "$PARTIAL/database.dump" \
> "$PARTIAL/database.list"
test -s "$PARTIAL/database.list"
IMAGE_ID="$(docker inspect -f '{{.Image}}' "$CONTAINER")"
IMAGE_NAME="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER")"
GIT_COMMIT="$(git rev-parse HEAD)"
GIT_DIRTY="false"
if [ -n "$(git status --porcelain=v1)" ]; then
GIT_DIRTY="true"
fi
docker exec "$CONTAINER" psql -X -v ON_ERROR_STOP=1 -U "$DB_USER" -d "$DB_NAME" -AtF $'\t' \
-c "SELECT 'alembic_head', version_num FROM alembic_version
UNION ALL SELECT 'postgis_version', postgis_version()
UNION ALL SELECT 'database_size_bytes', pg_database_size(current_database())::text
ORDER BY 1;" > "$PARTIAL/database-metadata.tsv"
: > "$PARTIAL/table-counts.tsv"
for table in projects areas datasets dataset_versions vector_features jobs analysis_runs detections segmentations quality_checks metrics exports; do
count="$(docker exec "$CONTAINER" psql -X -v ON_ERROR_STOP=1 -U "$DB_USER" -d "$DB_NAME" -At \
-c "SELECT count(*) FROM public.${table};")"
printf '%s\t%s\n' "$table" "$count" >> "$PARTIAL/table-counts.tsv"
done
inventory_path() {
local source_path="$1"
local output_path="$2"
if [ -z "$source_path" ]; then
printf 'not_requested\n' > "$output_path"
return
fi
python3 - "$source_path" "$output_path" "$INVENTORY_MODE" <<'PY'
import hashlib
import os
import pathlib
import sys
root = pathlib.Path(sys.argv[1]).expanduser().resolve()
output = pathlib.Path(sys.argv[2])
mode = sys.argv[3]
if not root.is_dir():
raise SystemExit(f"Inventory root is not a directory: {root}")
def digest(path: pathlib.Path) -> str:
value = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
with output.open("w", encoding="utf-8", newline="\n") as handle:
handle.write("relative_path\tsize_bytes\tmtime_ns\tsha256\n")
for path in sorted(root.rglob("*"), key=lambda item: item.as_posix()):
if path.is_symlink() or not path.is_file():
continue
stat = path.stat()
checksum = digest(path) if mode == "sha256" else ""
relative = path.relative_to(root).as_posix()
if "\t" in relative or "\n" in relative:
raise SystemExit(f"Unsupported inventory path: {relative!r}")
handle.write(f"{relative}\t{stat.st_size}\t{stat.st_mtime_ns}\t{checksum}\n")
PY
}
inventory_path "$STORAGE_PATH" "$PARTIAL/storage-manifest.tsv"
inventory_path "$MODELS_PATH" "$PARTIAL/models-manifest.tsv"
python3 - "$PARTIAL/manifest.json" <<PY
import json
import pathlib
payload = {
"schema_version": 1,
"release_id": ${RELEASE_ID@Q},
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"read_only_source": True,
"container": ${CONTAINER@Q},
"database_name": ${DB_NAME@Q},
"database_user": ${DB_USER@Q},
"database_password_secure": ${PASSWORD_SECURE@Q} == "true",
"image_id": ${IMAGE_ID@Q},
"image_name": ${IMAGE_NAME@Q},
"git_commit": ${GIT_COMMIT@Q},
"git_dirty": ${GIT_DIRTY@Q} == "true",
"inventory_mode": ${INVENTORY_MODE@Q},
"storage_inventory_requested": bool(${STORAGE_PATH@Q}),
"models_inventory_requested": bool(${MODELS_PATH@Q}),
}
path = pathlib.Path(__import__("sys").argv[1])
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
(
cd "$PARTIAL"
find . -maxdepth 1 -type f ! -name CHECKSUMS.sha256 -printf '%f\n' \
| LC_ALL=C sort \
| xargs sha256sum > CHECKSUMS.sha256
)
mv "$PARTIAL" "$FINAL"
trap - EXIT
echo "Release backup created: $FINAL"
if [ "$PASSWORD_SECURE" != "true" ] && [ "$ALLOW_INSECURE_PASSWORD" != "true" ]; then
echo "SECURITY GATE FAILED: production database password is empty or a known default." >&2
echo "The emergency backup is valid, but the release remains blocked." >&2
exit 4
fi
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""Capture a read-only, secret-free GeoIntel release evidence manifest."""
from __future__ import annotations
import argparse
import hashlib
import importlib.metadata
import json
import os
import platform
import subprocess
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Sequence
ROOT = Path(__file__).resolve().parents[1]
BACKEND = ROOT / "backend"
DEFAULT_HASHED_FILES = (
"AGENTS.md",
"backend/pyproject.toml",
"backend/alembic.ini",
"frontend/package.json",
"frontend/package-lock.json",
"docker-compose.yml",
"docker-compose.unraid.yml",
"deploy/unraid/Dockerfile.all-in-one",
"docs/RC_SCOPE_FREEZE_BELGIUM_NORTH_SEA.md",
"docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md",
)
DEPENDENCIES = (
"alembic",
"fastapi",
"geoalchemy2",
"geopandas",
"numpy",
"pydantic",
"pyproj",
"rasterio",
"shapely",
"sqlalchemy",
"torch",
"ultralytics",
"uvicorn",
)
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def run_command(
command: Sequence[str],
*,
cwd: Path = ROOT,
timeout_seconds: int = 30,
) -> dict[str, Any]:
try:
result = subprocess.run(
list(command),
cwd=cwd,
capture_output=True,
text=True,
check=False,
timeout=timeout_seconds,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return {
"ok": False,
"exit_code": None,
"stdout": "",
"stderr": str(exc),
}
return {
"ok": result.returncode == 0,
"exit_code": result.returncode,
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip(),
}
def git_evidence() -> dict[str, Any]:
commit = run_command(("git", "rev-parse", "HEAD"))
branch = run_command(("git", "branch", "--show-current"))
status = run_command(("git", "status", "--porcelain=v1"))
remote = run_command(("git", "remote", "get-url", "origin"))
dirty_paths = [
line[3:].strip()
for line in status["stdout"].splitlines()
if len(line) >= 4
]
return {
"commit": commit["stdout"] or None,
"branch": branch["stdout"] or None,
"origin": remote["stdout"] or None,
"dirty": bool(dirty_paths),
"dirty_paths": dirty_paths,
"commands_ok": all(item["ok"] for item in (commit, branch, status)),
}
def migration_evidence() -> dict[str, Any]:
heads = run_command((sys.executable, "-m", "alembic", "heads"), cwd=BACKEND)
head_lines = [
line.strip()
for line in heads["stdout"].splitlines()
if line.strip()
]
return {
"command_ok": heads["ok"],
"heads": head_lines,
"single_head": heads["ok"] and len(head_lines) == 1,
"error": heads["stderr"] or None,
}
def dependency_evidence() -> dict[str, str | None]:
versions: dict[str, str | None] = {}
for name in DEPENDENCIES:
try:
versions[name] = importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
versions[name] = None
return versions
def file_evidence() -> dict[str, dict[str, Any]]:
evidence: dict[str, dict[str, Any]] = {}
for relative_path in DEFAULT_HASHED_FILES:
path = ROOT / relative_path
evidence[relative_path] = {
"exists": path.is_file(),
"size_bytes": path.stat().st_size if path.is_file() else None,
"sha256": sha256_file(path) if path.is_file() else None,
}
return evidence
def storage_evidence(storage_root: Path | None) -> dict[str, Any]:
if storage_root is None:
return {"requested": False}
resolved = storage_root.expanduser().resolve()
if not resolved.is_dir():
return {
"requested": True,
"root": str(resolved),
"available": False,
}
total_size = 0
file_count = 0
errors: list[str] = []
for path in resolved.rglob("*"):
if path.is_symlink() or not path.is_file():
continue
try:
total_size += path.stat().st_size
file_count += 1
except OSError as exc:
errors.append(f"{path}: {exc}")
if len(errors) >= 20:
break
return {
"requested": True,
"root": str(resolved),
"available": True,
"file_count": file_count,
"size_bytes": total_size,
"scan_complete": not errors,
"errors": errors,
}
def fetch_json(url: str, timeout_seconds: int) -> dict[str, Any]:
request = urllib.request.Request(
url,
headers={"Accept": "application/json", "User-Agent": "GeoIntel-RC-Evidence/1.0"},
)
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
body = response.read(2 * 1024 * 1024 + 1)
if len(body) > 2 * 1024 * 1024:
raise ValueError("response exceeds 2 MiB evidence limit")
return {
"ok": 200 <= response.status < 300,
"status_code": response.status,
"payload": json.loads(body.decode("utf-8")),
"error": None,
}
except (urllib.error.URLError, ValueError, json.JSONDecodeError) as exc:
return {
"ok": False,
"status_code": getattr(exc, "code", None),
"payload": None,
"error": str(exc),
}
def live_evidence(base_url: str | None, timeout_seconds: int) -> dict[str, Any]:
if not base_url:
return {"requested": False}
normalized = base_url.rstrip("/")
return {
"requested": True,
"base_url": normalized,
"health": fetch_json(f"{normalized}/health", timeout_seconds),
"capabilities": fetch_json(
f"{normalized}/api/v1/system/capabilities",
timeout_seconds,
),
}
def build_manifest(args: argparse.Namespace) -> dict[str, Any]:
storage_root = Path(args.storage_root) if args.storage_root else None
return {
"schema_version": 1,
"release_id": args.release_id,
"captured_at": utc_now(),
"read_only": True,
"scope": "Belgium and the Belgian North Sea",
"host": {
"platform": platform.platform(),
"python": platform.python_version(),
"hostname": platform.node(),
},
"git": git_evidence(),
"migrations": migration_evidence(),
"dependencies": dependency_evidence(),
"files": file_evidence(),
"storage": storage_evidence(storage_root),
"live": live_evidence(args.live_base_url, args.timeout_seconds),
"environment_presence": {
"DATABASE_URL": bool(os.environ.get("DATABASE_URL")),
"STORAGE_ROOT": bool(os.environ.get("STORAGE_ROOT")),
"YOLO_ENABLED": bool(os.environ.get("YOLO_ENABLED")),
"YOLO_MODEL_PATH": bool(os.environ.get("YOLO_MODEL_PATH")),
"OLLAMA_ENABLED": bool(os.environ.get("OLLAMA_ENABLED")),
},
}
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--release-id", default="rc-current")
parser.add_argument("--live-base-url")
parser.add_argument("--storage-root")
parser.add_argument("--timeout-seconds", type=int, default=10)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
if args.timeout_seconds < 1 or args.timeout_seconds > 120:
raise SystemExit("--timeout-seconds must be between 1 and 120")
manifest = build_manifest(args)
output = args.output.expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(f"Wrote read-only release evidence to {output}")
if not manifest["git"]["commands_ok"]:
return 2
if not manifest["migrations"]["single_head"]:
return 3
return 0
if __name__ == "__main__":
raise SystemExit(main())
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
CONTAINER="geointel"
BACKUP_DIR=""
CONFIRM="false"
KEEP_DATABASE="false"
OUTPUT=""
usage() {
cat <<'EOF'
Usage: bash scripts/restore_release_backup_smoke.sh --backup-dir PATH \
--confirm-isolated-restore [options]
Restores into a generated geointel_restore_verify_* database only, verifies
PostGIS, Alembic and retained table counts, then drops that temporary database.
It refuses the production database name and never uses pg_restore --clean.
Options:
--container NAME
--output PATH Write result JSON outside the immutable backup directory
--keep-database Keep the isolated verification database for inspection
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--backup-dir) BACKUP_DIR="$2"; shift 2 ;;
--container) CONTAINER="$2"; shift 2 ;;
--confirm-isolated-restore) CONFIRM="true"; shift ;;
--keep-database) KEEP_DATABASE="true"; shift ;;
--output) OUTPUT="$2"; shift 2 ;;
--help|-h) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ "$CONFIRM" != "true" ] || [ -z "$BACKUP_DIR" ]; then
echo "Explicit --confirm-isolated-restore and --backup-dir are required." >&2
exit 2
fi
for required in docker python3; do
command -v "$required" >/dev/null 2>&1 || {
echo "Missing required command: $required" >&2
exit 2
}
done
BACKUP_DIR="$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve())' "$BACKUP_DIR")"
bash "$(dirname "$0")/verify_release_backup.sh" --backup-dir "$BACKUP_DIR" --container "$CONTAINER"
DB_NAME="$(docker exec "$CONTAINER" sh -c 'printf %s "${POSTGRES_DB:-${GEOINTEL_POSTGRES_DB:-geointel}}"' )"
DB_USER="$(docker exec "$CONTAINER" sh -c 'printf %s "${POSTGRES_USER:-${GEOINTEL_POSTGRES_USER:-geointel}}"' )"
TARGET_DB="geointel_restore_verify_$(date -u +%Y%m%d%H%M%S)_$$"
if [ "$TARGET_DB" = "$DB_NAME" ] || ! [[ "$TARGET_DB" =~ ^geointel_restore_verify_[0-9_]+$ ]]; then
echo "Unsafe restore target: $TARGET_DB" >&2
exit 3
fi
if docker exec "$CONTAINER" psql -X -U "$DB_USER" -d postgres -Atqc \
"SELECT 1 FROM pg_database WHERE datname = '$TARGET_DB';" | grep -q 1; then
echo "Generated restore database already exists." >&2
exit 3
fi
cleanup_database() {
if [ "$KEEP_DATABASE" != "true" ]; then
docker exec "$CONTAINER" dropdb --if-exists -U "$DB_USER" "$TARGET_DB" >/dev/null 2>&1 || true
fi
}
trap cleanup_database EXIT
docker exec "$CONTAINER" createdb -U "$DB_USER" "$TARGET_DB"
docker exec -i "$CONTAINER" pg_restore \
--exit-on-error \
--no-owner \
--no-privileges \
-U "$DB_USER" \
-d "$TARGET_DB" < "$BACKUP_DIR/database.dump"
POSTGIS_VERSION="$(docker exec "$CONTAINER" psql -X -v ON_ERROR_STOP=1 -U "$DB_USER" -d "$TARGET_DB" -Atqc \
"SELECT postgis_version();")"
ALEMBIC_HEAD="$(docker exec "$CONTAINER" psql -X -v ON_ERROR_STOP=1 -U "$DB_USER" -d "$TARGET_DB" -Atqc \
"SELECT version_num FROM alembic_version;")"
EXPECTED_HEAD="$(awk -F $'\t' '$1 == "alembic_head" { print $2 }' "$BACKUP_DIR/database-metadata.tsv")"
if [ "$ALEMBIC_HEAD" != "$EXPECTED_HEAD" ]; then
echo "Restored Alembic head '$ALEMBIC_HEAD' differs from '$EXPECTED_HEAD'." >&2
exit 4
fi
while IFS=$'\t' read -r table expected; do
[[ "$table" =~ ^[a-z_]+$ ]] || {
echo "Unsafe table name in retained counts: $table" >&2
exit 4
}
actual="$(docker exec "$CONTAINER" psql -X -v ON_ERROR_STOP=1 -U "$DB_USER" -d "$TARGET_DB" -Atqc \
"SELECT count(*) FROM public.${table};")"
if [ "$actual" != "$expected" ]; then
echo "Restored count mismatch for $table: expected $expected, got $actual." >&2
exit 4
fi
done < "$BACKUP_DIR/table-counts.tsv"
if [ -z "$OUTPUT" ]; then
OUTPUT="${BACKUP_DIR%/}-restore-smoke.json"
fi
python3 - "$OUTPUT" <<PY
import json
import pathlib
payload = {
"schema_version": 1,
"verified_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"backup_dir": ${BACKUP_DIR@Q},
"temporary_database": ${TARGET_DB@Q},
"production_database_untouched": True,
"temporary_database_retained": ${KEEP_DATABASE@Q} == "true",
"alembic_head": ${ALEMBIC_HEAD@Q},
"postgis_version": ${POSTGIS_VERSION@Q},
"table_counts_match": True,
}
path = pathlib.Path(__import__("sys").argv[1]).expanduser().resolve()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
echo "Isolated restore verified: $TARGET_DB"
if [ "$KEEP_DATABASE" != "true" ]; then
cleanup_database
trap - EXIT
echo "Temporary verification database removed."
else
trap - EXIT
echo "Temporary verification database retained by explicit request: $TARGET_DB"
fi
+5
View File
@@ -27,6 +27,11 @@ if [ -z "${PYTHON_BIN}" ]; then
fi
echo "== GeoIntel run readiness check =="
"$PYTHON_BIN" -m py_compile scripts/capture_release_evidence.py
bash -n scripts/backup_release_state.sh
bash -n scripts/verify_release_backup.sh
bash -n scripts/restore_release_backup_smoke.sh
echo "Using Python: ${PYTHON_BIN}"
bash scripts/check_repo_structure.sh
${PYTHON_BIN} scripts/smoke_docs.py
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
CONTAINER="geointel"
BACKUP_DIR=""
usage() {
cat <<'EOF'
Usage: bash scripts/verify_release_backup.sh --backup-dir PATH [--container NAME]
Verifies checksums, manifest shape and PostgreSQL archive readability. This
command is read-only and never creates, restores or drops a database.
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--backup-dir) BACKUP_DIR="$2"; shift 2 ;;
--container) CONTAINER="$2"; shift 2 ;;
--help|-h) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ -z "$BACKUP_DIR" ]; then
usage >&2
exit 2
fi
for required in docker python3 sha256sum; do
command -v "$required" >/dev/null 2>&1 || {
echo "Missing required command: $required" >&2
exit 2
}
done
BACKUP_DIR="$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve())' "$BACKUP_DIR")"
for required_file in manifest.json database.dump database.list database-metadata.tsv table-counts.tsv CHECKSUMS.sha256; do
test -s "$BACKUP_DIR/$required_file" || {
echo "Missing or empty backup artifact: $required_file" >&2
exit 3
}
done
(
cd "$BACKUP_DIR"
sha256sum -c CHECKSUMS.sha256
)
python3 - "$BACKUP_DIR/manifest.json" <<'PY'
import json
import pathlib
import sys
payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
required = {
"schema_version",
"release_id",
"created_at",
"read_only_source",
"database_name",
"database_user",
"image_id",
"git_commit",
}
missing = sorted(required - payload.keys())
if missing:
raise SystemExit(f"Backup manifest misses: {', '.join(missing)}")
if payload["schema_version"] != 1 or payload["read_only_source"] is not True:
raise SystemExit("Unsupported or unsafe backup manifest")
PY
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" != "true" ]; then
echo "Container '$CONTAINER' is required to run pg_restore --list." >&2
exit 3
fi
TMP_LIST="$(mktemp)"
trap 'rm -f -- "$TMP_LIST"' EXIT
docker exec -i "$CONTAINER" pg_restore --list \
< "$BACKUP_DIR/database.dump" \
> "$TMP_LIST"
cmp -s "$TMP_LIST" "$BACKUP_DIR/database.list" || {
echo "The current pg_restore listing differs from the retained listing." >&2
exit 3
}
grep -q "TABLE DATA" "$TMP_LIST" || {
echo "Archive contains no table data entries." >&2
exit 3
}
echo "Release backup verified read-only: $BACKUP_DIR"