Start autonomous Belgium and North Sea RC
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user