feat: server Reset action — wipe runtime, keep DB row

Reset stops the systemd service, unmounts the overlay, and rm -rf's both
runtime/<name> and instances/<name>, 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) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-08 18:10:32 +02:00
parent c8a2d563ce
commit 6b4eef22c2
No known key found for this signature in database
10 changed files with 226 additions and 18 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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]] = []

View file

@ -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/<int:server_id>/<operation>")
@ -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":

View file

@ -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}")

View file

@ -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 "{}")

View file

@ -13,6 +13,7 @@
<button type="submit">{{ operation }}</button>
</form>
{% endfor %}
<button type="button" class="danger" data-modal-open="reset-server-modal">reset</button>
<button type="button" class="danger" data-modal-open="delete-server-modal">delete</button>
</div>
</div>
@ -47,6 +48,23 @@
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</section>
<dialog id="reset-server-modal" class="modal" aria-labelledby="reset-server-title">
<div class="modal-header">
<h2 id="reset-server-title">Reset server "{{ server.name }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>This stops the server and wipes its runtime state (logs, caches, accumulated game state). The blueprint association is preserved; the next start rebuilds from the current blueprint.</p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<form method="post" action="/servers/{{ server.id }}/reset" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Reset</button>
</form>
</div>
</dialog>
<dialog id="delete-server-modal" class="modal" aria-labelledby="delete-server-title">
<div class="modal-header">
<h2 id="delete-server-title">Delete server "{{ server.name }}"?</h2>

View file

@ -265,6 +265,33 @@ def test_unexpected_exception_fails_job_with_exit_code_one(seeded_worker, monkey
assert session.scalar(select(Server).where(Server.id == ids.server_one)) is not None
def test_successful_reset_keeps_server_row_and_refreshes_state(seeded_worker, monkeypatch) -> None:
app, ids = seeded_worker
job_id = add_job(ids.user, "reset", server_id=ids.server_one)
calls = []
def fake_reset(server_id, *, on_stdout=None, on_stderr=None, should_cancel=None):
del should_cancel
calls.append(server_id)
on_stdout("removing instance files")
monkeypatch.setattr(l4d2_facade, "reset_server", fake_reset)
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: SimpleNamespace(state="stopped"))
with app.app_context():
assert run_worker_once() is True
assert calls == [ids.server_one]
job = load_job(job_id)
assert job.state == "succeeded"
assert job.exit_code == 0
with session_scope() as session:
server = session.scalar(select(Server).where(Server.id == ids.server_one))
assert server is not None
assert server.actual_state == "stopped"
def test_successful_delete_removes_server_row(seeded_worker, monkeypatch) -> None:
app, ids = seeded_worker
job_id = add_job(ids.user, "delete", server_id=ids.server_one)

View file

@ -100,25 +100,33 @@ def test_install_and_lifecycle_commands_use_l4d2ctl(
return CommandResult(returncode=0, stdout="", stderr="")
monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command)
for name in ["SteamInstaller", "start_instance", "stop_instance", "delete_instance"]:
for name in ["SteamInstaller", "start_instance", "stop_instance", "delete_instance", "reset_instance"]:
monkeypatch.setattr(
f"l4d2web.services.l4d2_facade.{name}",
lambda *args, **kwargs: pytest.fail(f"facade must not call l4d2host {name} directly"),
raising=False,
)
from l4d2web.services.l4d2_facade import delete_server, install_runtime, start_server, stop_server
from l4d2web.services.l4d2_facade import (
delete_server,
install_runtime,
reset_server,
start_server,
stop_server,
)
server_id, _ = server_with_blueprint
install_runtime()
start_server(server_id)
stop_server(server_id)
reset_server(server_id)
delete_server(server_id)
assert calls == [
["l4d2ctl", "install"],
["l4d2ctl", "start", "alpha"],
["l4d2ctl", "stop", "alpha"],
["l4d2ctl", "reset", "alpha"],
["l4d2ctl", "delete", "alpha"],
]

View file

@ -323,3 +323,42 @@ def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None:
assert response.status_code == 302
assert response.headers["Location"] == f"/servers/{server_id}"
def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None:
from sqlalchemy import select
from l4d2web.models import Job, Server
client, data = user_client_with_blueprints
create_response = client.post(
"/servers",
data=json.dumps({"name": "alpha", "port": 27015, "blueprint_id": data["blueprint_id"]}),
content_type="application/json",
headers={"X-CSRF-Token": "test-token"},
)
server_id = create_response.get_json()["id"]
# Pretend the user already started it.
with session_scope() as session:
server = session.scalar(select(Server).where(Server.id == server_id))
assert server is not None
server.desired_state = "running"
response = client.post(
f"/servers/{server_id}/reset",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == f"/servers/{server_id}"
with session_scope() as session:
server = session.scalar(select(Server).where(Server.id == server_id))
assert server is not None
assert server.desired_state == "stopped"
jobs = session.scalars(
select(Job).where(Job.server_id == server_id, Job.operation == "reset")
).all()
assert len(jobs) == 1
assert jobs[0].state == "queued"