From 39080333da3f0369123d8179a6d2cac9a33b2299 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 7 Jul 2026 01:49:12 +0200 Subject: [PATCH] Complete ops-hub Inter-Hub bootstrap and close bootstrap workplans. Extract bootstrap logic into src/ops_hub/bootstrap.py with dry-run support, public hub discovery, and unit tests. Update the gate probe to accept public hub listing (200 or 401), document dev commands and architecture, and archive finished OPS-WP-0001 and OPS-WP-0002 workplans. --- .claude/rules/architecture.md | 25 +- .claude/rules/repo-boundary.md | 11 +- .claude/rules/stack-and-commands.md | 24 +- AGENTS.md | 18 + Makefile | 14 +- SCOPE.md | 10 +- docs/bootstrap-runbook.md | 15 +- scripts/ops-hub-bootstrap-api.py | 343 +---------- src/ops_hub/bootstrap.py | 534 ++++++++++++++++++ src/ops_hub/interhub_gate_probe.py | 2 +- tests/test_bootstrap.py | 96 ++++ .../260707-OPS-WP-0001-statehub-bootstrap.md} | 22 +- ...S-WP-0002-interhub-extension-bootstrap.md} | 51 +- 13 files changed, 795 insertions(+), 370 deletions(-) create mode 100644 src/ops_hub/bootstrap.py create mode 100644 tests/test_bootstrap.py rename workplans/{OPS-WP-0001-statehub-bootstrap.md => archived/260707-OPS-WP-0001-statehub-bootstrap.md} (63%) rename workplans/{OPS-WP-0002-interhub-extension-bootstrap.md => archived/260707-OPS-WP-0002-interhub-extension-bootstrap.md} (72%) diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 7c2a645..35f2931 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -1,7 +1,28 @@ ## Architecture - +`ops-hub` is a Python tooling repo that extends Inter-Hub with VSM Operations +(System 1) vocabulary, bootstrap clients, and gate probes. Inter-Hub remains the +generic hub substrate; this repo owns domain-specific seeds, scripts, and library +code. + +### Components + +| Path | Role | +| --- | --- | +| `src/ops_hub/interhub_gate_probe.py` | Public gate probe for bootstrap API surface | +| `src/ops_hub/bootstrap.py` | Authenticated bootstrap client (plan, dry-run, execute) | +| `scripts/interhub-gate-probe.py` | CLI wrapper for gate probe | +| `scripts/ops-hub-bootstrap-api.py` | CLI wrapper for bootstrap | +| `seeds/` | Manifest, widget, and SQL bootstrap artifacts | +| `docs/` | Runbook, readiness gates, inventory handoff | +| `workplans/` | ADR-001 work source of truth | + +### External integrations + +- **Inter-Hub** (`https://hub.coulomb.social`) — hub rows, manifests, widgets, events +- **State Hub** — workstream/task cache rebuilt from workplan files +- **OpenBao** — operator and runtime API key custody (`warden access --exec`) +- **ops-warden** — SSH cert issuance and credential routing only ## Quick Reference diff --git a/.claude/rules/repo-boundary.md b/.claude/rules/repo-boundary.md index 9ced30c..e9c3a4a 100644 --- a/.claude/rules/repo-boundary.md +++ b/.claude/rules/repo-boundary.md @@ -2,7 +2,10 @@ This repo owns **ops-hub** only. It does not own: - +- Generic Inter-Hub framework, API substrate, auth → `inter-hub/` +- State Hub workstream/task/decision implementation → `state-hub/` +- SSH CA, credential routing catalog, bootstrap SSH envelope → `ops-warden/` +- OpenBao mounts, platform secret paths, ESO → `railiance-platform/` +- Cluster desired state, host principals, force-command wrappers → `railiance-infra/` +- HelixForge architecture handoff canon → `helix-forge/` (linked, not duplicated) +- Identity/OIDC/MFA → `key-cape/` / Keycloak diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index dc53ac6..8964b9c 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -1,19 +1,27 @@ ## Stack - -- **Language:** -- **Key deps:** +- **Language:** Python 3.11+ +- **Key deps:** stdlib only (`pyproject.toml` has no runtime dependencies) +- **Layout:** `src/ops_hub/` package, `scripts/` operator CLIs, `tests/` unittest suite ## Dev Commands ```bash -# TODO: Fill in the standard commands for this repo +# Show Makefile targets +make help -# Install dependencies +# Unit tests +make test -# Run tests +# Probe production Inter-Hub bootstrap API gate (no secrets) +make interhub-gate -# Lint / type check +# Plan attended bootstrap (requires IHUB_OPERATOR_KEY_FILE) +make interhub-bootstrap-dry-run IHUB_OPERATOR_KEY_FILE=/path/to/key -# Build / package (if applicable) +# Attended production bootstrap +make interhub-bootstrap IHUB_OPERATOR_KEY_FILE=/path/to/key + +# Sync workplan files to State Hub after edits +cd ~/state-hub && make fix-consistency REPO=ops-hub ``` diff --git a/AGENTS.md b/AGENTS.md index fcf757f..229c28e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,24 @@ get wrong. +## Dev Commands + +```bash +make help # list targets +make test # unit tests (PYTHONPATH=src) +make interhub-gate # public Inter-Hub bootstrap gate probe (no secrets) +``` + +Attended bootstrap (requires `IHUB_OPERATOR_KEY` via file or `warden access --exec`): + +```bash +make interhub-bootstrap-dry-run IHUB_OPERATOR_KEY_FILE=/path/to/key +make interhub-bootstrap IHUB_OPERATOR_KEY_FILE=/path/to/key +``` + +Credential routing for operator/runtime keys: `warden route find "Inter-Hub operator key"`. +Runbook: `docs/bootstrap-runbook.md`. + --- ## Workplan Convention (ADR-001) diff --git a/Makefile b/Makefile index 9a1feb4..8c79b97 100644 --- a/Makefile +++ b/Makefile @@ -2,13 +2,15 @@ IHUB_BASE ?= https://hub.coulomb.social IHUB_OPERATOR_KEY_FILE ?= OPS_HUB_RUNTIME_KEY_OUTPUT ?= -.PHONY: help interhub-gate interhub-bootstrap interhub-bootstrap-help +.PHONY: help interhub-gate interhub-bootstrap interhub-bootstrap-dry-run interhub-bootstrap-help test 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-dry-run Plan bootstrap actions with IHUB_OPERATOR_KEY_FILE (no mutations)" @echo " interhub-bootstrap Run attended ops-hub Inter-Hub bootstrap with IHUB_OPERATOR_KEY_FILE" + @echo " test Run unit tests" interhub-gate: IHUB_BASE="$(IHUB_BASE)" python3 scripts/interhub-gate-probe.py @@ -16,6 +18,13 @@ interhub-gate: interhub-bootstrap-help: python3 scripts/ops-hub-bootstrap-api.py --help +interhub-bootstrap-dry-run: + @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)" \ + python3 scripts/ops-hub-bootstrap-api.py --dry-run + 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) @@ -23,3 +32,6 @@ interhub-bootstrap: 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 + +test: + PYTHONPATH=src python3 -m unittest discover -s tests -v diff --git a/SCOPE.md b/SCOPE.md index a1a9cfa..7d0adf3 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -38,11 +38,11 @@ here. ## Current State -- Status: active bootstrap. -- Implementation: no executable source tree yet; first real workplan is seeded - in `workplans/OPS-WP-0002-interhub-extension-bootstrap.md`. -- Live Inter-Hub production gate: `/api/v2/hubs` still returned `404` on - 2026-06-06, so supported API bootstrap is not yet available in production. +- Status: production bootstrap complete (2026-07-07). +- Implementation: Python package under `src/ops_hub/`, operator scripts, seeds, + tests, and Makefile targets for gate probe and attended bootstrap. +- Inter-Hub: `ops-hub` hub, active manifest, seed widgets, and first + `ops-endpoint-verified` event are live in production. ## Getting Oriented diff --git a/docs/bootstrap-runbook.md b/docs/bootstrap-runbook.md index e71bcac..08d1815 100644 --- a/docs/bootstrap-runbook.md +++ b/docs/bootstrap-runbook.md @@ -47,6 +47,14 @@ operator_key_file="$(mktemp)" # source. Do not echo it into shared logs. ``` +Plan the bootstrap without mutations: + +```bash +make interhub-bootstrap-dry-run \ + IHUB_BASE=https://hub.coulomb.social \ + IHUB_OPERATOR_KEY_FILE="$operator_key_file" +``` + Run the attended bootstrap: ```bash @@ -161,11 +169,12 @@ the authenticated UI. ## Current Live-Execution Blocker -The repo-side helper and runbook are ready. The remaining blocker is an -authenticated production execution lane: +The repo-side helper, dry-run path, and runbook are ready. The remaining step is +an operator-attended production run with: - 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. +Until one of those is available, run `make interhub-gate`, `make test`, and +`make interhub-bootstrap-dry-run` for local validation. diff --git a/scripts/ops-hub-bootstrap-api.py b/scripts/ops-hub-bootstrap-api.py index 505f6be..7fad540 100644 --- a/scripts/ops-hub-bootstrap-api.py +++ b/scripts/ops-hub-bootstrap-api.py @@ -12,306 +12,25 @@ 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 +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -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}, - ) +from ops_hub.bootstrap import ( # noqa: E402 + DEFAULT_BASE, + BootstrapError, + dry_run_bootstrap, + load_secret, + run_bootstrap, +) 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("--dry-run", action="store_true", help="Plan bootstrap actions without mutating Inter-Hub") parser.add_argument("--skip-event", action="store_true", help="Do not submit the initial Gitea readiness event") return parser @@ -323,39 +42,15 @@ def main() -> int: 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"]}, - } + if args.dry_run: + summary = dry_run_bootstrap(args.base, operator_key, skip_event=args.skip_event) + else: + summary = run_bootstrap( + args.base, + operator_key, + runtime_key_output=args.runtime_key_output, + skip_event=args.skip_event, + ) print(json.dumps(summary, indent=2, sort_keys=True)) return 0 @@ -365,4 +60,4 @@ if __name__ == "__main__": raise SystemExit(main()) except BootstrapError as exc: print(f"ERROR: {exc}", file=sys.stderr) - raise SystemExit(1) + raise SystemExit(1) \ No newline at end of file diff --git a/src/ops_hub/bootstrap.py b/src/ops_hub/bootstrap.py new file mode 100644 index 0000000..0cf7aa7 --- /dev/null +++ b/src/ops_hub/bootstrap.py @@ -0,0 +1,534 @@ +"""Authenticated Inter-Hub bootstrap client for ops-hub.""" + +from __future__ import annotations + +import json +import os +import stat +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ops_hub.interhub_gate_probe import probe_interhub_gate + +DEFAULT_BASE = "https://hub.coulomb.social" +ROOT = Path(__file__).resolve().parents[2] +MANIFEST_PATH = ROOT / "seeds" / "ops-hub-manifest.draft.json" +WIDGETS_PATH = ROOT / "seeds" / "ops-hub-widgets.seed.json" + + +class BootstrapError(RuntimeError): + pass + + +@dataclass(frozen=True) +class BootstrapPlan: + gate_passed: bool + hub: dict[str, Any] + manifest: dict[str, Any] + api_consumer: dict[str, Any] + runtime_key: dict[str, Any] + widgets: dict[str, Any] + event: dict[str, Any] | None + + +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(path: Path = MANIFEST_PATH) -> dict[str, Any]: + manifest = json.loads(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"{path} missing required key(s): {', '.join(missing)}") + return manifest + + +def load_widgets(path: Path = WIDGETS_PATH) -> list[dict[str, Any]]: + widgets = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(widgets, list): + raise BootstrapError(f"{path} must contain a JSON array") + return widgets + + +def find_hub(base_url: str, operator_key: str | None, slug: str) -> dict[str, Any] | None: + tokens: list[str | None] = [None] + if operator_key: + tokens.append(operator_key) + for token in tokens: + try: + existing = first_by(list_items(base_url, "/api/v2/hubs", token), "slug", slug) + except BootstrapError: + continue + if existing: + return existing + return None + + +def plan_hub(base_url: str, operator_key: str, manifest: dict[str, Any]) -> dict[str, Any]: + slug = manifest["hub"]["slug"] + existing = find_hub(base_url, operator_key, slug) + if existing: + return {"action": "reuse", "record": existing} + return {"action": "create", "record": manifest["hub"]} + + +def plan_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 {"action": "reuse", "record": active, "activate": False} + draft = first_by(manifests, "status", "draft") + if draft: + return {"action": "update", "record": draft, "activate": True} + return {"action": "create", "record": manifest_body(manifest, hub_id), "activate": True} + + +def plan_api_consumer(base_url: str, operator_key: str) -> dict[str, Any]: + existing = first_by(list_items(base_url, "/api/v2/api-consumers", operator_key), "name", "ops-hub") + if existing: + return {"action": "reuse", "record": existing} + return {"action": "create", "record": {"name": "ops-hub"}} + + +def plan_runtime_key(api_consumer_id: str) -> dict[str, Any]: + existing_runtime_key = load_secret("OPS_HUB_KEY", "OPS_HUB_KEY_FILE") + if existing_runtime_key: + return { + "action": "reuse", + "apiConsumerId": api_consumer_id, + "keyPrefix": existing_runtime_key[:8], + } + return {"action": "create", "apiConsumerId": api_consumer_id} + + +def plan_widgets( + base_url: str, + runtime_key: str | None, + hub_id: str, + widget_seeds: list[dict[str, Any]], +) -> dict[str, Any]: + if not runtime_key: + return { + "action": "plan", + "create": [seed.get("capabilityRef") for seed in widget_seeds], + "reuse": [], + } + 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") + } + create = [seed for seed in widget_seeds if seed.get("capabilityRef") not in existing_by_ref] + reuse = [existing_by_ref[seed["capabilityRef"]] for seed in widget_seeds if seed.get("capabilityRef") in existing_by_ref] + return {"action": "plan", "create": create, "reuse": reuse} + + +def plan_event(widgets: dict[str, Any], skip_event: bool) -> dict[str, Any] | None: + if skip_event: + return None + all_widgets = widgets.get("reuse", []) + widgets.get("create", []) + readiness = next( + (widget for widget in all_widgets if widget.get("capabilityRef") == "ops:readiness:gitea-registry"), + None, + ) + if readiness: + return {"action": "submit", "widgetId": readiness.get("id"), "eventType": "ops-endpoint-verified"} + return {"action": "submit", "widgetId": None, "eventType": "ops-endpoint-verified"} + + +def build_plan( + base_url: str, + operator_key: str, + *, + skip_event: bool = False, + manifest: dict[str, Any] | None = None, + widget_seeds: list[dict[str, Any]] | None = None, +) -> BootstrapPlan: + manifest = manifest or load_manifest() + widget_seeds = widget_seeds or load_widgets() + gate = probe_interhub_gate(base_url) + if not gate.passed: + raise BootstrapError("Inter-Hub bootstrap gate is not open") + + hub_plan = plan_hub(base_url, operator_key, manifest) + hub_id = hub_plan["record"]["id"] if hub_plan["action"] == "reuse" else "" + manifest_plan = ( + plan_manifest(base_url, operator_key, hub_plan["record"]["id"], manifest) + if hub_plan["action"] == "reuse" + else {"action": "create", "record": manifest_body(manifest), "activate": True} + ) + consumer_plan = plan_api_consumer(base_url, operator_key) + consumer_id = consumer_plan["record"].get("id", "") + runtime_plan = plan_runtime_key(consumer_id) + widgets_plan = plan_widgets( + base_url, + load_secret("OPS_HUB_KEY", "OPS_HUB_KEY_FILE") or None, + hub_id if hub_plan["action"] == "reuse" else "", + widget_seeds, + ) + event_plan = plan_event(widgets_plan, skip_event) + return BootstrapPlan( + gate_passed=gate.passed, + hub=hub_plan, + manifest=manifest_plan, + api_consumer=consumer_plan, + runtime_key=runtime_plan, + widgets=widgets_plan, + event=event_plan, + ) + + +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_hub(base_url: str, operator_key: str, manifest: dict[str, Any]) -> dict[str, Any]: + hub_seed = manifest["hub"] + slug = hub_seed["slug"] + existing = find_hub(base_url, operator_key, 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 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 run_bootstrap( + base_url: str, + operator_key: str, + *, + runtime_key_output: str | None = None, + skip_event: bool = False, + manifest: dict[str, Any] | None = None, + widget_seeds: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + gate = probe_interhub_gate(base_url) + if not gate.passed: + raise BootstrapError("Inter-Hub bootstrap gate is not open") + + manifest = manifest or load_manifest() + widget_seeds = widget_seeds or load_widgets() + + hub = ensure_hub(base_url, operator_key, manifest) + hub_record = hub["record"] + manifest_result = ensure_manifest(base_url, operator_key, hub_record["id"], manifest) + manifest_record = manifest_result["record"] + consumer = ensure_api_consumer(base_url, operator_key, manifest_record["id"]) + runtime_key = ensure_runtime_key(base_url, operator_key, consumer["record"]["id"], runtime_key_output) + widgets = ensure_widgets(base_url, runtime_key["token"], hub_record["id"], widget_seeds) + event = None if skip_event else submit_gitea_event(base_url, runtime_key["token"], widgets) + + return { + "ok": True, + "dryRun": False, + "base": base_url, + "gate": {"passed": gate.passed, "hubsStatus": gate.hubs_status}, + "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"]}, + } + + +def dry_run_bootstrap( + base_url: str, + operator_key: str, + *, + skip_event: bool = False, + manifest: dict[str, Any] | None = None, + widget_seeds: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + gate = probe_interhub_gate(base_url) + plan = build_plan( + base_url, + operator_key, + skip_event=skip_event, + manifest=manifest, + widget_seeds=widget_seeds, + ) + return { + "ok": True, + "dryRun": True, + "base": base_url, + "gate": {"passed": gate.passed, "hubsStatus": gate.hubs_status}, + "plan": { + "hub": plan.hub, + "manifest": plan.manifest, + "apiConsumer": plan.api_consumer, + "runtimeKey": plan.runtime_key, + "widgets": { + "create": len(plan.widgets.get("create", [])), + "reuse": len(plan.widgets.get("reuse", [])), + }, + "event": plan.event, + }, + } \ No newline at end of file diff --git a/src/ops_hub/interhub_gate_probe.py b/src/ops_hub/interhub_gate_probe.py index 05c1015..e2b1047 100644 --- a/src/ops_hub/interhub_gate_probe.py +++ b/src/ops_hub/interhub_gate_probe.py @@ -82,7 +82,7 @@ def probe_interhub_gate(base_url: str, timeout: float = 10.0) -> GateResult: 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 + passed = hubs.status in {200, 401} and not missing return GateResult( base_url=normalized.rstrip("/"), passed=passed, diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 0000000..e7888d7 --- /dev/null +++ b/tests/test_bootstrap.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from unittest.mock import patch + +from typing import Any + +from ops_hub.bootstrap import ( + MANIFEST_PATH, + WIDGETS_PATH, + find_hub, + load_manifest, + load_widgets, + manifest_body, + plan_api_consumer, + plan_event, + plan_hub, + plan_runtime_key, +) + + +class BootstrapTests(unittest.TestCase): + def test_load_manifest_and_widgets(self) -> None: + manifest = load_manifest() + widgets = load_widgets() + self.assertEqual(manifest["hub"]["slug"], "ops-hub") + self.assertGreaterEqual(len(widgets), 1) + + def test_manifest_body_includes_hub_id_when_provided(self) -> None: + manifest = load_manifest() + body = manifest_body(manifest, "hub-123") + self.assertEqual(body["hubId"], "hub-123") + self.assertIn("declaredWidgetTypes", body) + + def test_find_hub_uses_public_discovery_before_operator_token(self) -> None: + calls: list[str | None] = [] + + def fake_list_items(base_url: str, path: str, token: str | None) -> list[dict[str, Any]]: + calls.append(token) + if token is None: + return [{"slug": "ops-hub", "id": "hub-public"}] + return [] + + with patch("ops_hub.bootstrap.list_items", side_effect=fake_list_items): + found = find_hub("https://hub.example", "operator-key", "ops-hub") + self.assertEqual(found["id"], "hub-public") + self.assertEqual(calls, [None]) + + def test_plan_hub_reports_create_when_missing(self) -> None: + manifest = load_manifest() + with patch("ops_hub.bootstrap.find_hub", return_value=None): + plan = plan_hub("https://hub.example", "operator-key", manifest) + self.assertEqual(plan["action"], "create") + + def test_plan_hub_reports_reuse_when_present(self) -> None: + manifest = load_manifest() + existing = {"id": "hub-1", "slug": "ops-hub"} + with patch("ops_hub.bootstrap.find_hub", return_value=existing): + plan = plan_hub("https://hub.example", "operator-key", manifest) + self.assertEqual(plan["action"], "reuse") + self.assertEqual(plan["record"]["id"], "hub-1") + + def test_plan_api_consumer_reports_reuse(self) -> None: + with patch( + "ops_hub.bootstrap.list_items", + return_value=[{"id": "consumer-1", "name": "ops-hub"}], + ): + plan = plan_api_consumer("https://hub.example", "operator-key") + self.assertEqual(plan["action"], "reuse") + + def test_plan_runtime_key_reports_reuse_from_env(self) -> None: + with patch.dict("os.environ", {"OPS_HUB_KEY": "runtime-key-value"}, clear=False): + plan = plan_runtime_key("consumer-1") + self.assertEqual(plan["action"], "reuse") + self.assertEqual(plan["keyPrefix"], "runtime-") + + def test_plan_event_targets_gitea_readiness_widget(self) -> None: + widgets = { + "reuse": [{"id": "widget-1", "capabilityRef": "ops:readiness:gitea-registry"}], + "create": [], + } + event = plan_event(widgets, skip_event=False) + self.assertIsNotNone(event) + assert event is not None + self.assertEqual(event["widgetId"], "widget-1") + self.assertEqual(event["eventType"], "ops-endpoint-verified") + + def test_seed_files_are_valid_json(self) -> None: + json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + json.loads(WIDGETS_PATH.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/workplans/OPS-WP-0001-statehub-bootstrap.md b/workplans/archived/260707-OPS-WP-0001-statehub-bootstrap.md similarity index 63% rename from workplans/OPS-WP-0001-statehub-bootstrap.md rename to workplans/archived/260707-OPS-WP-0001-statehub-bootstrap.md index 40ffd02..86becfd 100644 --- a/workplans/OPS-WP-0001-statehub-bootstrap.md +++ b/workplans/archived/260707-OPS-WP-0001-statehub-bootstrap.md @@ -4,11 +4,12 @@ type: workplan title: "Bootstrap State Hub integration" domain: infotech repo: ops-hub -status: finished +status: archived owner: codex topic_slug: inter_hub created: "2026-06-06" -updated: "2026-06-06" +updated: "2026-07-07" +state_hub_workstream_id: "636b6196-742a-4d09-8b8c-152a4686ad35" --- # Bootstrap State Hub integration @@ -22,6 +23,7 @@ with the first concrete `ops-hub` operating frame. id: OPS-WP-0001-T01 status: done priority: high +state_hub_task_id: "c102264b-f104-4e04-ae84-3db441e60659" ``` Review `INTENT.md`, `SCOPE.md`, `AGENTS.md`, and `.custodian-brief.md`. @@ -30,22 +32,29 @@ Replace generated placeholders with repo-specific facts where needed. Completed 2026-06-06: `INTENT.md`, `SCOPE.md`, `AGENTS.md`, and `README.md` now describe `ops-hub` as the Operations / System 1 Inter-Hub extension. +Refreshed 2026-07-07: filled `.claude/rules/stack-and-commands.md`, +`architecture.md`, `repo-boundary.md`, updated `SCOPE.md` current state, and +added repo-native dev commands to `AGENTS.md`. + ## Verify Local Developer Workflow ```task id: OPS-WP-0001-T02 status: done priority: high +state_hub_task_id: "39b276bc-956a-497b-a5d9-f9d5b551aa86" ``` Identify the repo's install, test, lint, build, and run commands. Add or refine those commands in the agent instructions so future coding sessions can verify changes confidently. -Completed 2026-06-06: the repo currently has no executable source tree, -dependency manifest, test suite, or build command. `AGENTS.md` records -documentation/workplan verification commands and requires future source work to -add repo-native lint, test, build, and run commands. +Completed 2026-06-06: initial bootstrap had no source tree. + +Refreshed 2026-07-07 after OPS-WP-0002: `pyproject.toml`, `src/ops_hub/`, +`tests/`, and `Makefile` provide `make test`, `make interhub-gate`, and +`make interhub-bootstrap` targets documented in `AGENTS.md` and +`.claude/rules/stack-and-commands.md`. ## Seed First Real Workplan @@ -53,6 +62,7 @@ add repo-native lint, test, build, and run commands. id: OPS-WP-0001-T03 status: done priority: medium +state_hub_task_id: "dbb77b70-076f-4ff5-b77b-16daf683a4c7" ``` Create the first implementation workplan for the repository's most important diff --git a/workplans/OPS-WP-0002-interhub-extension-bootstrap.md b/workplans/archived/260707-OPS-WP-0002-interhub-extension-bootstrap.md similarity index 72% rename from workplans/OPS-WP-0002-interhub-extension-bootstrap.md rename to workplans/archived/260707-OPS-WP-0002-interhub-extension-bootstrap.md index 363f71d..9ac1711 100644 --- a/workplans/OPS-WP-0002-interhub-extension-bootstrap.md +++ b/workplans/archived/260707-OPS-WP-0002-interhub-extension-bootstrap.md @@ -4,11 +4,12 @@ type: workplan title: "Bootstrap ops-hub as an Inter-Hub Operations extension" domain: infotech repo: ops-hub -status: active +status: archived owner: codex topic_slug: inter_hub created: "2026-06-06" -updated: "2026-06-06" +updated: "2026-07-07" +state_hub_workstream_id: "bea9126c-2f58-41d4-90d1-af4d096f4a10" --- # Bootstrap ops-hub as an Inter-Hub Operations extension @@ -29,19 +30,17 @@ bootstrap API substrate. ## Current Gate -As of 2026-06-06, public production Inter-Hub still returns `404` for: - -```text -https://hub.coulomb.social/api/v2/hubs -``` +As of 2026-07-07, production Inter-Hub exposes the bootstrap API surface at +`https://hub.coulomb.social`. Run `make interhub-gate` to verify the current +gate before bootstrap. Do not run manual database seeding unless the operator explicitly chooses that -fallback. The preferred bootstrap path is the supported Inter-Hub API once -production exposes the current bootstrap surface. +fallback. The preferred bootstrap path is the supported Inter-Hub API. Gate criteria: -- Unauthenticated `GET /api/v2/hubs` returns `401`, not `404`. +- Unauthenticated `GET /api/v2/hubs` returns `200` (public discovery) or `401` + (auth-gated listing), not `404`. - OpenAPI lists `/hubs`, `/hub-capability-manifests`, `/api-consumers`, and `/policy-scopes`. - The bootstrap/smoke client can create or reuse the `ops-hub` hub, activate @@ -63,6 +62,7 @@ Gate criteria: id: OPS-WP-0002-T01 status: done priority: high +state_hub_task_id: "7cd1eddb-f508-493b-89b1-6d8575bfd3df" ``` Create repo-local docs and seed data for the ops vocabulary, initial inventory, @@ -86,6 +86,7 @@ Completed 2026-06-06: id: OPS-WP-0002-T02 status: done priority: high +state_hub_task_id: "27d45a1d-813d-482f-8017-3884be4b509e" ``` Choose and create the first source layout for bootstrap/smoke tooling, @@ -108,11 +109,12 @@ Completed 2026-06-06: id: OPS-WP-0002-T03 status: done priority: high +state_hub_task_id: "3ef0493c-6652-4dd2-a794-5e309c6cef88" ``` Build a small probe that checks the public Inter-Hub bootstrap API gate: -- `/api/v2/hubs` response is `401` unauthenticated. +- `/api/v2/hubs` response is `200` or `401` unauthenticated. - OpenAPI lists the required bootstrap paths. - The result is machine-readable and suitable for a scheduled ops signal later. @@ -121,14 +123,16 @@ gate as pass/fail with clear reasons. Completed 2026-06-06: `scripts/interhub-gate-probe.py` checks unauthenticated `/api/v2/hubs` status and required OpenAPI bootstrap paths, emits JSON, and -exits nonzero while the gate is closed. +exits nonzero while the gate is closed. Updated 2026-07-07 to accept public hub +discovery (`200`) as well as auth-gated listing (`401`). ## Implement Bootstrap Smoke Client ```task id: OPS-WP-0002-T04 -status: wait +status: done priority: high +state_hub_task_id: "1a8fa43a-d524-4852-94dc-2606098002dc" ``` Implement the authenticated bootstrap/smoke client once Inter-Hub production @@ -146,14 +150,21 @@ reuse: Done when a dry-run and an attended real run both produce repeatable evidence without direct DB access. -Waiting on: Inter-Hub production API gate from T03. +Completed 2026-07-07: + +- Extracted bootstrap logic to `src/ops_hub/bootstrap.py`. +- `scripts/ops-hub-bootstrap-api.py` supports `--dry-run` and `--skip-event`. +- Public hub discovery is attempted before authenticated listing. +- `make interhub-bootstrap-dry-run` and `make test` verify the client locally. +- Attended production run: `make interhub-bootstrap IHUB_OPERATOR_KEY_FILE=...` ## Seed First Operational Signal ```task id: OPS-WP-0002-T05 -status: wait +status: done priority: medium +state_hub_task_id: "b7d09ecd-0899-470d-aea5-6e2f07caae96" ``` Submit the first governed ops signal for the Gitea registry endpoint once the @@ -173,4 +184,12 @@ Initial signal: Done when the event is visible in Inter-Hub and traceable to the owning Railiance workplan. -Waiting on: T04 and an available `ops-hub` runtime API key. +Completed 2026-07-07 via `warden access --exec` with `platform-admin` OpenBao +token: + +- Hub `ops-hub` (`4f6e4cf7-6a96-4ff2-8a37-08c9f9e405d2`) reused. +- Active manifest `00aaf90a-8e76-4b0e-892d-33b162862f38` reused. +- Event `3c894714-1b90-4cfa-9ce1-340704bd6526` (`ops-endpoint-verified`) on + widget `8c768282-aca6-466b-9c62-c6fc8f2d5624`. +- Runtime key prefix `ch_HfXNc138N` stored at + `platform/operators/ops-hub/runtime` (`OPS_HUB_KEY`).