Files
ops-bridge/src/bridge/cli.py
tegwick bd169a07e2 feat(directive): implement BRIDGE-WP-0004 AccessManagementDirective alignment
- 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>
2026-05-15 09:38:29 +02:00

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