Complete ops-hub Inter-Hub bootstrap and close bootstrap workplans.
Extract bootstrap logic into src/ops_hub/bootstrap.py with dry-run support, public hub discovery, and unit tests. Update the gate probe to accept public hub listing (200 or 401), document dev commands and architecture, and archive finished OPS-WP-0001 and OPS-WP-0002 workplans.
This commit is contained in:
parent
0454e126cb
commit
39080333da
13 changed files with 795 additions and 370 deletions
534
src/ops_hub/bootstrap.py
Normal file
534
src/ops_hub/bootstrap.py
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
"""Authenticated Inter-Hub bootstrap client for ops-hub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ops_hub.interhub_gate_probe import probe_interhub_gate
|
||||
|
||||
DEFAULT_BASE = "https://hub.coulomb.social"
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST_PATH = ROOT / "seeds" / "ops-hub-manifest.draft.json"
|
||||
WIDGETS_PATH = ROOT / "seeds" / "ops-hub-widgets.seed.json"
|
||||
|
||||
|
||||
class BootstrapError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BootstrapPlan:
|
||||
gate_passed: bool
|
||||
hub: dict[str, Any]
|
||||
manifest: dict[str, Any]
|
||||
api_consumer: dict[str, Any]
|
||||
runtime_key: dict[str, Any]
|
||||
widgets: dict[str, Any]
|
||||
event: dict[str, Any] | None
|
||||
|
||||
|
||||
def load_secret(name: str, file_name: str) -> str:
|
||||
value = os.environ.get(name, "").strip()
|
||||
if value:
|
||||
return value
|
||||
file_path = os.environ.get(file_name, "").strip()
|
||||
if file_path:
|
||||
return Path(file_path).read_text(encoding="utf-8").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def request_json(
|
||||
base_url: str,
|
||||
method: str,
|
||||
path: str,
|
||||
token: str | None,
|
||||
body: dict[str, Any] | None,
|
||||
*,
|
||||
expected: set[int],
|
||||
) -> dict[str, Any]:
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
request = urllib.request.Request(base_url + path, data=data, method=method)
|
||||
request.add_header("Accept", "application/json")
|
||||
request.add_header("User-Agent", "ops-hub-bootstrap/0.1")
|
||||
if token:
|
||||
request.add_header("Authorization", f"Bearer {token}")
|
||||
if body is not None:
|
||||
request.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
status = response.status
|
||||
payload = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as error:
|
||||
payload = error.read().decode("utf-8", errors="replace")
|
||||
raise BootstrapError(f"{method} {path} failed with HTTP {error.code}: {payload}") from error
|
||||
|
||||
if status not in expected:
|
||||
raise BootstrapError(f"{method} {path} returned HTTP {status}, expected {sorted(expected)}")
|
||||
if not payload:
|
||||
return {}
|
||||
return json.loads(payload)
|
||||
|
||||
|
||||
def list_items(base_url: str, path: str, token: str | None) -> list[dict[str, Any]]:
|
||||
response = request_json(base_url, "GET", path, token, None, expected={200})
|
||||
data = response.get("data", [])
|
||||
if not isinstance(data, list):
|
||||
raise BootstrapError(f"expected paginated data array from {path}")
|
||||
return data
|
||||
|
||||
|
||||
def first_by(items: list[dict[str, Any]], key: str, value: Any) -> dict[str, Any] | None:
|
||||
return next((item for item in items if item.get(key) == value), None)
|
||||
|
||||
|
||||
def load_manifest(path: Path = MANIFEST_PATH) -> dict[str, Any]:
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
required = [
|
||||
"hub",
|
||||
"manifestVersion",
|
||||
"declaredWidgetTypes",
|
||||
"declaredEventTypes",
|
||||
"declaredAnnotationCategories",
|
||||
"declaredPolicyScopes",
|
||||
]
|
||||
missing = [key for key in required if key not in manifest]
|
||||
if missing:
|
||||
raise BootstrapError(f"{path} missing required key(s): {', '.join(missing)}")
|
||||
return manifest
|
||||
|
||||
|
||||
def load_widgets(path: Path = WIDGETS_PATH) -> list[dict[str, Any]]:
|
||||
widgets = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(widgets, list):
|
||||
raise BootstrapError(f"{path} must contain a JSON array")
|
||||
return widgets
|
||||
|
||||
|
||||
def find_hub(base_url: str, operator_key: str | None, slug: str) -> dict[str, Any] | None:
|
||||
tokens: list[str | None] = [None]
|
||||
if operator_key:
|
||||
tokens.append(operator_key)
|
||||
for token in tokens:
|
||||
try:
|
||||
existing = first_by(list_items(base_url, "/api/v2/hubs", token), "slug", slug)
|
||||
except BootstrapError:
|
||||
continue
|
||||
if existing:
|
||||
return existing
|
||||
return None
|
||||
|
||||
|
||||
def plan_hub(base_url: str, operator_key: str, manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
slug = manifest["hub"]["slug"]
|
||||
existing = find_hub(base_url, operator_key, slug)
|
||||
if existing:
|
||||
return {"action": "reuse", "record": existing}
|
||||
return {"action": "create", "record": manifest["hub"]}
|
||||
|
||||
|
||||
def plan_manifest(
|
||||
base_url: str,
|
||||
operator_key: str,
|
||||
hub_id: str,
|
||||
manifest: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
query = urllib.parse.urlencode({"hubId": hub_id})
|
||||
manifests = list_items(base_url, f"/api/v2/hub-capability-manifests?{query}", operator_key)
|
||||
active = first_by(manifests, "status", "active")
|
||||
if active:
|
||||
return {"action": "reuse", "record": active, "activate": False}
|
||||
draft = first_by(manifests, "status", "draft")
|
||||
if draft:
|
||||
return {"action": "update", "record": draft, "activate": True}
|
||||
return {"action": "create", "record": manifest_body(manifest, hub_id), "activate": True}
|
||||
|
||||
|
||||
def plan_api_consumer(base_url: str, operator_key: str) -> dict[str, Any]:
|
||||
existing = first_by(list_items(base_url, "/api/v2/api-consumers", operator_key), "name", "ops-hub")
|
||||
if existing:
|
||||
return {"action": "reuse", "record": existing}
|
||||
return {"action": "create", "record": {"name": "ops-hub"}}
|
||||
|
||||
|
||||
def plan_runtime_key(api_consumer_id: str) -> dict[str, Any]:
|
||||
existing_runtime_key = load_secret("OPS_HUB_KEY", "OPS_HUB_KEY_FILE")
|
||||
if existing_runtime_key:
|
||||
return {
|
||||
"action": "reuse",
|
||||
"apiConsumerId": api_consumer_id,
|
||||
"keyPrefix": existing_runtime_key[:8],
|
||||
}
|
||||
return {"action": "create", "apiConsumerId": api_consumer_id}
|
||||
|
||||
|
||||
def plan_widgets(
|
||||
base_url: str,
|
||||
runtime_key: str | None,
|
||||
hub_id: str,
|
||||
widget_seeds: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
if not runtime_key:
|
||||
return {
|
||||
"action": "plan",
|
||||
"create": [seed.get("capabilityRef") for seed in widget_seeds],
|
||||
"reuse": [],
|
||||
}
|
||||
existing_widgets = list_items(base_url, "/api/v2/widgets", runtime_key)
|
||||
existing_by_ref = {
|
||||
widget.get("capabilityRef"): widget
|
||||
for widget in existing_widgets
|
||||
if widget.get("hubId") == hub_id and widget.get("capabilityRef")
|
||||
}
|
||||
create = [seed for seed in widget_seeds if seed.get("capabilityRef") not in existing_by_ref]
|
||||
reuse = [existing_by_ref[seed["capabilityRef"]] for seed in widget_seeds if seed.get("capabilityRef") in existing_by_ref]
|
||||
return {"action": "plan", "create": create, "reuse": reuse}
|
||||
|
||||
|
||||
def plan_event(widgets: dict[str, Any], skip_event: bool) -> dict[str, Any] | None:
|
||||
if skip_event:
|
||||
return None
|
||||
all_widgets = widgets.get("reuse", []) + widgets.get("create", [])
|
||||
readiness = next(
|
||||
(widget for widget in all_widgets if widget.get("capabilityRef") == "ops:readiness:gitea-registry"),
|
||||
None,
|
||||
)
|
||||
if readiness:
|
||||
return {"action": "submit", "widgetId": readiness.get("id"), "eventType": "ops-endpoint-verified"}
|
||||
return {"action": "submit", "widgetId": None, "eventType": "ops-endpoint-verified"}
|
||||
|
||||
|
||||
def build_plan(
|
||||
base_url: str,
|
||||
operator_key: str,
|
||||
*,
|
||||
skip_event: bool = False,
|
||||
manifest: dict[str, Any] | None = None,
|
||||
widget_seeds: list[dict[str, Any]] | None = None,
|
||||
) -> BootstrapPlan:
|
||||
manifest = manifest or load_manifest()
|
||||
widget_seeds = widget_seeds or load_widgets()
|
||||
gate = probe_interhub_gate(base_url)
|
||||
if not gate.passed:
|
||||
raise BootstrapError("Inter-Hub bootstrap gate is not open")
|
||||
|
||||
hub_plan = plan_hub(base_url, operator_key, manifest)
|
||||
hub_id = hub_plan["record"]["id"] if hub_plan["action"] == "reuse" else "<new>"
|
||||
manifest_plan = (
|
||||
plan_manifest(base_url, operator_key, hub_plan["record"]["id"], manifest)
|
||||
if hub_plan["action"] == "reuse"
|
||||
else {"action": "create", "record": manifest_body(manifest), "activate": True}
|
||||
)
|
||||
consumer_plan = plan_api_consumer(base_url, operator_key)
|
||||
consumer_id = consumer_plan["record"].get("id", "<new>")
|
||||
runtime_plan = plan_runtime_key(consumer_id)
|
||||
widgets_plan = plan_widgets(
|
||||
base_url,
|
||||
load_secret("OPS_HUB_KEY", "OPS_HUB_KEY_FILE") or None,
|
||||
hub_id if hub_plan["action"] == "reuse" else "<new>",
|
||||
widget_seeds,
|
||||
)
|
||||
event_plan = plan_event(widgets_plan, skip_event)
|
||||
return BootstrapPlan(
|
||||
gate_passed=gate.passed,
|
||||
hub=hub_plan,
|
||||
manifest=manifest_plan,
|
||||
api_consumer=consumer_plan,
|
||||
runtime_key=runtime_plan,
|
||||
widgets=widgets_plan,
|
||||
event=event_plan,
|
||||
)
|
||||
|
||||
|
||||
def manifest_body(manifest: dict[str, Any], hub_id: str | None = None) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"manifestVersion": manifest["manifestVersion"],
|
||||
"declaredWidgetTypes": manifest["declaredWidgetTypes"],
|
||||
"declaredEventTypes": manifest["declaredEventTypes"],
|
||||
"declaredAnnotationCategories": manifest["declaredAnnotationCategories"],
|
||||
"declaredPolicyScopes": manifest["declaredPolicyScopes"],
|
||||
"capabilityDescription": manifest.get("capabilityDescription", ""),
|
||||
"contact": manifest.get("contact", "operator"),
|
||||
}
|
||||
if hub_id:
|
||||
body["hubId"] = hub_id
|
||||
return body
|
||||
|
||||
|
||||
def ensure_hub(base_url: str, operator_key: str, manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
hub_seed = manifest["hub"]
|
||||
slug = hub_seed["slug"]
|
||||
existing = find_hub(base_url, operator_key, slug)
|
||||
if existing:
|
||||
return {"record": existing, "created": False}
|
||||
|
||||
body = {
|
||||
"slug": slug,
|
||||
"name": hub_seed["name"],
|
||||
"domain": hub_seed["domain"],
|
||||
"hubKind": hub_seed.get("hubKind", "domain"),
|
||||
"hubFamily": "vsm",
|
||||
"vsmFunction": "OPS",
|
||||
"vsmSystem": "1",
|
||||
}
|
||||
record = request_json(base_url, "POST", "/api/v2/hubs", operator_key, body, expected={201})
|
||||
return {"record": record, "created": True}
|
||||
|
||||
|
||||
def ensure_manifest(base_url: str, operator_key: str, hub_id: str, manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
query = urllib.parse.urlencode({"hubId": hub_id})
|
||||
manifests = list_items(base_url, f"/api/v2/hub-capability-manifests?{query}", operator_key)
|
||||
active = first_by(manifests, "status", "active")
|
||||
if active:
|
||||
return {"record": active, "created": False, "activated": False}
|
||||
|
||||
draft = first_by(manifests, "status", "draft")
|
||||
if draft:
|
||||
record = request_json(
|
||||
base_url,
|
||||
"PATCH",
|
||||
f"/api/v2/hub-capability-manifests/{draft['id']}",
|
||||
operator_key,
|
||||
manifest_body(manifest),
|
||||
expected={200},
|
||||
)
|
||||
created = False
|
||||
else:
|
||||
record = request_json(
|
||||
base_url,
|
||||
"POST",
|
||||
"/api/v2/hub-capability-manifests",
|
||||
operator_key,
|
||||
manifest_body(manifest, hub_id),
|
||||
expected={201},
|
||||
)
|
||||
created = True
|
||||
|
||||
activated = request_json(
|
||||
base_url,
|
||||
"POST",
|
||||
f"/api/v2/hub-capability-manifests/{record['id']}/activate",
|
||||
operator_key,
|
||||
None,
|
||||
expected={200},
|
||||
)
|
||||
return {"record": activated, "created": created, "activated": True}
|
||||
|
||||
|
||||
def ensure_api_consumer(base_url: str, operator_key: str, manifest_id: str) -> dict[str, Any]:
|
||||
consumers = list_items(base_url, "/api/v2/api-consumers", operator_key)
|
||||
existing = first_by(consumers, "name", "ops-hub")
|
||||
if existing:
|
||||
return {"record": existing, "created": False}
|
||||
|
||||
record = request_json(
|
||||
base_url,
|
||||
"POST",
|
||||
"/api/v2/api-consumers",
|
||||
operator_key,
|
||||
{
|
||||
"name": "ops-hub",
|
||||
"description": "API consumer for the VSM Operations hub",
|
||||
"hubCapabilityManifestId": manifest_id,
|
||||
"rateLimitPerMinute": 120,
|
||||
"quotaPerDay": 50000,
|
||||
},
|
||||
expected={201},
|
||||
)
|
||||
return {"record": record, "created": True}
|
||||
|
||||
|
||||
def write_runtime_key(full_key: str, output_path: str | None) -> Path:
|
||||
if output_path:
|
||||
path = Path(output_path)
|
||||
path.write_text(full_key, encoding="utf-8")
|
||||
else:
|
||||
fd, raw_path = tempfile.mkstemp(prefix="ops-hub-runtime-key-", text=True)
|
||||
path = Path(raw_path)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(full_key)
|
||||
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
return path
|
||||
|
||||
|
||||
def ensure_runtime_key(
|
||||
base_url: str,
|
||||
operator_key: str,
|
||||
api_consumer_id: str,
|
||||
output_path: str | None,
|
||||
) -> dict[str, Any]:
|
||||
existing_runtime_key = load_secret("OPS_HUB_KEY", "OPS_HUB_KEY_FILE")
|
||||
if existing_runtime_key:
|
||||
return {
|
||||
"created": False,
|
||||
"token": existing_runtime_key,
|
||||
"keyPrefix": existing_runtime_key[:8],
|
||||
"keyFile": os.environ.get("OPS_HUB_KEY_FILE"),
|
||||
}
|
||||
|
||||
response = request_json(
|
||||
base_url,
|
||||
"POST",
|
||||
f"/api/v2/api-consumers/{api_consumer_id}/api-keys",
|
||||
operator_key,
|
||||
{"scopes": "framework:read hub:ops-hub:read hub:ops-hub:write"},
|
||||
expected={201},
|
||||
)
|
||||
full_key = response.get("fullKey")
|
||||
if not full_key:
|
||||
raise BootstrapError("api key creation did not return display-once fullKey")
|
||||
key_file = write_runtime_key(full_key, output_path)
|
||||
api_key = response.get("apiKey") or {}
|
||||
return {
|
||||
"created": True,
|
||||
"token": full_key,
|
||||
"keyPrefix": api_key.get("keyPrefix", full_key[:8]),
|
||||
"keyFile": str(key_file),
|
||||
}
|
||||
|
||||
|
||||
def ensure_widgets(base_url: str, runtime_key: str, hub_id: str, widget_seeds: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
existing_widgets = list_items(base_url, "/api/v2/widgets", runtime_key)
|
||||
existing_by_ref = {
|
||||
widget.get("capabilityRef"): widget
|
||||
for widget in existing_widgets
|
||||
if widget.get("hubId") == hub_id and widget.get("capabilityRef")
|
||||
}
|
||||
created: list[dict[str, Any]] = []
|
||||
reused: list[dict[str, Any]] = []
|
||||
for seed in widget_seeds:
|
||||
existing = existing_by_ref.get(seed.get("capabilityRef"))
|
||||
if existing:
|
||||
reused.append(existing)
|
||||
continue
|
||||
body = {"hubId": hub_id, "status": "active", **seed}
|
||||
created.append(request_json(base_url, "POST", "/api/v2/widgets", runtime_key, body, expected={201}))
|
||||
return {"created": created, "reused": reused}
|
||||
|
||||
|
||||
def submit_gitea_event(base_url: str, runtime_key: str, widgets: dict[str, Any]) -> dict[str, Any] | None:
|
||||
all_widgets = widgets["created"] + widgets["reused"]
|
||||
readiness = next(
|
||||
(widget for widget in all_widgets if widget.get("capabilityRef") == "ops:readiness:gitea-registry"),
|
||||
None,
|
||||
)
|
||||
if not readiness:
|
||||
return None
|
||||
return request_json(
|
||||
base_url,
|
||||
"POST",
|
||||
"/api/v2/interaction-events",
|
||||
runtime_key,
|
||||
{
|
||||
"widgetId": readiness["id"],
|
||||
"eventType": "ops-endpoint-verified",
|
||||
"viewContext": "railiance-apps/workplans/RAIL-AP-WP-0001",
|
||||
"metadata": {
|
||||
"vsmFunction": "OPS",
|
||||
"vsmSystem": "S1",
|
||||
"endpoint": "https://gitea.coulomb.social/v2/",
|
||||
"expectedStatus": 401,
|
||||
"observedHeader": "Docker-Distribution-Api-Version: registry/2.0",
|
||||
"recordedBy": "ops-hub/scripts/ops-hub-bootstrap-api.py",
|
||||
"recordedAt": int(time.time()),
|
||||
},
|
||||
},
|
||||
expected={201},
|
||||
)
|
||||
|
||||
|
||||
def run_bootstrap(
|
||||
base_url: str,
|
||||
operator_key: str,
|
||||
*,
|
||||
runtime_key_output: str | None = None,
|
||||
skip_event: bool = False,
|
||||
manifest: dict[str, Any] | None = None,
|
||||
widget_seeds: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
gate = probe_interhub_gate(base_url)
|
||||
if not gate.passed:
|
||||
raise BootstrapError("Inter-Hub bootstrap gate is not open")
|
||||
|
||||
manifest = manifest or load_manifest()
|
||||
widget_seeds = widget_seeds or load_widgets()
|
||||
|
||||
hub = ensure_hub(base_url, operator_key, manifest)
|
||||
hub_record = hub["record"]
|
||||
manifest_result = ensure_manifest(base_url, operator_key, hub_record["id"], manifest)
|
||||
manifest_record = manifest_result["record"]
|
||||
consumer = ensure_api_consumer(base_url, operator_key, manifest_record["id"])
|
||||
runtime_key = ensure_runtime_key(base_url, operator_key, consumer["record"]["id"], runtime_key_output)
|
||||
widgets = ensure_widgets(base_url, runtime_key["token"], hub_record["id"], widget_seeds)
|
||||
event = None if skip_event else submit_gitea_event(base_url, runtime_key["token"], widgets)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"dryRun": False,
|
||||
"base": base_url,
|
||||
"gate": {"passed": gate.passed, "hubsStatus": gate.hubs_status},
|
||||
"hub": {"id": hub_record["id"], "slug": hub_record["slug"], "created": hub["created"]},
|
||||
"manifest": {
|
||||
"id": manifest_record["id"],
|
||||
"status": manifest_record["status"],
|
||||
"created": manifest_result["created"],
|
||||
"activated": manifest_result["activated"],
|
||||
},
|
||||
"apiConsumer": {"id": consumer["record"]["id"], "name": consumer["record"]["name"], "created": consumer["created"]},
|
||||
"runtimeKey": {
|
||||
"created": runtime_key["created"],
|
||||
"keyPrefix": runtime_key["keyPrefix"],
|
||||
"keyFile": runtime_key["keyFile"],
|
||||
"storeImmediatelyInOpenBao": "platform/operators/ops-hub/runtime",
|
||||
"field": "OPS_HUB_KEY",
|
||||
},
|
||||
"widgets": {"created": len(widgets["created"]), "reused": len(widgets["reused"])},
|
||||
"event": None
|
||||
if event is None
|
||||
else {"id": event["id"], "eventType": event["eventType"], "widgetId": event["widgetId"]},
|
||||
}
|
||||
|
||||
|
||||
def dry_run_bootstrap(
|
||||
base_url: str,
|
||||
operator_key: str,
|
||||
*,
|
||||
skip_event: bool = False,
|
||||
manifest: dict[str, Any] | None = None,
|
||||
widget_seeds: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
gate = probe_interhub_gate(base_url)
|
||||
plan = build_plan(
|
||||
base_url,
|
||||
operator_key,
|
||||
skip_event=skip_event,
|
||||
manifest=manifest,
|
||||
widget_seeds=widget_seeds,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"dryRun": True,
|
||||
"base": base_url,
|
||||
"gate": {"passed": gate.passed, "hubsStatus": gate.hubs_status},
|
||||
"plan": {
|
||||
"hub": plan.hub,
|
||||
"manifest": plan.manifest,
|
||||
"apiConsumer": plan.api_consumer,
|
||||
"runtimeKey": plan.runtime_key,
|
||||
"widgets": {
|
||||
"create": len(plan.widgets.get("create", [])),
|
||||
"reuse": len(plan.widgets.get("reuse", [])),
|
||||
},
|
||||
"event": plan.event,
|
||||
},
|
||||
}
|
||||
|
|
@ -82,7 +82,7 @@ def probe_interhub_gate(base_url: str, timeout: float = 10.0) -> GateResult:
|
|||
hubs = observe_status(api_url(normalized, "/api/v2/hubs"), timeout)
|
||||
openapi_observation, openapi = fetch_json(api_url(normalized, "/api/v2/openapi.json"), timeout)
|
||||
present, missing = evaluate_openapi_paths(openapi)
|
||||
passed = hubs.status == 401 and not missing
|
||||
passed = hubs.status in {200, 401} and not missing
|
||||
return GateResult(
|
||||
base_url=normalized.rstrip("/"),
|
||||
passed=passed,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue