the-custodian/tools/repo_classification_convergence.py
codex 93b8174abd
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Python Tests / pytest (push) Successful in 21s
finish three custodian workplans from live hub evidence
Close CUST-WP-0064 after the 2026-08-24 unassisted fire ingested
clay-borg, close CUST-WP-0065 now that all 120 active repos project a
classification, and close ADHOC-2026-08-25. Mark CUST-WP-0067 T02/T10
done (reverse relays already gone; work-record recovery lives on 0068).
Park the later no-checkout SBOM regression as CUST-IN-0015. Teach the
classification gate to use this host's checkout path.
2026-08-28 20:27:05 +02:00

237 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""Report active-fleet Repo Classification convergence from State Hub.
The repository file remains authoritative. This command compares the live
State Hub projection with each active record's registered local checkout and
separates missing owner decisions from projection failures.
"""
from __future__ import annotations
import argparse
import json
import os
import socket
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Callable
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from tools.validate_repo_classification import load_allowed, validate # noqa: E402
DEFAULT_API_BASE = "http://127.0.0.1:8000"
UrlOpen = Callable[..., Any]
class FleetReadError(RuntimeError):
"""State Hub did not return a usable repository projection."""
def fetch_repositories(
api_base: str,
*,
timeout: float = 15.0,
opener: UrlOpen = urllib.request.urlopen,
) -> list[dict[str, Any]]:
url = f"{api_base.rstrip('/')}/repos/?limit=500"
request = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with opener(request, timeout=timeout) as response:
payload = json.load(response)
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
raise FleetReadError(f"cannot read State Hub repositories: {exc}") from exc
if not isinstance(payload, list) or any(not isinstance(item, dict) for item in payload):
raise FleetReadError("State Hub /repos response is not a list of objects")
return payload
def _checkout_candidates(record: dict[str, Any], *, hostname: str) -> list[Path]:
"""Prefer this host's recorded checkout over a `local_path` from another machine."""
seen: set[str] = set()
candidates: list[Path] = []
def add(raw: object) -> None:
if not isinstance(raw, str):
return
text = raw.strip()
if not text or text in seen:
return
seen.add(text)
candidates.append(Path(text))
host_paths = record.get("host_paths")
if isinstance(host_paths, dict):
short = hostname.split(".")[0]
add(host_paths.get(hostname))
add(host_paths.get(short))
for value in host_paths.values():
if isinstance(value, str) and Path(value).is_dir():
add(value)
add(record.get("local_path"))
return candidates
def _source_path(record: dict[str, Any], *, hostname: str) -> Path | None:
preferred: Path | None = None
for checkout in _checkout_candidates(record, hostname=hostname):
path = checkout / ".repo-classification.yaml"
if preferred is None:
preferred = path
if path.is_file():
return path
return preferred
def analyze_repositories(
records: list[dict[str, Any]],
*,
validate_sources: bool = True,
hostname: str | None = None,
) -> dict[str, Any]:
active = [record for record in records if record.get("status") == "active"]
classified = [record for record in active if record.get("category") is not None]
null_records = [record for record in active if record.get("category") is None]
missing_source: list[str] = []
present_unprojected: list[str] = []
projected_without_source: list[str] = []
invalid_source: dict[str, list[str]] = {}
source_warnings: dict[str, list[str]] = {}
duplicate_slugs: list[str] = []
slug_counts: dict[str, int] = {}
allowed = load_allowed() if validate_sources else None
current_host = hostname or socket.gethostname()
for record in active:
slug = str(record.get("slug") or "<missing-slug>")
slug_counts[slug] = slug_counts.get(slug, 0) + 1
path = _source_path(record, hostname=current_host)
source_present = path is not None and path.is_file()
if record.get("category") is None:
(present_unprojected if source_present else missing_source).append(slug)
elif not source_present:
projected_without_source.append(slug)
if validate_sources and source_present and path is not None:
try:
document = yaml.safe_load(path.read_text(encoding="utf-8"))
errors, warnings = validate(document, allowed)
except (OSError, yaml.YAMLError) as exc:
errors, warnings = [f"cannot read/parse source: {exc}"], []
if errors:
invalid_source[slug] = errors
if warnings:
source_warnings[slug] = warnings
duplicate_slugs = sorted(slug for slug, count in slug_counts.items() if count > 1)
missing_source.sort()
present_unprojected.sort()
projected_without_source.sort()
blocking = bool(
missing_source
or present_unprojected
or projected_without_source
or invalid_source
or duplicate_slugs
)
return {
"registered": len(records),
"active": len(active),
"active_classified": len(classified),
"active_null_category": len(null_records),
"missing_source": missing_source,
"present_unprojected": present_unprojected,
"projected_without_source": projected_without_source,
"invalid_source": dict(sorted(invalid_source.items())),
"source_warnings": dict(sorted(source_warnings.items())),
"source_warning_count": sum(len(items) for items in source_warnings.values()),
"duplicate_active_slugs": duplicate_slugs,
"converged": not blocking,
}
def render_text(report: dict[str, Any], *, show_warnings: bool = False) -> str:
lines = [
f"registered: {report['registered']}",
f"active: {report['active']}",
f"active classified: {report['active_classified']}",
f"active null category: {report['active_null_category']}",
f"missing source file: {len(report['missing_source'])}",
f"source present but unprojected: {len(report['present_unprojected'])}",
f"projected without source file: {len(report['projected_without_source'])}",
f"invalid source file: {len(report['invalid_source'])}",
f"source validation warnings: {report['source_warning_count']}",
f"duplicate active slug: {len(report['duplicate_active_slugs'])}",
f"converged: {'yes' if report['converged'] else 'no'}",
]
for key in (
"missing_source",
"present_unprojected",
"projected_without_source",
"duplicate_active_slugs",
):
if report[key]:
lines.append(f"{key}: {', '.join(report[key])}")
for slug, errors in report["invalid_source"].items():
lines.append(f"invalid_source[{slug}]: {'; '.join(errors)}")
if show_warnings:
for slug, warnings in report["source_warnings"].items():
lines.append(f"source_warnings[{slug}]: {'; '.join(warnings)}")
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--api-base",
default=os.environ.get("STATE_HUB_API_BASE", DEFAULT_API_BASE),
help="State Hub REST base URL (default: %(default)s)",
)
parser.add_argument("--timeout", type=float, default=15.0)
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
parser.add_argument(
"--show-warnings",
action="store_true",
help="include individual non-blocking source validation warnings",
)
parser.add_argument(
"--skip-source-validation",
action="store_true",
help="only compare file presence and projection state",
)
parser.add_argument(
"--require-converged",
action="store_true",
help="exit 1 while any active classification gap remains",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
records = fetch_repositories(args.api_base, timeout=args.timeout)
report = analyze_repositories(
records,
validate_sources=not args.skip_source_validation,
)
except FleetReadError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if args.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
print(render_text(report, show_warnings=args.show_warnings))
return 1 if args.require_converged and not report["converged"] else 0
if __name__ == "__main__":
raise SystemExit(main())