generated from coulomb/repo-seed
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:
@@ -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})")
|
||||
|
||||
Reference in New Issue
Block a user