diff --git a/deploy/files/usr/local/libexec/left4me/left4me-systemctl b/deploy/files/usr/local/libexec/left4me/left4me-systemctl index d7ab8e5..fc7cbd9 100755 --- a/deploy/files/usr/local/libexec/left4me/left4me-systemctl +++ b/deploy/files/usr/local/libexec/left4me/left4me-systemctl @@ -2,7 +2,7 @@ set -eu usage() { - printf '%s\n' "usage: left4me-systemctl start|stop|show " >&2 + printf '%s\n' "usage: left4me-systemctl enable|disable|show " >&2 exit 2 } @@ -22,7 +22,7 @@ action=$1 name=$2 case "$action" in - start|stop|show) ;; + enable|disable|show) ;; *) usage ;; esac @@ -38,7 +38,7 @@ else fi case "$action" in - start) exec "$systemctl" start "$unit" ;; - stop) exec "$systemctl" stop "$unit" ;; + enable) exec "$systemctl" enable --now "$unit" ;; + disable) exec "$systemctl" disable --now "$unit" ;; show) exec "$systemctl" show --property=ActiveState --property=SubState "$unit" ;; esac diff --git a/deploy/tests/test_deploy_artifacts.py b/deploy/tests/test_deploy_artifacts.py index 03541ff..96d0300 100644 --- a/deploy/tests/test_deploy_artifacts.py +++ b/deploy/tests/test_deploy_artifacts.py @@ -233,12 +233,16 @@ def test_systemctl_helper_passes_shell_syntax_check_and_rejects_bad_args(tmp_pat for args in [ ["bad/action", "alpha"], - ["start", ""], - ["start", ".hidden"], - ["start", "bad..name"], - ["start", "bad/name"], - ["start", "bad\\name"], - ["start", "bad name"], + # `start` and `stop` are no longer accepted verbs — the lifecycle now + # uses `enable`/`disable` for reboot survival via WantedBy= symlinks. + ["start", "alpha"], + ["stop", "alpha"], + ["enable", ""], + ["enable", ".hidden"], + ["enable", "bad..name"], + ["enable", "bad/name"], + ["enable", "bad\\name"], + ["enable", "bad name"], ]: result = subprocess.run(["sh", str(SYSTEMCTL_HELPER), *args], env=_env_with_fake_commands(tmp_path), check=False) assert result.returncode != 0 @@ -246,8 +250,8 @@ def test_systemctl_helper_passes_shell_syntax_check_and_rejects_bad_args(tmp_pat script = SYSTEMCTL_HELPER.read_text() assert 'unit="left4me-server@${name}.service"' in script - assert 'start) exec "$systemctl" start "$unit"' in script - assert 'stop) exec "$systemctl" stop "$unit"' in script + assert 'enable) exec "$systemctl" enable --now "$unit"' in script + assert 'disable) exec "$systemctl" disable --now "$unit"' in script assert "--property=ActiveState" in script assert "--property=SubState" in script diff --git a/l4d2host/instances.py b/l4d2host/instances.py index 63a3ac4..4a97b52 100644 --- a/l4d2host/instances.py +++ b/l4d2host/instances.py @@ -6,7 +6,7 @@ from typing import Callable from l4d2host.fs.kernel_overlayfs import KernelOverlayFSMounter from l4d2host.paths import DEFAULT_LEFT4ME_ROOT, get_left4me_root, overlay_path, validate_instance_name -from l4d2host.service_control import start_service, stop_service +from l4d2host.service_control import disable_service, enable_service from l4d2host.spec import load_spec @@ -133,8 +133,8 @@ def start_instance( should_cancel=should_cancel, ) - emit_step("starting systemd service...", on_stdout, passthrough) - start_service( + emit_step("enabling + starting systemd service...", on_stdout, passthrough) + enable_service( name, on_stdout=on_stdout, on_stderr=on_stderr, @@ -155,8 +155,8 @@ def stop_instance( ) -> None: name = validate_instance_name(name) root = get_left4me_root() if root is None else Path(root) - emit_step("stopping systemd service...", on_stdout, passthrough) - stop_service( + emit_step("disabling + stopping systemd service...", on_stdout, passthrough) + disable_service( name, on_stdout=on_stdout, on_stderr=on_stderr, @@ -189,9 +189,9 @@ def _purge_instance( instance_dir = root / "instances" / name runtime_dir = root / "runtime" / name - emit_step("stopping systemd service (if running)...", on_stdout, passthrough) + emit_step("disabling + stopping systemd service (if running)...", on_stdout, passthrough) try: - stop_service( + disable_service( name, on_stdout=on_stdout, on_stderr=on_stderr, diff --git a/l4d2host/service_control.py b/l4d2host/service_control.py index 8edd0b7..9a62280 100644 --- a/l4d2host/service_control.py +++ b/l4d2host/service_control.py @@ -17,7 +17,7 @@ def journalctl_command(name: str, lines: int = 200, follow: bool = True) -> list return ["sudo", "-n", JOURNALCTL_HELPER, name, "--lines", str(lines), follow_arg] -def start_service( +def enable_service( name: str, *, on_stdout: Callable[[str], None] | None = None, @@ -26,7 +26,7 @@ def start_service( should_cancel: Callable[[], bool] | None = None, ) -> CommandResult: return run_command( - systemctl_command("start", name), + systemctl_command("enable", name), on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, @@ -34,7 +34,7 @@ def start_service( ) -def stop_service( +def disable_service( name: str, *, on_stdout: Callable[[str], None] | None = None, @@ -43,7 +43,7 @@ def stop_service( should_cancel: Callable[[], bool] | None = None, ) -> CommandResult: return run_command( - systemctl_command("stop", name), + systemctl_command("disable", name), on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, diff --git a/l4d2host/tests/test_lifecycle.py b/l4d2host/tests/test_lifecycle.py index ea61fac..f082d24 100644 --- a/l4d2host/tests/test_lifecycle.py +++ b/l4d2host/tests/test_lifecycle.py @@ -41,7 +41,7 @@ def test_start_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: "mount", "alpha", ] - assert calls[1] == ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "start", "alpha"] + assert calls[1] == ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "enable", "alpha"] def test_start_copies_per_overlay_aliases_and_sweeps_stale( @@ -127,7 +127,7 @@ def test_delete_succeeds_when_stop_service_fails(tmp_path: Path, monkeypatch: py def fake_run_command(cmd, **kwargs): del kwargs calls.append(list(cmd)) - if cmd[:2] == ["sudo", "-n"] and "left4me-systemctl" in cmd[2] and "stop" in cmd: + if cmd[:2] == ["sudo", "-n"] and "left4me-systemctl" in cmd[2] and "disable" in cmd: raise subprocess.CalledProcessError( returncode=5, cmd=list(cmd), @@ -180,7 +180,7 @@ def test_reset_stops_unmounts_and_removes_dirs(tmp_path: Path, monkeypatch: pyte assert not instance_dir.exists() assert not runtime_dir.exists() assert any("left4me-systemctl" in arg for cmd in calls for arg in cmd) - assert any("stop" in cmd for cmd in calls) + assert any("disable" in cmd for cmd in calls) def test_reset_on_never_initialized_is_noop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -188,7 +188,7 @@ def test_reset_on_never_initialized_is_noop(tmp_path: Path, monkeypatch: pytest. stop+unmount (both suppressed on failure) and not raise.""" def fake_run_command(cmd, **kwargs): del kwargs - if "stop" in cmd: + if "disable" in cmd: raise subprocess.CalledProcessError(returncode=5, cmd=list(cmd), stderr="not loaded") monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) @@ -217,7 +217,7 @@ def test_delete_stopped_instance_removes_dirs(tmp_path: Path, monkeypatch: pytes assert not (tmp_path / "instances" / "alpha").exists() assert not (tmp_path / "runtime" / "alpha").exists() - assert ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "stop", "alpha"] in calls + assert ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "disable", "alpha"] in calls def test_stop_succeeds_when_unmount_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/l4d2host/tests/test_service_control.py b/l4d2host/tests/test_service_control.py new file mode 100644 index 0000000..d7d065b --- /dev/null +++ b/l4d2host/tests/test_service_control.py @@ -0,0 +1,21 @@ +from unittest.mock import patch + +from l4d2host.service_control import ( + SYSTEMCTL_HELPER, + disable_service, + enable_service, +) + + +@patch("l4d2host.service_control.run_command") +def test_enable_service_invokes_helper_with_enable_action(mock_run): + enable_service("instance-7") + args, _ = mock_run.call_args + assert args[0] == ["sudo", "-n", SYSTEMCTL_HELPER, "enable", "instance-7"] + + +@patch("l4d2host.service_control.run_command") +def test_disable_service_invokes_helper_with_disable_action(mock_run): + disable_service("instance-7") + args, _ = mock_run.call_args + assert args[0] == ["sudo", "-n", SYSTEMCTL_HELPER, "disable", "instance-7"]