generated from coulomb/repo-seed
- ActorType enum (adm/agt/atm) replaces actor_class string; config validates naming convention (adm-*/agt-*/atm-*) with hard ConfigError on mismatch; legacy 'human'/'automation' values accepted with DeprecationWarning - cert_command: pluggable shell string run before each SSH launch; cert written to state dir; -i cert appended to SSH command alongside -i key - TTL-aware cert refresh: parses Valid-to via ssh-keygen -L; pre-emptive restart 5 min before expiry (no backoff, no attempt increment); CERT_EXPIRING logged - CertAcquisitionError: cert failures trigger normal backoff/retry loop - cert_identity: Key ID parsed from cert and recorded in BRIDGE_CONNECTED event - bridge cert-status: new CLI command; exit 1 on expired cert; --json flag - 233 tests passing, ruff clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
636 lines
21 KiB
Python
636 lines
21 KiB
Python
"""CLI for OpsBridge — bridge command."""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from bridge.audit import AuditLogger
|
|
from bridge.config import ConfigError, load_config
|
|
from bridge.diagnostics import check_all_tunnels, check_tunnel
|
|
from bridge.manager import TunnelManager
|
|
from bridge.state import StateManager, _pid_alive
|
|
|
|
app = typer.Typer(
|
|
name="bridge",
|
|
help="OpsBridge — SSH reverse tunnel lifecycle manager.",
|
|
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")))
|
|
|
|
|
|
def _load_or_exit():
|
|
try:
|
|
return load_config()
|
|
except ConfigError as e:
|
|
typer.echo(f"Error: {e}", err=True)
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def _load_catalog_or_exit(cfg):
|
|
from bridge.catalog.loader import 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)
|
|
|
|
|
|
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 inline)"),
|
|
):
|
|
"""Start one or all tunnels."""
|
|
cfg = _load_or_exit()
|
|
sd = _state_dir()
|
|
|
|
if tunnel:
|
|
tcfg = _resolve_tunnel(cfg, tunnel)
|
|
mgr = TunnelManager(tcfg, state_dir=sd)
|
|
if mgr.is_running():
|
|
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 inline)"),
|
|
):
|
|
"""Stop one or all tunnels."""
|
|
cfg = _load_or_exit()
|
|
sd = _state_dir()
|
|
|
|
if tunnel:
|
|
tcfg = _resolve_tunnel(cfg, tunnel)
|
|
mgr = TunnelManager(tcfg, state_dir=sd)
|
|
if not mgr.is_running():
|
|
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 inline)"),
|
|
):
|
|
"""Restart one or all tunnels."""
|
|
cfg = _load_or_exit()
|
|
sd = _state_dir()
|
|
|
|
if tunnel:
|
|
tcfg = _resolve_tunnel(cfg, tunnel)
|
|
mgr = TunnelManager(tcfg, state_dir=sd)
|
|
mgr.stop()
|
|
mgr.start()
|
|
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()
|
|
def status(
|
|
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
):
|
|
"""Show status of all tunnels."""
|
|
cfg = _load_or_exit()
|
|
sd = _state_dir()
|
|
state_mgr = StateManager(state_dir=sd)
|
|
|
|
rows = []
|
|
for name, tcfg in cfg.tunnels.items():
|
|
state = state_mgr.read_state(name)
|
|
raw_pid = state_mgr.read_raw_pid(name)
|
|
pid_alive_val = _pid_alive(raw_pid) if raw_pid is not None else None
|
|
stale = (
|
|
state.value in ("connected", "degraded")
|
|
and pid_alive_val is not True
|
|
)
|
|
rows.append({
|
|
"tunnel": name,
|
|
"state": state.value,
|
|
"actor": tcfg.actor,
|
|
"host": tcfg.host,
|
|
"pid": raw_pid,
|
|
"pid_alive": pid_alive_val,
|
|
"stale": stale,
|
|
"uptime": None,
|
|
"health": None,
|
|
})
|
|
|
|
if as_json:
|
|
typer.echo(json.dumps(rows, indent=2))
|
|
else:
|
|
_print_status_table(rows)
|
|
|
|
|
|
def _print_status_table(rows):
|
|
if not rows:
|
|
typer.echo("No tunnels configured.")
|
|
return
|
|
|
|
def _state_display(row):
|
|
s = row["state"]
|
|
if row.get("stale"):
|
|
s += " [STALE]"
|
|
return s
|
|
|
|
def _live_display(row):
|
|
alive = row.get("pid_alive")
|
|
if alive is True:
|
|
return "yes"
|
|
elif alive is False:
|
|
return "no"
|
|
return "\u2014"
|
|
|
|
headers = ["TUNNEL", "STATE", "ACTOR", "HOST", "PID", "LIVE"]
|
|
col_widths = [
|
|
max(len("TUNNEL"), max((len(row["tunnel"]) for row in rows), default=0)),
|
|
max(len("STATE"), max((len(_state_display(row)) for row in rows), default=0)),
|
|
max(len("ACTOR"), max((len(str(row.get("actor", "") or "")) for row in rows), default=0)),
|
|
max(len("HOST"), max((len(str(row.get("host", "") or "")) for row in rows), default=0)),
|
|
max(len("PID"), max((len(str(row["pid"] or "")) for row in rows), default=0)),
|
|
max(len("LIVE"), max((len(_live_display(row)) for row in rows), default=0)),
|
|
]
|
|
|
|
def _fmt_row(vals):
|
|
return " ".join(str(v).ljust(w) for v, w in zip(vals, col_widths))
|
|
|
|
typer.echo(_fmt_row(headers))
|
|
typer.echo(_fmt_row(["-" * w for w in col_widths]))
|
|
for row in rows:
|
|
typer.echo(_fmt_row([
|
|
row["tunnel"],
|
|
_state_display(row),
|
|
row["actor"],
|
|
row["host"],
|
|
str(row["pid"] or ""),
|
|
_live_display(row),
|
|
]))
|
|
|
|
|
|
@app.command()
|
|
def logs(
|
|
tunnel: str = typer.Argument(..., help="Tunnel name"),
|
|
lines: int = typer.Option(50, "--lines", "-n", help="Number of lines to show"),
|
|
follow: bool = typer.Option(False, "--follow", "-f", help="Follow the log"),
|
|
):
|
|
"""Show audit log for a tunnel."""
|
|
cfg = _load_or_exit()
|
|
_resolve_tunnel(cfg, tunnel) # validate name
|
|
|
|
sd = _state_dir()
|
|
logger = AuditLogger(state_dir=sd)
|
|
events = logger.read_events(tunnel)
|
|
|
|
if not events:
|
|
typer.echo(f"No log entries for tunnel '{tunnel}'.")
|
|
return
|
|
|
|
for entry in events[-lines:]:
|
|
ts = entry.get("timestamp", "")
|
|
event = entry.get("event", "")
|
|
actor = entry.get("actor", "")
|
|
detail = entry.get("detail", "")
|
|
parts = [ts, event, f"actor={actor}"]
|
|
if detail:
|
|
parts.append(detail)
|
|
typer.echo(" ".join(parts))
|
|
|
|
if follow:
|
|
import time
|
|
log_path = sd / f"{tunnel}.log"
|
|
try:
|
|
with log_path.open() as f:
|
|
f.seek(0, 2)
|
|
while True:
|
|
line = f.readline()
|
|
if line:
|
|
try:
|
|
entry = json.loads(line)
|
|
ts = entry.get("timestamp", "")
|
|
event = entry.get("event", "")
|
|
actor = entry.get("actor", "")
|
|
detail = entry.get("detail", "")
|
|
parts = [ts, event, f"actor={actor}"]
|
|
if detail:
|
|
parts.append(detail)
|
|
typer.echo(" ".join(parts))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
else:
|
|
time.sleep(0.5)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
@app.command()
|
|
def check(
|
|
tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all inline)"),
|
|
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
):
|
|
"""End-to-end diagnostics: verify SSH PID alive and remote port listening."""
|
|
cfg = _load_or_exit()
|
|
sd = _state_dir()
|
|
state_mgr = StateManager(state_dir=sd)
|
|
|
|
if tunnel:
|
|
results = [check_tunnel(_resolve_tunnel(cfg, tunnel), state_mgr)]
|
|
else:
|
|
results = check_all_tunnels(cfg, state_mgr)
|
|
|
|
if as_json:
|
|
typer.echo(json.dumps(
|
|
[{**dataclasses.asdict(r), "ok": r.ok} for r in results],
|
|
indent=2,
|
|
))
|
|
else:
|
|
_print_check_table(results)
|
|
|
|
if any(not r.ok for r in results):
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def _print_check_table(results):
|
|
if not results:
|
|
typer.echo("No tunnels configured.")
|
|
return
|
|
headers = ["TUNNEL", "SSH", "PID", "PORT", "API", "OK"]
|
|
rows_data = []
|
|
for r in results:
|
|
rows_data.append([
|
|
r.tunnel,
|
|
r.ssh_process,
|
|
str(r.pid or ""),
|
|
r.remote_port,
|
|
r.local_api or "\u2014",
|
|
"yes" if r.ok else "no",
|
|
])
|
|
col_widths = [
|
|
max(len(h), max((len(row[i]) for row in rows_data), default=0))
|
|
for i, h in enumerate(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_data:
|
|
typer.echo(_fmt(row))
|
|
|
|
|
|
@app.command("cert-status")
|
|
def cert_status(
|
|
tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all inline)"),
|
|
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
):
|
|
"""Show certificate status for tunnels using cert_command mode."""
|
|
cfg = _load_or_exit()
|
|
sd = _state_dir()
|
|
|
|
names = [tunnel] if tunnel else list(cfg.tunnels.keys())
|
|
rows = []
|
|
any_expired = False
|
|
|
|
for name in names:
|
|
cert_file = sd / f"{name}-cert.pub"
|
|
if not cert_file.exists():
|
|
rows.append({"tunnel": name, "mode": "static-key", "cert_file": None})
|
|
continue
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["ssh-keygen", "-L", "-f", str(cert_file)],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
info = {"tunnel": name, "mode": "cert", "cert_file": str(cert_file)}
|
|
for line in result.stdout.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("Key ID:"):
|
|
info["key_id"] = line.split(":", 1)[1].strip().strip('"')
|
|
elif line.startswith("Valid:"):
|
|
parts = line.split()
|
|
if len(parts) >= 5 and parts[1] == "from" and parts[3] == "to":
|
|
info["valid_from"] = parts[2]
|
|
info["valid_until"] = parts[4]
|
|
try:
|
|
expires = datetime.fromisoformat(parts[4])
|
|
now = datetime.now()
|
|
remaining = expires - now
|
|
if remaining.total_seconds() <= 0:
|
|
info["expired"] = True
|
|
any_expired = True
|
|
else:
|
|
info["expired"] = False
|
|
mins = int(remaining.total_seconds() // 60)
|
|
info["ttl_remaining"] = f"{mins}m"
|
|
except ValueError:
|
|
pass
|
|
rows.append(info)
|
|
except FileNotFoundError:
|
|
rows.append({"tunnel": name, "mode": "cert", "error": "ssh-keygen not found"})
|
|
|
|
if as_json:
|
|
typer.echo(json.dumps(rows, indent=2))
|
|
else:
|
|
for row in rows:
|
|
mode = row.get("mode", "unknown")
|
|
if mode == "static-key":
|
|
typer.echo(f"{row['tunnel']} static-key / no cert")
|
|
elif "error" in row:
|
|
typer.echo(f"{row['tunnel']} ERROR: {row['error']}")
|
|
else:
|
|
parts = [row["tunnel"]]
|
|
if "key_id" in row:
|
|
parts.append(f"id={row['key_id']}")
|
|
if "valid_from" in row:
|
|
parts.append(f"from={row['valid_from']}")
|
|
if "valid_until" in row:
|
|
parts.append(f"until={row['valid_until']}")
|
|
if row.get("expired"):
|
|
parts.append("EXPIRED")
|
|
elif "ttl_remaining" in row:
|
|
parts.append(f"ttl={row['ttl_remaining']}")
|
|
typer.echo(" ".join(parts))
|
|
|
|
if any_expired:
|
|
raise typer.Exit(1)
|
|
|
|
|
|
# ─── 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})")
|