"""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})")