Add governed source freshness audit
This commit is contained in:
@@ -1688,6 +1688,26 @@ for an explicit source refetch.
|
||||
operators use canonical APIs and persistent operator-evidence storage. They do
|
||||
not run on application startup.
|
||||
|
||||
## Read-only source freshness audit
|
||||
|
||||
Inspect the persisted publication, version and storage evidence for one
|
||||
project without contacting an external provider:
|
||||
|
||||
```bash
|
||||
docker exec geointel python /app/scripts/audit_source_freshness.py \
|
||||
--project-id 82a85913-c522-45d7-84a1-02b393d89e55 \
|
||||
--api-url http://127.0.0.1/api/v1 \
|
||||
--fail-on integrity
|
||||
```
|
||||
|
||||
Add `--output /app/storage/operator-evidence/source-freshness/latest.json` for
|
||||
a persistent JSON evidence copy. `--fail-on integrity` exits non-zero only for
|
||||
missing versions/checksum/file/size evidence; `due` also gates planned source
|
||||
reviews and `attention` additionally gates unclassified sources. This command
|
||||
uses one canonical `GET`, changes no application data and performs no source
|
||||
download. It can therefore be scheduled explicitly through Unraid cron without
|
||||
turning GeoIntel into a real-time monitoring system.
|
||||
|
||||
## Tower deployment
|
||||
|
||||
Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime:
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only source freshness and local integrity audit for one GeoIntel project."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inspect persisted source versions and storage evidence without refreshing source data."
|
||||
)
|
||||
parser.add_argument("--project-id", required=True, help="GeoIntel project UUID")
|
||||
parser.add_argument("--api-url", default="http://127.0.0.1/api/v1", help="API base URL ending in /api/v1")
|
||||
parser.add_argument("--timeout", type=float, default=30.0, help="HTTP timeout in seconds")
|
||||
parser.add_argument("--json", action="store_true", help="Print the canonical report data as JSON")
|
||||
parser.add_argument("--output", type=Path, help="Optional path for a JSON evidence copy")
|
||||
parser.add_argument(
|
||||
"--fail-on",
|
||||
choices=("never", "integrity", "due", "attention"),
|
||||
default="integrity",
|
||||
help="Non-zero exit policy for cron or release automation",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def fetch_report(api_url: str, project_id: str, timeout: float) -> dict:
|
||||
endpoint = f"{api_url.rstrip('/')}/projects/{project_id}/datasets/source-freshness"
|
||||
request = Request(endpoint, headers={"Accept": "application/json", "User-Agent": "GeoIntel-source-audit/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}") 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("Response is not a canonical GeoIntel data envelope")
|
||||
return payload["data"]
|
||||
|
||||
|
||||
def print_text(report: dict) -> None:
|
||||
summary = report.get("summary", {})
|
||||
print(
|
||||
"GeoIntel source audit: "
|
||||
f"{summary.get('source_count', 0)} sources, "
|
||||
f"{summary.get('dataset_count', 0)} datasets, "
|
||||
f"{summary.get('due_count', 0)} due, "
|
||||
f"{summary.get('review_required_count', 0)} review, "
|
||||
f"{summary.get('integrity_issue_count', 0)} integrity issues"
|
||||
)
|
||||
for item in report.get("items", []):
|
||||
status = item.get("status", "unknown")
|
||||
if status not in {"current", "local"} or item.get("integrity", {}).get("missing_version_count", 0):
|
||||
print(f"- {status:15} {item.get('display_name', item.get('source_name'))}: {item.get('reason', '')}")
|
||||
print("No external catalog was queried and no dataset was modified.")
|
||||
|
||||
|
||||
def should_fail(report: dict, fail_on: str) -> bool:
|
||||
summary = report.get("summary", {})
|
||||
integrity = int(summary.get("integrity_issue_count", 0))
|
||||
due = int(summary.get("due_count", 0))
|
||||
review = int(summary.get("review_required_count", 0))
|
||||
if fail_on == "never":
|
||||
return False
|
||||
if fail_on == "integrity":
|
||||
return integrity > 0
|
||||
if fail_on == "due":
|
||||
return integrity > 0 or due > 0
|
||||
return integrity > 0 or due > 0 or review > 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
report = fetch_report(args.api_url, args.project_id, args.timeout)
|
||||
except RuntimeError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
serialized = json.dumps(report, indent=2, sort_keys=True)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(serialized + "\n", encoding="utf-8")
|
||||
if args.json:
|
||||
print(serialized)
|
||||
else:
|
||||
print_text(report)
|
||||
return 1 if should_fail(report, args.fail_on) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -65,6 +65,7 @@ ${PYTHON_BIN} -m py_compile scripts/geographic_scopes.py
|
||||
${PYTHON_BIN} -m py_compile scripts/provision_geographic_scope.py
|
||||
${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_buildings.py
|
||||
${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_context.py
|
||||
${PYTHON_BIN} -m py_compile scripts/audit_source_freshness.py
|
||||
${PYTHON_BIN} -m py_compile scripts/export_operator_yolo_dataset.py
|
||||
${PYTHON_BIN} -m py_compile scripts/export_operator_yolo_tile_dataset.py
|
||||
${PYTHON_BIN} -m py_compile scripts/audit_operator_yolo_dataset_quality.py
|
||||
|
||||
Reference in New Issue
Block a user