GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
374 lines
18 KiB
Python
374 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Plan, stage and explicitly apply a governed regional GRB refresh.
|
|
|
|
Planning is read-only. Staging downloads resumable municipality partitions but
|
|
does not persist application data. Applying requires the exact staged plan
|
|
SHA-256 and delegates persistence to the existing DatasetService-based regional
|
|
operators. Existing snapshots are immutable and remain available.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
from geographic_scopes import GEOGRAPHIC_SCOPES
|
|
|
|
|
|
DEFAULT_API_URL = "http://127.0.0.1:8000/api/v1"
|
|
DEFAULT_SCOPE = "kempen-transport-region"
|
|
DEFAULT_OUTPUT_ROOT = Path("/app/storage/operator-data/regional-themes")
|
|
DEFAULT_EVIDENCE_ROOT = Path("/app/storage/operator-evidence/grb-refresh")
|
|
THEMES = ("buildings", "roads", "water", "parcels")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Governed GRB refresh: plan, stage, then checksum-confirmed apply.")
|
|
parser.add_argument("action", choices=("plan", "stage", "apply"))
|
|
parser.add_argument("--project-id", required=True, help="GeoIntel project UUID for the governed scope")
|
|
parser.add_argument("--scope", choices=(DEFAULT_SCOPE,), default=DEFAULT_SCOPE)
|
|
parser.add_argument("--api-url", default=os.environ.get("GEOINTEL_API_URL", DEFAULT_API_URL))
|
|
parser.add_argument("--layers", nargs="+", default=list(THEMES), help="Subset: buildings roads water parcels")
|
|
parser.add_argument("--confirm-edition", help="Exact official ISO edition date required for stage/apply")
|
|
parser.add_argument("--confirm-plan-sha256", help="Exact staged plan hash required for apply")
|
|
parser.add_argument("--plan-path", type=Path, help="Staged plan path; defaults to the governed evidence location")
|
|
parser.add_argument("--output-root", type=Path, default=Path(os.environ.get("GEOINTEL_REGIONAL_THEME_OUTPUT_ROOT", DEFAULT_OUTPUT_ROOT)))
|
|
parser.add_argument("--evidence-root", type=Path, default=Path(os.environ.get("GEOINTEL_OPERATOR_EVIDENCE_ROOT", DEFAULT_EVIDENCE_ROOT)))
|
|
parser.add_argument("--refresh-catalog", action="store_true", help="Bypass the short catalog probe cache")
|
|
parser.add_argument("--request-timeout", type=int, default=180)
|
|
parser.add_argument("--api-timeout", type=int, default=180)
|
|
parser.add_argument("--batch-size", type=int, default=1000)
|
|
parser.add_argument("--page-limit", type=int, default=1000)
|
|
parser.add_argument("--max-features-per-member", type=int, default=100000)
|
|
parser.add_argument("--max-total-features", type=int, default=1500000)
|
|
return parser.parse_args()
|
|
|
|
|
|
def selected_themes(values: str | list[str]) -> list[str]:
|
|
raw = [values] if isinstance(values, str) else values
|
|
requested = {item.strip().lower() for value in raw for item in value.split(",") if item.strip()}
|
|
unknown = requested - set(THEMES)
|
|
if unknown or not requested:
|
|
raise ValueError(f"Unsupported GRB layers: {sorted(unknown)}")
|
|
return [theme for theme in THEMES if theme in requested]
|
|
|
|
|
|
def api_data(api_url: str, path: str, timeout: int) -> dict[str, Any]:
|
|
endpoint = f"{api_url.rstrip('/')}/{path.lstrip('/')}"
|
|
request = Request(endpoint, headers={"Accept": "application/json", "User-Agent": "GeoIntel-GRB-refresh/1.0"})
|
|
try:
|
|
with urlopen(request, timeout=timeout) as response:
|
|
payload = json.load(response)
|
|
except HTTPError as exc:
|
|
body = exc.read().decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"GeoIntel API returned HTTP {exc.code}: {body[-1000:]}") from exc
|
|
except URLError as exc:
|
|
raise RuntimeError(f"GeoIntel API is unreachable: {exc.reason}") from exc
|
|
if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict):
|
|
raise RuntimeError("GeoIntel API response is not a canonical data envelope")
|
|
return payload["data"]
|
|
|
|
|
|
def fetch_refresh_plan(args: argparse.Namespace, *, refresh: bool) -> dict[str, Any]:
|
|
query = "true" if refresh else "false"
|
|
return api_data(
|
|
args.api_url,
|
|
f"projects/{args.project_id}/datasets/grb-refresh-plan?scope={args.scope}&refresh_catalog={query}",
|
|
args.api_timeout,
|
|
)
|
|
|
|
|
|
def validate_project_scope(args: argparse.Namespace) -> None:
|
|
project = api_data(args.api_url, f"projects/{args.project_id}", args.api_timeout)
|
|
expected_name = GEOGRAPHIC_SCOPES[args.scope].project_name
|
|
if project.get("name") != expected_name:
|
|
raise RuntimeError(
|
|
f"Project {args.project_id} is '{project.get('name')}', but scope {args.scope} requires '{expected_name}'"
|
|
)
|
|
|
|
|
|
def require_confirmed_edition(plan: dict[str, Any], confirmation: str | None) -> str:
|
|
edition = str(plan.get("remote_edition_date") or "")
|
|
if not edition:
|
|
raise RuntimeError("The official GRB catalog did not provide a safe ISO edition date")
|
|
if confirmation != edition:
|
|
raise RuntimeError(f"Explicit --confirm-edition {edition} is required")
|
|
return edition
|
|
|
|
|
|
def internal_base_url(api_url: str) -> str:
|
|
value = api_url.rstrip("/")
|
|
if value.endswith("/api/v1"):
|
|
value = value[:-7]
|
|
parsed = urlparse(value)
|
|
if parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
|
|
raise RuntimeError("Stage/apply must run inside GeoIntel against the local backend API")
|
|
return value
|
|
|
|
|
|
def build_operator_commands(
|
|
args: argparse.Namespace,
|
|
themes: list[str],
|
|
edition: str,
|
|
*,
|
|
fetch_only: bool,
|
|
) -> list[tuple[str, list[str]]]:
|
|
scripts_dir = Path(__file__).resolve().parent
|
|
common = [
|
|
"--scope", args.scope,
|
|
"--observed-date", edition,
|
|
"--base-url", internal_base_url(args.api_url),
|
|
"--output-root", str(args.output_root),
|
|
"--request-timeout", str(args.request_timeout),
|
|
"--api-timeout", str(args.api_timeout),
|
|
"--batch-size", str(args.batch_size),
|
|
"--page-limit", str(args.page_limit),
|
|
"--max-features-per-member", str(args.max_features_per_member),
|
|
"--max-total-features", str(args.max_total_features),
|
|
]
|
|
if fetch_only:
|
|
common.append("--fetch-only")
|
|
commands: list[tuple[str, list[str]]] = []
|
|
if "buildings" in themes:
|
|
commands.append(("buildings", [sys.executable, str(scripts_dir / "provision_regional_grb_buildings.py"), *common]))
|
|
context = [theme for theme in themes if theme != "buildings"]
|
|
if context:
|
|
commands.append(
|
|
(
|
|
"context",
|
|
[
|
|
sys.executable,
|
|
str(scripts_dir / "provision_regional_grb_context.py"),
|
|
"--layers",
|
|
*context,
|
|
*common,
|
|
],
|
|
)
|
|
)
|
|
return commands
|
|
|
|
|
|
def run_operator(label: str, command: list[str]) -> dict[str, Any]:
|
|
print(f"GRB {label}: {'staging' if '--fetch-only' in command else 'applying'}...", file=sys.stderr, flush=True)
|
|
completed = subprocess.run(command, check=False, capture_output=True, text=True, encoding="utf-8")
|
|
if completed.returncode != 0:
|
|
detail = completed.stderr.strip() or completed.stdout.strip() or "operator returned no diagnostics"
|
|
raise RuntimeError(f"GRB {label} failed with exit {completed.returncode}: {detail[-3000:]}")
|
|
try:
|
|
payload = json.loads(completed.stdout)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"GRB {label} returned invalid JSON: {completed.stdout[-1000:]}") from exc
|
|
if payload.get("status") != "ok":
|
|
raise RuntimeError(f"GRB {label} did not report success")
|
|
return payload
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(8 * 1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def canonical_plan_sha256(payload: dict[str, Any]) -> str:
|
|
content = {key: value for key, value in payload.items() if key != "plan_sha256"}
|
|
encoded = json.dumps(content, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def manifest_path(output_root: Path, scope: str, theme: str, edition: str) -> Path:
|
|
name = "regional_buildings_manifest.json" if theme == "buildings" else f"regional_{theme}_manifest.json"
|
|
return output_root / scope / theme / edition / name
|
|
|
|
|
|
def validate_manifest(path: Path, *, output_root: Path, scope: str, theme: str, edition: str) -> dict[str, Any]:
|
|
root = output_root.resolve()
|
|
resolved = path.resolve()
|
|
if not resolved.is_relative_to(root):
|
|
raise RuntimeError(f"Manifest is outside the governed output root: {path}")
|
|
if not path.is_file():
|
|
raise RuntimeError(f"Staged manifest is missing: {path}")
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
if payload.get("status") != "complete" or payload.get("scope") != scope or payload.get("theme") != theme:
|
|
raise RuntimeError(f"Staged manifest identity is invalid: {path}")
|
|
if payload.get("observed_at") != edition or payload.get("reference_truncated") is not False:
|
|
raise RuntimeError(f"Staged manifest edition or completeness is invalid: {path}")
|
|
artifact = path.parent / str(payload.get("artifact_filename") or "")
|
|
if not artifact.is_file() or sha256_file(artifact) != payload.get("artifact_sha256"):
|
|
raise RuntimeError(f"Staged artifact checksum is invalid: {artifact}")
|
|
partitions = payload.get("partitions")
|
|
if not isinstance(partitions, list) or len(partitions) != int(payload.get("member_count") or 0):
|
|
raise RuntimeError(f"Staged partition count is invalid: {path}")
|
|
partition_root = path.parent / "partitions"
|
|
for item in partitions:
|
|
partition = partition_root / str(item.get("filename") or "")
|
|
if not partition.is_file() or sha256_file(partition) != item.get("sha256"):
|
|
raise RuntimeError(f"Staged partition checksum is invalid: {partition}")
|
|
return payload
|
|
|
|
|
|
def default_plan_path(args: argparse.Namespace, edition: str) -> Path:
|
|
return args.evidence_root / args.scope / edition / "staged-plan.json"
|
|
|
|
|
|
def build_staged_plan(
|
|
args: argparse.Namespace,
|
|
remote_plan: dict[str, Any],
|
|
themes: list[str],
|
|
edition: str,
|
|
) -> dict[str, Any]:
|
|
local_by_theme = {item["theme"]: item for item in remote_plan.get("layers", [])}
|
|
layers: list[dict[str, Any]] = []
|
|
for theme in themes:
|
|
path = manifest_path(args.output_root, args.scope, theme, edition)
|
|
manifest = validate_manifest(path, output_root=args.output_root, scope=args.scope, theme=theme, edition=edition)
|
|
local = local_by_theme.get(theme, {})
|
|
staged_count = int(manifest["feature_count"])
|
|
local_count = local.get("local_feature_count")
|
|
layers.append(
|
|
{
|
|
"theme": theme,
|
|
"collections": manifest.get("grb_collections") or (["GBG"] if theme == "buildings" else []),
|
|
"manifest_path": str(path),
|
|
"artifact_path": str(path.parent / manifest["artifact_filename"]),
|
|
"artifact_sha256": manifest["artifact_sha256"],
|
|
"artifact_size_bytes": int(manifest["artifact_size_bytes"]),
|
|
"partition_count": len(manifest["partitions"]),
|
|
"staged_feature_count": staged_count,
|
|
"local_dataset_id": local.get("local_dataset_id"),
|
|
"local_source_version": local.get("local_source_version"),
|
|
"local_feature_count": local_count,
|
|
"feature_count_delta": staged_count - int(local_count) if local_count is not None else None,
|
|
"existing_snapshot_retained": True,
|
|
}
|
|
)
|
|
payload: dict[str, Any] = {
|
|
"schema_version": 1,
|
|
"status": "staged",
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"project_id": args.project_id,
|
|
"scope": args.scope,
|
|
"official_remote_version": remote_plan.get("remote_version"),
|
|
"edition": edition,
|
|
"layers": layers,
|
|
"total_staged_feature_count": sum(item["staged_feature_count"] for item in layers),
|
|
"total_artifact_size_bytes": sum(item["artifact_size_bytes"] for item in layers),
|
|
"automatic_import": False,
|
|
"destructive_replacement": False,
|
|
"apply_requires_plan_sha256": True,
|
|
}
|
|
payload["plan_sha256"] = canonical_plan_sha256(payload)
|
|
return payload
|
|
|
|
|
|
def write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_suffix(f"{path.suffix}.partial")
|
|
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
temporary.replace(path)
|
|
|
|
|
|
def load_and_validate_staged_plan(args: argparse.Namespace, edition: str) -> tuple[Path, dict[str, Any]]:
|
|
path = args.plan_path or default_plan_path(args, edition)
|
|
if not path.is_file():
|
|
raise RuntimeError(f"Staged refresh plan is missing: {path}")
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
actual_sha = canonical_plan_sha256(payload)
|
|
if payload.get("plan_sha256") != actual_sha:
|
|
raise RuntimeError("Staged refresh plan checksum is invalid")
|
|
if args.confirm_plan_sha256 != actual_sha:
|
|
raise RuntimeError(f"Explicit --confirm-plan-sha256 {actual_sha} is required")
|
|
if payload.get("status") != "staged" or payload.get("project_id") != args.project_id:
|
|
raise RuntimeError("Staged refresh plan project or status is invalid")
|
|
if payload.get("scope") != args.scope or payload.get("edition") != edition:
|
|
raise RuntimeError("Staged refresh plan scope or edition is invalid")
|
|
for layer in payload.get("layers") or []:
|
|
manifest = validate_manifest(
|
|
Path(layer["manifest_path"]),
|
|
output_root=args.output_root,
|
|
scope=args.scope,
|
|
theme=layer["theme"],
|
|
edition=edition,
|
|
)
|
|
if manifest.get("artifact_sha256") != layer.get("artifact_sha256"):
|
|
raise RuntimeError(f"Staged plan no longer matches {layer['theme']} artifact")
|
|
return path, payload
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
try:
|
|
themes = selected_themes(args.layers)
|
|
if min(args.request_timeout, args.api_timeout, args.batch_size, args.page_limit, args.max_features_per_member, args.max_total_features) <= 0:
|
|
raise ValueError("All timeout, paging, batching and feature safety limits must be positive")
|
|
validate_project_scope(args)
|
|
remote_plan = fetch_refresh_plan(args, refresh=args.refresh_catalog or args.action == "stage")
|
|
if args.action == "plan":
|
|
print(json.dumps(remote_plan, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
edition = require_confirmed_edition(remote_plan, args.confirm_edition)
|
|
if args.action == "stage":
|
|
actionable = {
|
|
item["theme"]
|
|
for item in remote_plan.get("layers", [])
|
|
if item.get("status") in {"update_available", "not_loaded"}
|
|
}
|
|
blocked = set(themes) - actionable
|
|
if blocked:
|
|
raise RuntimeError(f"Layers are not safely stageable according to the refresh plan: {sorted(blocked)}")
|
|
commands = build_operator_commands(args, themes, edition, fetch_only=True)
|
|
operator_results = {label: run_operator(label, command) for label, command in commands}
|
|
staged = build_staged_plan(args, remote_plan, themes, edition)
|
|
staged["operator_results"] = operator_results
|
|
staged["plan_sha256"] = canonical_plan_sha256(staged)
|
|
path = args.plan_path or default_plan_path(args, edition)
|
|
write_json(path, staged)
|
|
print(json.dumps({"status": "staged", "plan_path": str(path), **staged}, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
path, staged = load_and_validate_staged_plan(args, edition)
|
|
staged_themes = [item["theme"] for item in staged["layers"]]
|
|
commands = build_operator_commands(args, staged_themes, edition, fetch_only=False)
|
|
operator_results = {label: run_operator(label, command) for label, command in commands}
|
|
final_plan = fetch_refresh_plan(args, refresh=False)
|
|
by_theme = {item["theme"]: item for item in final_plan.get("layers", [])}
|
|
incomplete = [theme for theme in staged_themes if by_theme.get(theme, {}).get("status") != "current"]
|
|
if incomplete:
|
|
raise RuntimeError(f"Applied datasets did not become current: {incomplete}")
|
|
evidence = {
|
|
"schema_version": 1,
|
|
"status": "applied",
|
|
"applied_at": datetime.now(timezone.utc).isoformat(),
|
|
"project_id": args.project_id,
|
|
"scope": args.scope,
|
|
"edition": edition,
|
|
"staged_plan_path": str(path),
|
|
"staged_plan_sha256": staged["plan_sha256"],
|
|
"operator_results": operator_results,
|
|
"resulting_layers": [by_theme[theme] for theme in staged_themes],
|
|
"existing_snapshots_retained": True,
|
|
}
|
|
evidence_path = path.with_name("applied-evidence.json")
|
|
write_json(evidence_path, evidence)
|
|
print(json.dumps({"status": "applied", "evidence_path": str(evidence_path), **evidence}, ensure_ascii=False, indent=2))
|
|
return 0
|
|
except (OSError, RuntimeError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
|
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|