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

@@ -1,9 +1,10 @@
.PHONY: install test lint run openapi migrate-validate deployed-smoke staging-profile-check container-build playwright-install visual-check
.PHONY: install test lint run openapi migrate-validate deployed-smoke operator-cli staging-profile-check container-build playwright-install visual-check
UV ?= /home/worsch/.local/bin/uv
PYTHONPATH ?= src
IMAGE_REPOSITORY ?= gitea.coulomb.social/coulomb/core-hub
IMAGE_TAG ?= dev
CLI_ARGS ?=
install:
$(UV) sync --extra dev
@@ -27,6 +28,9 @@ migrate-validate:
deployed-smoke:
PYTHONPATH=$(PYTHONPATH) $(UV) run --extra dev python scripts/core_hub_deployed_smoke.py
operator-cli:
PYTHONPATH=$(PYTHONPATH) $(UV) run --extra dev python scripts/core_hub_cli.py $(CLI_ARGS)
staging-profile-check:
PYTHONPATH=$(PYTHONPATH) $(UV) run --extra dev python scripts/check_staging_profile.py

View File

@@ -0,0 +1,46 @@
# Core Hub Operator CLI
`make operator-cli` runs thin operator wrappers around Core Hub API behavior that
is already covered by tests and smoke scripts. It does not introduce a second
implementation path for Core Hub state.
## Commands
Run the deployed API smoke and emit the same non-secret evidence report as
`make deployed-smoke`:
```bash
CORE_HUB_BASE_URL=https://core-hub-staging.example.invalid \
CORE_HUB_OPERATOR_TOKEN_FILE=/secure/operator/path/core-hub-operator-token \
make operator-cli CLI_ARGS="deployed-smoke --output .local/smoke/core-hub-staging.json"
```
Summarize ops-hub bootstrap state through protected `/api/v2` routes:
```bash
CORE_HUB_BASE_URL=https://core-hub-staging.example.invalid \
CORE_HUB_OPERATOR_TOKEN_FILE=/secure/operator/path/core-hub-operator-token \
make operator-cli CLI_ARGS="ops-bootstrap-status"
```
Validate or import a migration bundle using the same migration code as
`scripts/core_hub_migrate.py`:
```bash
make operator-cli CLI_ARGS="migration validate /secure/operator/path/interhub-export.bundle.json"
CORE_HUB_DATABASE_URL=postgresql+asyncpg://... \
make operator-cli CLI_ARGS="migration import /secure/operator/path/interhub-export.bundle.json --dry-run"
```
Summarize cutover gates from non-secret evidence reports:
```bash
make operator-cli CLI_ARGS="readiness-summary \
--deployed-smoke-report .local/smoke/core-hub-staging.json \
--migration-report .local/migration/interhub-import.json \
--activity-core-report .local/smoke/activity-core-core-hub.json"
```
The readiness summary treats a migration dry-run as open, not cutover-ready. A
legacy Inter-Hub reference smoke can be supplied with `--inter-hub-report`; it is
reported as an advisory open gate when omitted.

View File

@@ -58,6 +58,14 @@ runtime shape, secret references, Alembic migration job, image build
command, health/readiness checks, rollback notes, and the handoff to
`CORE-WP-0005-T02` staging import.
## Operator CLI Wrappers
Thin CLI wrappers live in `scripts/core_hub_cli.py` and are documented
in `docs/deployment/operator-cli.md`. Run them through `make
operator-cli CLI_ARGS="<command> ..."`. They cover deployed smoke evidence, ops-hub
bootstrap status checks, migration validate/import, and readiness
summaries from non-secret evidence reports.
## Release Gates
A Core Hub release cannot replace Inter-Hub until:

7
scripts/core_hub_cli.py Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env python3
from __future__ import annotations
from core_hub.operator_cli import main
if __name__ == "__main__":
raise SystemExit(main())

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

130
tests/test_operator_cli.py Normal file
View File

@@ -0,0 +1,130 @@
import json
from core_hub.operator_cli import build_readiness_summary, collect_bootstrap_status
OPERATOR_HEADERS = {"Authorization": "Bearer operator-token"}
def _seed_ops_hub(client):
hub = client.post(
"/api/v2/hubs",
headers=OPERATOR_HEADERS,
json={
"slug": "ops-hub",
"name": "Ops Hub",
"domain": "ops.coulomb.social",
"hubKind": "domain",
"hubFamily": "vsm",
"vsmFunction": "OPS",
"vsmSystem": "1",
},
).json()
manifest = client.post(
"/api/v2/hub-capability-manifests",
headers=OPERATOR_HEADERS,
json={"hubId": hub["id"], "manifestVersion": "1.0"},
).json()
manifest = client.post(
f"/api/v2/hub-capability-manifests/{manifest['id']}/activate",
headers=OPERATOR_HEADERS,
).json()
consumer = client.post(
"/api/v2/api-consumers",
headers=OPERATOR_HEADERS,
json={"slug": "ops-hub", "name": "ops-hub", "hubCapabilityManifestId": manifest["id"]},
).json()
key = client.post(
f"/api/v2/api-consumers/{consumer['id']}/api-keys",
headers=OPERATOR_HEADERS,
json={"scopes": "framework:read hub:ops-hub:read hub:ops-hub:write"},
).json()["fullKey"]
runtime_headers = {"Authorization": f"Bearer {key}"}
widget = client.post(
"/api/v2/widgets",
headers=runtime_headers,
json={
"hubId": hub["id"],
"name": "Gitea Registry Readiness",
"widgetType": "ops-readiness-gate",
"capabilityRef": "ops:readiness:gitea-registry",
"viewContext": "ops-hub/readiness/gitea-registry",
"policyScope": "ops-registry",
},
).json()
client.post(
"/api/v2/interaction-events",
headers=runtime_headers,
json={
"widgetId": widget["id"],
"eventType": "ops-endpoint-verified",
"viewContext": "railiance-apps/workplans/RAIL-AP-WP-0001",
"metadata": {"expectedStatus": 401},
},
)
return key
def test_bootstrap_status_summarizes_ops_hub_without_secret_leak(client):
full_key = _seed_ops_hub(client)
report = collect_bootstrap_status(client, "operator-token")
assert report["ok"] is True
assert report["hubs"]["opsHubCandidates"][0]["slug"] == "ops-hub"
assert report["manifests"]["active"] == 1
assert report["apiConsumers"]["opsHubCandidates"][0]["keyPrefix"].startswith("ch_")
assert report["widgets"]["opsReadinessGates"] == 1
assert report["interactionEvents"]["opsEndpointVerified"] == 1
rendered = json.dumps(report, sort_keys=True)
assert full_key not in rendered
assert "operator-token" not in rendered
def test_readiness_summary_reports_cutover_ready_from_non_secret_evidence(tmp_path):
smoke = tmp_path / "smoke.json"
smoke.write_text(
json.dumps(
{
"ok": True,
"runId": "smoke-1",
"checks": [{"ok": True}],
"bootstrap": {"event": {"id": "event-1"}},
}
),
encoding="utf-8",
)
migration = tmp_path / "migration.json"
migration.write_text(
json.dumps(
{
"ok": True,
"dryRun": False,
"source": "inter-hub-haskell",
"bundleSha256": "abc",
"counts": {},
}
),
encoding="utf-8",
)
activity = tmp_path / "activity.json"
activity.write_text(json.dumps([{"status": "posted", "verified": True}]), encoding="utf-8")
report = build_readiness_summary(
deployed_smoke_report=smoke,
migration_report=migration,
activity_core_report=activity,
)
assert report["readyForCutover"] is True
assert "legacy_inter_hub_reference" in report["openGates"]
def test_readiness_summary_keeps_dry_run_import_open(tmp_path):
migration = tmp_path / "migration.json"
migration.write_text(json.dumps({"ok": True, "dryRun": True}), encoding="utf-8")
report = build_readiness_summary(migration_report=migration)
assert report["readyForCutover"] is False
assert "staging_import" in report["openGates"]

View File

@@ -195,7 +195,7 @@ staging cluster has already been deployed.
```task
id: CORE-WP-0008-T05
status: todo
status: done
priority: medium
state_hub_task_id: "a8b5d576-428e-4060-bf3d-46f54541bbe1"
```
@@ -212,6 +212,13 @@ Initial commands should cover:
The CLI must call the same API paths as tests and smokes. It must not become a
second implementation of Core Hub behavior.
Completed 2026-06-27: added `scripts/core_hub_cli.py` backed by
`core_hub.operator_cli`, plus `make operator-cli`, documentation, and tests.
The CLI wraps the existing deployed smoke logic, protected `/api/v2` ops-hub
bootstrap status checks, migration validate/import code, and a cutover
readiness summary over non-secret evidence reports. It reports ids, counts,
statuses, and key prefixes only; no full token material is emitted.
## Task: Gate and design the web UI rebuild
```task