generated from coulomb/repo-seed
Add Core Hub operator CLI wrappers
This commit is contained in:
417
src/core_hub/operator_cli.py
Normal file
417
src/core_hub/operator_cli.py
Normal 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
|
||||
Reference in New Issue
Block a user