diff --git a/src/autossh_mgr/cli.py b/src/autossh_mgr/cli.py index 539d149..a6a5a0d 100644 --- a/src/autossh_mgr/cli.py +++ b/src/autossh_mgr/cli.py @@ -4,7 +4,10 @@ from autossh_mgr.config import ( TunnelConfig, get_config_dir, ensure_dirs, load_tunnels, save_tunnels, get_tunnel, ) -from autossh_mgr.process import get_status +from autossh_mgr.check import check_connectivity +from autossh_mgr.process import ( + get_status, start_tunnel, stop_tunnel, delete_pid_file, +) from autossh_mgr.display import ( print_tunnel_table, print_tunnel_detail, print_status_table, ) @@ -156,3 +159,85 @@ def config_cmd(ctx, name, key, value): tunnels = [replace(t, **{key: typed_value}) if t.name == name else t for t in tunnels] save_tunnels(config_dir, tunnels) click.echo(f"Updated {name}.{key} = {typed_value}") + + +@cli.command() +@click.argument("name", required=False) +@click.pass_context +def start(ctx, name): + """Start a tunnel (or all tunnels if no name given).""" + config_dir = ctx.obj["config_dir"] + tunnels = load_tunnels(config_dir) + targets = ( + [get_tunnel(config_dir, name)] if name + else sorted(tunnels, key=lambda t: t.name) + ) + for tunnel in targets: + status = get_status(config_dir, tunnel.name) + if status.state == "running": + click.echo(f"{tunnel.name} already running (pid: {status.pid})") + continue + if status.state == "stale": + click.echo(f"{tunnel.name}: stale pid detected, cleaning up") + delete_pid_file(config_dir, tunnel.name) + pid = start_tunnel(config_dir, tunnel) + click.echo(f"Started {tunnel.name} (pid: {pid})") + + +@cli.command() +@click.argument("name", required=False) +@click.pass_context +def stop(ctx, name): + """Stop a tunnel (or all running tunnels if no name given).""" + config_dir = ctx.obj["config_dir"] + tunnels = load_tunnels(config_dir) + targets = ( + [get_tunnel(config_dir, name)] if name + else sorted(tunnels, key=lambda t: t.name) + ) + for tunnel in targets: + status = get_status(config_dir, tunnel.name) + if status.state == "stopped": + click.echo(f"{tunnel.name} is not running") + continue + stop_tunnel(config_dir, tunnel.name) + click.echo(f"Stopped {tunnel.name}") + + +@cli.command() +@click.argument("name", required=False) +@click.pass_context +def restart(ctx, name): + """Restart a tunnel (or all tunnels if no name given).""" + ctx.invoke(stop, name=name) + ctx.invoke(start, name=name) + + +@cli.command() +@click.argument("name", required=False) +@click.pass_context +def status(ctx, name): + """Show tunnel status (all tunnels if no name given).""" + config_dir = ctx.obj["config_dir"] + if name: + tunnel = get_tunnel(config_dir, name) + s = get_status(config_dir, name) + print_tunnel_detail(tunnel, s) + else: + tunnels = load_tunnels(config_dir) + statuses = {t.name: get_status(config_dir, t.name) for t in tunnels} + print_status_table(tunnels, statuses) + + +@cli.command(name="check") +@click.argument("name") +@click.pass_context +def check_cmd(ctx, name): + """Check SSH connectivity to a tunnel's server.""" + config_dir = ctx.obj["config_dir"] + tunnel = get_tunnel(config_dir, name) + success, msg = check_connectivity(tunnel) + if success: + click.echo(f"OK: {tunnel.user}@{tunnel.host}:{tunnel.port} is reachable") + else: + raise click.ClickException(f"Connection failed: {msg}") diff --git a/tests/unit/test_cli_lifecycle.py b/tests/unit/test_cli_lifecycle.py new file mode 100644 index 0000000..e8e3e3e --- /dev/null +++ b/tests/unit/test_cli_lifecycle.py @@ -0,0 +1,103 @@ +import os +import signal +import time +import pytest +from click.testing import CliRunner +from unittest.mock import patch, MagicMock +from autossh_mgr.cli import cli +from autossh_mgr.config import save_tunnels +from autossh_mgr.process import write_pid_file, read_pid_file +from datetime import datetime, timezone + + +@pytest.fixture(autouse=True) +def set_config_dir(config_dir, monkeypatch): + monkeypatch.setenv("AUTOSSH_MGR_CONFIG_DIR", str(config_dir)) + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def with_tunnel(config_dir, sample_tunnel): + save_tunnels(config_dir, [sample_tunnel]) + return sample_tunnel + + +def test_start_already_running(runner, config_dir, with_tunnel): + write_pid_file(config_dir, "web-service", os.getpid(), datetime.now(timezone.utc)) + result = runner.invoke(cli, ["start", "web-service"]) + assert result.exit_code == 0 + assert "already running" in result.output + + +def test_start_stale_pid_cleaned(runner, config_dir, with_tunnel, tmp_path, monkeypatch): + fake = tmp_path / "autossh" + fake.write_text("#!/bin/sh\nexec sleep 60\n") + fake.chmod(0o755) + monkeypatch.setenv("PATH", f"{tmp_path}:{os.environ['PATH']}") + + write_pid_file(config_dir, "web-service", 9999999, datetime.now(timezone.utc)) + result = runner.invoke(cli, ["start", "web-service"]) + assert result.exit_code == 0, result.output + assert "stale" in result.output.lower() + # Clean up launched process + pid_data = read_pid_file(config_dir, "web-service") + if pid_data: + os.kill(pid_data[0], signal.SIGKILL) + + +def test_start_unknown_tunnel(runner): + result = runner.invoke(cli, ["start", "missing"]) + assert result.exit_code != 0 + assert "No tunnel named" in result.output + + +def test_stop_running_tunnel(runner, config_dir, with_tunnel, tmp_path, monkeypatch): + fake = tmp_path / "autossh" + fake.write_text("#!/bin/sh\nexec sleep 60\n") + fake.chmod(0o755) + monkeypatch.setenv("PATH", f"{tmp_path}:{os.environ['PATH']}") + + runner.invoke(cli, ["start", "web-service"]) + result = runner.invoke(cli, ["stop", "web-service"]) + assert result.exit_code == 0 + assert read_pid_file(config_dir, "web-service") is None + + +def test_stop_already_stopped(runner, with_tunnel): + result = runner.invoke(cli, ["stop", "web-service"]) + assert result.exit_code == 0 + assert "not running" in result.output + + +def test_status_all_stopped(runner, config_dir, with_tunnel): + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 0 + assert "web-service" in result.output + assert "stopped" in result.output + + +def test_status_single(runner, config_dir, with_tunnel): + result = runner.invoke(cli, ["status", "web-service"]) + assert result.exit_code == 0 + assert "stopped" in result.output + assert "relay.example.com" in result.output + + +def test_check_success(runner, with_tunnel): + with patch("autossh_mgr.check.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr="") + result = runner.invoke(cli, ["check", "web-service"]) + assert result.exit_code == 0 + assert "OK" in result.output or "success" in result.output.lower() + + +def test_check_failure(runner, with_tunnel): + with patch("autossh_mgr.check.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=255, stderr="Connection refused") + result = runner.invoke(cli, ["check", "web-service"]) + assert result.exit_code != 0 + assert "Connection refused" in result.output