feat: implement OpsCatalog extension (BRIDGE-WP-0002)

Adds the OpsCatalog subsystem: a Git-backed YAML catalog of operations
domains, targets, bridges, and actor classes. Includes catalog loader,
cross-reference validator, bridge resolver (inline-first, catalog
fallback), and new CLI commands: `bridge targets`, `bridge targets show`,
`bridge catalog list/validate/show`. Updates `up/down/restart` to resolve
bridge names from the catalog when not defined inline. 142 tests, all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 02:05:06 +00:00
parent a7eaf59ced
commit 91d031ae20
13 changed files with 1435 additions and 50 deletions

View File

View File

@@ -0,0 +1,142 @@
"""Catalog loader — walks a catalog directory tree and parses YAML files."""
from __future__ import annotations
import logging
import warnings
from pathlib import Path
from typing import Any, Dict
import yaml
from bridge.catalog.models import (
ActorClass,
Catalog,
CatalogBridge,
CatalogDomain,
CatalogTarget,
)
from bridge.models import HealthCheckConfig, ReconnectPolicy
log = logging.getLogger(__name__)
class CatalogLoadError(Exception):
"""Raised when catalog loading fails."""
def load_catalog(path: Path) -> Catalog:
"""Walk the catalog directory and return a populated Catalog."""
path = Path(path)
if not path.exists():
raise CatalogLoadError(f"Catalog path not found: {path}")
catalog = Catalog()
for yaml_file in sorted(path.rglob("*.yaml")):
_load_file(yaml_file, catalog)
return catalog
def _load_file(path: Path, catalog: Catalog) -> None:
try:
with path.open() as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise CatalogLoadError(f"Invalid YAML in {path}: {e}") from e
if not isinstance(data, dict):
log.warning("Skipping %s: not a YAML mapping", path)
return
entry_type = data.get("type")
if not entry_type:
log.warning("Skipping %s: no 'type' field", path)
return
try:
if entry_type == "domain":
entry = _parse_domain(data, path)
catalog.domains[entry.id] = entry
elif entry_type == "target":
entry = _parse_target(data, path)
catalog.targets[entry.id] = entry
elif entry_type == "bridge":
entry = _parse_bridge(data, path)
catalog.bridges[entry.id] = entry
elif entry_type == "actor":
entry = _parse_actor(data, path)
catalog.actors[entry.id] = entry
else:
log.warning("Skipping %s: unknown type '%s'", path, entry_type)
except CatalogLoadError:
raise
except Exception as e:
raise CatalogLoadError(f"Error parsing {path}: {e}") from e
def _require(data: dict, field: str, path: Path) -> Any:
if field not in data:
raise CatalogLoadError(f"Missing required field '{field}' in {path}")
return data[field]
def _parse_domain(data: dict, path: Path) -> CatalogDomain:
return CatalogDomain(
id=str(_require(data, "id", path)),
name=str(_require(data, "name", path)),
description=str(data.get("description", "")),
environment=str(data.get("environment", "")),
)
def _parse_target(data: dict, path: Path) -> CatalogTarget:
return CatalogTarget(
id=str(_require(data, "id", path)),
domain=str(_require(data, "domain", path)),
kind=str(_require(data, "kind", path)),
description=str(data.get("description", "")),
reachable_via=list(data.get("reachable_via") or []),
)
def _parse_bridge(data: dict, path: Path) -> CatalogBridge:
health_check = None
if "health_check" in data and data["health_check"]:
hc = data["health_check"]
health_check = HealthCheckConfig(
url=str(_require(hc, "url", path)),
interval_seconds=int(hc.get("interval_seconds", 30)),
timeout_seconds=int(hc.get("timeout_seconds", 5)),
)
reconnect = None
if "reconnect" in data and data["reconnect"]:
r = data["reconnect"]
reconnect = ReconnectPolicy(
max_attempts=int(r.get("max_attempts", 0)),
backoff_initial=int(r.get("backoff_initial", 5)),
backoff_max=int(r.get("backoff_max", 60)),
)
return CatalogBridge(
id=str(_require(data, "id", path)),
domain=str(_require(data, "domain", path)),
target=str(_require(data, "target", path)),
host=str(_require(data, "host", path)),
remote_port=int(_require(data, "remote_port", path)),
local_port=int(_require(data, "local_port", path)),
ssh_user=str(_require(data, "ssh_user", path)),
ssh_key=str(_require(data, "ssh_key", path)),
actor=str(_require(data, "actor", path)),
description=str(data.get("description", "")),
access_method=str(data.get("access_method", "ssh-reverse")),
health_check=health_check,
reconnect=reconnect,
)
def _parse_actor(data: dict, path: Path) -> ActorClass:
return ActorClass(
id=str(_require(data, "id", path)),
actor_class=str(_require(data, "class", path)),
description=str(data.get("description", "")),
)

View File

@@ -0,0 +1,69 @@
"""Domain models for OpsCatalog."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from bridge.models import HealthCheckConfig, ReconnectPolicy, TunnelConfig
@dataclass
class CatalogDomain:
id: str
name: str
description: str = ""
environment: str = ""
@dataclass
class CatalogTarget:
id: str
domain: str
kind: str
description: str = ""
reachable_via: List[str] = field(default_factory=list)
@dataclass
class CatalogBridge:
id: str
domain: str
target: str
host: str
remote_port: int
local_port: int
ssh_user: str
ssh_key: str
actor: str
description: str = ""
access_method: str = "ssh-reverse"
health_check: Optional[HealthCheckConfig] = None
reconnect: Optional[ReconnectPolicy] = None
def to_tunnel_config(self) -> TunnelConfig:
return TunnelConfig(
name=self.id,
host=self.host,
remote_port=self.remote_port,
local_port=self.local_port,
ssh_user=self.ssh_user,
ssh_key=self.ssh_key,
actor=self.actor,
reconnect=self.reconnect if self.reconnect is not None else ReconnectPolicy(),
health_check=self.health_check,
)
@dataclass
class ActorClass:
id: str
actor_class: str
description: str = ""
@dataclass
class Catalog:
domains: Dict[str, CatalogDomain] = field(default_factory=dict)
targets: Dict[str, CatalogTarget] = field(default_factory=dict)
bridges: Dict[str, CatalogBridge] = field(default_factory=dict)
actors: Dict[str, ActorClass] = field(default_factory=dict)

View File

@@ -0,0 +1,35 @@
"""Catalog resolver — resolves a bridge name to a TunnelConfig."""
from __future__ import annotations
from typing import Dict, Optional
from bridge.catalog.models import Catalog
from bridge.models import TunnelConfig
class BridgeNotFound(Exception):
"""Raised when a bridge name cannot be resolved from inline config or catalog."""
def resolve(
name: str,
catalog: Optional[Catalog],
inline_tunnels: Dict[str, TunnelConfig],
) -> TunnelConfig:
"""Resolve bridge name to TunnelConfig.
Lookup order:
1. inline_tunnels (from tunnels.yaml) — wins if present
2. catalog bridges — fallback
3. raises BridgeNotFound if neither has the name
"""
if name in inline_tunnels:
return inline_tunnels[name]
if catalog is not None and name in catalog.bridges:
return catalog.bridges[name].to_tunnel_config()
raise BridgeNotFound(
f"Bridge '{name}' not found in inline config"
+ (" or catalog" if catalog is not None else " (no catalog configured)")
)

View File

@@ -0,0 +1,42 @@
"""Catalog validator — cross-reference checks for catalog consistency."""
from __future__ import annotations
from typing import List
from bridge.catalog.models import Catalog
class ValidationError(Exception):
"""Raised when catalog validation fails (used for programmatic access)."""
def validate_catalog(catalog: Catalog) -> List[str]:
"""Return a list of validation error strings (empty = valid)."""
errors: List[str] = []
for target in catalog.targets.values():
if target.domain not in catalog.domains:
errors.append(
f"Target '{target.id}': domain '{target.domain}' does not exist in catalog"
)
for bridge_id in target.reachable_via:
if bridge_id not in catalog.bridges:
errors.append(
f"Target '{target.id}': reachable_via references unknown bridge '{bridge_id}'"
)
for bridge in catalog.bridges.values():
if bridge.domain not in catalog.domains:
errors.append(
f"Bridge '{bridge.id}': domain '{bridge.domain}' does not exist in catalog"
)
if bridge.target not in catalog.targets:
errors.append(
f"Bridge '{bridge.id}': target '{bridge.target}' does not exist in catalog"
)
if bridge.actor not in catalog.actors:
errors.append(
f"Bridge '{bridge.id}': actor '{bridge.actor}' does not exist in catalog"
)
return errors

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Optional
@@ -21,6 +20,12 @@ app = typer.Typer(
no_args_is_help=True,
)
targets_app = typer.Typer(help="Inspect infrastructure targets from the OpsCatalog.")
catalog_app = typer.Typer(help="Inspect and validate the OpsCatalog.")
app.add_typer(targets_app, name="targets")
app.add_typer(catalog_app, name="catalog")
def _state_dir() -> Path:
return Path(os.environ.get("BRIDGE_STATE_DIR", str(Path.home() / ".local" / "state" / "bridge")))
@@ -34,85 +39,129 @@ def _load_or_exit():
raise typer.Exit(1)
def _require_tunnel(cfg, name: str):
if name not in cfg.tunnels:
typer.echo(f"Error: tunnel '{name}' not found in config", err=True)
def _load_catalog_or_exit(cfg):
from bridge.catalog.loader import CatalogLoadError, load_catalog
if cfg.catalog_path is None:
typer.echo("Error: catalog_path not configured in tunnels.yaml", err=True)
raise typer.Exit(1)
try:
return load_catalog(cfg.catalog_path)
except Exception as e:
typer.echo(f"Error loading catalog: {e}", err=True)
raise typer.Exit(1)
return cfg.tunnels[name]
def _resolve_tunnel(cfg, name: str):
"""Resolve tunnel name: inline first, then catalog, then error."""
from bridge.catalog.loader import load_catalog
from bridge.catalog.resolver import BridgeNotFound, resolve
catalog = None
if cfg.catalog_path is not None:
try:
catalog = load_catalog(cfg.catalog_path)
except Exception:
pass
try:
return resolve(name, catalog=catalog, inline_tunnels=cfg.tunnels)
except BridgeNotFound:
typer.echo(f"Error: tunnel '{name}' not found in config or catalog", err=True)
raise typer.Exit(1)
def _all_tunnel_names(cfg):
"""Return names from inline config (all-tunnels operations use inline only)."""
return list(cfg.tunnels.keys())
# ─── Tunnel lifecycle commands ────────────────────────────────────────────────
@app.command()
def up(
tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all)"),
tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all inline)"),
):
"""Start one or all tunnels."""
cfg = _load_or_exit()
sd = _state_dir()
names = [tunnel] if tunnel else list(cfg.tunnels.keys())
if tunnel:
_require_tunnel(cfg, tunnel)
any_already_running = False
for name in names:
tcfg = cfg.tunnels[name]
tcfg = _resolve_tunnel(cfg, tunnel)
mgr = TunnelManager(tcfg, state_dir=sd)
if mgr.is_running():
typer.echo(f"Tunnel '{name}' is already running.")
any_already_running = True
else:
mgr.start()
typer.echo(f"Started tunnel '{name}'.")
if any_already_running and len(names) == 1:
raise typer.Exit(2)
typer.echo(f"Tunnel '{tunnel}' is already running.")
raise typer.Exit(2)
mgr.start()
typer.echo(f"Started tunnel '{tunnel}'.")
else:
names = _all_tunnel_names(cfg)
any_already_running = False
for name in names:
tcfg = cfg.tunnels[name]
mgr = TunnelManager(tcfg, state_dir=sd)
if mgr.is_running():
typer.echo(f"Tunnel '{name}' is already running.")
any_already_running = True
else:
mgr.start()
typer.echo(f"Started tunnel '{name}'.")
if any_already_running and len(names) == 1:
raise typer.Exit(2)
@app.command()
def down(
tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all)"),
tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all inline)"),
):
"""Stop one or all tunnels."""
cfg = _load_or_exit()
sd = _state_dir()
names = [tunnel] if tunnel else list(cfg.tunnels.keys())
if tunnel:
_require_tunnel(cfg, tunnel)
any_not_running = False
for name in names:
tcfg = cfg.tunnels[name]
tcfg = _resolve_tunnel(cfg, tunnel)
mgr = TunnelManager(tcfg, state_dir=sd)
if not mgr.is_running():
typer.echo(f"Tunnel '{name}' is not running.")
any_not_running = True
else:
mgr.stop()
typer.echo(f"Stopped tunnel '{name}'.")
if any_not_running and len(names) == 1:
raise typer.Exit(2)
typer.echo(f"Tunnel '{tunnel}' is not running.")
raise typer.Exit(2)
mgr.stop()
typer.echo(f"Stopped tunnel '{tunnel}'.")
else:
names = _all_tunnel_names(cfg)
any_not_running = False
for name in names:
tcfg = cfg.tunnels[name]
mgr = TunnelManager(tcfg, state_dir=sd)
if not mgr.is_running():
typer.echo(f"Tunnel '{name}' is not running.")
any_not_running = True
else:
mgr.stop()
typer.echo(f"Stopped tunnel '{name}'.")
if any_not_running and len(names) == 1:
raise typer.Exit(2)
@app.command()
def restart(
tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all)"),
tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all inline)"),
):
"""Restart one or all tunnels."""
cfg = _load_or_exit()
sd = _state_dir()
names = [tunnel] if tunnel else list(cfg.tunnels.keys())
if tunnel:
_require_tunnel(cfg, tunnel)
for name in names:
tcfg = cfg.tunnels[name]
tcfg = _resolve_tunnel(cfg, tunnel)
mgr = TunnelManager(tcfg, state_dir=sd)
mgr.stop()
mgr.start()
typer.echo(f"Restarted tunnel '{name}'.")
typer.echo(f"Restarted tunnel '{tunnel}'.")
else:
for name in _all_tunnel_names(cfg):
tcfg = cfg.tunnels[name]
mgr = TunnelManager(tcfg, state_dir=sd)
mgr.stop()
mgr.start()
typer.echo(f"Restarted tunnel '{name}'.")
@app.command()
@@ -134,8 +183,8 @@ def status(
"actor": tcfg.actor,
"host": tcfg.host,
"pid": pid,
"uptime": None, # future: track start time
"health": None, # future: last health check result
"uptime": None,
"health": None,
})
if as_json:
@@ -145,8 +194,14 @@ def status(
def _print_status_table(rows):
if not rows:
typer.echo("No tunnels configured.")
return
headers = ["TUNNEL", "STATE", "ACTOR", "HOST", "PID"]
col_widths = [max(len(h), max((len(str(r.get(h.lower(), "") or "")) for r in rows), default=0)) for h in headers]
col_widths = [
max(len(h), max((len(str(r.get(h.lower(), "") or "")) for r in rows), default=0))
for h in headers
]
def _fmt_row(vals):
return " ".join(str(v).ljust(w) for v, w in zip(vals, col_widths))
@@ -171,7 +226,7 @@ def logs(
):
"""Show audit log for a tunnel."""
cfg = _load_or_exit()
_require_tunnel(cfg, tunnel)
_resolve_tunnel(cfg, tunnel) # validate name
sd = _state_dir()
logger = AuditLogger(state_dir=sd)
@@ -181,7 +236,6 @@ def logs(
typer.echo(f"No log entries for tunnel '{tunnel}'.")
return
# Show last N lines
for entry in events[-lines:]:
ts = entry.get("timestamp", "")
event = entry.get("event", "")
@@ -197,7 +251,7 @@ def logs(
log_path = sd / f"{tunnel}.log"
try:
with log_path.open() as f:
f.seek(0, 2) # seek to end
f.seek(0, 2)
while True:
line = f.readline()
if line:
@@ -217,3 +271,201 @@ def logs(
time.sleep(0.5)
except KeyboardInterrupt:
pass
# ─── targets commands ─────────────────────────────────────────────────────────
@targets_app.callback(invoke_without_command=True)
def targets_default(
ctx: typer.Context,
domain: Optional[str] = typer.Option(None, "--domain", help="Filter by domain"),
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""List infrastructure targets from the OpsCatalog."""
if ctx.invoked_subcommand is not None:
return
cfg = _load_or_exit()
cat = _load_catalog_or_exit(cfg)
rows = []
for t in cat.targets.values():
if domain and t.domain != domain:
continue
rows.append({
"domain": t.domain,
"target": t.id,
"kind": t.kind,
"description": t.description,
"bridges": t.reachable_via,
})
if as_json:
typer.echo(json.dumps(rows, indent=2))
else:
if not rows:
typer.echo("No targets found.")
return
headers = ["DOMAIN", "TARGET", "KIND", "BRIDGES"]
col_widths = [
max(len(h), max((len(str(r.get(h.lower(), "") or "")) for r in rows), default=0))
for h in headers
]
def _fmt(vals):
return " ".join(str(v).ljust(w) for v, w in zip(vals, col_widths))
typer.echo(_fmt(headers))
typer.echo(_fmt(["-" * w for w in col_widths]))
for row in rows:
typer.echo(_fmt([
row["domain"],
row["target"],
row["kind"],
", ".join(row["bridges"]),
]))
@targets_app.command("show")
def targets_show(
target: str = typer.Argument(..., help="Target ID"),
):
"""Show full metadata for a target."""
cfg = _load_or_exit()
cat = _load_catalog_or_exit(cfg)
if target not in cat.targets:
typer.echo(f"Error: target '{target}' not found in catalog", err=True)
raise typer.Exit(1)
t = cat.targets[target]
typer.echo(f"Target: {t.id}")
typer.echo(f"Domain: {t.domain}")
typer.echo(f"Kind: {t.kind}")
if t.description:
typer.echo(f"Description: {t.description}")
if t.reachable_via:
typer.echo(f"Bridges: {', '.join(t.reachable_via)}")
# Show ops notes from docs/ if available
if cfg.catalog_path:
docs_dir = cfg.catalog_path / "domains" / t.domain / "docs"
if docs_dir.exists():
for md_file in sorted(docs_dir.glob("*.md")):
typer.echo(f"\n--- {md_file.name} ---")
typer.echo(md_file.read_text())
# ─── catalog commands ─────────────────────────────────────────────────────────
@catalog_app.callback(invoke_without_command=True)
def catalog_default(ctx: typer.Context):
"""Inspect and validate the OpsCatalog."""
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
@catalog_app.command("list")
def catalog_list(
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""List all domains with target and bridge counts."""
cfg = _load_or_exit()
cat = _load_catalog_or_exit(cfg)
rows = []
for domain in cat.domains.values():
target_count = sum(1 for t in cat.targets.values() if t.domain == domain.id)
bridge_count = sum(1 for b in cat.bridges.values() if b.domain == domain.id)
rows.append({
"domain": domain.id,
"name": domain.name,
"environment": domain.environment,
"targets": target_count,
"bridges": bridge_count,
})
if as_json:
typer.echo(json.dumps(rows, indent=2))
else:
if not rows:
typer.echo("Catalog is empty.")
return
headers = ["DOMAIN", "NAME", "ENV", "TARGETS", "BRIDGES"]
col_widths = [
max(len(h), max((len(str(r.get(h.lower()[:3] if h == "ENV" else h.lower(), "") or "")) for r in rows), default=0))
for h in headers
]
# Manual col widths for cleaner output
col_widths = [
max(len("DOMAIN"), max((len(r["domain"]) for r in rows), default=0)),
max(len("NAME"), max((len(r["name"]) for r in rows), default=0)),
max(len("ENV"), max((len(r["environment"]) for r in rows), default=0)),
max(len("TARGETS"), max((len(str(r["targets"])) for r in rows), default=0)),
max(len("BRIDGES"), max((len(str(r["bridges"])) for r in rows), default=0)),
]
def _fmt(vals):
return " ".join(str(v).ljust(w) for v, w in zip(vals, col_widths))
typer.echo(_fmt(headers))
typer.echo(_fmt(["-" * w for w in col_widths]))
for row in rows:
typer.echo(_fmt([
row["domain"], row["name"], row["environment"],
str(row["targets"]), str(row["bridges"]),
]))
@catalog_app.command("validate")
def catalog_validate():
"""Validate catalog for consistency errors."""
from bridge.catalog.validator import validate_catalog
cfg = _load_or_exit()
cat = _load_catalog_or_exit(cfg)
errors = validate_catalog(cat)
if errors:
typer.echo(f"Catalog has {len(errors)} violation(s):")
for err in errors:
typer.echo(f" - {err}")
raise typer.Exit(1)
else:
typer.echo(f"Catalog OK — {len(cat.domains)} domain(s), {len(cat.targets)} target(s), {len(cat.bridges)} bridge(s).")
@catalog_app.command("show")
def catalog_show(
bridge_id: str = typer.Argument(..., help="Bridge ID"),
):
"""Show full metadata for a bridge."""
cfg = _load_or_exit()
cat = _load_catalog_or_exit(cfg)
if bridge_id not in cat.bridges:
typer.echo(f"Error: bridge '{bridge_id}' not found in catalog", err=True)
raise typer.Exit(1)
b = cat.bridges[bridge_id]
typer.echo(f"Bridge: {b.id}")
typer.echo(f"Domain: {b.domain}")
typer.echo(f"Target: {b.target}")
typer.echo(f"Host: {b.host}")
typer.echo(f"Ports: {b.remote_port} -> {b.local_port}")
typer.echo(f"SSH user: {b.ssh_user}")
typer.echo(f"Actor: {b.actor}")
typer.echo(f"Method: {b.access_method}")
if b.description:
typer.echo(f"Description: {b.description}")
if b.health_check:
typer.echo(f"Health: {b.health_check.url} (every {b.health_check.interval_seconds}s)")
# Domain context
if b.domain in cat.domains:
d = cat.domains[b.domain]
typer.echo(f"\nDomain context: {d.name} [{d.environment}]")
# Target context
if b.target in cat.targets:
t = cat.targets[b.target]
typer.echo(f"Target: {t.description or t.id} ({t.kind})")

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Dict
from typing import Dict, Optional
import yaml
@@ -19,6 +19,7 @@ class ConfigError(Exception):
class BridgeConfig:
tunnels: Dict[str, TunnelConfig]
actors: Dict[str, ActorInfo]
catalog_path: Optional[Path] = None
def _default_config_path() -> Path:
@@ -43,7 +44,12 @@ def load_config() -> BridgeConfig:
tunnels = _parse_tunnels(raw.get("tunnels") or {})
actors = _parse_actors(raw.get("actors") or {})
return BridgeConfig(tunnels=tunnels, actors=actors)
catalog_path = None
if "catalog_path" in raw and raw["catalog_path"]:
catalog_path = Path(os.path.expanduser(str(raw["catalog_path"])))
return BridgeConfig(tunnels=tunnels, actors=actors, catalog_path=catalog_path)
def _parse_tunnels(raw: dict) -> Dict[str, TunnelConfig]: