"""CLI for OpsBridge — bridge command.""" from __future__ import annotations import json import os from pathlib import Path from typing import Optional import typer from bridge.audit import AuditLogger from bridge.config import ConfigError, load_config from bridge.manager import TunnelManager from bridge.state import StateManager 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) pid = state_mgr.read_pid(name) rows.append({ "tunnel": name, "state": state.value, "actor": tcfg.actor, "host": tcfg.host, "pid": pid, "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 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 ] 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"], row["state"], row["actor"], row["host"], str(row["pid"] or ""), ])) @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 # ─── 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})")