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,288 @@
|
||||
#!/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 = (
|
||||
"VERSION",
|
||||
"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",
|
||||
"docs/RELEASE_RUNBOOK.md",
|
||||
"docs/KNOWN_LIMITATIONS.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,
|
||||
"version": (ROOT / "VERSION").read_text(encoding="utf-8").strip(),
|
||||
"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())
|
||||
Reference in New Issue
Block a user