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:
parent
c8a2d563ce
commit
6b4eef22c2
10 changed files with 226 additions and 18 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "{}")
|
||||
|
|
|
|||
|
|
@ -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">×</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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue