generated from coulomb/repo-seed
feat: implement OpsBridge CLI (BRIDGE-WP-0001)
Full TDD implementation of the `bridge` CLI tool covering all phases from BRIDGE-WP-0001: project scaffolding, config loading, state management, audit logging, health checks, tunnel lifecycle manager, and all CLI commands (up/down/restart/status/logs). 77 tests, all green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
219
src/bridge/cli.py
Normal file
219
src/bridge/cli.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""CLI for OpsBridge — bridge command."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
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.models import BridgeState
|
||||
from bridge.state import StateManager
|
||||
|
||||
app = typer.Typer(
|
||||
name="bridge",
|
||||
help="OpsBridge — SSH reverse tunnel lifecycle manager.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
|
||||
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 _require_tunnel(cfg, name: str):
|
||||
if name not in cfg.tunnels:
|
||||
typer.echo(f"Error: tunnel '{name}' not found in config", err=True)
|
||||
raise typer.Exit(1)
|
||||
return cfg.tunnels[name]
|
||||
|
||||
|
||||
@app.command()
|
||||
def up(
|
||||
tunnel: Optional[str] = typer.Argument(None, help="Tunnel name (omit for all)"),
|
||||
):
|
||||
"""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]
|
||||
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)"),
|
||||
):
|
||||
"""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]
|
||||
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)"),
|
||||
):
|
||||
"""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]
|
||||
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, # future: track start time
|
||||
"health": None, # future: last health check result
|
||||
})
|
||||
|
||||
if as_json:
|
||||
typer.echo(json.dumps(rows, indent=2))
|
||||
else:
|
||||
_print_status_table(rows)
|
||||
|
||||
|
||||
def _print_status_table(rows):
|
||||
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()
|
||||
_require_tunnel(cfg, tunnel)
|
||||
|
||||
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
|
||||
|
||||
# Show last N lines
|
||||
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) # seek to end
|
||||
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
|
||||
Reference in New Issue
Block a user