feat: add interhub bootstrap helper

This commit is contained in:
2026-06-17 00:17:59 +02:00
parent 9fce939d29
commit 6aff34cc5b
11 changed files with 1197 additions and 0 deletions

25
Makefile Normal file
View File

@@ -0,0 +1,25 @@
IHUB_BASE ?= https://hub.coulomb.social
IHUB_OPERATOR_KEY_FILE ?=
OPS_HUB_RUNTIME_KEY_OUTPUT ?=
.PHONY: help interhub-gate interhub-bootstrap interhub-bootstrap-help
help:
@echo "Targets:"
@echo " interhub-gate Probe whether production Inter-Hub exposes the ops-hub bootstrap API surface"
@echo " interhub-bootstrap-help Show bootstrap helper options"
@echo " interhub-bootstrap Run attended ops-hub Inter-Hub bootstrap with IHUB_OPERATOR_KEY_FILE"
interhub-gate:
IHUB_BASE="$(IHUB_BASE)" python3 scripts/interhub-gate-probe.py
interhub-bootstrap-help:
python3 scripts/ops-hub-bootstrap-api.py --help
interhub-bootstrap:
@test -n "$(IHUB_OPERATOR_KEY_FILE)" || \
(echo "IHUB_OPERATOR_KEY_FILE is required; materialize the operator key into a 0600 temp file first." >&2; exit 2)
IHUB_BASE="$(IHUB_BASE)" \
IHUB_OPERATOR_KEY_FILE="$(IHUB_OPERATOR_KEY_FILE)" \
OPS_HUB_RUNTIME_KEY_OUTPUT="$(OPS_HUB_RUNTIME_KEY_OUTPUT)" \
python3 scripts/ops-hub-bootstrap-api.py

171
docs/bootstrap-runbook.md Normal file
View File

@@ -0,0 +1,171 @@
# Ops Hub Bootstrap Runbook
Date: 2026-06-17
## Purpose
This runbook is the operator-ready path for activating `ops-hub` in production
Inter-Hub. It covers the preferred API bootstrap, key custody expectations, the
ops-warden remote execution lane, and the explicit SQL fallback.
Use this when finishing `CUST-WP-0047-T05` or any later ops-hub Inter-Hub
activation task.
## Inputs
- Manifest draft: `seeds/ops-hub-manifest.draft.json`
- Widget seed: `seeds/ops-hub-widgets.seed.json`
- API helper: `scripts/ops-hub-bootstrap-api.py`
- Migration fallback: `seeds/ops-hub-bootstrap.sql`
- Production Inter-Hub base URL: `https://hub.coulomb.social`
## Secret Custody Rules
- Do not paste operator keys into Codex-visible chat, workplans, commits, or
shell transcripts.
- Prefer `IHUB_OPERATOR_KEY_FILE` over `IHUB_OPERATOR_KEY`.
- Operator key temp files must be mode `0600` and removed after the run.
- The generated ops-hub runtime key is display-once material. Store it in
OpenBao or the approved operator secret store immediately.
- The helper prints only non-secret ids, key prefixes, and file paths.
## Preferred API Bootstrap
First confirm the API surface:
```bash
make interhub-gate
```
Then materialize the Inter-Hub operator key into a temporary file. Example
local pattern:
```bash
umask 077
operator_key_file="$(mktemp)"
# Write the operator key into "$operator_key_file" from the approved secret
# source. Do not echo it into shared logs.
```
Run the attended bootstrap:
```bash
make interhub-bootstrap \
IHUB_BASE=https://hub.coulomb.social \
IHUB_OPERATOR_KEY_FILE="$operator_key_file"
```
The helper creates or reuses:
- the `ops-hub` hub row
- the active ops-hub capability manifest
- the `ops-hub` API consumer
- an ops-hub runtime API key, when one was not supplied
- the seed widgets from `seeds/ops-hub-widgets.seed.json`
- the initial Gitea readiness event, unless `--skip-event` is used directly
If the helper creates a runtime key, it writes the full value to a `0600` file
and prints the path plus key prefix. Store that key immediately, then remove the
file:
```bash
runtime_key_file="/path/printed/as/runtimeKey.keyFile"
# Example path only; use the approved OpenBao mount/path for the environment.
bao kv put secret/platform/operators/ops-hub/runtime \
OPS_HUB_KEY="$(cat "$runtime_key_file")"
rm -f "$operator_key_file" "$runtime_key_file"
```
If an ops-hub runtime key already exists in the secret store, materialize it as
`OPS_HUB_KEY_FILE` before the run. The helper will reuse it instead of creating
a new display-once key.
## Remote Execution With Ops-Warden
When the operator key must stay on a trusted host, use the ops-warden access
lane instead of moving the key into the workstation session:
1. Add or reuse an `agt` actor such as `agt-codex-interhub-bootstrap` with a
narrow principal such as `agt-interhub-bootstrap` and a short TTL.
2. Use `ops-ssh-wrapper` to acquire a short-lived SSH certificate.
3. On the target host, railiance-infra should map the principal to a narrow
force-command or wrapper that runs only this bootstrap path.
4. The host wrapper reads the operator key from OpenBao or an attended temp
file, runs `make interhub-bootstrap`, stores the generated ops-hub runtime
key back into OpenBao, and emits non-secret JSON evidence.
See `ops-warden/wiki/InterHubBootstrapAccessLane.md` for the certificate
envelope and host-boundary rules.
## SQL Fallback Path
Prefer the API bootstrap whenever possible. Use SQL only when the operator
explicitly approves deployment-side migration/bootstrap execution.
From a shell with Railiance01 Kubernetes access:
```bash
kubectl exec -i -n databases net-kingdom-pg-1 -- \
psql -U postgres -d interhub \
< /home/worsch/ops-hub/seeds/ops-hub-bootstrap.sql
```
If using the HostEurope kubeconfig from the workstation:
```bash
KUBECONFIG=~/.kube/config-hosteurope \
kubectl exec -i -n databases net-kingdom-pg-1 -- \
psql -U postgres -d interhub \
< /home/worsch/ops-hub/seeds/ops-hub-bootstrap.sql
```
The SQL fallback creates the hub, active manifest, registry entries, API
consumer row, and seed widgets. It does not create the one-time visible static
API key. Generate that through the authenticated Inter-Hub UI or the supported
API helper and store it outside Git.
## Validation
After manifest activation:
```bash
curl -s https://hub.coulomb.social/api/v2/widget-types
curl -s https://hub.coulomb.social/api/v2/event-types
curl -s https://hub.coulomb.social/api/v2/annotation-categories
```
Expected: ops-owned vocabulary appears in the relevant registries.
After API key creation or reuse:
```bash
curl -s -X POST https://hub.coulomb.social/api/v2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=<api-consumer-id>" \
--data-urlencode "client_secret=<static-api-key>" \
--data-urlencode "scope=framework:read hub:ops-hub:read hub:ops-hub:write"
```
Expected: a short-lived access token is returned.
After widget seeding:
```bash
curl -s https://hub.coulomb.social/api/v2/hub-registry
```
Expected: `ops-hub` is visible, and the operator can see the seeded widgets in
the authenticated UI.
## Current Live-Execution Blocker
The repo-side helper and runbook are ready. The remaining blocker is an
authenticated production execution lane:
- an operator-provided `IHUB_OPERATOR_KEY_FILE`,
- an OpenBao-materialized key on a trusted host, or
- an explicitly approved deployment-side migration/bootstrap path.
Until one of those is available, run only local validation and gate probes.

9
pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "ops-hub"
version = "0.1.0"
description = "Operations / System 1 extension tooling for Inter-Hub"
requires-python = ">=3.11"
dependencies = []
[tool.pytest.ini_options]
pythonpath = ["src"]

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Run the Inter-Hub ops-hub bootstrap gate probe."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from ops_hub.interhub_gate_probe import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""Bootstrap ops-hub in Inter-Hub using the prepared ops-hub seeds.
The script never prints full API keys. The operator key is read from
IHUB_OPERATOR_KEY or IHUB_OPERATOR_KEY_FILE. If an ops-hub runtime key is
created, the full key is written to a 0600 temp file and only the file path and
key prefix are printed.
"""
from __future__ import annotations
import argparse
import json
import os
import stat
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
DEFAULT_BASE = "https://hub.coulomb.social"
ROOT = Path(__file__).resolve().parents[1]
MANIFEST_PATH = ROOT / "seeds" / "ops-hub-manifest.draft.json"
WIDGETS_PATH = ROOT / "seeds" / "ops-hub-widgets.seed.json"
class BootstrapError(RuntimeError):
pass
def load_secret(name: str, file_name: str) -> str:
value = os.environ.get(name, "").strip()
if value:
return value
file_path = os.environ.get(file_name, "").strip()
if file_path:
return Path(file_path).read_text(encoding="utf-8").strip()
return ""
def request_json(
base_url: str,
method: str,
path: str,
token: str | None,
body: dict[str, Any] | None,
*,
expected: set[int],
) -> dict[str, Any]:
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(base_url + path, data=data, method=method)
request.add_header("Accept", "application/json")
request.add_header("User-Agent", "ops-hub-bootstrap/0.1")
if token:
request.add_header("Authorization", f"Bearer {token}")
if body is not None:
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=30) as response:
status = response.status
payload = response.read().decode("utf-8")
except urllib.error.HTTPError as error:
payload = error.read().decode("utf-8", errors="replace")
raise BootstrapError(f"{method} {path} failed with HTTP {error.code}: {payload}") from error
if status not in expected:
raise BootstrapError(f"{method} {path} returned HTTP {status}, expected {sorted(expected)}")
if not payload:
return {}
return json.loads(payload)
def list_items(base_url: str, path: str, token: str | None) -> list[dict[str, Any]]:
response = request_json(base_url, "GET", path, token, None, expected={200})
data = response.get("data", [])
if not isinstance(data, list):
raise BootstrapError(f"expected paginated data array from {path}")
return data
def first_by(items: list[dict[str, Any]], key: str, value: Any) -> dict[str, Any] | None:
return next((item for item in items if item.get(key) == value), None)
def load_manifest() -> dict[str, Any]:
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
required = [
"hub",
"manifestVersion",
"declaredWidgetTypes",
"declaredEventTypes",
"declaredAnnotationCategories",
"declaredPolicyScopes",
]
missing = [key for key in required if key not in manifest]
if missing:
raise BootstrapError(f"{MANIFEST_PATH} missing required key(s): {', '.join(missing)}")
return manifest
def load_widgets() -> list[dict[str, Any]]:
widgets = json.loads(WIDGETS_PATH.read_text(encoding="utf-8"))
if not isinstance(widgets, list):
raise BootstrapError(f"{WIDGETS_PATH} must contain a JSON array")
return widgets
def ensure_hub(base_url: str, operator_key: str, manifest: dict[str, Any]) -> dict[str, Any]:
hub_seed = manifest["hub"]
slug = hub_seed["slug"]
existing = first_by(list_items(base_url, "/api/v2/hubs", operator_key), "slug", slug)
if existing:
return {"record": existing, "created": False}
body = {
"slug": slug,
"name": hub_seed["name"],
"domain": hub_seed["domain"],
"hubKind": hub_seed.get("hubKind", "domain"),
"hubFamily": "vsm",
"vsmFunction": "OPS",
"vsmSystem": "1",
}
record = request_json(base_url, "POST", "/api/v2/hubs", operator_key, body, expected={201})
return {"record": record, "created": True}
def manifest_body(manifest: dict[str, Any], hub_id: str | None = None) -> dict[str, Any]:
body: dict[str, Any] = {
"manifestVersion": manifest["manifestVersion"],
"declaredWidgetTypes": manifest["declaredWidgetTypes"],
"declaredEventTypes": manifest["declaredEventTypes"],
"declaredAnnotationCategories": manifest["declaredAnnotationCategories"],
"declaredPolicyScopes": manifest["declaredPolicyScopes"],
"capabilityDescription": manifest.get("capabilityDescription", ""),
"contact": manifest.get("contact", "operator"),
}
if hub_id:
body["hubId"] = hub_id
return body
def ensure_manifest(base_url: str, operator_key: str, hub_id: str, manifest: dict[str, Any]) -> dict[str, Any]:
query = urllib.parse.urlencode({"hubId": hub_id})
manifests = list_items(base_url, f"/api/v2/hub-capability-manifests?{query}", operator_key)
active = first_by(manifests, "status", "active")
if active:
return {"record": active, "created": False, "activated": False}
draft = first_by(manifests, "status", "draft")
if draft:
record = request_json(
base_url,
"PATCH",
f"/api/v2/hub-capability-manifests/{draft['id']}",
operator_key,
manifest_body(manifest),
expected={200},
)
created = False
else:
record = request_json(
base_url,
"POST",
"/api/v2/hub-capability-manifests",
operator_key,
manifest_body(manifest, hub_id),
expected={201},
)
created = True
activated = request_json(
base_url,
"POST",
f"/api/v2/hub-capability-manifests/{record['id']}/activate",
operator_key,
None,
expected={200},
)
return {"record": activated, "created": created, "activated": True}
def ensure_api_consumer(base_url: str, operator_key: str, manifest_id: str) -> dict[str, Any]:
consumers = list_items(base_url, "/api/v2/api-consumers", operator_key)
existing = first_by(consumers, "name", "ops-hub")
if existing:
return {"record": existing, "created": False}
record = request_json(
base_url,
"POST",
"/api/v2/api-consumers",
operator_key,
{
"name": "ops-hub",
"description": "API consumer for the VSM Operations hub",
"hubCapabilityManifestId": manifest_id,
"rateLimitPerMinute": 120,
"quotaPerDay": 50000,
},
expected={201},
)
return {"record": record, "created": True}
def write_runtime_key(full_key: str, output_path: str | None) -> Path:
if output_path:
path = Path(output_path)
path.write_text(full_key, encoding="utf-8")
else:
fd, raw_path = tempfile.mkstemp(prefix="ops-hub-runtime-key-", text=True)
path = Path(raw_path)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(full_key)
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
return path
def ensure_runtime_key(
base_url: str,
operator_key: str,
api_consumer_id: str,
output_path: str | None,
) -> dict[str, Any]:
existing_runtime_key = load_secret("OPS_HUB_KEY", "OPS_HUB_KEY_FILE")
if existing_runtime_key:
return {
"created": False,
"token": existing_runtime_key,
"keyPrefix": existing_runtime_key[:8],
"keyFile": os.environ.get("OPS_HUB_KEY_FILE"),
}
response = request_json(
base_url,
"POST",
f"/api/v2/api-consumers/{api_consumer_id}/api-keys",
operator_key,
{"scopes": "framework:read hub:ops-hub:read hub:ops-hub:write"},
expected={201},
)
full_key = response.get("fullKey")
if not full_key:
raise BootstrapError("api key creation did not return display-once fullKey")
key_file = write_runtime_key(full_key, output_path)
api_key = response.get("apiKey") or {}
return {
"created": True,
"token": full_key,
"keyPrefix": api_key.get("keyPrefix", full_key[:8]),
"keyFile": str(key_file),
}
def ensure_widgets(base_url: str, runtime_key: str, hub_id: str, widget_seeds: list[dict[str, Any]]) -> dict[str, Any]:
existing_widgets = list_items(base_url, "/api/v2/widgets", runtime_key)
existing_by_ref = {
widget.get("capabilityRef"): widget
for widget in existing_widgets
if widget.get("hubId") == hub_id and widget.get("capabilityRef")
}
created: list[dict[str, Any]] = []
reused: list[dict[str, Any]] = []
for seed in widget_seeds:
existing = existing_by_ref.get(seed.get("capabilityRef"))
if existing:
reused.append(existing)
continue
body = {"hubId": hub_id, "status": "active", **seed}
created.append(request_json(base_url, "POST", "/api/v2/widgets", runtime_key, body, expected={201}))
return {"created": created, "reused": reused}
def submit_gitea_event(base_url: str, runtime_key: str, widgets: dict[str, Any]) -> dict[str, Any] | None:
all_widgets = widgets["created"] + widgets["reused"]
readiness = next(
(widget for widget in all_widgets if widget.get("capabilityRef") == "ops:readiness:gitea-registry"),
None,
)
if not readiness:
return None
return request_json(
base_url,
"POST",
"/api/v2/interaction-events",
runtime_key,
{
"widgetId": readiness["id"],
"eventType": "ops-endpoint-verified",
"viewContext": "railiance-apps/workplans/RAIL-AP-WP-0001",
"metadata": {
"vsmFunction": "OPS",
"vsmSystem": "S1",
"endpoint": "https://gitea.coulomb.social/v2/",
"expectedStatus": 401,
"observedHeader": "Docker-Distribution-Api-Version: registry/2.0",
"recordedBy": "ops-hub/scripts/ops-hub-bootstrap-api.py",
"recordedAt": int(time.time()),
},
},
expected={201},
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", default=os.environ.get("IHUB_BASE", DEFAULT_BASE).rstrip("/"))
parser.add_argument("--runtime-key-output", default=os.environ.get("OPS_HUB_RUNTIME_KEY_OUTPUT"))
parser.add_argument("--skip-event", action="store_true", help="Do not submit the initial Gitea readiness event")
return parser
def main() -> int:
args = build_parser().parse_args()
operator_key = load_secret("IHUB_OPERATOR_KEY", "IHUB_OPERATOR_KEY_FILE")
if not operator_key:
print("ERROR: set IHUB_OPERATOR_KEY or IHUB_OPERATOR_KEY_FILE", file=sys.stderr)
return 2
manifest = load_manifest()
widget_seeds = load_widgets()
hub = ensure_hub(args.base, operator_key, manifest)
hub_record = hub["record"]
manifest_result = ensure_manifest(args.base, operator_key, hub_record["id"], manifest)
manifest_record = manifest_result["record"]
consumer = ensure_api_consumer(args.base, operator_key, manifest_record["id"])
runtime_key = ensure_runtime_key(args.base, operator_key, consumer["record"]["id"], args.runtime_key_output)
widgets = ensure_widgets(args.base, runtime_key["token"], hub_record["id"], widget_seeds)
event = None if args.skip_event else submit_gitea_event(args.base, runtime_key["token"], widgets)
summary = {
"ok": True,
"base": args.base,
"hub": {"id": hub_record["id"], "slug": hub_record["slug"], "created": hub["created"]},
"manifest": {
"id": manifest_record["id"],
"status": manifest_record["status"],
"created": manifest_result["created"],
"activated": manifest_result["activated"],
},
"apiConsumer": {"id": consumer["record"]["id"], "name": consumer["record"]["name"], "created": consumer["created"]},
"runtimeKey": {
"created": runtime_key["created"],
"keyPrefix": runtime_key["keyPrefix"],
"keyFile": runtime_key["keyFile"],
"storeImmediatelyInOpenBao": "platform/operators/ops-hub/runtime",
"field": "OPS_HUB_KEY",
},
"widgets": {"created": len(widgets["created"]), "reused": len(widgets["reused"])},
"event": None if event is None else {"id": event["id"], "eventType": event["eventType"], "widgetId": event["widgetId"]},
}
print(json.dumps(summary, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except BootstrapError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)

287
seeds/ops-hub-bootstrap.sql Normal file
View File

@@ -0,0 +1,287 @@
-- ops-hub bootstrap fallback for Inter-Hub.
--
-- Use only when authenticated UI bootstrap is not practical and a
-- deployment-side migration/bootstrap is acceptable.
--
-- This creates:
-- - Hub row
-- - Active HubCapabilityManifest
-- - Owned type registry entries
-- - ApiConsumer row
-- - Seed widgets
--
-- It intentionally does not create an ApiKey. Generate the key through the
-- authenticated Inter-Hub UI so the full static key can be shown once and
-- stored in the operator secret store.
BEGIN;
INSERT INTO hubs (slug, name, domain, hub_kind)
VALUES ('ops-hub', 'Ops Hub', 'ops.coulomb.social', 'domain')
ON CONFLICT (slug) DO UPDATE
SET name = EXCLUDED.name,
domain = EXCLUDED.domain,
hub_kind = EXCLUDED.hub_kind;
-- Newer inter-hub schemas have first-class VSM metadata columns. Keep this
-- block conditional so the bootstrap still works against an older deployment
-- where the metadata is only carried by the manifest description.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'hubs'
AND column_name = 'hub_family'
) THEN
UPDATE hubs
SET hub_family = 'vsm',
vsm_function = 'OPS',
vsm_system = '1'
WHERE slug = 'ops-hub';
END IF;
END $$;
WITH hub AS (
SELECT id FROM hubs WHERE slug = 'ops-hub'
)
INSERT INTO hub_capability_manifests (
hub_id,
manifest_version,
declared_widget_types,
declared_event_types,
declared_annotation_categories,
declared_policy_scopes,
capability_description,
contact,
status,
activated_at
)
SELECT
hub.id,
'1.0',
'[
"ops-environment",
"ops-host",
"ops-cluster",
"ops-service",
"ops-service-catalog",
"ops-endpoint",
"ops-release",
"ops-backup-set",
"ops-secret-set",
"ops-runbook",
"ops-incident",
"ops-readiness-gate",
"ops-migration-wave",
"ops-risk"
]'::jsonb,
'[
"ops-inventory-registered",
"ops-inventory-updated",
"ops-service-discovered",
"ops-health-checked",
"ops-release-observed",
"ops-endpoint-verified",
"ops-backup-verified",
"ops-restore-tested",
"ops-runbook-executed",
"ops-drift-detected",
"ops-risk-raised",
"ops-risk-accepted",
"ops-readiness-gate-updated",
"ops-migration-gate-passed",
"ops-migration-gate-failed"
]'::jsonb,
'[
"ops-drift",
"ops-service-catalog-gap",
"ops-backup-gap",
"ops-security-gap",
"ops-routing-gap",
"ops-secret-gap",
"ops-readiness-blocker",
"ops-migration-risk",
"ops-observability-gap",
"ops-recovery-gap"
]'::jsonb,
'[
"ops-local",
"ops-transitional-prod",
"ops-production",
"ops-threephoenix",
"ops-registry",
"ops-secrets",
"ops-backup-retention"
]'::jsonb,
'VSM Operations / System 1 hub for operational truth and evidence. Metadata: hub_family=vsm; vsm_function=OPS; vsm_system=S1; scope=operational truth, service catalog, readiness, incidents, runbooks, migration waves, and evidence events.',
'operator',
'active',
NOW()
FROM hub
ON CONFLICT (hub_id) DO UPDATE
SET manifest_version = EXCLUDED.manifest_version,
declared_widget_types = EXCLUDED.declared_widget_types,
declared_event_types = EXCLUDED.declared_event_types,
declared_annotation_categories = EXCLUDED.declared_annotation_categories,
declared_policy_scopes = EXCLUDED.declared_policy_scopes,
capability_description = EXCLUDED.capability_description,
contact = EXCLUDED.contact,
status = EXCLUDED.status,
activated_at = COALESCE(hub_capability_manifests.activated_at, NOW()),
updated_at = NOW();
WITH hub AS (
SELECT id FROM hubs WHERE slug = 'ops-hub'
), names(name) AS (
VALUES
('ops-environment'),
('ops-host'),
('ops-cluster'),
('ops-service'),
('ops-service-catalog'),
('ops-endpoint'),
('ops-release'),
('ops-backup-set'),
('ops-secret-set'),
('ops-runbook'),
('ops-incident'),
('ops-readiness-gate'),
('ops-migration-wave'),
('ops-risk')
)
INSERT INTO widget_type_registry (name, label, owner_hub_id, status)
SELECT names.name, names.name, hub.id, 'active'
FROM names CROSS JOIN hub
ON CONFLICT (name) DO NOTHING;
WITH hub AS (
SELECT id FROM hubs WHERE slug = 'ops-hub'
), names(name) AS (
VALUES
('ops-inventory-registered'),
('ops-inventory-updated'),
('ops-service-discovered'),
('ops-health-checked'),
('ops-release-observed'),
('ops-endpoint-verified'),
('ops-backup-verified'),
('ops-restore-tested'),
('ops-runbook-executed'),
('ops-drift-detected'),
('ops-risk-raised'),
('ops-risk-accepted'),
('ops-readiness-gate-updated'),
('ops-migration-gate-passed'),
('ops-migration-gate-failed')
)
INSERT INTO event_type_registry (name, label, owner_hub_id, status)
SELECT names.name, names.name, hub.id, 'active'
FROM names CROSS JOIN hub
ON CONFLICT (name) DO NOTHING;
WITH hub AS (
SELECT id FROM hubs WHERE slug = 'ops-hub'
), names(name) AS (
VALUES
('ops-drift'),
('ops-service-catalog-gap'),
('ops-backup-gap'),
('ops-security-gap'),
('ops-routing-gap'),
('ops-secret-gap'),
('ops-readiness-blocker'),
('ops-migration-risk'),
('ops-observability-gap'),
('ops-recovery-gap')
)
INSERT INTO annotation_category_registry (name, label, owner_hub_id, status)
SELECT names.name, names.name, hub.id, 'active'
FROM names CROSS JOIN hub
ON CONFLICT (name) DO NOTHING;
WITH hub AS (
SELECT id FROM hubs WHERE slug = 'ops-hub'
), names(name) AS (
VALUES
('ops-local'),
('ops-transitional-prod'),
('ops-production'),
('ops-threephoenix'),
('ops-registry'),
('ops-secrets'),
('ops-backup-retention')
)
INSERT INTO policy_scope_registry (name, label, owner_hub_id, status)
SELECT names.name, names.name, hub.id, 'active'
FROM names CROSS JOIN hub
ON CONFLICT (name) DO NOTHING;
WITH manifest AS (
SELECT id FROM hub_capability_manifests
WHERE hub_id = (SELECT id FROM hubs WHERE slug = 'ops-hub')
)
INSERT INTO api_consumers (
name,
description,
hub_capability_manifest_id,
rate_limit_per_minute,
quota_per_day,
is_active
)
SELECT
'ops-hub',
'API consumer for the VSM Operations hub',
manifest.id,
60,
10000,
TRUE
FROM manifest
WHERE NOT EXISTS (
SELECT 1 FROM api_consumers WHERE name = 'ops-hub'
);
WITH hub AS (
SELECT id FROM hubs WHERE slug = 'ops-hub'
), seed(name, widget_type, capability_ref, view_context, policy_scope) AS (
VALUES
('Local Environment', 'ops-environment', 'ops:environment:local', 'ops-hub/environments/local', 'ops-local'),
('CoulombCore Environment', 'ops-environment', 'ops:environment:coulombcore', 'ops-hub/environments/coulombcore', 'ops-transitional-prod'),
('Railiance01 Environment', 'ops-environment', 'ops:environment:railiance01', 'ops-hub/environments/railiance01', 'ops-threephoenix'),
('ThreePhoenix Production Environment', 'ops-environment', 'ops:environment:threephoenix-prod', 'ops-hub/environments/threephoenix-prod', 'ops-production'),
('CoulombCore Host', 'ops-host', 'ops:host:coulombcore', 'ops-hub/hosts/coulombcore', 'ops-transitional-prod'),
('Railiance01 Host', 'ops-host', 'ops:host:railiance01', 'ops-hub/hosts/railiance01', 'ops-threephoenix'),
('Operations Service Catalog', 'ops-service-catalog', 'ops:service-catalog', 'ops-hub/service-catalog', 'ops-production'),
('Gitea Service', 'ops-service', 'ops:service:gitea', 'ops-hub/services/gitea', 'ops-transitional-prod'),
('State Hub Service', 'ops-service', 'ops:service:state-hub', 'ops-hub/services/state-hub', 'ops-local'),
('Inter-Hub Service', 'ops-service', 'ops:service:inter-hub', 'ops-hub/services/inter-hub', 'ops-production'),
('Gitea Registry Endpoint', 'ops-endpoint', 'ops:endpoint:gitea-registry', 'ops-hub/endpoints/gitea-registry', 'ops-registry'),
('Gitea Registry Readiness', 'ops-readiness-gate', 'ops:readiness:gitea-registry', 'ops-hub/readiness/gitea-registry', 'ops-registry'),
('State Hub Cluster Deploy Readiness', 'ops-readiness-gate', 'ops:readiness:state-hub-cluster-deploy', 'ops-hub/readiness/state-hub-cluster-deploy', 'ops-production'),
('CoulombCore to ThreePhoenix Migration', 'ops-migration-wave', 'ops:migration:coulombcore-to-threephoenix', 'ops-hub/migrations/coulombcore-to-threephoenix', 'ops-threephoenix')
)
INSERT INTO widgets (
hub_id,
name,
widget_type,
capability_ref,
view_context,
policy_scope,
status
)
SELECT
hub.id,
seed.name,
seed.widget_type,
seed.capability_ref,
seed.view_context,
seed.policy_scope,
'active'
FROM seed CROSS JOIN hub
WHERE NOT EXISTS (
SELECT 1 FROM widgets
WHERE hub_id = hub.id
AND capability_ref = seed.capability_ref
);
COMMIT;

View File

@@ -0,0 +1,65 @@
{
"hub": {
"name": "Ops Hub",
"slug": "ops-hub",
"domain": "ops.coulomb.social",
"hubKind": "domain"
},
"manifestVersion": "1.0",
"capabilityDescription": "VSM Operations / System 1 hub for operational truth and evidence. Metadata: hub_family=vsm; vsm_function=OPS; vsm_system=S1; scope=operational truth, service catalog, readiness, incidents, runbooks, migration waves, and evidence events.",
"contact": "operator",
"declaredWidgetTypes": [
"ops-environment",
"ops-host",
"ops-cluster",
"ops-service",
"ops-service-catalog",
"ops-endpoint",
"ops-release",
"ops-backup-set",
"ops-secret-set",
"ops-runbook",
"ops-incident",
"ops-readiness-gate",
"ops-migration-wave",
"ops-risk"
],
"declaredEventTypes": [
"ops-inventory-registered",
"ops-inventory-updated",
"ops-service-discovered",
"ops-health-checked",
"ops-release-observed",
"ops-endpoint-verified",
"ops-backup-verified",
"ops-restore-tested",
"ops-runbook-executed",
"ops-drift-detected",
"ops-risk-raised",
"ops-risk-accepted",
"ops-readiness-gate-updated",
"ops-migration-gate-passed",
"ops-migration-gate-failed"
],
"declaredAnnotationCategories": [
"ops-drift",
"ops-service-catalog-gap",
"ops-backup-gap",
"ops-security-gap",
"ops-routing-gap",
"ops-secret-gap",
"ops-readiness-blocker",
"ops-migration-risk",
"ops-observability-gap",
"ops-recovery-gap"
],
"declaredPolicyScopes": [
"ops-local",
"ops-transitional-prod",
"ops-production",
"ops-threephoenix",
"ops-registry",
"ops-secrets",
"ops-backup-retention"
]
}

View File

@@ -0,0 +1,100 @@
[
{
"name": "Local Environment",
"widgetType": "ops-environment",
"capabilityRef": "ops:environment:local",
"viewContext": "ops-hub/environments/local",
"policyScope": "ops-local"
},
{
"name": "CoulombCore Environment",
"widgetType": "ops-environment",
"capabilityRef": "ops:environment:coulombcore",
"viewContext": "ops-hub/environments/coulombcore",
"policyScope": "ops-transitional-prod"
},
{
"name": "Railiance01 Environment",
"widgetType": "ops-environment",
"capabilityRef": "ops:environment:railiance01",
"viewContext": "ops-hub/environments/railiance01",
"policyScope": "ops-threephoenix"
},
{
"name": "ThreePhoenix Production Environment",
"widgetType": "ops-environment",
"capabilityRef": "ops:environment:threephoenix-prod",
"viewContext": "ops-hub/environments/threephoenix-prod",
"policyScope": "ops-production"
},
{
"name": "CoulombCore Host",
"widgetType": "ops-host",
"capabilityRef": "ops:host:coulombcore",
"viewContext": "ops-hub/hosts/coulombcore",
"policyScope": "ops-transitional-prod"
},
{
"name": "Railiance01 Host",
"widgetType": "ops-host",
"capabilityRef": "ops:host:railiance01",
"viewContext": "ops-hub/hosts/railiance01",
"policyScope": "ops-threephoenix"
},
{
"name": "Operations Service Catalog",
"widgetType": "ops-service-catalog",
"capabilityRef": "ops:service-catalog",
"viewContext": "ops-hub/service-catalog",
"policyScope": "ops-production"
},
{
"name": "Gitea Service",
"widgetType": "ops-service",
"capabilityRef": "ops:service:gitea",
"viewContext": "ops-hub/services/gitea",
"policyScope": "ops-transitional-prod"
},
{
"name": "State Hub Service",
"widgetType": "ops-service",
"capabilityRef": "ops:service:state-hub",
"viewContext": "ops-hub/services/state-hub",
"policyScope": "ops-local"
},
{
"name": "Inter-Hub Service",
"widgetType": "ops-service",
"capabilityRef": "ops:service:inter-hub",
"viewContext": "ops-hub/services/inter-hub",
"policyScope": "ops-production"
},
{
"name": "Gitea Registry Endpoint",
"widgetType": "ops-endpoint",
"capabilityRef": "ops:endpoint:gitea-registry",
"viewContext": "ops-hub/endpoints/gitea-registry",
"policyScope": "ops-registry"
},
{
"name": "Gitea Registry Readiness",
"widgetType": "ops-readiness-gate",
"capabilityRef": "ops:readiness:gitea-registry",
"viewContext": "ops-hub/readiness/gitea-registry",
"policyScope": "ops-registry"
},
{
"name": "State Hub Cluster Deploy Readiness",
"widgetType": "ops-readiness-gate",
"capabilityRef": "ops:readiness:state-hub-cluster-deploy",
"viewContext": "ops-hub/readiness/state-hub-cluster-deploy",
"policyScope": "ops-production"
},
{
"name": "CoulombCore to ThreePhoenix Migration",
"widgetType": "ops-migration-wave",
"capabilityRef": "ops:migration:coulombcore-to-threephoenix",
"viewContext": "ops-hub/migrations/coulombcore-to-threephoenix",
"policyScope": "ops-threephoenix"
}
]

5
src/ops_hub/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""ops-hub implementation helpers."""
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@@ -0,0 +1,111 @@
"""Probe whether Inter-Hub production exposes the ops-hub bootstrap API."""
from __future__ import annotations
import argparse
import json
from dataclasses import asdict, dataclass
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin
from urllib.request import Request, urlopen
REQUIRED_OPENAPI_PATHS = (
"/hubs",
"/hub-capability-manifests",
"/api-consumers",
"/policy-scopes",
)
@dataclass(frozen=True)
class HttpObservation:
url: str
status: int | None
ok: bool
error: str | None = None
@dataclass(frozen=True)
class GateResult:
base_url: str
passed: bool
hubs_status: int | None
required_paths_present: list[str]
required_paths_missing: list[str]
observations: list[HttpObservation]
def normalize_base_url(base_url: str) -> str:
return base_url.rstrip("/") + "/"
def api_url(base_url: str, path: str) -> str:
return urljoin(normalize_base_url(base_url), path.lstrip("/"))
def fetch_json(url: str, timeout: float) -> tuple[HttpObservation, dict[str, Any] | None]:
request = Request(url, headers={"Accept": "application/json", "User-Agent": "ops-hub-gate-probe/0.1"})
try:
with urlopen(request, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
return HttpObservation(url=url, status=response.status, ok=True), payload
except HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return HttpObservation(url=url, status=exc.code, ok=False, error=body[:240]), None
except (URLError, TimeoutError, json.JSONDecodeError) as exc:
return HttpObservation(url=url, status=None, ok=False, error=str(exc)), None
def observe_status(url: str, timeout: float) -> HttpObservation:
request = Request(url, headers={"Accept": "application/json", "User-Agent": "ops-hub-gate-probe/0.1"})
try:
with urlopen(request, timeout=timeout) as response:
response.read()
return HttpObservation(url=url, status=response.status, ok=True)
except HTTPError as exc:
exc.read()
return HttpObservation(url=url, status=exc.code, ok=False, error=exc.reason)
except (URLError, TimeoutError) as exc:
return HttpObservation(url=url, status=None, ok=False, error=str(exc))
def evaluate_openapi_paths(openapi: dict[str, Any] | None) -> tuple[list[str], list[str]]:
paths = set((openapi or {}).get("paths", {}).keys())
present = [path for path in REQUIRED_OPENAPI_PATHS if path in paths]
missing = [path for path in REQUIRED_OPENAPI_PATHS if path not in paths]
return present, missing
def probe_interhub_gate(base_url: str, timeout: float = 10.0) -> GateResult:
normalized = normalize_base_url(base_url)
hubs = observe_status(api_url(normalized, "/api/v2/hubs"), timeout)
openapi_observation, openapi = fetch_json(api_url(normalized, "/api/v2/openapi.json"), timeout)
present, missing = evaluate_openapi_paths(openapi)
passed = hubs.status == 401 and not missing
return GateResult(
base_url=normalized.rstrip("/"),
passed=passed,
hubs_status=hubs.status,
required_paths_present=present,
required_paths_missing=missing,
observations=[hubs, openapi_observation],
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", default="https://hub.coulomb.social", help="Inter-Hub base URL")
parser.add_argument("--timeout", default=10.0, type=float, help="HTTP timeout in seconds")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
result = probe_interhub_gate(args.base, timeout=args.timeout)
print(json.dumps(asdict(result), indent=2, sort_keys=True))
return 0 if result.passed else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import unittest
from ops_hub.interhub_gate_probe import (
REQUIRED_OPENAPI_PATHS,
api_url,
evaluate_openapi_paths,
normalize_base_url,
)
class InterHubGateProbeTests(unittest.TestCase):
def test_normalize_base_url(self) -> None:
self.assertEqual(normalize_base_url("https://hub.example"), "https://hub.example/")
self.assertEqual(normalize_base_url("https://hub.example/"), "https://hub.example/")
def test_api_url_joins_versioned_paths(self) -> None:
self.assertEqual(
api_url("https://hub.example", "/api/v2/hubs"),
"https://hub.example/api/v2/hubs",
)
def test_evaluate_openapi_paths_detects_all_required_paths(self) -> None:
openapi = {"paths": {path: {} for path in REQUIRED_OPENAPI_PATHS}}
present, missing = evaluate_openapi_paths(openapi)
self.assertEqual(present, list(REQUIRED_OPENAPI_PATHS))
self.assertEqual(missing, [])
def test_evaluate_openapi_paths_reports_missing_paths(self) -> None:
openapi = {"paths": {"/hubs": {}}}
present, missing = evaluate_openapi_paths(openapi)
self.assertEqual(present, ["/hubs"])
self.assertEqual(
missing,
["/hub-capability-manifests", "/api-consumers", "/policy-scopes"],
)
if __name__ == "__main__":
unittest.main()