generated from coulomb/repo-seed
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.
This commit is contained in:
@@ -1,7 +1,28 @@
|
||||
## Architecture
|
||||
|
||||
<!-- TODO: Describe the key design decisions and component structure.
|
||||
Key modules, data flows, external integrations, state machines, etc. -->
|
||||
`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
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
This repo owns **ops-hub** only. It does not own:
|
||||
|
||||
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||
- SSH key management → railiance-infra/
|
||||
- State hub code → state-hub/
|
||||
-->
|
||||
- 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
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
## Stack
|
||||
|
||||
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||
- **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
|
||||
```
|
||||
|
||||
18
AGENTS.md
18
AGENTS.md
@@ -156,6 +156,24 @@ get wrong.
|
||||
<!-- Append repo-specific agent instructions below this marker.
|
||||
The state-hub template sync preserves content after this line. -->
|
||||
|
||||
## 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)
|
||||
|
||||
14
Makefile
14
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
|
||||
|
||||
10
SCOPE.md
10
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
534
src/ops_hub/bootstrap.py
Normal file
534
src/ops_hub/bootstrap.py
Normal file
@@ -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 "<new>"
|
||||
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", "<new>")
|
||||
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 "<new>",
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
96
tests/test_bootstrap.py
Normal file
96
tests/test_bootstrap.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
@@ -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`).
|
||||
Reference in New Issue
Block a user