Add Core Hub operator CLI wrappers

This commit is contained in:
2026-06-27 21:23:52 +02:00
parent 81a3a1daa9
commit 4b9c216070
7 changed files with 621 additions and 2 deletions

View File

@@ -0,0 +1,417 @@
from __future__ import annotations
import argparse
import asyncio
import json
import os
from pathlib import Path
from typing import Any
import httpx
from core_hub.db import get_sessionmaker
from core_hub.migration import import_bundle, validate_bundle
from core_hub.smoke import (
DEFAULT_OPERATOR_TOKEN_ENV,
DEFAULT_RUNTIME_TOKEN_ENV,
SmokeError,
load_secret_value,
normalize_base_url,
run_smoke,
)
def load_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def load_bundle(path: Path) -> dict[str, Any]:
value = load_json(path)
if not isinstance(value, dict):
raise SystemExit("migration bundle must be a JSON object")
return value
def print_report(report: Any, output_path: Path | None = None) -> None:
body = json.dumps(report, indent=2, sort_keys=True)
if output_path is not None:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(body + "\n", encoding="utf-8")
print(body)
def optional_path(value: str | None) -> Path | None:
return Path(value) if value else None
def failure_report(reason: str, **extra: Any) -> dict[str, Any]:
return {"ok": False, "failures": [reason], **extra}
def operator_token_from_args(args: argparse.Namespace) -> str:
return load_secret_value(args.operator_token_env, optional_path(args.operator_token_file))
def runtime_token_from_args(args: argparse.Namespace) -> str:
return load_secret_value(args.runtime_token_env, optional_path(args.runtime_token_file))
def api_get(client: Any, path: str, token: str) -> Any:
response = client.request(
"GET",
path,
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
)
if response.status_code != 200:
raise SmokeError(f"GET {path} returned HTTP {response.status_code}")
return response.json()
def page_items(payload: Any, path: str) -> list[dict[str, Any]]:
if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
raise SmokeError(f"expected paginated data array from {path}")
return [item for item in payload["data"] if isinstance(item, dict)]
def collect_bootstrap_status(client: Any, operator_token: str) -> dict[str, Any]:
failures: list[str] = []
report: dict[str, Any] = {
"ok": False,
"hubs": {"count": 0, "opsHubCandidates": []},
"manifests": {"count": 0, "active": 0},
"apiConsumers": {"count": 0, "opsHubCandidates": []},
"widgets": {"count": 0, "opsReadinessGates": 0},
"interactionEvents": {"count": 0, "opsEndpointVerified": 0},
"hubRegistry": {"hubs": 0, "hubCapabilityManifests": 0},
"failures": failures,
}
try:
hubs = page_items(api_get(client, "/api/v2/hubs", operator_token), "/api/v2/hubs")
manifests = page_items(
api_get(client, "/api/v2/hub-capability-manifests", operator_token),
"/api/v2/hub-capability-manifests",
)
consumers = page_items(
api_get(client, "/api/v2/api-consumers", operator_token),
"/api/v2/api-consumers",
)
widgets = page_items(api_get(client, "/api/v2/widgets", operator_token), "/api/v2/widgets")
events = page_items(
api_get(client, "/api/v2/interaction-events", operator_token),
"/api/v2/interaction-events",
)
registry_payload = api_get(client, "/api/v2/hub-registry", operator_token)
except SmokeError as exc:
failures.append(str(exc))
return report
hub_candidates = [
{"id": hub.get("id"), "slug": hub.get("slug"), "status": hub.get("status")}
for hub in hubs
if str(hub.get("slug", "")).startswith("ops-hub")
]
consumer_candidates = [
{
"id": consumer.get("id"),
"slug": consumer.get("slug"),
"name": consumer.get("name"),
"keyPrefix": consumer.get("keyPrefix"),
"status": consumer.get("status"),
}
for consumer in consumers
if "ops-hub" in str(consumer.get("slug") or consumer.get("name") or "")
]
registry = registry_payload.get("data") if isinstance(registry_payload, dict) else {}
registry_hubs = registry.get("hubs", []) if isinstance(registry, dict) else []
registry_manifests = (
registry.get("hubCapabilityManifests", []) if isinstance(registry, dict) else []
)
report["hubs"] = {"count": len(hubs), "opsHubCandidates": hub_candidates}
report["manifests"] = {
"count": len(manifests),
"active": sum(1 for manifest in manifests if manifest.get("status") == "active"),
}
report["apiConsumers"] = {
"count": len(consumers),
"opsHubCandidates": consumer_candidates,
}
report["widgets"] = {
"count": len(widgets),
"opsReadinessGates": sum(
1 for widget in widgets if widget.get("widgetType") == "ops-readiness-gate"
),
}
report["interactionEvents"] = {
"count": len(events),
"opsEndpointVerified": sum(
1 for event in events if event.get("eventType") == "ops-endpoint-verified"
),
}
report["hubRegistry"] = {
"hubs": len(registry_hubs) if isinstance(registry_hubs, list) else 0,
"hubCapabilityManifests": (
len(registry_manifests) if isinstance(registry_manifests, list) else 0
),
}
required = {
"ops_hub": bool(hub_candidates),
"active_manifest": report["manifests"]["active"] > 0,
"api_consumer": bool(consumer_candidates),
"widget": report["widgets"]["opsReadinessGates"] > 0,
"event": report["interactionEvents"]["opsEndpointVerified"] > 0,
"registry": report["hubRegistry"]["hubs"] > 0,
}
report["required"] = required
for name, ok in required.items():
if not ok:
failures.append(f"missing {name}")
report["ok"] = not failures
return report
def gate(
name: str,
ok: bool,
evidence: dict[str, Any] | list[Any] | None,
reason: str,
) -> dict[str, Any]:
return {"name": name, "ok": ok, "reason": "ok" if ok else reason, "evidence": evidence or {}}
def build_readiness_summary(
*,
deployed_smoke_report: Path | None = None,
migration_report: Path | None = None,
activity_core_report: Path | None = None,
inter_hub_report: Path | None = None,
) -> dict[str, Any]:
gates: list[dict[str, Any]] = []
smoke = load_json(deployed_smoke_report) if deployed_smoke_report else None
smoke_ok = isinstance(smoke, dict) and smoke.get("ok") is True
smoke_evidence = {}
if isinstance(smoke, dict):
smoke_evidence = {
"runId": smoke.get("runId"),
"checks": len(smoke.get("checks", [])) if isinstance(smoke.get("checks"), list) else 0,
"eventId": (smoke.get("bootstrap") or {}).get("event", {}).get("id"),
}
gates.append(
gate(
"deployed_api_smoke",
smoke_ok,
smoke_evidence,
"missing or failed Core Hub smoke",
)
)
migration = load_json(migration_report) if migration_report else None
migration_ok = (
isinstance(migration, dict)
and migration.get("ok") is True
and migration.get("dryRun") is False
)
migration_evidence = {}
if isinstance(migration, dict):
migration_evidence = {
"source": migration.get("source"),
"bundleSha256": migration.get("bundleSha256"),
"dryRun": migration.get("dryRun"),
"counts": migration.get("counts"),
}
gates.append(
gate(
"staging_import",
migration_ok,
migration_evidence,
"missing, failed, or dry-run-only migration import report",
)
)
activity = load_json(activity_core_report) if activity_core_report else None
if isinstance(activity, list):
activity_results = activity
elif isinstance(activity, dict) and isinstance(activity.get("results"), list):
activity_results = activity["results"]
elif isinstance(activity, dict):
activity_results = [activity]
else:
activity_results = []
activity_ok = any(
isinstance(result, dict)
and result.get("status") == "posted"
and result.get("verified") is True
for result in activity_results
)
gates.append(
gate(
"activity_core_sink",
activity_ok,
{
"posted": sum(
1
for result in activity_results
if isinstance(result, dict) and result.get("status") == "posted"
)
},
"missing or unverified activity-core Core Hub sink evidence",
)
)
inter_hub = load_json(inter_hub_report) if inter_hub_report else None
inter_hub_ok = isinstance(inter_hub, dict) and inter_hub.get("ok") is True
gates.append(
gate(
"legacy_inter_hub_reference",
inter_hub_ok,
{"provided": inter_hub_report is not None},
"missing legacy Inter-Hub reference smoke evidence",
)
)
required_gate_names = {"deployed_api_smoke", "staging_import", "activity_core_sink"}
ready_for_cutover = all(
item["ok"] for item in gates if item["name"] in required_gate_names
)
return {
"ok": ready_for_cutover,
"readyForCutover": ready_for_cutover,
"gates": gates,
"openGates": [item["name"] for item in gates if not item["ok"]],
}
async def run_import(path: Path, dry_run: bool) -> dict[str, Any]:
bundle = load_bundle(path)
sessionmaker = get_sessionmaker()
async with sessionmaker() as session:
return await import_bundle(session, bundle, dry_run=dry_run)
def add_token_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--base-url", default=os.environ.get("CORE_HUB_BASE_URL"))
parser.add_argument(
"--operator-token-env",
default=os.environ.get("CORE_HUB_OPERATOR_TOKEN_ENV", DEFAULT_OPERATOR_TOKEN_ENV),
)
parser.add_argument(
"--operator-token-file",
default=os.environ.get("CORE_HUB_OPERATOR_TOKEN_FILE"),
)
parser.add_argument(
"--runtime-token-env",
default=os.environ.get("CORE_HUB_RUNTIME_TOKEN_ENV", DEFAULT_RUNTIME_TOKEN_ENV),
)
parser.add_argument(
"--runtime-token-file",
default=os.environ.get("CORE_HUB_RUNTIME_TOKEN_FILE"),
)
parser.add_argument("--timeout", type=float, default=30.0)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Core Hub operator CLI wrappers.")
parser.add_argument("--output", type=Path, default=None)
subparsers = parser.add_subparsers(dest="command", required=True)
smoke_parser = subparsers.add_parser("deployed-smoke", help="Run deployed API smoke.")
add_token_args(smoke_parser)
smoke_parser.add_argument(
"--runtime-token-output",
default=os.environ.get("CORE_HUB_RUNTIME_TOKEN_OUTPUT"),
)
smoke_parser.add_argument("--run-id", default=os.environ.get("CORE_HUB_SMOKE_RUN_ID"))
status_parser = subparsers.add_parser(
"ops-bootstrap-status", help="Summarize ops-hub API state."
)
add_token_args(status_parser)
migration_parser = subparsers.add_parser("migration", help="Migration bundle commands.")
migration_subparsers = migration_parser.add_subparsers(dest="migration_command", required=True)
validate_parser = migration_subparsers.add_parser("validate")
validate_parser.add_argument("bundle", type=Path)
import_parser = migration_subparsers.add_parser("import")
import_parser.add_argument("bundle", type=Path)
import_parser.add_argument("--dry-run", action="store_true")
readiness_parser = subparsers.add_parser("readiness-summary", help="Summarize cutover gates.")
readiness_parser.add_argument("--deployed-smoke-report", type=Path)
readiness_parser.add_argument("--migration-report", type=Path)
readiness_parser.add_argument("--activity-core-report", type=Path)
readiness_parser.add_argument("--inter-hub-report", type=Path)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "deployed-smoke":
if not args.base_url:
print_report(failure_report("set CORE_HUB_BASE_URL or --base-url"), args.output)
return 2
try:
base_url = normalize_base_url(args.base_url)
operator_token = operator_token_from_args(args)
runtime_token = runtime_token_from_args(args)
except (OSError, SmokeError) as exc:
print_report(failure_report(str(exc)), args.output)
return 2
if not operator_token:
print_report(failure_report("operator token was not provided"), args.output)
return 2
with httpx.Client(base_url=base_url, timeout=args.timeout) as client:
report = run_smoke(
client,
base_url=base_url,
operator_token=operator_token,
runtime_token=runtime_token or None,
runtime_token_output=optional_path(args.runtime_token_output),
run_id=args.run_id,
)
print_report(report, args.output)
return 0 if report.get("ok") else 1
if args.command == "ops-bootstrap-status":
if not args.base_url:
print_report(failure_report("set CORE_HUB_BASE_URL or --base-url"), args.output)
return 2
try:
base_url = normalize_base_url(args.base_url)
operator_token = operator_token_from_args(args)
except (OSError, SmokeError) as exc:
print_report(failure_report(str(exc)), args.output)
return 2
if not operator_token:
print_report(failure_report("operator token was not provided"), args.output)
return 2
with httpx.Client(base_url=base_url, timeout=args.timeout) as client:
report = collect_bootstrap_status(client, operator_token)
print_report(report, args.output)
return 0 if report.get("ok") else 1
if args.command == "migration":
if args.migration_command == "validate":
report = validate_bundle(load_bundle(args.bundle))
else:
report = asyncio.run(run_import(args.bundle, dry_run=args.dry_run))
print_report(report, args.output)
return 0 if report.get("ok") else 1
if args.command == "readiness-summary":
report = build_readiness_summary(
deployed_smoke_report=args.deployed_smoke_report,
migration_report=args.migration_report,
activity_core_report=args.activity_core_report,
inter_hub_report=args.inter_hub_report,
)
print_report(report, args.output)
return 0 if report.get("ok") else 1
parser.error(f"unknown command: {args.command}")
return 2