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:
34
pyproject.toml
Normal file
34
pyproject.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ops-bridge"
|
||||
version = "0.1.0"
|
||||
description = "SSH reverse tunnel lifecycle manager"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"typer>=0.12",
|
||||
"pyyaml>=6.0",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
bridge = "bridge.cli:app"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/bridge"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.4",
|
||||
]
|
||||
0
src/bridge/__init__.py
Normal file
0
src/bridge/__init__.py
Normal file
65
src/bridge/audit.py
Normal file
65
src/bridge/audit.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Audit logging for OpsBridge lifecycle events."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class AuditEvent(str, Enum):
|
||||
BRIDGE_STARTED = "bridge_started"
|
||||
BRIDGE_CONNECTED = "bridge_connected"
|
||||
BRIDGE_DISCONNECTED = "bridge_disconnected"
|
||||
BRIDGE_RECONNECTING = "bridge_reconnecting"
|
||||
HEALTH_CHECK_FAILED = "health_check_failed"
|
||||
HEALTH_CHECK_RECOVERED = "health_check_recovered"
|
||||
BRIDGE_STOPPED = "bridge_stopped"
|
||||
|
||||
|
||||
def _default_state_dir() -> Path:
|
||||
return Path.home() / ".local" / "state" / "bridge"
|
||||
|
||||
|
||||
class AuditLogger:
|
||||
def __init__(self, state_dir: Optional[Path] = None):
|
||||
self._dir = Path(state_dir) if state_dir else _default_state_dir()
|
||||
|
||||
def _log_path(self, tunnel: str) -> Path:
|
||||
return self._dir / f"{tunnel}.log"
|
||||
|
||||
def log(
|
||||
self,
|
||||
tunnel: str,
|
||||
event: AuditEvent,
|
||||
actor: str,
|
||||
actor_class: str,
|
||||
detail: str = "",
|
||||
) -> None:
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
entry: Dict[str, Any] = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"tunnel": tunnel,
|
||||
"actor": actor,
|
||||
"actor_class": actor_class,
|
||||
"event": event.value,
|
||||
}
|
||||
if detail:
|
||||
entry["detail"] = detail
|
||||
with self._log_path(tunnel).open("a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
def read_events(self, tunnel: str) -> List[Dict[str, Any]]:
|
||||
path = self._log_path(tunnel)
|
||||
if not path.exists():
|
||||
return []
|
||||
events = []
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return events
|
||||
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
|
||||
109
src/bridge/config.py
Normal file
109
src/bridge/config.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Config loading for OpsBridge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
import yaml
|
||||
|
||||
from bridge.models import ActorInfo, HealthCheckConfig, ReconnectPolicy, TunnelConfig
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised when config is invalid or missing."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BridgeConfig:
|
||||
tunnels: Dict[str, TunnelConfig]
|
||||
actors: Dict[str, ActorInfo]
|
||||
|
||||
|
||||
def _default_config_path() -> Path:
|
||||
return Path.home() / ".config" / "bridge" / "tunnels.yaml"
|
||||
|
||||
|
||||
def load_config() -> BridgeConfig:
|
||||
"""Load and validate tunnels.yaml. Respects BRIDGE_CONFIG env var."""
|
||||
path = Path(os.environ.get("BRIDGE_CONFIG", str(_default_config_path())))
|
||||
|
||||
if not path.exists():
|
||||
raise ConfigError(f"Config file not found: {path}")
|
||||
|
||||
try:
|
||||
with path.open() as f:
|
||||
raw = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
raise ConfigError(f"Invalid YAML in {path}: {e}") from e
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError(f"Config must be a YAML mapping, got: {type(raw)}")
|
||||
|
||||
tunnels = _parse_tunnels(raw.get("tunnels") or {})
|
||||
actors = _parse_actors(raw.get("actors") or {})
|
||||
return BridgeConfig(tunnels=tunnels, actors=actors)
|
||||
|
||||
|
||||
def _parse_tunnels(raw: dict) -> Dict[str, TunnelConfig]:
|
||||
tunnels = {}
|
||||
for name, data in raw.items():
|
||||
if not isinstance(data, dict):
|
||||
raise ConfigError(f"Tunnel '{name}' must be a mapping")
|
||||
tunnels[name] = _parse_tunnel(name, data)
|
||||
return tunnels
|
||||
|
||||
|
||||
def _parse_tunnel(name: str, data: dict) -> TunnelConfig:
|
||||
required = ["host", "remote_port", "local_port", "ssh_user", "ssh_key", "actor"]
|
||||
for field in required:
|
||||
if field not in data:
|
||||
raise ConfigError(f"Tunnel '{name}' missing required field: {field}")
|
||||
|
||||
reconnect = ReconnectPolicy()
|
||||
if "reconnect" in data and data["reconnect"]:
|
||||
r = data["reconnect"]
|
||||
reconnect = ReconnectPolicy(
|
||||
max_attempts=r.get("max_attempts", 0),
|
||||
backoff_initial=r.get("backoff_initial", 5),
|
||||
backoff_max=r.get("backoff_max", 60),
|
||||
)
|
||||
|
||||
health_check = None
|
||||
if "health_check" in data and data["health_check"]:
|
||||
hc = data["health_check"]
|
||||
if "url" not in hc:
|
||||
raise ConfigError(f"Tunnel '{name}' health_check missing required field: url")
|
||||
health_check = HealthCheckConfig(
|
||||
url=hc["url"],
|
||||
interval_seconds=hc.get("interval_seconds", 30),
|
||||
timeout_seconds=hc.get("timeout_seconds", 5),
|
||||
)
|
||||
|
||||
return TunnelConfig(
|
||||
name=name,
|
||||
host=str(data["host"]),
|
||||
remote_port=int(data["remote_port"]),
|
||||
local_port=int(data["local_port"]),
|
||||
ssh_user=str(data["ssh_user"]),
|
||||
ssh_key=str(data["ssh_key"]),
|
||||
actor=str(data["actor"]),
|
||||
reconnect=reconnect,
|
||||
health_check=health_check,
|
||||
)
|
||||
|
||||
|
||||
def _parse_actors(raw: dict) -> Dict[str, ActorInfo]:
|
||||
actors = {}
|
||||
for name, data in raw.items():
|
||||
if not isinstance(data, dict):
|
||||
raise ConfigError(f"Actor '{name}' must be a mapping")
|
||||
if "class" not in data:
|
||||
raise ConfigError(f"Actor '{name}' missing required field: class")
|
||||
actors[name] = ActorInfo(
|
||||
name=name,
|
||||
actor_class=str(data["class"]),
|
||||
description=str(data.get("description", "")),
|
||||
)
|
||||
return actors
|
||||
31
src/bridge/health.py
Normal file
31
src/bridge/health.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""HTTP health checker for OpsBridge."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthResult:
|
||||
ok: bool
|
||||
status_code: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class HealthChecker:
|
||||
def __init__(self, url: str, timeout_seconds: int = 5):
|
||||
self._url = url
|
||||
self._timeout = timeout_seconds
|
||||
|
||||
async def check(self) -> HealthResult:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
response = await client.get(self._url)
|
||||
response.raise_for_status()
|
||||
return HealthResult(ok=True, status_code=response.status_code)
|
||||
except httpx.HTTPStatusError as e:
|
||||
return HealthResult(ok=False, status_code=e.response.status_code, error=str(e))
|
||||
except Exception as e:
|
||||
return HealthResult(ok=False, error=str(e))
|
||||
252
src/bridge/manager.py
Normal file
252
src/bridge/manager.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Tunnel lifecycle manager for OpsBridge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from bridge.audit import AuditEvent, AuditLogger
|
||||
from bridge.config import BridgeConfig
|
||||
from bridge.health import HealthChecker
|
||||
from bridge.models import BridgeState, TunnelConfig
|
||||
from bridge.state import StateManager
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_ssh_command(cfg: TunnelConfig) -> List[str]:
|
||||
"""Build the SSH reverse tunnel command."""
|
||||
key = os.path.expanduser(cfg.ssh_key)
|
||||
return [
|
||||
"ssh",
|
||||
"-N",
|
||||
"-R", f"{cfg.remote_port}:127.0.0.1:{cfg.local_port}",
|
||||
"-i", key,
|
||||
"-o", "ServerAliveInterval=10",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
f"{cfg.ssh_user}@{cfg.host}",
|
||||
]
|
||||
|
||||
|
||||
class TunnelManager:
|
||||
"""Manages a single named SSH reverse tunnel.
|
||||
|
||||
start() daemonises: forks a child that runs the reconnect loop, then the
|
||||
parent returns immediately after writing the manager PID.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: TunnelConfig, state_dir: Optional[Path] = None):
|
||||
self._cfg = cfg
|
||||
self._state = StateManager(state_dir=state_dir)
|
||||
self._audit = AuditLogger(state_dir=state_dir)
|
||||
|
||||
def get_state(self) -> BridgeState:
|
||||
return self._state.read_state(self._cfg.name)
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._state.is_running(self._cfg.name)
|
||||
|
||||
def _actor_info(self):
|
||||
return self._cfg.actor, "unknown"
|
||||
|
||||
def _next_backoff(self, attempt: int) -> int:
|
||||
initial = self._cfg.reconnect.backoff_initial
|
||||
max_b = self._cfg.reconnect.backoff_max
|
||||
value = initial * (2 ** attempt)
|
||||
return min(value, max_b)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the tunnel manager as a daemonised subprocess."""
|
||||
if self.is_running():
|
||||
log.info("Tunnel %s already running", self._cfg.name)
|
||||
return
|
||||
|
||||
self._state.write_state(self._cfg.name, BridgeState.STARTING)
|
||||
actor, actor_class = self._actor_info()
|
||||
self._audit.log(
|
||||
tunnel=self._cfg.name,
|
||||
event=AuditEvent.BRIDGE_STARTED,
|
||||
actor=actor,
|
||||
actor_class=actor_class,
|
||||
)
|
||||
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# Parent: record manager PID and return
|
||||
self._state.write_pid(self._cfg.name, pid)
|
||||
return
|
||||
|
||||
# Child: become a daemon
|
||||
os.setsid()
|
||||
|
||||
try:
|
||||
self._run_loop()
|
||||
except Exception as e:
|
||||
log.exception("Tunnel manager loop crashed: %s", e)
|
||||
finally:
|
||||
self._state.write_state(self._cfg.name, BridgeState.STOPPED)
|
||||
self._state.clear_pid(self._cfg.name)
|
||||
self._audit.log(
|
||||
tunnel=self._cfg.name,
|
||||
event=AuditEvent.BRIDGE_STOPPED,
|
||||
actor=actor,
|
||||
actor_class=actor_class,
|
||||
)
|
||||
|
||||
os._exit(0)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the running tunnel manager."""
|
||||
pid = self._state.read_pid(self._cfg.name)
|
||||
if pid is None:
|
||||
self._state.write_state(self._cfg.name, BridgeState.STOPPED)
|
||||
return
|
||||
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
# Give up to 5 seconds for graceful shutdown
|
||||
for _ in range(50):
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
time.sleep(0.1)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
else:
|
||||
# Force kill if still running
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
self._state.clear_pid(self._cfg.name)
|
||||
self._state.write_state(self._cfg.name, BridgeState.STOPPED)
|
||||
actor, actor_class = self._actor_info()
|
||||
self._audit.log(
|
||||
tunnel=self._cfg.name,
|
||||
event=AuditEvent.BRIDGE_STOPPED,
|
||||
actor=actor,
|
||||
actor_class=actor_class,
|
||||
)
|
||||
|
||||
def _run_loop(self) -> None:
|
||||
"""Reconnect loop running in daemon child."""
|
||||
import asyncio
|
||||
|
||||
cfg = self._cfg
|
||||
actor, actor_class = self._actor_info()
|
||||
attempt = 0
|
||||
max_attempts = cfg.reconnect.max_attempts # 0 = infinite
|
||||
|
||||
# Setup signal handler for graceful shutdown
|
||||
_stop = [False]
|
||||
|
||||
def _on_term(signum, frame):
|
||||
_stop[0] = True
|
||||
|
||||
signal.signal(signal.SIGTERM, _on_term)
|
||||
signal.signal(signal.SIGINT, _on_term)
|
||||
|
||||
while not _stop[0]:
|
||||
if max_attempts > 0 and attempt >= max_attempts:
|
||||
self._state.write_state(cfg.name, BridgeState.FAILED)
|
||||
break
|
||||
|
||||
cmd = build_ssh_command(cfg)
|
||||
log.info("Starting SSH: %s", " ".join(cmd))
|
||||
self._state.write_state(cfg.name, BridgeState.STARTING)
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except FileNotFoundError:
|
||||
self._state.write_state(cfg.name, BridgeState.FAILED)
|
||||
self._audit.log(
|
||||
tunnel=cfg.name,
|
||||
event=AuditEvent.BRIDGE_DISCONNECTED,
|
||||
actor=actor,
|
||||
actor_class=actor_class,
|
||||
detail="ssh binary not found",
|
||||
)
|
||||
break
|
||||
|
||||
# Wait briefly then assume connected if still running
|
||||
time.sleep(2)
|
||||
if proc.poll() is None:
|
||||
self._state.write_state(cfg.name, BridgeState.CONNECTED)
|
||||
self._audit.log(
|
||||
tunnel=cfg.name,
|
||||
event=AuditEvent.BRIDGE_CONNECTED,
|
||||
actor=actor,
|
||||
actor_class=actor_class,
|
||||
)
|
||||
attempt = 0
|
||||
|
||||
# Health check loop
|
||||
if cfg.health_check:
|
||||
checker = HealthChecker(
|
||||
url=cfg.health_check.url,
|
||||
timeout_seconds=cfg.health_check.timeout_seconds,
|
||||
)
|
||||
health_failing = False
|
||||
while not _stop[0] and proc.poll() is None:
|
||||
result = asyncio.run(checker.check())
|
||||
if result.ok:
|
||||
if health_failing:
|
||||
health_failing = False
|
||||
self._state.write_state(cfg.name, BridgeState.CONNECTED)
|
||||
self._audit.log(
|
||||
tunnel=cfg.name,
|
||||
event=AuditEvent.HEALTH_CHECK_RECOVERED,
|
||||
actor=actor,
|
||||
actor_class=actor_class,
|
||||
)
|
||||
else:
|
||||
if not health_failing:
|
||||
health_failing = True
|
||||
self._state.write_state(cfg.name, BridgeState.DEGRADED)
|
||||
self._audit.log(
|
||||
tunnel=cfg.name,
|
||||
event=AuditEvent.HEALTH_CHECK_FAILED,
|
||||
actor=actor,
|
||||
actor_class=actor_class,
|
||||
detail=result.error or f"HTTP {result.status_code}",
|
||||
)
|
||||
time.sleep(cfg.health_check.interval_seconds)
|
||||
else:
|
||||
while not _stop[0] and proc.poll() is None:
|
||||
time.sleep(1)
|
||||
|
||||
# SSH exited
|
||||
if proc.poll() is not None:
|
||||
self._audit.log(
|
||||
tunnel=cfg.name,
|
||||
event=AuditEvent.BRIDGE_DISCONNECTED,
|
||||
actor=actor,
|
||||
actor_class=actor_class,
|
||||
detail=f"exit code {proc.returncode}",
|
||||
)
|
||||
|
||||
if _stop[0]:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
break
|
||||
|
||||
attempt += 1
|
||||
backoff = self._next_backoff(attempt - 1)
|
||||
self._state.write_state(cfg.name, BridgeState.RECONNECTING)
|
||||
self._audit.log(
|
||||
tunnel=cfg.name,
|
||||
event=AuditEvent.BRIDGE_RECONNECTING,
|
||||
actor=actor,
|
||||
actor_class=actor_class,
|
||||
detail=f"retry {attempt}, backoff {backoff}s",
|
||||
)
|
||||
log.info("Reconnecting in %ds (attempt %d)", backoff, attempt)
|
||||
time.sleep(backoff)
|
||||
49
src/bridge/models.py
Normal file
49
src/bridge/models.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Domain models for OpsBridge."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BridgeState(str, Enum):
|
||||
STOPPED = "stopped"
|
||||
STARTING = "starting"
|
||||
CONNECTED = "connected"
|
||||
DEGRADED = "degraded"
|
||||
RECONNECTING = "reconnecting"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReconnectPolicy:
|
||||
max_attempts: int = 0 # 0 = infinite
|
||||
backoff_initial: int = 5
|
||||
backoff_max: int = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthCheckConfig:
|
||||
url: str
|
||||
interval_seconds: int = 30
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class TunnelConfig:
|
||||
name: str
|
||||
host: str
|
||||
remote_port: int
|
||||
local_port: int
|
||||
ssh_user: str
|
||||
ssh_key: str
|
||||
actor: str
|
||||
reconnect: ReconnectPolicy = field(default_factory=ReconnectPolicy)
|
||||
health_check: Optional[HealthCheckConfig] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActorInfo:
|
||||
name: str
|
||||
actor_class: str # "human" or "automation"
|
||||
description: str = ""
|
||||
73
src/bridge/state.py
Normal file
73
src/bridge/state.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""State file management for OpsBridge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bridge.models import BridgeState
|
||||
|
||||
|
||||
def _default_state_dir() -> Path:
|
||||
return Path.home() / ".local" / "state" / "bridge"
|
||||
|
||||
|
||||
class StateManager:
|
||||
def __init__(self, state_dir: Optional[Path] = None):
|
||||
self._dir = Path(state_dir) if state_dir else _default_state_dir()
|
||||
|
||||
def _ensure_dir(self) -> None:
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _state_path(self, name: str) -> Path:
|
||||
return self._dir / f"{name}.state"
|
||||
|
||||
def _pid_path(self, name: str) -> Path:
|
||||
return self._dir / f"{name}.pid"
|
||||
|
||||
def read_state(self, name: str) -> BridgeState:
|
||||
path = self._state_path(name)
|
||||
if not path.exists():
|
||||
return BridgeState.STOPPED
|
||||
text = path.read_text().strip()
|
||||
try:
|
||||
return BridgeState(text)
|
||||
except ValueError:
|
||||
return BridgeState.STOPPED
|
||||
|
||||
def write_state(self, name: str, state: BridgeState) -> None:
|
||||
self._ensure_dir()
|
||||
self._state_path(name).write_text(state.value)
|
||||
|
||||
def read_pid(self, name: str) -> Optional[int]:
|
||||
path = self._pid_path(name)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
pid = int(path.read_text().strip())
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
if _pid_alive(pid):
|
||||
return pid
|
||||
return None
|
||||
|
||||
def write_pid(self, name: str, pid: int) -> None:
|
||||
self._ensure_dir()
|
||||
self._pid_path(name).write_text(str(pid))
|
||||
|
||||
def clear_pid(self, name: str) -> None:
|
||||
path = self._pid_path(name)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def is_running(self, name: str) -> bool:
|
||||
return self.read_pid(name) is not None
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Return True if the process with given PID exists."""
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
90
tests/test_audit.py
Normal file
90
tests/test_audit.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Tests for audit logging."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bridge.audit import AuditLogger, AuditEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log_dir(tmp_path):
|
||||
return tmp_path / "bridge"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logger(log_dir):
|
||||
return AuditLogger(state_dir=log_dir)
|
||||
|
||||
|
||||
class TestAuditLogger:
|
||||
def test_log_event_creates_file(self, logger, log_dir):
|
||||
logger.log(
|
||||
tunnel="my-tunnel",
|
||||
event=AuditEvent.BRIDGE_STARTED,
|
||||
actor="operator.bernd",
|
||||
actor_class="human",
|
||||
)
|
||||
log_file = log_dir / "my-tunnel.log"
|
||||
assert log_file.exists()
|
||||
|
||||
def test_log_event_is_json_line(self, logger, log_dir):
|
||||
logger.log(
|
||||
tunnel="my-tunnel",
|
||||
event=AuditEvent.BRIDGE_STARTED,
|
||||
actor="operator.bernd",
|
||||
actor_class="human",
|
||||
)
|
||||
lines = (log_dir / "my-tunnel.log").read_text().strip().splitlines()
|
||||
assert len(lines) == 1
|
||||
entry = json.loads(lines[0])
|
||||
assert entry["tunnel"] == "my-tunnel"
|
||||
assert entry["event"] == "bridge_started"
|
||||
assert entry["actor"] == "operator.bernd"
|
||||
assert entry["actor_class"] == "human"
|
||||
assert "timestamp" in entry
|
||||
|
||||
def test_multiple_events_append(self, logger, log_dir):
|
||||
for event in [AuditEvent.BRIDGE_STARTED, AuditEvent.BRIDGE_CONNECTED, AuditEvent.BRIDGE_STOPPED]:
|
||||
logger.log(tunnel="t", event=event, actor="a", actor_class="human")
|
||||
lines = (log_dir / "t.log").read_text().strip().splitlines()
|
||||
assert len(lines) == 3
|
||||
|
||||
def test_log_with_detail(self, logger, log_dir):
|
||||
logger.log(
|
||||
tunnel="t",
|
||||
event=AuditEvent.HEALTH_CHECK_FAILED,
|
||||
actor="a",
|
||||
actor_class="automation",
|
||||
detail="connection refused",
|
||||
)
|
||||
entry = json.loads((log_dir / "t.log").read_text().strip())
|
||||
assert entry["detail"] == "connection refused"
|
||||
|
||||
def test_all_event_types_defined(self):
|
||||
events = {e.value for e in AuditEvent}
|
||||
assert "bridge_started" in events
|
||||
assert "bridge_connected" in events
|
||||
assert "bridge_disconnected" in events
|
||||
assert "bridge_reconnecting" in events
|
||||
assert "health_check_failed" in events
|
||||
assert "health_check_recovered" in events
|
||||
assert "bridge_stopped" in events
|
||||
|
||||
def test_timestamp_is_iso8601(self, logger, log_dir):
|
||||
from datetime import datetime
|
||||
logger.log(tunnel="t", event=AuditEvent.BRIDGE_STOPPED, actor="a", actor_class="human")
|
||||
entry = json.loads((log_dir / "t.log").read_text().strip())
|
||||
# Should parse without error
|
||||
dt = datetime.fromisoformat(entry["timestamp"])
|
||||
assert dt.tzinfo is not None or True # UTC or naive both acceptable
|
||||
|
||||
def test_read_events(self, logger, log_dir):
|
||||
logger.log(tunnel="t", event=AuditEvent.BRIDGE_STARTED, actor="a", actor_class="human")
|
||||
logger.log(tunnel="t", event=AuditEvent.BRIDGE_STOPPED, actor="a", actor_class="human")
|
||||
events = logger.read_events("t")
|
||||
assert len(events) == 2
|
||||
assert events[0]["event"] == "bridge_started"
|
||||
|
||||
def test_read_events_missing_returns_empty(self, logger):
|
||||
assert logger.read_events("nonexistent") == []
|
||||
201
tests/test_cli.py
Normal file
201
tests/test_cli.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Tests for CLI commands."""
|
||||
import json
|
||||
import os
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from bridge.cli import app
|
||||
|
||||
|
||||
VALID_CONFIG = textwrap.dedent("""\
|
||||
tunnels:
|
||||
test-tunnel:
|
||||
host: host.local
|
||||
remote_port: 18000
|
||||
local_port: 8000
|
||||
ssh_user: ubuntu
|
||||
ssh_key: ~/.ssh/id_ops
|
||||
actor: operator.bernd
|
||||
actors:
|
||||
operator.bernd:
|
||||
class: human
|
||||
description: Bernd
|
||||
""")
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_file(tmp_path):
|
||||
f = tmp_path / "tunnels.yaml"
|
||||
f.write_text(VALID_CONFIG)
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_dir(tmp_path):
|
||||
return tmp_path / "state"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(config_file, state_dir):
|
||||
return {"BRIDGE_CONFIG": str(config_file), "BRIDGE_STATE_DIR": str(state_dir)}
|
||||
|
||||
|
||||
class TestHelpCommand:
|
||||
def test_app_help(self):
|
||||
result = runner.invoke(app, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "bridge" in result.output.lower() or "Usage" in result.output
|
||||
|
||||
def test_up_help(self):
|
||||
result = runner.invoke(app, ["up", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_down_help(self):
|
||||
result = runner.invoke(app, ["down", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_status_help(self):
|
||||
result = runner.invoke(app, ["status", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_logs_help(self):
|
||||
result = runner.invoke(app, ["logs", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_restart_help(self):
|
||||
result = runner.invoke(app, ["restart", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestStatusCommand:
|
||||
def test_status_shows_tunnels(self, env, state_dir):
|
||||
result = runner.invoke(app, ["status"], env=env)
|
||||
assert result.exit_code == 0
|
||||
assert "test-tunnel" in result.output
|
||||
|
||||
def test_status_json_flag(self, env, state_dir):
|
||||
result = runner.invoke(app, ["status", "--json"], env=env)
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["tunnel"] == "test-tunnel"
|
||||
assert "state" in data[0]
|
||||
assert "actor" in data[0]
|
||||
assert "host" in data[0]
|
||||
|
||||
def test_status_shows_state(self, env, state_dir):
|
||||
result = runner.invoke(app, ["status"], env=env)
|
||||
assert result.exit_code == 0
|
||||
assert "stopped" in result.output.lower()
|
||||
|
||||
def test_status_unknown_config_exit_1(self, tmp_path):
|
||||
result = runner.invoke(app, ["status"], env={"BRIDGE_CONFIG": str(tmp_path / "no.yaml")})
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
class TestUpCommand:
|
||||
def test_up_unknown_tunnel_exit_1(self, env):
|
||||
result = runner.invoke(app, ["up", "nonexistent"], env=env)
|
||||
assert result.exit_code == 1
|
||||
assert "nonexistent" in result.output
|
||||
|
||||
def test_up_calls_manager_start(self, env, state_dir):
|
||||
with patch("bridge.cli.TunnelManager") as mock_mgr_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.is_running.return_value = False
|
||||
mock_mgr_cls.return_value = mock_mgr
|
||||
|
||||
result = runner.invoke(app, ["up", "test-tunnel"], env=env)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_mgr.start.assert_called_once()
|
||||
|
||||
def test_up_already_running_exit_2(self, env, state_dir):
|
||||
with patch("bridge.cli.TunnelManager") as mock_mgr_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.is_running.return_value = True
|
||||
mock_mgr_cls.return_value = mock_mgr
|
||||
|
||||
result = runner.invoke(app, ["up", "test-tunnel"], env=env)
|
||||
|
||||
assert result.exit_code == 2
|
||||
|
||||
|
||||
class TestDownCommand:
|
||||
def test_down_unknown_tunnel_exit_1(self, env):
|
||||
result = runner.invoke(app, ["down", "nonexistent"], env=env)
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_down_calls_manager_stop(self, env, state_dir):
|
||||
with patch("bridge.cli.TunnelManager") as mock_mgr_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.is_running.return_value = True
|
||||
mock_mgr_cls.return_value = mock_mgr
|
||||
|
||||
result = runner.invoke(app, ["down", "test-tunnel"], env=env)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_mgr.stop.assert_called_once()
|
||||
|
||||
def test_down_not_running_exit_2(self, env, state_dir):
|
||||
with patch("bridge.cli.TunnelManager") as mock_mgr_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.is_running.return_value = False
|
||||
mock_mgr_cls.return_value = mock_mgr
|
||||
|
||||
result = runner.invoke(app, ["down", "test-tunnel"], env=env)
|
||||
|
||||
assert result.exit_code == 2
|
||||
|
||||
|
||||
class TestLogsCommand:
|
||||
def test_logs_unknown_tunnel_exit_1(self, env):
|
||||
result = runner.invoke(app, ["logs", "nonexistent"], env=env)
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_logs_no_log_file_shows_empty(self, env, state_dir):
|
||||
result = runner.invoke(app, ["logs", "test-tunnel"], env=env)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_logs_shows_events(self, env, state_dir):
|
||||
import json as _json
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = state_dir / "test-tunnel.log"
|
||||
log_file.write_text(
|
||||
_json.dumps({
|
||||
"timestamp": "2026-01-01T00:00:00+00:00",
|
||||
"tunnel": "test-tunnel",
|
||||
"actor": "operator.bernd",
|
||||
"actor_class": "human",
|
||||
"event": "bridge_started",
|
||||
}) + "\n"
|
||||
)
|
||||
result = runner.invoke(app, ["logs", "test-tunnel"], env=env)
|
||||
assert result.exit_code == 0
|
||||
assert "bridge_started" in result.output
|
||||
|
||||
|
||||
class TestRestartCommand:
|
||||
def test_restart_unknown_tunnel_exit_1(self, env):
|
||||
result = runner.invoke(app, ["restart", "nonexistent"], env=env)
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_restart_calls_stop_then_start(self, env):
|
||||
with patch("bridge.cli.TunnelManager") as mock_mgr_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr_cls.return_value = mock_mgr
|
||||
call_order = []
|
||||
mock_mgr.stop.side_effect = lambda: call_order.append("stop")
|
||||
mock_mgr.start.side_effect = lambda: call_order.append("start")
|
||||
|
||||
result = runner.invoke(app, ["restart", "test-tunnel"], env=env)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert call_order == ["stop", "start"]
|
||||
130
tests/test_config.py
Normal file
130
tests/test_config.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Tests for config loading."""
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from bridge.config import ConfigError, load_config
|
||||
|
||||
|
||||
VALID_YAML = textwrap.dedent("""\
|
||||
tunnels:
|
||||
state-hub-coulombcore:
|
||||
host: coulombcore.local
|
||||
remote_port: 18000
|
||||
local_port: 8000
|
||||
ssh_user: ubuntu
|
||||
ssh_key: ~/.ssh/id_ops
|
||||
actor: agent.claude-coulombcore
|
||||
health_check:
|
||||
url: http://127.0.0.1:18000/health
|
||||
interval_seconds: 30
|
||||
timeout_seconds: 5
|
||||
reconnect:
|
||||
max_attempts: 0
|
||||
backoff_initial: 5
|
||||
backoff_max: 60
|
||||
|
||||
actors:
|
||||
agent.claude-coulombcore:
|
||||
class: automation
|
||||
description: Claude Code agent on CoulombCore
|
||||
operator.bernd:
|
||||
class: human
|
||||
description: Bernd Worsch
|
||||
""")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_file(tmp_path):
|
||||
f = tmp_path / "tunnels.yaml"
|
||||
f.write_text(VALID_YAML)
|
||||
return f
|
||||
|
||||
|
||||
def test_load_valid_config(config_file, monkeypatch):
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(config_file))
|
||||
cfg = load_config()
|
||||
assert "state-hub-coulombcore" in cfg.tunnels
|
||||
t = cfg.tunnels["state-hub-coulombcore"]
|
||||
assert t.host == "coulombcore.local"
|
||||
assert t.remote_port == 18000
|
||||
assert t.local_port == 8000
|
||||
assert t.ssh_user == "ubuntu"
|
||||
assert t.actor == "agent.claude-coulombcore"
|
||||
|
||||
|
||||
def test_health_check_loaded(config_file, monkeypatch):
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(config_file))
|
||||
cfg = load_config()
|
||||
t = cfg.tunnels["state-hub-coulombcore"]
|
||||
assert t.health_check is not None
|
||||
assert t.health_check.url == "http://127.0.0.1:18000/health"
|
||||
assert t.health_check.interval_seconds == 30
|
||||
|
||||
|
||||
def test_reconnect_policy_loaded(config_file, monkeypatch):
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(config_file))
|
||||
cfg = load_config()
|
||||
t = cfg.tunnels["state-hub-coulombcore"]
|
||||
assert t.reconnect.max_attempts == 0
|
||||
assert t.reconnect.backoff_initial == 5
|
||||
assert t.reconnect.backoff_max == 60
|
||||
|
||||
|
||||
def test_actors_loaded(config_file, monkeypatch):
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(config_file))
|
||||
cfg = load_config()
|
||||
assert "agent.claude-coulombcore" in cfg.actors
|
||||
a = cfg.actors["agent.claude-coulombcore"]
|
||||
assert a.actor_class == "automation"
|
||||
assert "operator.bernd" in cfg.actors
|
||||
|
||||
|
||||
def test_missing_required_field_raises(tmp_path, monkeypatch):
|
||||
f = tmp_path / "bad.yaml"
|
||||
f.write_text(textwrap.dedent("""\
|
||||
tunnels:
|
||||
broken:
|
||||
remote_port: 18000
|
||||
local_port: 8000
|
||||
actors: {}
|
||||
"""))
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(f))
|
||||
with pytest.raises(ConfigError, match="host"):
|
||||
load_config()
|
||||
|
||||
|
||||
def test_invalid_yaml_raises(tmp_path, monkeypatch):
|
||||
f = tmp_path / "bad.yaml"
|
||||
f.write_text("tunnels: [\nnot: valid: yaml")
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(f))
|
||||
with pytest.raises(ConfigError):
|
||||
load_config()
|
||||
|
||||
|
||||
def test_missing_config_file_raises(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(tmp_path / "nonexistent.yaml"))
|
||||
with pytest.raises(ConfigError, match="not found"):
|
||||
load_config()
|
||||
|
||||
|
||||
def test_tunnel_without_health_check(tmp_path, monkeypatch):
|
||||
f = tmp_path / "tunnels.yaml"
|
||||
f.write_text(textwrap.dedent("""\
|
||||
tunnels:
|
||||
simple:
|
||||
host: host.local
|
||||
remote_port: 9000
|
||||
local_port: 8000
|
||||
ssh_user: ubuntu
|
||||
ssh_key: ~/.ssh/id_rsa
|
||||
actor: operator.bernd
|
||||
actors:
|
||||
operator.bernd:
|
||||
class: human
|
||||
description: Bernd
|
||||
"""))
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(f))
|
||||
cfg = load_config()
|
||||
assert cfg.tunnels["simple"].health_check is None
|
||||
78
tests/test_health.py
Normal file
78
tests/test_health.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Tests for health checking."""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
from bridge.health import HealthChecker, HealthResult
|
||||
|
||||
|
||||
class TestHealthResult:
|
||||
def test_ok(self):
|
||||
r = HealthResult(ok=True, status_code=200)
|
||||
assert r.ok
|
||||
assert r.status_code == 200
|
||||
assert r.error is None
|
||||
|
||||
def test_failure(self):
|
||||
r = HealthResult(ok=False, error="connection refused")
|
||||
assert not r.ok
|
||||
assert r.error == "connection refused"
|
||||
|
||||
|
||||
class TestHealthChecker:
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_ok(self):
|
||||
checker = HealthChecker(url="http://127.0.0.1:18000/health", timeout_seconds=5)
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await checker.check()
|
||||
|
||||
assert result.ok
|
||||
assert result.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_connection_error(self):
|
||||
import httpx
|
||||
checker = HealthChecker(url="http://127.0.0.1:19999/health", timeout_seconds=1)
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused"))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await checker.check()
|
||||
|
||||
assert not result.ok
|
||||
assert result.error is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_http_error(self):
|
||||
import httpx
|
||||
checker = HealthChecker(url="http://127.0.0.1:18000/health", timeout_seconds=5)
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 503
|
||||
mock_response.raise_for_status = MagicMock(
|
||||
side_effect=httpx.HTTPStatusError("503", request=MagicMock(), response=mock_response)
|
||||
)
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await checker.check()
|
||||
|
||||
assert not result.ok
|
||||
assert result.status_code == 503
|
||||
219
tests/test_integration.py
Normal file
219
tests/test_integration.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Integration tests for OpsBridge."""
|
||||
import json
|
||||
import os
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from bridge.config import load_config
|
||||
from bridge.manager import TunnelManager
|
||||
from bridge.models import BridgeState, ReconnectPolicy, TunnelConfig
|
||||
from bridge.state import StateManager
|
||||
|
||||
|
||||
MINIMAL_CONFIG = textwrap.dedent("""\
|
||||
tunnels:
|
||||
local-test:
|
||||
host: 127.0.0.1
|
||||
remote_port: 19000
|
||||
local_port: 8000
|
||||
ssh_user: testuser
|
||||
ssh_key: ~/.ssh/id_rsa
|
||||
actor: operator.bernd
|
||||
reconnect:
|
||||
max_attempts: 2
|
||||
backoff_initial: 1
|
||||
backoff_max: 2
|
||||
actors:
|
||||
operator.bernd:
|
||||
class: human
|
||||
description: Bernd
|
||||
""")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_file(tmp_path):
|
||||
f = tmp_path / "tunnels.yaml"
|
||||
f.write_text(MINIMAL_CONFIG)
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_dir(tmp_path):
|
||||
return tmp_path / "bridge"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tunnel_cfg():
|
||||
return TunnelConfig(
|
||||
name="local-test",
|
||||
host="127.0.0.1",
|
||||
remote_port=19000,
|
||||
local_port=8000,
|
||||
ssh_user="testuser",
|
||||
ssh_key="~/.ssh/id_rsa",
|
||||
actor="operator.bernd",
|
||||
reconnect=ReconnectPolicy(max_attempts=2, backoff_initial=1, backoff_max=2),
|
||||
)
|
||||
|
||||
|
||||
class TestConfigRoundtrip:
|
||||
def test_load_config_from_file(self, config_file, monkeypatch):
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(config_file))
|
||||
cfg = load_config()
|
||||
assert "local-test" in cfg.tunnels
|
||||
t = cfg.tunnels["local-test"]
|
||||
assert t.host == "127.0.0.1"
|
||||
assert t.reconnect.max_attempts == 2
|
||||
assert t.reconnect.backoff_initial == 1
|
||||
|
||||
|
||||
class TestStateRoundtrip:
|
||||
def test_state_persists_across_manager_instances(self, state_dir, tunnel_cfg):
|
||||
mgr1 = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
mgr1._state.write_state(tunnel_cfg.name, BridgeState.CONNECTED)
|
||||
|
||||
mgr2 = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
assert mgr2.get_state() == BridgeState.CONNECTED
|
||||
|
||||
def test_stale_pid_cleanup(self, state_dir, tunnel_cfg):
|
||||
sm = StateManager(state_dir=state_dir)
|
||||
sm.write_pid(tunnel_cfg.name, 999999) # guaranteed not alive
|
||||
sm.write_state(tunnel_cfg.name, BridgeState.CONNECTED)
|
||||
|
||||
# is_running should return False for dead pid
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
assert not mgr.is_running()
|
||||
|
||||
|
||||
class TestReconnectLoop:
|
||||
def test_reconnect_loop_gives_up_after_max_attempts(self, state_dir, tunnel_cfg):
|
||||
"""Manager should set FAILED state after exhausting max_attempts."""
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
|
||||
attempt_count = [0]
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 1 # immediately "dead"
|
||||
proc.returncode = 1
|
||||
attempt_count[0] += 1
|
||||
return proc
|
||||
|
||||
with patch("subprocess.Popen", side_effect=fake_popen), \
|
||||
patch("time.sleep"): # skip sleeps for speed
|
||||
mgr._run_loop()
|
||||
|
||||
assert attempt_count[0] >= 1
|
||||
assert mgr.get_state() == BridgeState.FAILED
|
||||
|
||||
def test_reconnect_logs_events(self, state_dir, tunnel_cfg):
|
||||
"""Audit log should contain reconnect events."""
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 1
|
||||
proc.returncode = 1
|
||||
return proc
|
||||
|
||||
with patch("subprocess.Popen", side_effect=fake_popen), \
|
||||
patch("time.sleep"):
|
||||
mgr._run_loop()
|
||||
|
||||
events = mgr._audit.read_events(tunnel_cfg.name)
|
||||
event_types = [e["event"] for e in events]
|
||||
assert "bridge_started" in event_types or "bridge_reconnecting" in event_types or "bridge_disconnected" in event_types
|
||||
|
||||
|
||||
class TestHealthCheckDegradedPath:
|
||||
def test_degraded_state_on_health_failure(self, state_dir):
|
||||
"""Health check failure sets state to DEGRADED."""
|
||||
from bridge.health import HealthChecker, HealthResult
|
||||
|
||||
hc_cfg = MagicMock()
|
||||
hc_cfg.url = "http://127.0.0.1:19001/health"
|
||||
hc_cfg.interval_seconds = 0
|
||||
hc_cfg.timeout_seconds = 1
|
||||
|
||||
tunnel_cfg = TunnelConfig(
|
||||
name="hc-test",
|
||||
host="127.0.0.1",
|
||||
remote_port=19001,
|
||||
local_port=8001,
|
||||
ssh_user="u",
|
||||
ssh_key="k",
|
||||
actor="operator.bernd",
|
||||
reconnect=ReconnectPolicy(max_attempts=1, backoff_initial=1, backoff_max=1),
|
||||
health_check=hc_cfg,
|
||||
)
|
||||
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
|
||||
proc_call_count = [0]
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
proc = MagicMock()
|
||||
# First call: "alive" for 1 health check cycle then dies
|
||||
proc_call_count[0] += 1
|
||||
if proc_call_count[0] == 1:
|
||||
# Poll returns None (alive) once then dies
|
||||
poll_calls = [None, 1]
|
||||
proc.poll.side_effect = poll_calls + [1] * 100
|
||||
proc.returncode = 1
|
||||
else:
|
||||
proc.poll.return_value = 1
|
||||
proc.returncode = 1
|
||||
return proc
|
||||
|
||||
failed_result = HealthResult(ok=False, error="connection refused")
|
||||
recovered_result = HealthResult(ok=True, status_code=200)
|
||||
|
||||
import asyncio
|
||||
|
||||
async def fake_check_failing():
|
||||
return failed_result
|
||||
|
||||
with patch("subprocess.Popen", side_effect=fake_popen), \
|
||||
patch("time.sleep"), \
|
||||
patch("bridge.manager.HealthChecker") as mock_hc_cls:
|
||||
mock_checker = MagicMock()
|
||||
mock_checker.check = MagicMock(side_effect=lambda: failed_result)
|
||||
# Use asyncio.run compatibility
|
||||
mock_hc_cls.return_value = mock_checker
|
||||
|
||||
with patch("asyncio.run", side_effect=lambda coro: failed_result):
|
||||
mgr._run_loop()
|
||||
|
||||
# Should have set degraded at some point — check audit log
|
||||
events = mgr._audit.read_events("hc-test")
|
||||
event_types = [e["event"] for e in events]
|
||||
assert "health_check_failed" in event_types or "bridge_disconnected" in event_types
|
||||
|
||||
|
||||
class TestAuditTrail:
|
||||
def test_full_lifecycle_logged(self, state_dir, tunnel_cfg):
|
||||
"""A start + immediate-exit SSH produces at minimum started + disconnected events."""
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 1
|
||||
proc.returncode = 1
|
||||
return proc
|
||||
|
||||
with patch("subprocess.Popen", side_effect=fake_popen), \
|
||||
patch("time.sleep"):
|
||||
mgr._run_loop()
|
||||
|
||||
events = mgr._audit.read_events(tunnel_cfg.name)
|
||||
assert len(events) >= 2
|
||||
# Each event has required fields
|
||||
for e in events:
|
||||
assert "timestamp" in e
|
||||
assert "tunnel" in e
|
||||
assert "actor" in e
|
||||
assert "event" in e
|
||||
109
tests/test_manager.py
Normal file
109
tests/test_manager.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Tests for TunnelManager."""
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
from bridge.models import BridgeState, ReconnectPolicy, TunnelConfig
|
||||
from bridge.manager import TunnelManager, build_ssh_command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tunnel_cfg():
|
||||
return TunnelConfig(
|
||||
name="test-tunnel",
|
||||
host="host.local",
|
||||
remote_port=18000,
|
||||
local_port=8000,
|
||||
ssh_user="ubuntu",
|
||||
ssh_key="~/.ssh/id_ops",
|
||||
actor="operator.bernd",
|
||||
reconnect=ReconnectPolicy(max_attempts=3, backoff_initial=1, backoff_max=5),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_dir(tmp_path):
|
||||
return tmp_path / "bridge"
|
||||
|
||||
|
||||
class TestBuildSshCommand:
|
||||
def test_basic_command(self, tunnel_cfg):
|
||||
cmd = build_ssh_command(tunnel_cfg)
|
||||
assert cmd[0] == "ssh"
|
||||
assert "-N" in cmd
|
||||
assert "-R" in cmd
|
||||
assert "18000:127.0.0.1:8000" in cmd
|
||||
assert "-i" in cmd
|
||||
assert "ubuntu@host.local" in cmd
|
||||
|
||||
def test_server_alive_options(self, tunnel_cfg):
|
||||
cmd = build_ssh_command(tunnel_cfg)
|
||||
assert "-o" in cmd
|
||||
assert "ServerAliveInterval=10" in cmd
|
||||
assert "ExitOnForwardFailure=yes" in cmd
|
||||
|
||||
def test_ssh_key_expanded(self, tunnel_cfg):
|
||||
cmd = build_ssh_command(tunnel_cfg)
|
||||
key_idx = cmd.index("-i") + 1
|
||||
assert not cmd[key_idx].startswith("~")
|
||||
|
||||
|
||||
class TestTunnelManager:
|
||||
def test_get_state_initial(self, tunnel_cfg, state_dir):
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
assert mgr.get_state() == BridgeState.STOPPED
|
||||
|
||||
def test_stop_when_not_running_is_noop(self, tunnel_cfg, state_dir):
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
# Should not raise
|
||||
mgr.stop()
|
||||
assert mgr.get_state() == BridgeState.STOPPED
|
||||
|
||||
def test_stop_kills_pid(self, tunnel_cfg, state_dir):
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
# Write a fake PID of our own process to simulate running
|
||||
mgr._state.write_pid(tunnel_cfg.name, os.getpid())
|
||||
mgr._state.write_state(tunnel_cfg.name, BridgeState.CONNECTED)
|
||||
|
||||
with patch("os.kill") as mock_kill:
|
||||
mgr.stop()
|
||||
|
||||
# Should have sent SIGTERM
|
||||
mock_kill.assert_any_call(os.getpid(), signal.SIGTERM)
|
||||
assert mgr.get_state() == BridgeState.STOPPED
|
||||
|
||||
def test_backoff_calculation(self, tunnel_cfg, state_dir):
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
# First backoff = initial
|
||||
assert mgr._next_backoff(0) == 1
|
||||
# Doubles each time up to max
|
||||
assert mgr._next_backoff(1) == 2
|
||||
assert mgr._next_backoff(2) == 4
|
||||
assert mgr._next_backoff(3) == 5 # capped at max
|
||||
|
||||
def test_start_daemonizes(self, tunnel_cfg, state_dir):
|
||||
"""Verify start() forks without hanging."""
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
|
||||
# We can't actually fork in tests; verify state transitions via mock
|
||||
with patch("subprocess.Popen") as mock_popen, \
|
||||
patch("os.fork", return_value=1234) as mock_fork, \
|
||||
patch("os.setsid"), \
|
||||
patch("os._exit"):
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.pid = 9999
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
# When fork returns non-zero we're the parent — just check PID written
|
||||
mgr.start()
|
||||
|
||||
# After start the state should be STARTING (set before fork)
|
||||
# and PID file should exist (written in parent branch)
|
||||
|
||||
def test_is_running_false_initially(self, tunnel_cfg, state_dir):
|
||||
mgr = TunnelManager(tunnel_cfg, state_dir=state_dir)
|
||||
assert not mgr.is_running()
|
||||
75
tests/test_models.py
Normal file
75
tests/test_models.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Tests for domain models."""
|
||||
import pytest
|
||||
from bridge.models import (
|
||||
ActorInfo,
|
||||
BridgeState,
|
||||
HealthCheckConfig,
|
||||
ReconnectPolicy,
|
||||
TunnelConfig,
|
||||
)
|
||||
|
||||
|
||||
class TestBridgeState:
|
||||
def test_all_states_defined(self):
|
||||
states = {s.value for s in BridgeState}
|
||||
assert states == {"stopped", "starting", "connected", "degraded", "reconnecting", "failed"}
|
||||
|
||||
def test_state_is_string(self):
|
||||
assert BridgeState.STOPPED == "stopped"
|
||||
|
||||
|
||||
class TestReconnectPolicy:
|
||||
def test_defaults(self):
|
||||
p = ReconnectPolicy()
|
||||
assert p.max_attempts == 0
|
||||
assert p.backoff_initial == 5
|
||||
assert p.backoff_max == 60
|
||||
|
||||
def test_custom(self):
|
||||
p = ReconnectPolicy(max_attempts=3, backoff_initial=2, backoff_max=30)
|
||||
assert p.max_attempts == 3
|
||||
|
||||
|
||||
class TestHealthCheckConfig:
|
||||
def test_required_url(self):
|
||||
h = HealthCheckConfig(url="http://127.0.0.1:18000/health")
|
||||
assert h.url == "http://127.0.0.1:18000/health"
|
||||
assert h.interval_seconds == 30
|
||||
assert h.timeout_seconds == 5
|
||||
|
||||
|
||||
class TestTunnelConfig:
|
||||
def test_minimal(self):
|
||||
t = TunnelConfig(
|
||||
name="test-tunnel",
|
||||
host="host.local",
|
||||
remote_port=18000,
|
||||
local_port=8000,
|
||||
ssh_user="ubuntu",
|
||||
ssh_key="~/.ssh/id_ops",
|
||||
actor="operator.bernd",
|
||||
)
|
||||
assert t.name == "test-tunnel"
|
||||
assert t.health_check is None
|
||||
assert isinstance(t.reconnect, ReconnectPolicy)
|
||||
|
||||
def test_with_health_check(self):
|
||||
hc = HealthCheckConfig(url="http://127.0.0.1:18000/health")
|
||||
t = TunnelConfig(
|
||||
name="test",
|
||||
host="h",
|
||||
remote_port=1,
|
||||
local_port=2,
|
||||
ssh_user="u",
|
||||
ssh_key="k",
|
||||
actor="a",
|
||||
health_check=hc,
|
||||
)
|
||||
assert t.health_check is hc
|
||||
|
||||
|
||||
class TestActorInfo:
|
||||
def test_fields(self):
|
||||
a = ActorInfo(name="operator.bernd", actor_class="human", description="Bernd")
|
||||
assert a.name == "operator.bernd"
|
||||
assert a.actor_class == "human"
|
||||
69
tests/test_state.py
Normal file
69
tests/test_state.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for state management."""
|
||||
import os
|
||||
import signal
|
||||
|
||||
import pytest
|
||||
|
||||
from bridge.models import BridgeState
|
||||
from bridge.state import StateManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_dir(tmp_path):
|
||||
return tmp_path / "bridge"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mgr(state_dir):
|
||||
return StateManager(state_dir=state_dir)
|
||||
|
||||
|
||||
class TestStateManager:
|
||||
def test_read_state_no_file_returns_stopped(self, mgr):
|
||||
assert mgr.read_state("my-tunnel") == BridgeState.STOPPED
|
||||
|
||||
def test_write_and_read_state(self, mgr):
|
||||
mgr.write_state("my-tunnel", BridgeState.CONNECTED)
|
||||
assert mgr.read_state("my-tunnel") == BridgeState.CONNECTED
|
||||
|
||||
def test_state_roundtrip_all_values(self, mgr):
|
||||
for state in BridgeState:
|
||||
mgr.write_state("t", state)
|
||||
assert mgr.read_state("t") == state
|
||||
|
||||
def test_write_pid(self, mgr):
|
||||
# Write a live PID (our own process) so read_pid can confirm it's alive
|
||||
pid = os.getpid()
|
||||
mgr.write_pid("my-tunnel", pid)
|
||||
assert mgr.read_pid("my-tunnel") == pid
|
||||
|
||||
def test_read_pid_no_file_returns_none(self, mgr):
|
||||
assert mgr.read_pid("nonexistent") is None
|
||||
|
||||
def test_stale_pid_returns_none(self, mgr):
|
||||
# PID 999999 almost certainly does not exist
|
||||
mgr.write_pid("my-tunnel", 999999)
|
||||
assert mgr.read_pid("my-tunnel") is None
|
||||
|
||||
def test_current_pid_is_alive(self, mgr):
|
||||
mgr.write_pid("my-tunnel", os.getpid())
|
||||
assert mgr.read_pid("my-tunnel") == os.getpid()
|
||||
|
||||
def test_clear_pid(self, mgr):
|
||||
mgr.write_pid("my-tunnel", os.getpid())
|
||||
mgr.clear_pid("my-tunnel")
|
||||
assert mgr.read_pid("my-tunnel") is None
|
||||
|
||||
def test_state_dir_created_on_write(self, state_dir):
|
||||
assert not state_dir.exists()
|
||||
mgr = StateManager(state_dir=state_dir)
|
||||
mgr.write_state("t", BridgeState.STOPPED)
|
||||
assert state_dir.exists()
|
||||
|
||||
def test_is_running_false_when_stopped(self, mgr):
|
||||
assert not mgr.is_running("my-tunnel")
|
||||
|
||||
def test_is_running_true_when_pid_alive(self, mgr):
|
||||
mgr.write_pid("my-tunnel", os.getpid())
|
||||
mgr.write_state("my-tunnel", BridgeState.CONNECTED)
|
||||
assert mgr.is_running("my-tunnel")
|
||||
Reference in New Issue
Block a user