generated from coulomb/repo-seed
feat: add interhub bootstrap helper
This commit is contained in:
25
Makefile
Normal file
25
Makefile
Normal 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
171
docs/bootstrap-runbook.md
Normal 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
9
pyproject.toml
Normal 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"]
|
||||
15
scripts/interhub-gate-probe.py
Normal file
15
scripts/interhub-gate-probe.py
Normal 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())
|
||||
368
scripts/ops-hub-bootstrap-api.py
Normal file
368
scripts/ops-hub-bootstrap-api.py
Normal 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
287
seeds/ops-hub-bootstrap.sql
Normal 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;
|
||||
65
seeds/ops-hub-manifest.draft.json
Normal file
65
seeds/ops-hub-manifest.draft.json
Normal 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"
|
||||
]
|
||||
}
|
||||
100
seeds/ops-hub-widgets.seed.json
Normal file
100
seeds/ops-hub-widgets.seed.json
Normal 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
5
src/ops_hub/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""ops-hub implementation helpers."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
111
src/ops_hub/interhub_gate_probe.py
Normal file
111
src/ops_hub/interhub_gate_probe.py
Normal 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())
|
||||
41
tests/test_interhub_gate_probe.py
Normal file
41
tests/test_interhub_gate_probe.py
Normal 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()
|
||||
Reference in New Issue
Block a user