From 6b4eef22c28f6c8666175f91e7ff37f9539857c0 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 8 May 2026 18:10:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20server=20Reset=20action=20=E2=80=94=20w?= =?UTF-8?q?ipe=20runtime,=20keep=20DB=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset stops the systemd service, unmounts the overlay, and rm -rf's both runtime/ and instances/, but keeps the Server row, blueprint, and (shared) systemd template. Next Start re-initializes from the current blueprint, so users can clean up logs/caches/accumulated game state without losing the server. Implementation factors a shared _purge_instance helper out of delete_instance; reset_instance reuses it without the existence guard. New "reset" lifecycle op flows through the same route + worker + facade plumbing as the other server ops. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2host/cli.py | 10 ++++- l4d2host/instances.py | 66 +++++++++++++++++++++++----- l4d2host/tests/test_lifecycle.py | 46 ++++++++++++++++++- l4d2web/routes/server_routes.py | 4 +- l4d2web/services/job_worker.py | 12 ++++- l4d2web/services/l4d2_facade.py | 10 +++++ l4d2web/templates/server_detail.html | 18 ++++++++ l4d2web/tests/test_job_worker.py | 27 ++++++++++++ l4d2web/tests/test_l4d2_facade.py | 12 ++++- l4d2web/tests/test_servers.py | 39 ++++++++++++++++ 10 files changed, 226 insertions(+), 18 deletions(-) diff --git a/l4d2host/cli.py b/l4d2host/cli.py index b3a964b..f55807d 100644 --- a/l4d2host/cli.py +++ b/l4d2host/cli.py @@ -4,7 +4,7 @@ import subprocess import typer -from l4d2host.instances import delete_instance, initialize_instance, start_instance, stop_instance +from l4d2host.instances import delete_instance, initialize_instance, reset_instance, start_instance, stop_instance from l4d2host.logs import stream_instance_logs from l4d2host.status import get_instance_status from l4d2host.steam_install import SteamInstaller @@ -59,6 +59,14 @@ def delete(name: str) -> None: _exit_from_subprocess_error(exc) +@app.command() +def reset(name: str) -> None: + try: + reset_instance(name, passthrough=True) + except subprocess.CalledProcessError as exc: + _exit_from_subprocess_error(exc) + + @app.command() def status(name: str, json_output: bool = typer.Option(False, "--json")) -> None: instance_status = get_instance_status(name) diff --git a/l4d2host/instances.py b/l4d2host/instances.py index f0244de..60f9847 100644 --- a/l4d2host/instances.py +++ b/l4d2host/instances.py @@ -158,23 +158,18 @@ def stop_instance( emit_step("stop complete.", on_stdout, passthrough) -def delete_instance( +def _purge_instance( name: str, *, - root: Path | None = None, - on_stdout: Callable[[str], None] | None = None, - on_stderr: Callable[[str], None] | None = None, - passthrough: bool = False, - should_cancel: Callable[[], bool] | None = None, + root: Path, + on_stdout: Callable[[str], None] | None, + on_stderr: Callable[[str], None] | None, + passthrough: bool, + should_cancel: Callable[[], bool] | None, ) -> None: - name = validate_instance_name(name) - root = get_left4me_root() if root is None else Path(root) instance_dir = root / "instances" / name runtime_dir = root / "runtime" / name - if not instance_dir.exists() and not runtime_dir.exists(): - return - emit_step("stopping systemd service (if running)...", on_stdout, passthrough) try: stop_service( @@ -204,4 +199,53 @@ def delete_instance( shutil.rmtree(instance_dir) if runtime_dir.exists(): shutil.rmtree(runtime_dir) + + +def delete_instance( + name: str, + *, + root: Path | None = None, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, + should_cancel: Callable[[], bool] | None = None, +) -> None: + name = validate_instance_name(name) + root = get_left4me_root() if root is None else Path(root) + instance_dir = root / "instances" / name + runtime_dir = root / "runtime" / name + + if not instance_dir.exists() and not runtime_dir.exists(): + return + + _purge_instance( + name, + root=root, + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + should_cancel=should_cancel, + ) emit_step("delete complete.", on_stdout, passthrough) + + +def reset_instance( + name: str, + *, + root: Path | None = None, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, + should_cancel: Callable[[], bool] | None = None, +) -> None: + name = validate_instance_name(name) + root = get_left4me_root() if root is None else Path(root) + _purge_instance( + name, + root=root, + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + should_cancel=should_cancel, + ) + emit_step("reset complete; next start will reinitialize from blueprint.", on_stdout, passthrough) diff --git a/l4d2host/tests/test_lifecycle.py b/l4d2host/tests/test_lifecycle.py index de9cdfd..bf814b0 100644 --- a/l4d2host/tests/test_lifecycle.py +++ b/l4d2host/tests/test_lifecycle.py @@ -6,6 +6,7 @@ import pytest from l4d2host.instances import ( delete_instance, initialize_instance, + reset_instance, start_instance, stop_instance, ) @@ -103,7 +104,7 @@ def test_delete_succeeds_when_stop_service_fails(tmp_path: Path, monkeypatch: py @pytest.mark.parametrize("bad_name", ["..", "../escape", "foo/bar", " foo", "Foo"]) def test_lifecycle_rejects_unsafe_instance_names(tmp_path: Path, bad_name: str) -> None: - for func in (start_instance, stop_instance, delete_instance): + for func in (start_instance, stop_instance, delete_instance, reset_instance): with pytest.raises(ValueError): func(bad_name, root=tmp_path) with pytest.raises(ValueError): @@ -112,6 +113,49 @@ def test_lifecycle_rejects_unsafe_instance_names(tmp_path: Path, bad_name: str) assert not (tmp_path / "runtime").exists() +def test_reset_stops_unmounts_and_removes_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[list[str]] = [] + + def fake_run_command(cmd, **kwargs): + del kwargs + calls.append(list(cmd)) + + instance_dir = tmp_path / "instances" / "alpha" + runtime_dir = tmp_path / "runtime" / "alpha" + instance_dir.mkdir(parents=True) + (runtime_dir / "merged").mkdir(parents=True) + (instance_dir / "instance.env").write_text("L4D2_PORT=27015\n") + (runtime_dir / "upper" / "logs").mkdir(parents=True) + (runtime_dir / "upper" / "logs" / "console.log").write_text("noise") + + monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) + + reset_instance("alpha", root=tmp_path) + + 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) + + +def test_reset_on_never_initialized_is_noop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """No instance/runtime directories yet — reset should still attempt the + stop+unmount (both suppressed on failure) and not raise.""" + def fake_run_command(cmd, **kwargs): + del kwargs + if "stop" in cmd: + 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.service_control.run_command", fake_run_command) + + reset_instance("alpha", root=tmp_path) + + assert not (tmp_path / "instances" / "alpha").exists() + assert not (tmp_path / "runtime" / "alpha").exists() + + def test_delete_stopped_instance_removes_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: calls: list[list[str]] = [] diff --git a/l4d2web/routes/server_routes.py b/l4d2web/routes/server_routes.py index 8e501ef..805a943 100644 --- a/l4d2web/routes/server_routes.py +++ b/l4d2web/routes/server_routes.py @@ -119,7 +119,7 @@ def update_server(server_id: int) -> Response: return jsonify({"id": server_id}), 200 -LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"} +LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete", "reset"} @bp.post("/servers//") @@ -137,7 +137,7 @@ def enqueue_server_operation(server_id: int, operation: str) -> Response: db.add(Job(user_id=user.id, server_id=server.id, operation=operation, state="queued")) if operation == "start": server.desired_state = "running" - if operation in {"stop", "delete"}: + if operation in {"stop", "delete", "reset"}: server.desired_state = "stopped" if operation == "delete": diff --git a/l4d2web/services/job_worker.py b/l4d2web/services/job_worker.py index 0c084c9..37cb959 100644 --- a/l4d2web/services/job_worker.py +++ b/l4d2web/services/job_worker.py @@ -25,7 +25,7 @@ from l4d2web.services.host_commands import CommandCancelledError TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"} ACTIVE_JOB_STATES = {"running", "cancelling"} -SERVER_OPERATIONS = {"initialize", "start", "stop", "delete"} +SERVER_OPERATIONS = {"initialize", "start", "stop", "delete", "reset"} OVERLAY_OPERATIONS = {"build_overlay"} GLOBAL_OPERATIONS = {"install", "refresh_workshop_items"} WORKSHOP_REFRESH_DOWNLOAD_WORKERS = 1 @@ -320,6 +320,16 @@ def run_job(job_id: int) -> None: db.delete(server) finish_job(job_id, "succeeded", 0) return + elif operation == "reset": + _run_with_boundaries( + "reset", + server_name, + l4d2_facade.reset_server, + server_id, + on_stdout=on_stdout, + on_stderr=on_stderr, + should_cancel=should_cancel, + ) else: raise ValueError(f"unknown job operation: {operation}") diff --git a/l4d2web/services/l4d2_facade.py b/l4d2web/services/l4d2_facade.py index 7fb5d1d..fc741da 100644 --- a/l4d2web/services/l4d2_facade.py +++ b/l4d2web/services/l4d2_facade.py @@ -203,6 +203,16 @@ def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel= ) +def reset_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: + server, _, _ = load_server_blueprint_bundle(server_id) + host_commands.run_command( + ["l4d2ctl", "reset", server.name], + on_stdout=on_stdout, + on_stderr=on_stderr, + should_cancel=should_cancel, + ) + + def server_status(server_name: str) -> ServerStatus: result = host_commands.run_command(["l4d2ctl", "status", server_name, "--json"]) payload = json.loads(result.stdout or "{}") diff --git a/l4d2web/templates/server_detail.html b/l4d2web/templates/server_detail.html index 3c9a993..5e8ad06 100644 --- a/l4d2web/templates/server_detail.html +++ b/l4d2web/templates/server_detail.html @@ -13,6 +13,7 @@ {% endfor %} + @@ -47,6 +48,23 @@

 
 
+
+  
+  
+  
+
+