feat(l4d2-host): server lifecycle uses systemctl enable --now / disable --now

Servers started via the web UI now create a WantedBy= symlink under
multi-user.target.wants/, so they auto-start on the next host reboot.
Helper verbs renamed start/stop -> enable/disable; service_control.py
renamed start_service/stop_service -> enable_service/disable_service.
The user-facing l4d2ctl start/stop commands keep their names per the
AGENTS.md contract -- only the implementation changes. Spec:
docs/superpowers/specs/2026-05-09-l4d2-server-lifecycle-reboot-and-drift-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-09 12:28:44 +02:00
parent 1dd674714a
commit 8552c559d3
No known key found for this signature in database
6 changed files with 53 additions and 28 deletions

View file

@ -2,7 +2,7 @@
set -eu set -eu
usage() { usage() {
printf '%s\n' "usage: left4me-systemctl start|stop|show <server-name>" >&2 printf '%s\n' "usage: left4me-systemctl enable|disable|show <server-name>" >&2
exit 2 exit 2
} }
@ -22,7 +22,7 @@ action=$1
name=$2 name=$2
case "$action" in case "$action" in
start|stop|show) ;; enable|disable|show) ;;
*) usage ;; *) usage ;;
esac esac
@ -38,7 +38,7 @@ else
fi fi
case "$action" in case "$action" in
start) exec "$systemctl" start "$unit" ;; enable) exec "$systemctl" enable --now "$unit" ;;
stop) exec "$systemctl" stop "$unit" ;; disable) exec "$systemctl" disable --now "$unit" ;;
show) exec "$systemctl" show --property=ActiveState --property=SubState "$unit" ;; show) exec "$systemctl" show --property=ActiveState --property=SubState "$unit" ;;
esac esac

View file

@ -233,12 +233,16 @@ def test_systemctl_helper_passes_shell_syntax_check_and_rejects_bad_args(tmp_pat
for args in [ for args in [
["bad/action", "alpha"], ["bad/action", "alpha"],
["start", ""], # `start` and `stop` are no longer accepted verbs — the lifecycle now
["start", ".hidden"], # uses `enable`/`disable` for reboot survival via WantedBy= symlinks.
["start", "bad..name"], ["start", "alpha"],
["start", "bad/name"], ["stop", "alpha"],
["start", "bad\\name"], ["enable", ""],
["start", "bad name"], ["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) result = subprocess.run(["sh", str(SYSTEMCTL_HELPER), *args], env=_env_with_fake_commands(tmp_path), check=False)
assert result.returncode != 0 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() script = SYSTEMCTL_HELPER.read_text()
assert 'unit="left4me-server@${name}.service"' in script assert 'unit="left4me-server@${name}.service"' in script
assert 'start) exec "$systemctl" start "$unit"' in script assert 'enable) exec "$systemctl" enable --now "$unit"' in script
assert 'stop) exec "$systemctl" stop "$unit"' in script assert 'disable) exec "$systemctl" disable --now "$unit"' in script
assert "--property=ActiveState" in script assert "--property=ActiveState" in script
assert "--property=SubState" in script assert "--property=SubState" in script

View file

@ -6,7 +6,7 @@ from typing import Callable
from l4d2host.fs.kernel_overlayfs import KernelOverlayFSMounter from l4d2host.fs.kernel_overlayfs import KernelOverlayFSMounter
from l4d2host.paths import DEFAULT_LEFT4ME_ROOT, get_left4me_root, overlay_path, validate_instance_name 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 from l4d2host.spec import load_spec
@ -133,8 +133,8 @@ def start_instance(
should_cancel=should_cancel, should_cancel=should_cancel,
) )
emit_step("starting systemd service...", on_stdout, passthrough) emit_step("enabling + starting systemd service...", on_stdout, passthrough)
start_service( enable_service(
name, name,
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
@ -155,8 +155,8 @@ def stop_instance(
) -> None: ) -> None:
name = validate_instance_name(name) name = validate_instance_name(name)
root = get_left4me_root() if root is None else Path(root) root = get_left4me_root() if root is None else Path(root)
emit_step("stopping systemd service...", on_stdout, passthrough) emit_step("disabling + stopping systemd service...", on_stdout, passthrough)
stop_service( disable_service(
name, name,
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
@ -189,9 +189,9 @@ def _purge_instance(
instance_dir = root / "instances" / name instance_dir = root / "instances" / name
runtime_dir = root / "runtime" / 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: try:
stop_service( disable_service(
name, name,
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,

View file

@ -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] return ["sudo", "-n", JOURNALCTL_HELPER, name, "--lines", str(lines), follow_arg]
def start_service( def enable_service(
name: str, name: str,
*, *,
on_stdout: Callable[[str], None] | None = None, on_stdout: Callable[[str], None] | None = None,
@ -26,7 +26,7 @@ def start_service(
should_cancel: Callable[[], bool] | None = None, should_cancel: Callable[[], bool] | None = None,
) -> CommandResult: ) -> CommandResult:
return run_command( return run_command(
systemctl_command("start", name), systemctl_command("enable", name),
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
passthrough=passthrough, passthrough=passthrough,
@ -34,7 +34,7 @@ def start_service(
) )
def stop_service( def disable_service(
name: str, name: str,
*, *,
on_stdout: Callable[[str], None] | None = None, on_stdout: Callable[[str], None] | None = None,
@ -43,7 +43,7 @@ def stop_service(
should_cancel: Callable[[], bool] | None = None, should_cancel: Callable[[], bool] | None = None,
) -> CommandResult: ) -> CommandResult:
return run_command( return run_command(
systemctl_command("stop", name), systemctl_command("disable", name),
on_stdout=on_stdout, on_stdout=on_stdout,
on_stderr=on_stderr, on_stderr=on_stderr,
passthrough=passthrough, passthrough=passthrough,

View file

@ -41,7 +41,7 @@ def test_start_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"mount", "mount",
"alpha", "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( 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): def fake_run_command(cmd, **kwargs):
del kwargs del kwargs
calls.append(list(cmd)) 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( raise subprocess.CalledProcessError(
returncode=5, returncode=5,
cmd=list(cmd), 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 instance_dir.exists()
assert not runtime_dir.exists() assert not runtime_dir.exists()
assert any("left4me-systemctl" in arg for cmd in calls for arg in cmd) 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: 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.""" stop+unmount (both suppressed on failure) and not raise."""
def fake_run_command(cmd, **kwargs): def fake_run_command(cmd, **kwargs):
del kwargs del kwargs
if "stop" in cmd: if "disable" in cmd:
raise subprocess.CalledProcessError(returncode=5, cmd=list(cmd), stderr="not loaded") raise subprocess.CalledProcessError(returncode=5, cmd=list(cmd), stderr="not loaded")
monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) 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 / "instances" / "alpha").exists()
assert not (tmp_path / "runtime" / "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: def test_stop_succeeds_when_unmount_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -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"]