From 6aff34cc5bcacf25eb0b0a92c62c6a1e1aa8e8ab Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 17 Jun 2026 00:17:59 +0200 Subject: [PATCH] feat: add interhub bootstrap helper --- Makefile | 25 ++ docs/bootstrap-runbook.md | 171 ++++++++++++++ pyproject.toml | 9 + scripts/interhub-gate-probe.py | 15 ++ scripts/ops-hub-bootstrap-api.py | 368 +++++++++++++++++++++++++++++ seeds/ops-hub-bootstrap.sql | 287 ++++++++++++++++++++++ seeds/ops-hub-manifest.draft.json | 65 +++++ seeds/ops-hub-widgets.seed.json | 100 ++++++++ src/ops_hub/__init__.py | 5 + src/ops_hub/interhub_gate_probe.py | 111 +++++++++ tests/test_interhub_gate_probe.py | 41 ++++ 11 files changed, 1197 insertions(+) create mode 100644 Makefile create mode 100644 docs/bootstrap-runbook.md create mode 100644 pyproject.toml create mode 100644 scripts/interhub-gate-probe.py create mode 100644 scripts/ops-hub-bootstrap-api.py create mode 100644 seeds/ops-hub-bootstrap.sql create mode 100644 seeds/ops-hub-manifest.draft.json create mode 100644 seeds/ops-hub-widgets.seed.json create mode 100644 src/ops_hub/__init__.py create mode 100644 src/ops_hub/interhub_gate_probe.py create mode 100644 tests/test_interhub_gate_probe.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9a1feb4 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +IHUB_BASE ?= https://hub.coulomb.social +IHUB_OPERATOR_KEY_FILE ?= +OPS_HUB_RUNTIME_KEY_OUTPUT ?= + +.PHONY: help interhub-gate interhub-bootstrap interhub-bootstrap-help + +help: + @echo "Targets:" + @echo " interhub-gate Probe whether production Inter-Hub exposes the ops-hub bootstrap API surface" + @echo " interhub-bootstrap-help Show bootstrap helper options" + @echo " interhub-bootstrap Run attended ops-hub Inter-Hub bootstrap with IHUB_OPERATOR_KEY_FILE" + +interhub-gate: + IHUB_BASE="$(IHUB_BASE)" python3 scripts/interhub-gate-probe.py + +interhub-bootstrap-help: + python3 scripts/ops-hub-bootstrap-api.py --help + +interhub-bootstrap: + @test -n "$(IHUB_OPERATOR_KEY_FILE)" || \ + (echo "IHUB_OPERATOR_KEY_FILE is required; materialize the operator key into a 0600 temp file first." >&2; exit 2) + IHUB_BASE="$(IHUB_BASE)" \ + IHUB_OPERATOR_KEY_FILE="$(IHUB_OPERATOR_KEY_FILE)" \ + OPS_HUB_RUNTIME_KEY_OUTPUT="$(OPS_HUB_RUNTIME_KEY_OUTPUT)" \ + python3 scripts/ops-hub-bootstrap-api.py diff --git a/docs/bootstrap-runbook.md b/docs/bootstrap-runbook.md new file mode 100644 index 0000000..e71bcac --- /dev/null +++ b/docs/bootstrap-runbook.md @@ -0,0 +1,171 @@ +# Ops Hub Bootstrap Runbook + +Date: 2026-06-17 + +## Purpose + +This runbook is the operator-ready path for activating `ops-hub` in production +Inter-Hub. It covers the preferred API bootstrap, key custody expectations, the +ops-warden remote execution lane, and the explicit SQL fallback. + +Use this when finishing `CUST-WP-0047-T05` or any later ops-hub Inter-Hub +activation task. + +## Inputs + +- Manifest draft: `seeds/ops-hub-manifest.draft.json` +- Widget seed: `seeds/ops-hub-widgets.seed.json` +- API helper: `scripts/ops-hub-bootstrap-api.py` +- Migration fallback: `seeds/ops-hub-bootstrap.sql` +- Production Inter-Hub base URL: `https://hub.coulomb.social` + +## Secret Custody Rules + +- Do not paste operator keys into Codex-visible chat, workplans, commits, or + shell transcripts. +- Prefer `IHUB_OPERATOR_KEY_FILE` over `IHUB_OPERATOR_KEY`. +- Operator key temp files must be mode `0600` and removed after the run. +- The generated ops-hub runtime key is display-once material. Store it in + OpenBao or the approved operator secret store immediately. +- The helper prints only non-secret ids, key prefixes, and file paths. + +## Preferred API Bootstrap + +First confirm the API surface: + +```bash +make interhub-gate +``` + +Then materialize the Inter-Hub operator key into a temporary file. Example +local pattern: + +```bash +umask 077 +operator_key_file="$(mktemp)" +# Write the operator key into "$operator_key_file" from the approved secret +# source. Do not echo it into shared logs. +``` + +Run the attended bootstrap: + +```bash +make interhub-bootstrap \ + IHUB_BASE=https://hub.coulomb.social \ + IHUB_OPERATOR_KEY_FILE="$operator_key_file" +``` + +The helper creates or reuses: + +- the `ops-hub` hub row +- the active ops-hub capability manifest +- the `ops-hub` API consumer +- an ops-hub runtime API key, when one was not supplied +- the seed widgets from `seeds/ops-hub-widgets.seed.json` +- the initial Gitea readiness event, unless `--skip-event` is used directly + +If the helper creates a runtime key, it writes the full value to a `0600` file +and prints the path plus key prefix. Store that key immediately, then remove the +file: + +```bash +runtime_key_file="/path/printed/as/runtimeKey.keyFile" +# Example path only; use the approved OpenBao mount/path for the environment. +bao kv put secret/platform/operators/ops-hub/runtime \ + OPS_HUB_KEY="$(cat "$runtime_key_file")" + +rm -f "$operator_key_file" "$runtime_key_file" +``` + +If an ops-hub runtime key already exists in the secret store, materialize it as +`OPS_HUB_KEY_FILE` before the run. The helper will reuse it instead of creating +a new display-once key. + +## Remote Execution With Ops-Warden + +When the operator key must stay on a trusted host, use the ops-warden access +lane instead of moving the key into the workstation session: + +1. Add or reuse an `agt` actor such as `agt-codex-interhub-bootstrap` with a + narrow principal such as `agt-interhub-bootstrap` and a short TTL. +2. Use `ops-ssh-wrapper` to acquire a short-lived SSH certificate. +3. On the target host, railiance-infra should map the principal to a narrow + force-command or wrapper that runs only this bootstrap path. +4. The host wrapper reads the operator key from OpenBao or an attended temp + file, runs `make interhub-bootstrap`, stores the generated ops-hub runtime + key back into OpenBao, and emits non-secret JSON evidence. + +See `ops-warden/wiki/InterHubBootstrapAccessLane.md` for the certificate +envelope and host-boundary rules. + +## SQL Fallback Path + +Prefer the API bootstrap whenever possible. Use SQL only when the operator +explicitly approves deployment-side migration/bootstrap execution. + +From a shell with Railiance01 Kubernetes access: + +```bash +kubectl exec -i -n databases net-kingdom-pg-1 -- \ + psql -U postgres -d interhub \ + < /home/worsch/ops-hub/seeds/ops-hub-bootstrap.sql +``` + +If using the HostEurope kubeconfig from the workstation: + +```bash +KUBECONFIG=~/.kube/config-hosteurope \ +kubectl exec -i -n databases net-kingdom-pg-1 -- \ + psql -U postgres -d interhub \ + < /home/worsch/ops-hub/seeds/ops-hub-bootstrap.sql +``` + +The SQL fallback creates the hub, active manifest, registry entries, API +consumer row, and seed widgets. It does not create the one-time visible static +API key. Generate that through the authenticated Inter-Hub UI or the supported +API helper and store it outside Git. + +## Validation + +After manifest activation: + +```bash +curl -s https://hub.coulomb.social/api/v2/widget-types +curl -s https://hub.coulomb.social/api/v2/event-types +curl -s https://hub.coulomb.social/api/v2/annotation-categories +``` + +Expected: ops-owned vocabulary appears in the relevant registries. + +After API key creation or reuse: + +```bash +curl -s -X POST https://hub.coulomb.social/api/v2/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "client_id=" \ + --data-urlencode "client_secret=" \ + --data-urlencode "scope=framework:read hub:ops-hub:read hub:ops-hub:write" +``` + +Expected: a short-lived access token is returned. + +After widget seeding: + +```bash +curl -s https://hub.coulomb.social/api/v2/hub-registry +``` + +Expected: `ops-hub` is visible, and the operator can see the seeded widgets in +the authenticated UI. + +## Current Live-Execution Blocker + +The repo-side helper and runbook are ready. The remaining blocker is an +authenticated production execution lane: + +- an operator-provided `IHUB_OPERATOR_KEY_FILE`, +- an OpenBao-materialized key on a trusted host, or +- an explicitly approved deployment-side migration/bootstrap path. + +Until one of those is available, run only local validation and gate probes. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a2396ed --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "ops-hub" +version = "0.1.0" +description = "Operations / System 1 extension tooling for Inter-Hub" +requires-python = ">=3.11" +dependencies = [] + +[tool.pytest.ini_options] +pythonpath = ["src"] diff --git a/scripts/interhub-gate-probe.py b/scripts/interhub-gate-probe.py new file mode 100644 index 0000000..8faeca1 --- /dev/null +++ b/scripts/interhub-gate-probe.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Run the Inter-Hub ops-hub bootstrap gate probe.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from ops_hub.interhub_gate_probe import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ops-hub-bootstrap-api.py b/scripts/ops-hub-bootstrap-api.py new file mode 100644 index 0000000..505f6be --- /dev/null +++ b/scripts/ops-hub-bootstrap-api.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +"""Bootstrap ops-hub in Inter-Hub using the prepared ops-hub seeds. + +The script never prints full API keys. The operator key is read from +IHUB_OPERATOR_KEY or IHUB_OPERATOR_KEY_FILE. If an ops-hub runtime key is +created, the full key is written to a 0600 temp file and only the file path and +key prefix are printed. +""" + +from __future__ import annotations + +import argparse +import json +import os +import stat +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +DEFAULT_BASE = "https://hub.coulomb.social" +ROOT = Path(__file__).resolve().parents[1] +MANIFEST_PATH = ROOT / "seeds" / "ops-hub-manifest.draft.json" +WIDGETS_PATH = ROOT / "seeds" / "ops-hub-widgets.seed.json" + + +class BootstrapError(RuntimeError): + pass + + +def load_secret(name: str, file_name: str) -> str: + value = os.environ.get(name, "").strip() + if value: + return value + file_path = os.environ.get(file_name, "").strip() + if file_path: + return Path(file_path).read_text(encoding="utf-8").strip() + return "" + + +def request_json( + base_url: str, + method: str, + path: str, + token: str | None, + body: dict[str, Any] | None, + *, + expected: set[int], +) -> dict[str, Any]: + data = json.dumps(body).encode("utf-8") if body is not None else None + request = urllib.request.Request(base_url + path, data=data, method=method) + request.add_header("Accept", "application/json") + request.add_header("User-Agent", "ops-hub-bootstrap/0.1") + if token: + request.add_header("Authorization", f"Bearer {token}") + if body is not None: + request.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(request, timeout=30) as response: + status = response.status + payload = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + payload = error.read().decode("utf-8", errors="replace") + raise BootstrapError(f"{method} {path} failed with HTTP {error.code}: {payload}") from error + + if status not in expected: + raise BootstrapError(f"{method} {path} returned HTTP {status}, expected {sorted(expected)}") + if not payload: + return {} + return json.loads(payload) + + +def list_items(base_url: str, path: str, token: str | None) -> list[dict[str, Any]]: + response = request_json(base_url, "GET", path, token, None, expected={200}) + data = response.get("data", []) + if not isinstance(data, list): + raise BootstrapError(f"expected paginated data array from {path}") + return data + + +def first_by(items: list[dict[str, Any]], key: str, value: Any) -> dict[str, Any] | None: + return next((item for item in items if item.get(key) == value), None) + + +def load_manifest() -> dict[str, Any]: + manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + required = [ + "hub", + "manifestVersion", + "declaredWidgetTypes", + "declaredEventTypes", + "declaredAnnotationCategories", + "declaredPolicyScopes", + ] + missing = [key for key in required if key not in manifest] + if missing: + raise BootstrapError(f"{MANIFEST_PATH} missing required key(s): {', '.join(missing)}") + return manifest + + +def load_widgets() -> list[dict[str, Any]]: + widgets = json.loads(WIDGETS_PATH.read_text(encoding="utf-8")) + if not isinstance(widgets, list): + raise BootstrapError(f"{WIDGETS_PATH} must contain a JSON array") + return widgets + + +def ensure_hub(base_url: str, operator_key: str, manifest: dict[str, Any]) -> dict[str, Any]: + hub_seed = manifest["hub"] + slug = hub_seed["slug"] + existing = first_by(list_items(base_url, "/api/v2/hubs", operator_key), "slug", slug) + if existing: + return {"record": existing, "created": False} + + body = { + "slug": slug, + "name": hub_seed["name"], + "domain": hub_seed["domain"], + "hubKind": hub_seed.get("hubKind", "domain"), + "hubFamily": "vsm", + "vsmFunction": "OPS", + "vsmSystem": "1", + } + record = request_json(base_url, "POST", "/api/v2/hubs", operator_key, body, expected={201}) + return {"record": record, "created": True} + + +def manifest_body(manifest: dict[str, Any], hub_id: str | None = None) -> dict[str, Any]: + body: dict[str, Any] = { + "manifestVersion": manifest["manifestVersion"], + "declaredWidgetTypes": manifest["declaredWidgetTypes"], + "declaredEventTypes": manifest["declaredEventTypes"], + "declaredAnnotationCategories": manifest["declaredAnnotationCategories"], + "declaredPolicyScopes": manifest["declaredPolicyScopes"], + "capabilityDescription": manifest.get("capabilityDescription", ""), + "contact": manifest.get("contact", "operator"), + } + if hub_id: + body["hubId"] = hub_id + return body + + +def ensure_manifest(base_url: str, operator_key: str, hub_id: str, manifest: dict[str, Any]) -> dict[str, Any]: + query = urllib.parse.urlencode({"hubId": hub_id}) + manifests = list_items(base_url, f"/api/v2/hub-capability-manifests?{query}", operator_key) + active = first_by(manifests, "status", "active") + if active: + return {"record": active, "created": False, "activated": False} + + draft = first_by(manifests, "status", "draft") + if draft: + record = request_json( + base_url, + "PATCH", + f"/api/v2/hub-capability-manifests/{draft['id']}", + operator_key, + manifest_body(manifest), + expected={200}, + ) + created = False + else: + record = request_json( + base_url, + "POST", + "/api/v2/hub-capability-manifests", + operator_key, + manifest_body(manifest, hub_id), + expected={201}, + ) + created = True + + activated = request_json( + base_url, + "POST", + f"/api/v2/hub-capability-manifests/{record['id']}/activate", + operator_key, + None, + expected={200}, + ) + return {"record": activated, "created": created, "activated": True} + + +def ensure_api_consumer(base_url: str, operator_key: str, manifest_id: str) -> dict[str, Any]: + consumers = list_items(base_url, "/api/v2/api-consumers", operator_key) + existing = first_by(consumers, "name", "ops-hub") + if existing: + return {"record": existing, "created": False} + + record = request_json( + base_url, + "POST", + "/api/v2/api-consumers", + operator_key, + { + "name": "ops-hub", + "description": "API consumer for the VSM Operations hub", + "hubCapabilityManifestId": manifest_id, + "rateLimitPerMinute": 120, + "quotaPerDay": 50000, + }, + expected={201}, + ) + return {"record": record, "created": True} + + +def write_runtime_key(full_key: str, output_path: str | None) -> Path: + if output_path: + path = Path(output_path) + path.write_text(full_key, encoding="utf-8") + else: + fd, raw_path = tempfile.mkstemp(prefix="ops-hub-runtime-key-", text=True) + path = Path(raw_path) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(full_key) + path.chmod(stat.S_IRUSR | stat.S_IWUSR) + return path + + +def ensure_runtime_key( + base_url: str, + operator_key: str, + api_consumer_id: str, + output_path: str | None, +) -> dict[str, Any]: + existing_runtime_key = load_secret("OPS_HUB_KEY", "OPS_HUB_KEY_FILE") + if existing_runtime_key: + return { + "created": False, + "token": existing_runtime_key, + "keyPrefix": existing_runtime_key[:8], + "keyFile": os.environ.get("OPS_HUB_KEY_FILE"), + } + + response = request_json( + base_url, + "POST", + f"/api/v2/api-consumers/{api_consumer_id}/api-keys", + operator_key, + {"scopes": "framework:read hub:ops-hub:read hub:ops-hub:write"}, + expected={201}, + ) + full_key = response.get("fullKey") + if not full_key: + raise BootstrapError("api key creation did not return display-once fullKey") + key_file = write_runtime_key(full_key, output_path) + api_key = response.get("apiKey") or {} + return { + "created": True, + "token": full_key, + "keyPrefix": api_key.get("keyPrefix", full_key[:8]), + "keyFile": str(key_file), + } + + +def ensure_widgets(base_url: str, runtime_key: str, hub_id: str, widget_seeds: list[dict[str, Any]]) -> dict[str, Any]: + existing_widgets = list_items(base_url, "/api/v2/widgets", runtime_key) + existing_by_ref = { + widget.get("capabilityRef"): widget + for widget in existing_widgets + if widget.get("hubId") == hub_id and widget.get("capabilityRef") + } + created: list[dict[str, Any]] = [] + reused: list[dict[str, Any]] = [] + for seed in widget_seeds: + existing = existing_by_ref.get(seed.get("capabilityRef")) + if existing: + reused.append(existing) + continue + body = {"hubId": hub_id, "status": "active", **seed} + created.append(request_json(base_url, "POST", "/api/v2/widgets", runtime_key, body, expected={201})) + return {"created": created, "reused": reused} + + +def submit_gitea_event(base_url: str, runtime_key: str, widgets: dict[str, Any]) -> dict[str, Any] | None: + all_widgets = widgets["created"] + widgets["reused"] + readiness = next( + (widget for widget in all_widgets if widget.get("capabilityRef") == "ops:readiness:gitea-registry"), + None, + ) + if not readiness: + return None + return request_json( + base_url, + "POST", + "/api/v2/interaction-events", + runtime_key, + { + "widgetId": readiness["id"], + "eventType": "ops-endpoint-verified", + "viewContext": "railiance-apps/workplans/RAIL-AP-WP-0001", + "metadata": { + "vsmFunction": "OPS", + "vsmSystem": "S1", + "endpoint": "https://gitea.coulomb.social/v2/", + "expectedStatus": 401, + "observedHeader": "Docker-Distribution-Api-Version: registry/2.0", + "recordedBy": "ops-hub/scripts/ops-hub-bootstrap-api.py", + "recordedAt": int(time.time()), + }, + }, + expected={201}, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", default=os.environ.get("IHUB_BASE", DEFAULT_BASE).rstrip("/")) + parser.add_argument("--runtime-key-output", default=os.environ.get("OPS_HUB_RUNTIME_KEY_OUTPUT")) + parser.add_argument("--skip-event", action="store_true", help="Do not submit the initial Gitea readiness event") + return parser + + +def main() -> int: + args = build_parser().parse_args() + operator_key = load_secret("IHUB_OPERATOR_KEY", "IHUB_OPERATOR_KEY_FILE") + if not operator_key: + print("ERROR: set IHUB_OPERATOR_KEY or IHUB_OPERATOR_KEY_FILE", file=sys.stderr) + return 2 + + manifest = load_manifest() + widget_seeds = load_widgets() + + hub = ensure_hub(args.base, operator_key, manifest) + hub_record = hub["record"] + manifest_result = ensure_manifest(args.base, operator_key, hub_record["id"], manifest) + manifest_record = manifest_result["record"] + consumer = ensure_api_consumer(args.base, operator_key, manifest_record["id"]) + runtime_key = ensure_runtime_key(args.base, operator_key, consumer["record"]["id"], args.runtime_key_output) + widgets = ensure_widgets(args.base, runtime_key["token"], hub_record["id"], widget_seeds) + event = None if args.skip_event else submit_gitea_event(args.base, runtime_key["token"], widgets) + + summary = { + "ok": True, + "base": args.base, + "hub": {"id": hub_record["id"], "slug": hub_record["slug"], "created": hub["created"]}, + "manifest": { + "id": manifest_record["id"], + "status": manifest_record["status"], + "created": manifest_result["created"], + "activated": manifest_result["activated"], + }, + "apiConsumer": {"id": consumer["record"]["id"], "name": consumer["record"]["name"], "created": consumer["created"]}, + "runtimeKey": { + "created": runtime_key["created"], + "keyPrefix": runtime_key["keyPrefix"], + "keyFile": runtime_key["keyFile"], + "storeImmediatelyInOpenBao": "platform/operators/ops-hub/runtime", + "field": "OPS_HUB_KEY", + }, + "widgets": {"created": len(widgets["created"]), "reused": len(widgets["reused"])}, + "event": None if event is None else {"id": event["id"], "eventType": event["eventType"], "widgetId": event["widgetId"]}, + } + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except BootstrapError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/seeds/ops-hub-bootstrap.sql b/seeds/ops-hub-bootstrap.sql new file mode 100644 index 0000000..40c406c --- /dev/null +++ b/seeds/ops-hub-bootstrap.sql @@ -0,0 +1,287 @@ +-- ops-hub bootstrap fallback for Inter-Hub. +-- +-- Use only when authenticated UI bootstrap is not practical and a +-- deployment-side migration/bootstrap is acceptable. +-- +-- This creates: +-- - Hub row +-- - Active HubCapabilityManifest +-- - Owned type registry entries +-- - ApiConsumer row +-- - Seed widgets +-- +-- It intentionally does not create an ApiKey. Generate the key through the +-- authenticated Inter-Hub UI so the full static key can be shown once and +-- stored in the operator secret store. + +BEGIN; + +INSERT INTO hubs (slug, name, domain, hub_kind) +VALUES ('ops-hub', 'Ops Hub', 'ops.coulomb.social', 'domain') +ON CONFLICT (slug) DO UPDATE +SET name = EXCLUDED.name, + domain = EXCLUDED.domain, + hub_kind = EXCLUDED.hub_kind; + +-- Newer inter-hub schemas have first-class VSM metadata columns. Keep this +-- block conditional so the bootstrap still works against an older deployment +-- where the metadata is only carried by the manifest description. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'hubs' + AND column_name = 'hub_family' + ) THEN + UPDATE hubs + SET hub_family = 'vsm', + vsm_function = 'OPS', + vsm_system = '1' + WHERE slug = 'ops-hub'; + END IF; +END $$; + +WITH hub AS ( + SELECT id FROM hubs WHERE slug = 'ops-hub' +) +INSERT INTO hub_capability_manifests ( + hub_id, + manifest_version, + declared_widget_types, + declared_event_types, + declared_annotation_categories, + declared_policy_scopes, + capability_description, + contact, + status, + activated_at +) +SELECT + hub.id, + '1.0', + '[ + "ops-environment", + "ops-host", + "ops-cluster", + "ops-service", + "ops-service-catalog", + "ops-endpoint", + "ops-release", + "ops-backup-set", + "ops-secret-set", + "ops-runbook", + "ops-incident", + "ops-readiness-gate", + "ops-migration-wave", + "ops-risk" + ]'::jsonb, + '[ + "ops-inventory-registered", + "ops-inventory-updated", + "ops-service-discovered", + "ops-health-checked", + "ops-release-observed", + "ops-endpoint-verified", + "ops-backup-verified", + "ops-restore-tested", + "ops-runbook-executed", + "ops-drift-detected", + "ops-risk-raised", + "ops-risk-accepted", + "ops-readiness-gate-updated", + "ops-migration-gate-passed", + "ops-migration-gate-failed" + ]'::jsonb, + '[ + "ops-drift", + "ops-service-catalog-gap", + "ops-backup-gap", + "ops-security-gap", + "ops-routing-gap", + "ops-secret-gap", + "ops-readiness-blocker", + "ops-migration-risk", + "ops-observability-gap", + "ops-recovery-gap" + ]'::jsonb, + '[ + "ops-local", + "ops-transitional-prod", + "ops-production", + "ops-threephoenix", + "ops-registry", + "ops-secrets", + "ops-backup-retention" + ]'::jsonb, + 'VSM Operations / System 1 hub for operational truth and evidence. Metadata: hub_family=vsm; vsm_function=OPS; vsm_system=S1; scope=operational truth, service catalog, readiness, incidents, runbooks, migration waves, and evidence events.', + 'operator', + 'active', + NOW() +FROM hub +ON CONFLICT (hub_id) DO UPDATE +SET manifest_version = EXCLUDED.manifest_version, + declared_widget_types = EXCLUDED.declared_widget_types, + declared_event_types = EXCLUDED.declared_event_types, + declared_annotation_categories = EXCLUDED.declared_annotation_categories, + declared_policy_scopes = EXCLUDED.declared_policy_scopes, + capability_description = EXCLUDED.capability_description, + contact = EXCLUDED.contact, + status = EXCLUDED.status, + activated_at = COALESCE(hub_capability_manifests.activated_at, NOW()), + updated_at = NOW(); + +WITH hub AS ( + SELECT id FROM hubs WHERE slug = 'ops-hub' +), names(name) AS ( + VALUES + ('ops-environment'), + ('ops-host'), + ('ops-cluster'), + ('ops-service'), + ('ops-service-catalog'), + ('ops-endpoint'), + ('ops-release'), + ('ops-backup-set'), + ('ops-secret-set'), + ('ops-runbook'), + ('ops-incident'), + ('ops-readiness-gate'), + ('ops-migration-wave'), + ('ops-risk') +) +INSERT INTO widget_type_registry (name, label, owner_hub_id, status) +SELECT names.name, names.name, hub.id, 'active' +FROM names CROSS JOIN hub +ON CONFLICT (name) DO NOTHING; + +WITH hub AS ( + SELECT id FROM hubs WHERE slug = 'ops-hub' +), names(name) AS ( + VALUES + ('ops-inventory-registered'), + ('ops-inventory-updated'), + ('ops-service-discovered'), + ('ops-health-checked'), + ('ops-release-observed'), + ('ops-endpoint-verified'), + ('ops-backup-verified'), + ('ops-restore-tested'), + ('ops-runbook-executed'), + ('ops-drift-detected'), + ('ops-risk-raised'), + ('ops-risk-accepted'), + ('ops-readiness-gate-updated'), + ('ops-migration-gate-passed'), + ('ops-migration-gate-failed') +) +INSERT INTO event_type_registry (name, label, owner_hub_id, status) +SELECT names.name, names.name, hub.id, 'active' +FROM names CROSS JOIN hub +ON CONFLICT (name) DO NOTHING; + +WITH hub AS ( + SELECT id FROM hubs WHERE slug = 'ops-hub' +), names(name) AS ( + VALUES + ('ops-drift'), + ('ops-service-catalog-gap'), + ('ops-backup-gap'), + ('ops-security-gap'), + ('ops-routing-gap'), + ('ops-secret-gap'), + ('ops-readiness-blocker'), + ('ops-migration-risk'), + ('ops-observability-gap'), + ('ops-recovery-gap') +) +INSERT INTO annotation_category_registry (name, label, owner_hub_id, status) +SELECT names.name, names.name, hub.id, 'active' +FROM names CROSS JOIN hub +ON CONFLICT (name) DO NOTHING; + +WITH hub AS ( + SELECT id FROM hubs WHERE slug = 'ops-hub' +), names(name) AS ( + VALUES + ('ops-local'), + ('ops-transitional-prod'), + ('ops-production'), + ('ops-threephoenix'), + ('ops-registry'), + ('ops-secrets'), + ('ops-backup-retention') +) +INSERT INTO policy_scope_registry (name, label, owner_hub_id, status) +SELECT names.name, names.name, hub.id, 'active' +FROM names CROSS JOIN hub +ON CONFLICT (name) DO NOTHING; + +WITH manifest AS ( + SELECT id FROM hub_capability_manifests + WHERE hub_id = (SELECT id FROM hubs WHERE slug = 'ops-hub') +) +INSERT INTO api_consumers ( + name, + description, + hub_capability_manifest_id, + rate_limit_per_minute, + quota_per_day, + is_active +) +SELECT + 'ops-hub', + 'API consumer for the VSM Operations hub', + manifest.id, + 60, + 10000, + TRUE +FROM manifest +WHERE NOT EXISTS ( + SELECT 1 FROM api_consumers WHERE name = 'ops-hub' +); + +WITH hub AS ( + SELECT id FROM hubs WHERE slug = 'ops-hub' +), seed(name, widget_type, capability_ref, view_context, policy_scope) AS ( + VALUES + ('Local Environment', 'ops-environment', 'ops:environment:local', 'ops-hub/environments/local', 'ops-local'), + ('CoulombCore Environment', 'ops-environment', 'ops:environment:coulombcore', 'ops-hub/environments/coulombcore', 'ops-transitional-prod'), + ('Railiance01 Environment', 'ops-environment', 'ops:environment:railiance01', 'ops-hub/environments/railiance01', 'ops-threephoenix'), + ('ThreePhoenix Production Environment', 'ops-environment', 'ops:environment:threephoenix-prod', 'ops-hub/environments/threephoenix-prod', 'ops-production'), + ('CoulombCore Host', 'ops-host', 'ops:host:coulombcore', 'ops-hub/hosts/coulombcore', 'ops-transitional-prod'), + ('Railiance01 Host', 'ops-host', 'ops:host:railiance01', 'ops-hub/hosts/railiance01', 'ops-threephoenix'), + ('Operations Service Catalog', 'ops-service-catalog', 'ops:service-catalog', 'ops-hub/service-catalog', 'ops-production'), + ('Gitea Service', 'ops-service', 'ops:service:gitea', 'ops-hub/services/gitea', 'ops-transitional-prod'), + ('State Hub Service', 'ops-service', 'ops:service:state-hub', 'ops-hub/services/state-hub', 'ops-local'), + ('Inter-Hub Service', 'ops-service', 'ops:service:inter-hub', 'ops-hub/services/inter-hub', 'ops-production'), + ('Gitea Registry Endpoint', 'ops-endpoint', 'ops:endpoint:gitea-registry', 'ops-hub/endpoints/gitea-registry', 'ops-registry'), + ('Gitea Registry Readiness', 'ops-readiness-gate', 'ops:readiness:gitea-registry', 'ops-hub/readiness/gitea-registry', 'ops-registry'), + ('State Hub Cluster Deploy Readiness', 'ops-readiness-gate', 'ops:readiness:state-hub-cluster-deploy', 'ops-hub/readiness/state-hub-cluster-deploy', 'ops-production'), + ('CoulombCore to ThreePhoenix Migration', 'ops-migration-wave', 'ops:migration:coulombcore-to-threephoenix', 'ops-hub/migrations/coulombcore-to-threephoenix', 'ops-threephoenix') +) +INSERT INTO widgets ( + hub_id, + name, + widget_type, + capability_ref, + view_context, + policy_scope, + status +) +SELECT + hub.id, + seed.name, + seed.widget_type, + seed.capability_ref, + seed.view_context, + seed.policy_scope, + 'active' +FROM seed CROSS JOIN hub +WHERE NOT EXISTS ( + SELECT 1 FROM widgets + WHERE hub_id = hub.id + AND capability_ref = seed.capability_ref +); + +COMMIT; diff --git a/seeds/ops-hub-manifest.draft.json b/seeds/ops-hub-manifest.draft.json new file mode 100644 index 0000000..025d8de --- /dev/null +++ b/seeds/ops-hub-manifest.draft.json @@ -0,0 +1,65 @@ +{ + "hub": { + "name": "Ops Hub", + "slug": "ops-hub", + "domain": "ops.coulomb.social", + "hubKind": "domain" + }, + "manifestVersion": "1.0", + "capabilityDescription": "VSM Operations / System 1 hub for operational truth and evidence. Metadata: hub_family=vsm; vsm_function=OPS; vsm_system=S1; scope=operational truth, service catalog, readiness, incidents, runbooks, migration waves, and evidence events.", + "contact": "operator", + "declaredWidgetTypes": [ + "ops-environment", + "ops-host", + "ops-cluster", + "ops-service", + "ops-service-catalog", + "ops-endpoint", + "ops-release", + "ops-backup-set", + "ops-secret-set", + "ops-runbook", + "ops-incident", + "ops-readiness-gate", + "ops-migration-wave", + "ops-risk" + ], + "declaredEventTypes": [ + "ops-inventory-registered", + "ops-inventory-updated", + "ops-service-discovered", + "ops-health-checked", + "ops-release-observed", + "ops-endpoint-verified", + "ops-backup-verified", + "ops-restore-tested", + "ops-runbook-executed", + "ops-drift-detected", + "ops-risk-raised", + "ops-risk-accepted", + "ops-readiness-gate-updated", + "ops-migration-gate-passed", + "ops-migration-gate-failed" + ], + "declaredAnnotationCategories": [ + "ops-drift", + "ops-service-catalog-gap", + "ops-backup-gap", + "ops-security-gap", + "ops-routing-gap", + "ops-secret-gap", + "ops-readiness-blocker", + "ops-migration-risk", + "ops-observability-gap", + "ops-recovery-gap" + ], + "declaredPolicyScopes": [ + "ops-local", + "ops-transitional-prod", + "ops-production", + "ops-threephoenix", + "ops-registry", + "ops-secrets", + "ops-backup-retention" + ] +} diff --git a/seeds/ops-hub-widgets.seed.json b/seeds/ops-hub-widgets.seed.json new file mode 100644 index 0000000..155b977 --- /dev/null +++ b/seeds/ops-hub-widgets.seed.json @@ -0,0 +1,100 @@ +[ + { + "name": "Local Environment", + "widgetType": "ops-environment", + "capabilityRef": "ops:environment:local", + "viewContext": "ops-hub/environments/local", + "policyScope": "ops-local" + }, + { + "name": "CoulombCore Environment", + "widgetType": "ops-environment", + "capabilityRef": "ops:environment:coulombcore", + "viewContext": "ops-hub/environments/coulombcore", + "policyScope": "ops-transitional-prod" + }, + { + "name": "Railiance01 Environment", + "widgetType": "ops-environment", + "capabilityRef": "ops:environment:railiance01", + "viewContext": "ops-hub/environments/railiance01", + "policyScope": "ops-threephoenix" + }, + { + "name": "ThreePhoenix Production Environment", + "widgetType": "ops-environment", + "capabilityRef": "ops:environment:threephoenix-prod", + "viewContext": "ops-hub/environments/threephoenix-prod", + "policyScope": "ops-production" + }, + { + "name": "CoulombCore Host", + "widgetType": "ops-host", + "capabilityRef": "ops:host:coulombcore", + "viewContext": "ops-hub/hosts/coulombcore", + "policyScope": "ops-transitional-prod" + }, + { + "name": "Railiance01 Host", + "widgetType": "ops-host", + "capabilityRef": "ops:host:railiance01", + "viewContext": "ops-hub/hosts/railiance01", + "policyScope": "ops-threephoenix" + }, + { + "name": "Operations Service Catalog", + "widgetType": "ops-service-catalog", + "capabilityRef": "ops:service-catalog", + "viewContext": "ops-hub/service-catalog", + "policyScope": "ops-production" + }, + { + "name": "Gitea Service", + "widgetType": "ops-service", + "capabilityRef": "ops:service:gitea", + "viewContext": "ops-hub/services/gitea", + "policyScope": "ops-transitional-prod" + }, + { + "name": "State Hub Service", + "widgetType": "ops-service", + "capabilityRef": "ops:service:state-hub", + "viewContext": "ops-hub/services/state-hub", + "policyScope": "ops-local" + }, + { + "name": "Inter-Hub Service", + "widgetType": "ops-service", + "capabilityRef": "ops:service:inter-hub", + "viewContext": "ops-hub/services/inter-hub", + "policyScope": "ops-production" + }, + { + "name": "Gitea Registry Endpoint", + "widgetType": "ops-endpoint", + "capabilityRef": "ops:endpoint:gitea-registry", + "viewContext": "ops-hub/endpoints/gitea-registry", + "policyScope": "ops-registry" + }, + { + "name": "Gitea Registry Readiness", + "widgetType": "ops-readiness-gate", + "capabilityRef": "ops:readiness:gitea-registry", + "viewContext": "ops-hub/readiness/gitea-registry", + "policyScope": "ops-registry" + }, + { + "name": "State Hub Cluster Deploy Readiness", + "widgetType": "ops-readiness-gate", + "capabilityRef": "ops:readiness:state-hub-cluster-deploy", + "viewContext": "ops-hub/readiness/state-hub-cluster-deploy", + "policyScope": "ops-production" + }, + { + "name": "CoulombCore to ThreePhoenix Migration", + "widgetType": "ops-migration-wave", + "capabilityRef": "ops:migration:coulombcore-to-threephoenix", + "viewContext": "ops-hub/migrations/coulombcore-to-threephoenix", + "policyScope": "ops-threephoenix" + } +] diff --git a/src/ops_hub/__init__.py b/src/ops_hub/__init__.py new file mode 100644 index 0000000..d289e44 --- /dev/null +++ b/src/ops_hub/__init__.py @@ -0,0 +1,5 @@ +"""ops-hub implementation helpers.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/src/ops_hub/interhub_gate_probe.py b/src/ops_hub/interhub_gate_probe.py new file mode 100644 index 0000000..05c1015 --- /dev/null +++ b/src/ops_hub/interhub_gate_probe.py @@ -0,0 +1,111 @@ +"""Probe whether Inter-Hub production exposes the ops-hub bootstrap API.""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict, dataclass +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin +from urllib.request import Request, urlopen + +REQUIRED_OPENAPI_PATHS = ( + "/hubs", + "/hub-capability-manifests", + "/api-consumers", + "/policy-scopes", +) + + +@dataclass(frozen=True) +class HttpObservation: + url: str + status: int | None + ok: bool + error: str | None = None + + +@dataclass(frozen=True) +class GateResult: + base_url: str + passed: bool + hubs_status: int | None + required_paths_present: list[str] + required_paths_missing: list[str] + observations: list[HttpObservation] + + +def normalize_base_url(base_url: str) -> str: + return base_url.rstrip("/") + "/" + + +def api_url(base_url: str, path: str) -> str: + return urljoin(normalize_base_url(base_url), path.lstrip("/")) + + +def fetch_json(url: str, timeout: float) -> tuple[HttpObservation, dict[str, Any] | None]: + request = Request(url, headers={"Accept": "application/json", "User-Agent": "ops-hub-gate-probe/0.1"}) + try: + with urlopen(request, timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + return HttpObservation(url=url, status=response.status, ok=True), payload + except HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + return HttpObservation(url=url, status=exc.code, ok=False, error=body[:240]), None + except (URLError, TimeoutError, json.JSONDecodeError) as exc: + return HttpObservation(url=url, status=None, ok=False, error=str(exc)), None + + +def observe_status(url: str, timeout: float) -> HttpObservation: + request = Request(url, headers={"Accept": "application/json", "User-Agent": "ops-hub-gate-probe/0.1"}) + try: + with urlopen(request, timeout=timeout) as response: + response.read() + return HttpObservation(url=url, status=response.status, ok=True) + except HTTPError as exc: + exc.read() + return HttpObservation(url=url, status=exc.code, ok=False, error=exc.reason) + except (URLError, TimeoutError) as exc: + return HttpObservation(url=url, status=None, ok=False, error=str(exc)) + + +def evaluate_openapi_paths(openapi: dict[str, Any] | None) -> tuple[list[str], list[str]]: + paths = set((openapi or {}).get("paths", {}).keys()) + present = [path for path in REQUIRED_OPENAPI_PATHS if path in paths] + missing = [path for path in REQUIRED_OPENAPI_PATHS if path not in paths] + return present, missing + + +def probe_interhub_gate(base_url: str, timeout: float = 10.0) -> GateResult: + normalized = normalize_base_url(base_url) + hubs = observe_status(api_url(normalized, "/api/v2/hubs"), timeout) + openapi_observation, openapi = fetch_json(api_url(normalized, "/api/v2/openapi.json"), timeout) + present, missing = evaluate_openapi_paths(openapi) + passed = hubs.status == 401 and not missing + return GateResult( + base_url=normalized.rstrip("/"), + passed=passed, + hubs_status=hubs.status, + required_paths_present=present, + required_paths_missing=missing, + observations=[hubs, openapi_observation], + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", default="https://hub.coulomb.social", help="Inter-Hub base URL") + parser.add_argument("--timeout", default=10.0, type=float, help="HTTP timeout in seconds") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + result = probe_interhub_gate(args.base, timeout=args.timeout) + print(json.dumps(asdict(result), indent=2, sort_keys=True)) + return 0 if result.passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_interhub_gate_probe.py b/tests/test_interhub_gate_probe.py new file mode 100644 index 0000000..c5b40e8 --- /dev/null +++ b/tests/test_interhub_gate_probe.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import unittest + +from ops_hub.interhub_gate_probe import ( + REQUIRED_OPENAPI_PATHS, + api_url, + evaluate_openapi_paths, + normalize_base_url, +) + + +class InterHubGateProbeTests(unittest.TestCase): + def test_normalize_base_url(self) -> None: + self.assertEqual(normalize_base_url("https://hub.example"), "https://hub.example/") + self.assertEqual(normalize_base_url("https://hub.example/"), "https://hub.example/") + + def test_api_url_joins_versioned_paths(self) -> None: + self.assertEqual( + api_url("https://hub.example", "/api/v2/hubs"), + "https://hub.example/api/v2/hubs", + ) + + def test_evaluate_openapi_paths_detects_all_required_paths(self) -> None: + openapi = {"paths": {path: {} for path in REQUIRED_OPENAPI_PATHS}} + present, missing = evaluate_openapi_paths(openapi) + self.assertEqual(present, list(REQUIRED_OPENAPI_PATHS)) + self.assertEqual(missing, []) + + def test_evaluate_openapi_paths_reports_missing_paths(self) -> None: + openapi = {"paths": {"/hubs": {}}} + present, missing = evaluate_openapi_paths(openapi) + self.assertEqual(present, ["/hubs"]) + self.assertEqual( + missing, + ["/hub-capability-manifests", "/api-consumers", "/policy-scopes"], + ) + + +if __name__ == "__main__": + unittest.main()