Initial public release
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
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
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user