feat: add CLI lifecycle commands (start, stop, restart, status, check)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,10 @@ from autossh_mgr.config import (
|
|||||||
TunnelConfig, get_config_dir, ensure_dirs,
|
TunnelConfig, get_config_dir, ensure_dirs,
|
||||||
load_tunnels, save_tunnels, get_tunnel,
|
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 (
|
from autossh_mgr.display import (
|
||||||
print_tunnel_table, print_tunnel_detail, print_status_table,
|
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]
|
tunnels = [replace(t, **{key: typed_value}) if t.name == name else t for t in tunnels]
|
||||||
save_tunnels(config_dir, tunnels)
|
save_tunnels(config_dir, tunnels)
|
||||||
click.echo(f"Updated {name}.{key} = {typed_value}")
|
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}")
|
||||||
|
|||||||
103
tests/unit/test_cli_lifecycle.py
Normal file
103
tests/unit/test_cli_lifecycle.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user