generated from coulomb/repo-seed
Document the 2026-06-15 attended Inter-Hub bootstrap: hub row, active manifest, widget seeding, and runtime API key creation. Add scripts/ops-hub-bootstrap-api.py, extend the SQL fallback for widget versions and the first Gitea event, refresh OpsHubBootstrapRunbook, and inline credential-routing guidance for agents.
369 lines
13 KiB
Python
Executable File
369 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Bootstrap ops-hub in Inter-Hub using the prepared HelixForge seeds.
|
|
|
|
The script never prints full API keys. The operator key is read from
|
|
IHUB_OPERATOR_KEY or IHUB_OPERATOR_KEY_FILE. If an ops-hub runtime key is
|
|
created, the full key is written to a 0600 temp file and only the file path and
|
|
key prefix are printed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
DEFAULT_BASE = "https://hub.coulomb.social"
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MANIFEST_PATH = ROOT / "wiki" / "ops-hub-manifest.draft.json"
|
|
WIDGETS_PATH = ROOT / "wiki" / "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", "helix-forge-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", None), "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": "helix-forge/scripts/ops-hub-bootstrap-api.py",
|
|
"recordedAt": int(time.time()),
|
|
},
|
|
},
|
|
expected={201},
|
|
)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--base", default=os.environ.get("IHUB_BASE", DEFAULT_BASE).rstrip("/"))
|
|
parser.add_argument("--runtime-key-output", default=os.environ.get("OPS_HUB_RUNTIME_KEY_OUTPUT"))
|
|
parser.add_argument("--skip-event", action="store_true", help="Do not submit the initial Gitea readiness event")
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
operator_key = load_secret("IHUB_OPERATOR_KEY", "IHUB_OPERATOR_KEY_FILE")
|
|
if not operator_key:
|
|
print("ERROR: set IHUB_OPERATOR_KEY or IHUB_OPERATOR_KEY_FILE", file=sys.stderr)
|
|
return 2
|
|
|
|
manifest = load_manifest()
|
|
widget_seeds = load_widgets()
|
|
|
|
hub = ensure_hub(args.base, operator_key, manifest)
|
|
hub_record = hub["record"]
|
|
manifest_result = ensure_manifest(args.base, operator_key, hub_record["id"], manifest)
|
|
manifest_record = manifest_result["record"]
|
|
consumer = ensure_api_consumer(args.base, operator_key, manifest_record["id"])
|
|
runtime_key = ensure_runtime_key(args.base, operator_key, consumer["record"]["id"], args.runtime_key_output)
|
|
widgets = ensure_widgets(args.base, runtime_key["token"], hub_record["id"], widget_seeds)
|
|
event = None if args.skip_event else submit_gitea_event(args.base, runtime_key["token"], widgets)
|
|
|
|
summary = {
|
|
"ok": True,
|
|
"base": args.base,
|
|
"hub": {"id": hub_record["id"], "slug": hub_record["slug"], "created": hub["created"]},
|
|
"manifest": {
|
|
"id": manifest_record["id"],
|
|
"status": manifest_record["status"],
|
|
"created": manifest_result["created"],
|
|
"activated": manifest_result["activated"],
|
|
},
|
|
"apiConsumer": {"id": consumer["record"]["id"], "name": consumer["record"]["name"], "created": consumer["created"]},
|
|
"runtimeKey": {
|
|
"created": runtime_key["created"],
|
|
"keyPrefix": runtime_key["keyPrefix"],
|
|
"keyFile": runtime_key["keyFile"],
|
|
"storeImmediatelyInOpenBao": "platform/operators/ops-hub/runtime",
|
|
"field": "OPS_HUB_KEY",
|
|
},
|
|
"widgets": {"created": len(widgets["created"]), "reused": len(widgets["reused"])},
|
|
"event": None if event is None else {"id": event["id"], "eventType": event["eventType"], "widgetId": event["widgetId"]},
|
|
}
|
|
print(json.dumps(summary, indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except BootstrapError as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
raise SystemExit(1)
|