Compare commits

..

No commits in common. "6b4eef22c28f6c8666175f91e7ff37f9539857c0" and "fb3c6be052dafd89bfe74ebfa29d7ed76d8bb197" have entirely different histories.

10 changed files with 18 additions and 273 deletions

View file

@ -4,7 +4,7 @@ import subprocess
import typer
from l4d2host.instances import delete_instance, initialize_instance, reset_instance, start_instance, stop_instance
from l4d2host.instances import delete_instance, initialize_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,14 +59,6 @@ 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,18 +158,23 @@ def stop_instance(
emit_step("stop complete.", on_stdout, passthrough)
def _purge_instance(
def delete_instance(
name: str,
*,
root: Path,
on_stdout: Callable[[str], None] | None,
on_stderr: Callable[[str], None] | None,
passthrough: bool,
should_cancel: Callable[[], bool] | None,
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
emit_step("stopping systemd service (if running)...", on_stdout, passthrough)
try:
stop_service(
@ -199,53 +204,4 @@ def _purge_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,7 +6,6 @@ import pytest
from l4d2host.instances import (
delete_instance,
initialize_instance,
reset_instance,
start_instance,
stop_instance,
)
@ -104,7 +103,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, reset_instance):
for func in (start_instance, stop_instance, delete_instance):
with pytest.raises(ValueError):
func(bad_name, root=tmp_path)
with pytest.raises(ValueError):
@ -113,49 +112,6 @@ 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", "reset"}
LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"}
@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", "reset"}:
if operation in {"stop", "delete"}:
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", "reset"}
SERVER_OPERATIONS = {"initialize", "start", "stop", "delete"}
OVERLAY_OPERATIONS = {"build_overlay"}
GLOBAL_OPERATIONS = {"install", "refresh_workshop_items"}
WORKSHOP_REFRESH_DOWNLOAD_WORKERS = 1
@ -311,25 +311,6 @@ def run_job(job_id: int) -> None:
on_stderr=on_stderr,
should_cancel=should_cancel,
)
# Host-side cleanup succeeded; remove the DB row so the server
# disappears from /servers. Status refresh is skipped — the
# systemd unit is gone and querying it would just log noise.
with session_scope() as db:
server = db.scalar(select(Server).where(Server.id == server_id))
if server is not 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,16 +203,6 @@ 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,7 +13,6 @@
<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>
@ -48,23 +47,6 @@
<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

@ -260,71 +260,6 @@ def test_unexpected_exception_fails_job_with_exit_code_one(seeded_worker, monkey
job = load_job(job_id)
assert job.state == "failed"
assert job.exit_code == 1
# Failed delete must keep the Server row.
with session_scope() as session:
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)
calls = []
def fake_delete(server_id, *, on_stdout=None, on_stderr=None, should_cancel=None):
del should_cancel
calls.append(server_id)
on_stdout("removing systemd unit")
def fake_status(name): # pragma: no cover — would mean delete didn't skip refresh
pytest.fail(f"server_status must not be called after a successful delete (got {name!r})")
monkeypatch.setattr(l4d2_facade, "delete_server", fake_delete)
monkeypatch.setattr(l4d2_facade, "server_status", fake_status)
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:
assert session.scalar(select(Server).where(Server.id == ids.server_one)) is None
# Sibling server is untouched.
assert session.scalar(select(Server).where(Server.id == ids.server_two)) is not None
# The delete job itself stays in the job log; outerjoin in views shows
# "-" for its (now-orphaned) server_id pointer.
finished_job = session.scalar(select(Job).where(Job.id == job_id))
assert finished_job is not None
assert finished_job.server_id == ids.server_one
def test_same_server_jobs_do_not_overlap(seeded_worker, monkeypatch) -> None:

View file

@ -100,33 +100,25 @@ 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", "reset_instance"]:
for name in ["SteamInstaller", "start_instance", "stop_instance", "delete_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,
reset_server,
start_server,
stop_server,
)
from l4d2web.services.l4d2_facade import delete_server, install_runtime, 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,42 +323,3 @@ 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"