generated from coulomb/repo-seed
bridge restart now means blank-slate recovery: reverse tunnels run should_cleanup_tunnel and clear orphan remote listeners before reconnecting; healthy forwards are left running. Local-direction tunnels keep stop/start only. CLI and MCP report per-tunnel actions (healthy, cleaned_and_restarted, restarted, error) and exit non-zero on cleanup failure. Closes BRIDGE-WP-0005.
130 lines
4.4 KiB
Python
130 lines
4.4 KiB
Python
"""Tests for stale SSH forward cleanup."""
|
|
from __future__ import annotations
|
|
|
|
import textwrap
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from typer.testing import CliRunner
|
|
|
|
from bridge.cleanup import (
|
|
CleanupAction,
|
|
build_cron_line,
|
|
cleanup_all_tunnels,
|
|
remote_forward_health_url,
|
|
should_cleanup_tunnel,
|
|
)
|
|
from bridge.cli import app
|
|
from bridge.config import load_config
|
|
from bridge.models import HealthCheckConfig, TunnelConfig
|
|
from bridge.state import StateManager
|
|
|
|
|
|
def _tunnel(**overrides) -> TunnelConfig:
|
|
base = dict(
|
|
name="state-hub-railiance01",
|
|
host="92.205.62.239",
|
|
remote_port=18000,
|
|
local_port=8000,
|
|
ssh_user="tegwick",
|
|
ssh_key="~/.ssh/id_ops",
|
|
actor="agt-claude-railiance01",
|
|
health_check=HealthCheckConfig(
|
|
url="http://127.0.0.1:8000/state/health",
|
|
timeout_seconds=5,
|
|
),
|
|
)
|
|
base.update(overrides)
|
|
return TunnelConfig(**base)
|
|
|
|
|
|
class TestRemoteForwardHealthUrl:
|
|
def test_maps_local_port_to_remote(self):
|
|
cfg = _tunnel()
|
|
assert remote_forward_health_url(cfg) == "http://127.0.0.1:18000/state/health"
|
|
|
|
def test_returns_none_for_local_tunnel(self):
|
|
cfg = _tunnel(direction="local")
|
|
assert remote_forward_health_url(cfg) is None
|
|
|
|
|
|
class TestShouldCleanupTunnel:
|
|
def test_skips_healthy_remote_forward(self, tmp_path):
|
|
cfg = _tunnel()
|
|
state_mgr = StateManager(state_dir=tmp_path)
|
|
with (
|
|
patch("bridge.cleanup.remote_port_listening", return_value=True),
|
|
patch("bridge.cleanup.probe_remote_forward", return_value=(True, "ok")),
|
|
):
|
|
needed, reason = should_cleanup_tunnel(cfg, state_mgr)
|
|
assert needed is False
|
|
|
|
def test_detects_stale_forward_when_local_ok_remote_fails(self, tmp_path):
|
|
cfg = _tunnel()
|
|
state_mgr = StateManager(state_dir=tmp_path)
|
|
with (
|
|
patch("bridge.cleanup.remote_port_listening", return_value=True),
|
|
patch("bridge.cleanup.probe_remote_forward", return_value=(False, "timeout")),
|
|
patch("bridge.cleanup.local_service_healthy", return_value=True),
|
|
patch(
|
|
"bridge.cleanup.check_tunnel",
|
|
return_value=MagicMock(ssh_process="ok", remote_port="listening"),
|
|
),
|
|
):
|
|
needed, reason = should_cleanup_tunnel(cfg, state_mgr)
|
|
assert needed is True
|
|
assert "stale forward" in reason
|
|
|
|
|
|
class TestCleanupAllTunnels:
|
|
def test_reports_cleaned_tunnel(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("BRIDGE_CONFIG", str(tmp_path / "tunnels.yaml"))
|
|
(tmp_path / "tunnels.yaml").write_text(
|
|
textwrap.dedent(
|
|
"""\
|
|
tunnels:
|
|
state-hub-railiance01:
|
|
host: 92.205.62.239
|
|
remote_port: 18000
|
|
local_port: 8000
|
|
ssh_user: tegwick
|
|
ssh_key: ~/.ssh/id_ops
|
|
actor: agt-claude-railiance01
|
|
health_check:
|
|
url: http://127.0.0.1:8000/state/health
|
|
actors:
|
|
agt-claude-railiance01:
|
|
class: agt
|
|
"""
|
|
)
|
|
)
|
|
cfg = load_config()
|
|
state_mgr = StateManager(state_dir=tmp_path / "state")
|
|
with patch(
|
|
"bridge.cleanup.cleanup_tunnel",
|
|
return_value=CleanupAction("state-hub-railiance01", "cleaned", "cleared"),
|
|
):
|
|
report = cleanup_all_tunnels(cfg, state_mgr, restart=False)
|
|
assert report.cleaned_count == 1
|
|
assert report.actions[0].action == "cleaned"
|
|
|
|
|
|
class TestMaintenanceCli:
|
|
def test_cleanup_help(self):
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["maintenance", "cleanup", "--help"])
|
|
assert result.exit_code == 0
|
|
assert "restart" in result.output.lower()
|
|
|
|
def test_show_cron_prints_template_when_not_installed(self):
|
|
runner = CliRunner()
|
|
with patch("bridge.cli.read_installed_cron", return_value=None):
|
|
result = runner.invoke(app, ["maintenance", "show-cron"])
|
|
assert result.exit_code == 0
|
|
assert "0 3 * * *" in result.output
|
|
|
|
|
|
def test_build_cron_line_contains_marker():
|
|
line = build_cron_line()
|
|
assert "0 3 * * *" in line
|
|
assert "maintenance cleanup --restart" in line
|
|
assert "ops-bridge: maintenance cleanup" in line |