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
342 lines
12 KiB
Python
342 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Provision and fingerprint the bounded Belgium/North Sea RC journey areas.
|
|
|
|
The command is dry-run-first. With --apply it creates only missing Area rows
|
|
through the public API. Existing Mol and Kempen authoritative geometries are
|
|
copied into the national release workspace and retained as source evidence;
|
|
no dataset, feature or source artifact is created by this command.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
DEFAULT_BASE_URL = os.environ.get("GE_INTEL_BASE_URL", "http://127.0.0.1:1202")
|
|
NATIONAL_PROJECT = "Belgium and North Sea Workbench"
|
|
MOL_PROJECT = "Mol Municipality Workbench"
|
|
KEMPEN_PROJECT = "Kempen Regional Workbench"
|
|
|
|
|
|
def rectangle(minx: float, miny: float, maxx: float, maxy: float) -> dict[str, Any]:
|
|
return {
|
|
"type": "MultiPolygon",
|
|
"coordinates": [
|
|
[
|
|
[
|
|
[minx, miny],
|
|
[maxx, miny],
|
|
[maxx, maxy],
|
|
[minx, maxy],
|
|
[minx, miny],
|
|
]
|
|
]
|
|
],
|
|
}
|
|
|
|
|
|
GOLDEN_AREAS = (
|
|
{
|
|
"key": "wallonia_urban_rural",
|
|
"project": NATIONAL_PROJECT,
|
|
"name": "RC Golden - Wallonia urban-rural",
|
|
"geometry": rectangle(4.80, 50.42, 4.95, 50.53),
|
|
"expected_zones": ["wallonia"],
|
|
},
|
|
{
|
|
"key": "brussels_urban",
|
|
"project": NATIONAL_PROJECT,
|
|
"name": "RC Golden - Brussels urban",
|
|
"geometry": rectangle(4.32, 50.82, 4.39, 50.88),
|
|
"expected_zones": ["brussels"],
|
|
},
|
|
{
|
|
"key": "language_boundary",
|
|
"project": NATIONAL_PROJECT,
|
|
"name": "RC Golden - language boundary",
|
|
"geometry": rectangle(4.05, 50.70, 4.20, 50.80),
|
|
"expected_zones": ["flanders", "wallonia"],
|
|
},
|
|
{
|
|
"key": "coast_land_sea",
|
|
"project": NATIONAL_PROJECT,
|
|
"name": "RC Golden - coast land-sea",
|
|
"geometry": rectangle(2.88, 51.20, 2.98, 51.28),
|
|
"expected_zones": ["flanders", "territorial_sea"],
|
|
},
|
|
{
|
|
"key": "north_sea_multi_zone",
|
|
"project": NATIONAL_PROJECT,
|
|
"name": "RC Golden - North Sea multi-zone",
|
|
"geometry": rectangle(2.55, 51.35, 2.75, 51.55),
|
|
"expected_zones": [
|
|
"territorial_sea",
|
|
"exclusive_economic_zone",
|
|
"continental_shelf",
|
|
],
|
|
},
|
|
)
|
|
|
|
SOURCE_AREAS: tuple[dict[str, Any], ...] = (
|
|
{
|
|
"key": "mol_municipality",
|
|
"source_project": MOL_PROJECT,
|
|
"target_name": "RC Golden - Mol municipality",
|
|
"predicate": lambda name: name.startswith("Gemeente Mol"),
|
|
"expected_zones": ["flanders"],
|
|
},
|
|
{
|
|
"key": "kempen_region",
|
|
"source_project": KEMPEN_PROJECT,
|
|
"target_name": "RC Golden - Kempen region",
|
|
"predicate": lambda name: "Vervoerregio Kempen" in name,
|
|
"expected_zones": ["flanders"],
|
|
},
|
|
)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
|
parser.add_argument("--output", type=Path)
|
|
parser.add_argument(
|
|
"--apply",
|
|
action="store_true",
|
|
help="Create missing bounded RC Areas through the canonical API.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
class ApiClient:
|
|
def __init__(self, base_url: str) -> None:
|
|
self.base_url = base_url.rstrip("/")
|
|
|
|
def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
|
|
data = None
|
|
headers = {"Accept": "application/json"}
|
|
if payload is not None:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
request = Request(f"{self.base_url}{path}", data=data, headers=headers, method=method)
|
|
try:
|
|
with urlopen(request, timeout=60) as response:
|
|
body = json.load(response)
|
|
except HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"{method} {path} failed with HTTP {exc.code}: {detail}") from exc
|
|
except URLError as exc:
|
|
raise RuntimeError(f"{method} {path} failed: {exc.reason}") from exc
|
|
if not isinstance(body, dict) or "data" not in body:
|
|
raise RuntimeError(f"{method} {path} did not return the canonical data envelope")
|
|
return body["data"]
|
|
|
|
|
|
def canonical_hash(value: Any) -> str:
|
|
serialized = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def geometry_bbox(geometry: dict[str, Any]) -> dict[str, float]:
|
|
points: list[tuple[float, float]] = []
|
|
|
|
def walk(value: Any) -> None:
|
|
if not isinstance(value, list):
|
|
return
|
|
if len(value) >= 2 and isinstance(value[0], (int, float)) and isinstance(value[1], (int, float)):
|
|
points.append((float(value[0]), float(value[1])))
|
|
return
|
|
for child in value:
|
|
walk(child)
|
|
|
|
walk(geometry.get("coordinates"))
|
|
if not points:
|
|
raise RuntimeError("Golden Area geometry has no coordinates")
|
|
xs = [point[0] for point in points]
|
|
ys = [point[1] for point in points]
|
|
return {"minx": min(xs), "miny": min(ys), "maxx": max(xs), "maxy": max(ys)}
|
|
|
|
|
|
def find_one(items: list[dict[str, Any]], predicate: Callable[[str], bool], label: str) -> dict[str, Any]:
|
|
matches = [item for item in items if predicate(str(item.get("name", "")))]
|
|
if len(matches) != 1:
|
|
raise RuntimeError(f"Expected exactly one {label}; found {len(matches)}")
|
|
return matches[0]
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
client = ApiClient(args.base_url)
|
|
projects_payload = client.request("GET", "/api/v1/projects?limit=200")
|
|
projects = projects_payload.get("items", [])
|
|
projects_by_name = {str(project["name"]): project for project in projects}
|
|
required_projects = {NATIONAL_PROJECT, MOL_PROJECT, KEMPEN_PROJECT}
|
|
missing_projects = sorted(required_projects - set(projects_by_name))
|
|
if missing_projects:
|
|
raise RuntimeError(f"Required release projects are missing: {', '.join(missing_projects)}")
|
|
|
|
areas_by_project: dict[str, list[dict[str, Any]]] = {}
|
|
for project_name in required_projects:
|
|
project_id = projects_by_name[project_name]["id"]
|
|
payload = client.request("GET", f"/api/v1/projects/{project_id}/areas?limit=200")
|
|
areas_by_project[project_name] = list(payload.get("items", []))
|
|
|
|
evidence: list[dict[str, Any]] = []
|
|
missing_area_names: list[str] = []
|
|
for definition in GOLDEN_AREAS:
|
|
project_name = str(definition["project"])
|
|
project = projects_by_name[project_name]
|
|
existing = [
|
|
area
|
|
for area in areas_by_project[project_name]
|
|
if area.get("name") == definition["name"]
|
|
]
|
|
if len(existing) > 1:
|
|
raise RuntimeError(f"Duplicate release Area name: {definition['name']}")
|
|
created = False
|
|
if not existing:
|
|
if not args.apply:
|
|
missing_area_names.append(str(definition["name"]))
|
|
continue
|
|
area = client.request(
|
|
"POST",
|
|
f"/api/v1/projects/{project['id']}/areas",
|
|
{
|
|
"name": definition["name"],
|
|
"geometry": definition["geometry"],
|
|
"crs": "EPSG:4326",
|
|
},
|
|
)
|
|
areas_by_project[project_name].append(area)
|
|
created = True
|
|
else:
|
|
area = existing[0]
|
|
|
|
geometry = area.get("geometry")
|
|
if not isinstance(geometry, dict):
|
|
raise RuntimeError(f"Area {definition['name']} has no serialized geometry")
|
|
expected_hash = canonical_hash(definition["geometry"])
|
|
actual_hash = canonical_hash(geometry)
|
|
if actual_hash != expected_hash:
|
|
raise RuntimeError(
|
|
f"Area {definition['name']} geometry drifted: expected {expected_hash}, got {actual_hash}"
|
|
)
|
|
evidence.append(
|
|
{
|
|
"key": definition["key"],
|
|
"project_name": project_name,
|
|
"project_id": str(project["id"]),
|
|
"area_id": str(area["id"]),
|
|
"area_name": str(area["name"]),
|
|
"bbox": geometry_bbox(geometry),
|
|
"geometry_sha256": actual_hash,
|
|
"expected_zones": definition["expected_zones"],
|
|
"created": created,
|
|
}
|
|
)
|
|
|
|
if missing_area_names:
|
|
print("Missing release Areas (rerun with --apply):", file=sys.stderr)
|
|
for name in missing_area_names:
|
|
print(f"- {name}", file=sys.stderr)
|
|
return 3
|
|
|
|
national_project = projects_by_name[NATIONAL_PROJECT]
|
|
for definition in SOURCE_AREAS:
|
|
source_project_name = str(definition["source_project"])
|
|
source_area = find_one(
|
|
areas_by_project[source_project_name],
|
|
definition["predicate"],
|
|
f"{definition['key']} Area in {source_project_name}",
|
|
)
|
|
geometry = source_area.get("geometry")
|
|
if not isinstance(geometry, dict):
|
|
raise RuntimeError(f"Area {source_area['name']} has no serialized geometry")
|
|
|
|
existing = [
|
|
area
|
|
for area in areas_by_project[NATIONAL_PROJECT]
|
|
if area.get("name") == definition["target_name"]
|
|
]
|
|
if len(existing) > 1:
|
|
raise RuntimeError(f"Duplicate release Area name: {definition['target_name']}")
|
|
created = False
|
|
if not existing:
|
|
if not args.apply:
|
|
missing_area_names.append(str(definition["target_name"]))
|
|
continue
|
|
area = client.request(
|
|
"POST",
|
|
f"/api/v1/projects/{national_project['id']}/areas",
|
|
{
|
|
"name": definition["target_name"],
|
|
"geometry": geometry,
|
|
"crs": "EPSG:4326",
|
|
},
|
|
)
|
|
areas_by_project[NATIONAL_PROJECT].append(area)
|
|
created = True
|
|
else:
|
|
area = existing[0]
|
|
|
|
target_geometry = area.get("geometry")
|
|
if not isinstance(target_geometry, dict):
|
|
raise RuntimeError(f"Area {area['name']} has no serialized geometry")
|
|
source_hash = canonical_hash(geometry)
|
|
target_hash = canonical_hash(target_geometry)
|
|
if target_hash != source_hash:
|
|
raise RuntimeError(
|
|
f"Area {definition['target_name']} geometry drifted from "
|
|
f"{source_project_name}: expected {source_hash}, got {target_hash}"
|
|
)
|
|
evidence.append(
|
|
{
|
|
"key": definition["key"],
|
|
"project_name": NATIONAL_PROJECT,
|
|
"project_id": str(national_project["id"]),
|
|
"area_id": str(area["id"]),
|
|
"area_name": str(area["name"]),
|
|
"bbox": geometry_bbox(target_geometry),
|
|
"geometry_sha256": target_hash,
|
|
"expected_zones": definition["expected_zones"],
|
|
"created": created,
|
|
"source_project_name": source_project_name,
|
|
"source_project_id": str(projects_by_name[source_project_name]["id"]),
|
|
"source_area_id": str(source_area["id"]),
|
|
"source_area_name": str(source_area["name"]),
|
|
}
|
|
)
|
|
|
|
if missing_area_names:
|
|
print("Missing release Areas (rerun with --apply):", file=sys.stderr)
|
|
for name in missing_area_names:
|
|
print(f"- {name}", file=sys.stderr)
|
|
return 3
|
|
|
|
evidence.sort(key=lambda item: item["key"])
|
|
manifest = {
|
|
"schema_version": 1,
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"base_url": args.base_url.rstrip("/"),
|
|
"area_count": len(evidence),
|
|
"areas": evidence,
|
|
}
|
|
output = json.dumps(manifest, indent=2, sort_keys=True)
|
|
if args.output:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(f"{output}\n", encoding="utf-8")
|
|
print(output)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|