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:
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