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
|
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.logs import stream_instance_logs
|
||||||
from l4d2host.status import get_instance_status
|
from l4d2host.status import get_instance_status
|
||||||
from l4d2host.steam_install import SteamInstaller
|
from l4d2host.steam_install import SteamInstaller
|
||||||
|
|
@ -59,6 +59,14 @@ def delete(name: str) -> None:
|
||||||
_exit_from_subprocess_error(exc)
|
_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()
|
@app.command()
|
||||||
def status(name: str, json_output: bool = typer.Option(False, "--json")) -> None:
|
def status(name: str, json_output: bool = typer.Option(False, "--json")) -> None:
|
||||||
instance_status = get_instance_status(name)
|
instance_status = get_instance_status(name)
|
||||||
|
|
|
||||||
|
|
@ -158,23 +158,18 @@ def stop_instance(
|
||||||
emit_step("stop complete.", on_stdout, passthrough)
|
emit_step("stop complete.", on_stdout, passthrough)
|
||||||
|
|
||||||
|
|
||||||
def delete_instance(
|
def _purge_instance(
|
||||||
name: str,
|
name: str,
|
||||||
*,
|
*,
|
||||||
root: Path | None = None,
|
root: Path,
|
||||||
on_stdout: Callable[[str], None] | None = None,
|
on_stdout: Callable[[str], None] | None,
|
||||||
on_stderr: Callable[[str], None] | None = None,
|
on_stderr: Callable[[str], None] | None,
|
||||||
passthrough: bool = False,
|
passthrough: bool,
|
||||||
should_cancel: Callable[[], bool] | None = None,
|
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
|
instance_dir = root / "instances" / name
|
||||||
runtime_dir = root / "runtime" / 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)
|
emit_step("stopping systemd service (if running)...", on_stdout, passthrough)
|
||||||
try:
|
try:
|
||||||
stop_service(
|
stop_service(
|
||||||
|
|
@ -204,4 +199,53 @@ def delete_instance(
|
||||||
shutil.rmtree(instance_dir)
|
shutil.rmtree(instance_dir)
|
||||||
if runtime_dir.exists():
|
if runtime_dir.exists():
|
||||||
shutil.rmtree(runtime_dir)
|
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)
|
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 (
|
from l4d2host.instances import (
|
||||||
delete_instance,
|
delete_instance,
|
||||||
initialize_instance,
|
initialize_instance,
|
||||||
|
reset_instance,
|
||||||
start_instance,
|
start_instance,
|
||||||
stop_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"])
|
@pytest.mark.parametrize("bad_name", ["..", "../escape", "foo/bar", " foo", "Foo"])
|
||||||
def test_lifecycle_rejects_unsafe_instance_names(tmp_path: Path, bad_name: str) -> None:
|
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):
|
with pytest.raises(ValueError):
|
||||||
func(bad_name, root=tmp_path)
|
func(bad_name, root=tmp_path)
|
||||||
with pytest.raises(ValueError):
|
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()
|
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:
|
def test_delete_stopped_instance_removes_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def update_server(server_id: int) -> Response:
|
||||||
return jsonify({"id": server_id}), 200
|
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>")
|
@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"))
|
db.add(Job(user_id=user.id, server_id=server.id, operation=operation, state="queued"))
|
||||||
if operation == "start":
|
if operation == "start":
|
||||||
server.desired_state = "running"
|
server.desired_state = "running"
|
||||||
if operation in {"stop", "delete"}:
|
if operation in {"stop", "delete", "reset"}:
|
||||||
server.desired_state = "stopped"
|
server.desired_state = "stopped"
|
||||||
|
|
||||||
if operation == "delete":
|
if operation == "delete":
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ from l4d2web.services.host_commands import CommandCancelledError
|
||||||
|
|
||||||
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
|
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
|
||||||
ACTIVE_JOB_STATES = {"running", "cancelling"}
|
ACTIVE_JOB_STATES = {"running", "cancelling"}
|
||||||
SERVER_OPERATIONS = {"initialize", "start", "stop", "delete"}
|
SERVER_OPERATIONS = {"initialize", "start", "stop", "delete", "reset"}
|
||||||
OVERLAY_OPERATIONS = {"build_overlay"}
|
OVERLAY_OPERATIONS = {"build_overlay"}
|
||||||
GLOBAL_OPERATIONS = {"install", "refresh_workshop_items"}
|
GLOBAL_OPERATIONS = {"install", "refresh_workshop_items"}
|
||||||
WORKSHOP_REFRESH_DOWNLOAD_WORKERS = 1
|
WORKSHOP_REFRESH_DOWNLOAD_WORKERS = 1
|
||||||
|
|
@ -320,6 +320,16 @@ def run_job(job_id: int) -> None:
|
||||||
db.delete(server)
|
db.delete(server)
|
||||||
finish_job(job_id, "succeeded", 0)
|
finish_job(job_id, "succeeded", 0)
|
||||||
return
|
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:
|
else:
|
||||||
raise ValueError(f"unknown job operation: {operation}")
|
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:
|
def server_status(server_name: str) -> ServerStatus:
|
||||||
result = host_commands.run_command(["l4d2ctl", "status", server_name, "--json"])
|
result = host_commands.run_command(["l4d2ctl", "status", server_name, "--json"])
|
||||||
payload = json.loads(result.stdout or "{}")
|
payload = json.loads(result.stdout or "{}")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<button type="submit">{{ operation }}</button>
|
<button type="submit">{{ operation }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% 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>
|
<button type="button" class="danger" data-modal-open="delete-server-modal">delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -47,6 +48,23 @@
|
||||||
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||||
</section>
|
</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">
|
<dialog id="delete-server-modal" class="modal" aria-labelledby="delete-server-title">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="delete-server-title">Delete server "{{ server.name }}"?</h2>
|
<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
|
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:
|
def test_successful_delete_removes_server_row(seeded_worker, monkeypatch) -> None:
|
||||||
app, ids = seeded_worker
|
app, ids = seeded_worker
|
||||||
job_id = add_job(ids.user, "delete", server_id=ids.server_one)
|
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="")
|
return CommandResult(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command)
|
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(
|
monkeypatch.setattr(
|
||||||
f"l4d2web.services.l4d2_facade.{name}",
|
f"l4d2web.services.l4d2_facade.{name}",
|
||||||
lambda *args, **kwargs: pytest.fail(f"facade must not call l4d2host {name} directly"),
|
lambda *args, **kwargs: pytest.fail(f"facade must not call l4d2host {name} directly"),
|
||||||
raising=False,
|
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
|
server_id, _ = server_with_blueprint
|
||||||
install_runtime()
|
install_runtime()
|
||||||
start_server(server_id)
|
start_server(server_id)
|
||||||
stop_server(server_id)
|
stop_server(server_id)
|
||||||
|
reset_server(server_id)
|
||||||
delete_server(server_id)
|
delete_server(server_id)
|
||||||
|
|
||||||
assert calls == [
|
assert calls == [
|
||||||
["l4d2ctl", "install"],
|
["l4d2ctl", "install"],
|
||||||
["l4d2ctl", "start", "alpha"],
|
["l4d2ctl", "start", "alpha"],
|
||||||
["l4d2ctl", "stop", "alpha"],
|
["l4d2ctl", "stop", "alpha"],
|
||||||
|
["l4d2ctl", "reset", "alpha"],
|
||||||
["l4d2ctl", "delete", "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.status_code == 302
|
||||||
assert response.headers["Location"] == f"/servers/{server_id}"
|
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