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:
parent
1dd674714a
commit
8552c559d3
6 changed files with 53 additions and 28 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
21
l4d2host/tests/test_service_control.py
Normal file
21
l4d2host/tests/test_service_control.py
Normal 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"]
|
||||||
Loading…
Reference in a new issue