generated from coulomb/repo-seed
Add Core Hub operator CLI wrappers
This commit is contained in:
6
Makefile
6
Makefile
@@ -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
|
||||
|
||||
|
||||
46
docs/deployment/operator-cli.md
Normal file
46
docs/deployment/operator-cli.md
Normal 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.
|
||||
@@ -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
7
scripts/core_hub_cli.py
Executable 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())
|
||||
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
|
||||
130
tests/test_operator_cli.py
Normal file
130
tests/test_operator_cli.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user