From de8613932304e9781f3b94320c3e71991e250ef8 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Wed, 6 May 2026 16:35:20 +0200 Subject: [PATCH] feat(l4d2): add l4d2ctl host command boundary --- AGENTS.md | 11 +- README.md | 6 +- .../plans/2026-04-22-l4d2-host-lib-v1.md | 13 +- .../plans/2026-04-23-l4d2-web-app-v1.md | 4 +- .../plans/2026-05-05-l4d2-host-smoke-test.md | 2 +- .../plans/2026-05-06-l4d2-cli-host-client.md | 66 +++++++ .../plans/2026-05-06-l4d2-web-queue-worker.md | 2 +- l4d2host/README.md | 11 +- l4d2host/cli.py | 30 ++++ l4d2host/tests/test_cli.py | 32 ++++ l4d2web/README.md | 1 + l4d2web/services/host_commands.py | 166 ++++++++++++++++++ l4d2web/services/job_worker.py | 2 +- l4d2web/services/l4d2_facade.py | 62 +++++-- l4d2web/tests/test_host_commands.py | 55 ++++++ l4d2web/tests/test_job_worker.py | 2 +- l4d2web/tests/test_l4d2_facade.py | 112 +++++++++++- 17 files changed, 538 insertions(+), 39 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-06-l4d2-cli-host-client.md create mode 100644 l4d2web/services/host_commands.py create mode 100644 l4d2web/tests/test_host_commands.py diff --git a/AGENTS.md b/AGENTS.md index 9d0c9cb..07fc24a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,8 @@ Do not invent architecture outside these plans unless explicitly requested. ## Current Project State -- Repo is newly initialized. -- Only planning docs exist right now. -- Implementation directories are planned, not yet created. +- `l4d2host/` and `l4d2web/` implementation directories exist. +- Implementation plans remain the source of truth for contract changes and task sequencing. ## Non-Negotiable Constraints @@ -31,12 +30,15 @@ Do not invent architecture outside these plans unless explicitly requested. ### Host library (`l4d2host` / `l4d2ctl`) -- Exposed CLI command set is fixed: +- Exposed CLI write command set is fixed: - `install` - `initialize -f ` - `start ` - `stop ` - `delete ` +- CLI read commands are allowed for web/host boundary consistency: + - `status --json` + - `logs --lines --follow/--no-follow` - Hard-coded paths under `/opt/l4d2`. - Overlays are external directories (no overlay content management here). - Fail-fast subprocess behavior; pass raw stderr; propagate return code. @@ -55,6 +57,7 @@ Do not invent architecture outside these plans unless explicitly requested. - Persist command logs in `job_logs` table (retain indefinitely). - Desired vs actual server state model. - Live logs in UI for both jobs and servers. +- Web app host operations go through `l4d2ctl` via a host command client, not direct `l4d2host` imports. - Blueprint semantics (locked): - private per user in v1 - live-linked to servers diff --git a/README.md b/README.md index 037cdf0..c378514 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,16 @@ Implementation plans are the source of truth: - Naming is strictly `l4d2` (not `l4d`). - Host library and web app are separate components. -- Host CLI commands are fixed to: +- Host CLI write commands are fixed to: - `install` - `initialize -f ` - `start ` - `stop ` - `delete ` +- Host CLI read commands are available for the web/host boundary: + - `status --json` + - `logs --lines --follow/--no-follow` +- The web app calls host operations through `l4d2ctl`, not direct `l4d2host` imports. - Runtime paths are hard-coded under `/opt/l4d2`. - Overlay handling is directory-based and externally populated. - No lock manager, no rollback, no preflight checks in host library. diff --git a/docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md b/docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md index d1b09bb..3a003c5 100644 --- a/docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md +++ b/docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md @@ -2,9 +2,9 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Build a Python `l4d2` host library and `l4d2ctl` CLI with exactly five commands (`install`, `initialize`, `start`, `stop`, `delete`) plus read APIs needed by the local web app. +**Goal:** Build a Python `l4d2` host library and `l4d2ctl` CLI with fixed write commands (`install`, `initialize`, `start`, `stop`, `delete`) plus read commands/APIs needed by the web app. -**Architecture:** Runtime paths are hard-coded under `/opt/l4d2`. Write operations are imperative and fail-fast with no lock manager, no rollback, and no preflight checks. CLI behavior remains raw/stderr-first, while library internals additionally expose callback-based streaming and read APIs (`get_instance_status`, `stream_instance_logs`) for the web app. +**Architecture:** Runtime paths are hard-coded under `/opt/l4d2`. Write operations are imperative and fail-fast with no lock manager, no rollback, and no preflight checks. CLI write behavior remains raw/stderr-first, and CLI read commands expose status/log output for the web/host boundary while library internals keep callback-based streaming and read APIs. **Tech Stack:** Python 3.12+, Typer, PyYAML, pytest, subprocess, systemd user units, fuse-overlayfs. @@ -12,7 +12,7 @@ ## Scope and Contracts -- Command surface is fixed in v1: +- Write command surface is fixed in v1: - `l4d2ctl install` - `l4d2ctl initialize -f ` - `l4d2ctl start ` @@ -32,7 +32,10 @@ - `initialize` always writes `server.cfg`; if `config` is empty/missing, `server.cfg` is empty. - `delete` is no-op success when instance/runtime directories are already missing. - CLI errors: print raw subprocess stderr and exit with subprocess return code. -- Additional read APIs for web app (no extra CLI commands): +- Read commands are allowed for web/host boundary consistency: + - `l4d2ctl status --json` + - `l4d2ctl logs --lines --follow/--no-follow` +- Additional host-local read APIs: - `get_instance_status(name)` - `stream_instance_logs(name, lines=200, follow=True)` - Blueprints are intentionally out of scope for this library; callers must resolve any blueprint linkage to a concrete YAML spec before calling `initialize`. @@ -734,7 +737,7 @@ git commit -m "docs(l4d2): finalize v1 CLI contracts and web-facing read APIs" ## Self-Review -- [ ] Spec coverage: command surface fixed, hard-coded paths, config semantics, delete no-op, callback streaming, read APIs. +- [ ] Spec coverage: write command surface fixed, read commands allowed, hard-coded paths, config semantics, delete no-op, callback streaming, read APIs. - [ ] Placeholder scan: no TODO/TBD placeholders. - [ ] Consistency: argument names (`on_stdout`, `on_stderr`, `passthrough`) are consistent across tasks. - [ ] Verification: each task contains exact test commands and expected outcomes. diff --git a/docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md b/docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md index dd680c5..80240ec 100644 --- a/docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md +++ b/docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md @@ -4,7 +4,7 @@ **Goal:** Build a local Flask web app where users create blueprints and manage L4D2 servers derived from those blueprints, with async lifecycle jobs and live logs. -**Architecture:** Run a single Flask process with Jinja templates, vendored HTMX, custom CSS, and in-process worker threads. Persist app state in SQLite with Rails-style foreign-key naming (`user_id`, `server_id`, `blueprint_id`, `overlay_id`, `job_id`). Integrate directly with `l4d2host` write/read APIs: jobs call `install/initialize/start/stop/delete` with output callbacks, while status/logs use `get_instance_status` and `stream_instance_logs`. +**Architecture:** Run a single Flask process with Jinja templates, vendored HTMX, custom CSS, and in-process worker threads. Persist app state in SQLite with Rails-style foreign-key naming (`user_id`, `server_id`, `blueprint_id`, `overlay_id`, `job_id`). Integrate with host operations through `l4d2ctl` via a host command client: jobs call `install/initialize/start/stop/delete` with output callbacks, while status/logs use `status --json` and `logs` CLI read commands. **Tech Stack:** Python 3.12+, Flask, SQLAlchemy, Alembic, pytest, vendored HTMX, custom CSS, vanilla JS (SSE). @@ -505,7 +505,7 @@ git add l4d2web/routes/server_routes.py l4d2web/tests/test_servers.py git commit -m "feat(l4d2-web): add server creation and blueprint reassignment routes" ``` -### Task 7: Add direct `l4d2host` facade and blueprint-to-spec generation +### Task 7: Add `l4d2ctl` facade and blueprint-to-spec generation **Files:** - Create: `l4d2web/services/spec_yaml.py` diff --git a/docs/superpowers/plans/2026-05-05-l4d2-host-smoke-test.md b/docs/superpowers/plans/2026-05-05-l4d2-host-smoke-test.md index b90e4ff..052f04f 100644 --- a/docs/superpowers/plans/2026-05-05-l4d2-host-smoke-test.md +++ b/docs/superpowers/plans/2026-05-05-l4d2-host-smoke-test.md @@ -297,7 +297,7 @@ Report: Task 3 evidence: - archive creation/copy: report local archive path and remote unpack path - venv/pip install: report virtualenv path and pip install status -- l4d2ctl command surface: report the five commands found in help output +- l4d2ctl command surface: report write commands plus status/log read commands found in help output Approve Task 4: run l4d2ctl install on ckn@10.0.4.128? ``` diff --git a/docs/superpowers/plans/2026-05-06-l4d2-cli-host-client.md b/docs/superpowers/plans/2026-05-06-l4d2-cli-host-client.md new file mode 100644 index 0000000..e3c4e49 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-l4d2-cli-host-client.md @@ -0,0 +1,66 @@ +# L4D2 CLI Host Client Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `l4d2web` manage the local host through `l4d2ctl` instead of importing `l4d2host` internals, so the same execution boundary can later be transported over SSH. + +**Architecture:** `l4d2host` remains the host-local implementation behind `l4d2ctl`. `l4d2web` gains a small local command runner that streams CLI stdout/stderr into jobs, supports cancellation, and parses status JSON. Hosts and overlay sync remain out of this change; the current machine is the implicit local host. + +**Tech Stack:** Python 3.12+, Typer, subprocess, Flask, SQLAlchemy, pytest. + +--- + +## File Map + +- `l4d2host/cli.py`: add read commands for status and logs. +- `l4d2host/tests/test_cli.py`: cover the expanded CLI contract. +- `l4d2web/services/host_commands.py`: new subprocess-based host command runner and cancellation exception. +- `l4d2web/services/l4d2_facade.py`: call `l4d2ctl` through `host_commands` instead of importing `l4d2host` internals. +- `l4d2web/services/job_worker.py`: catch the web-side cancellation exception. +- `l4d2web/tests/test_host_commands.py`: cover callback streaming, failures, and cancellation. +- `l4d2web/tests/test_l4d2_facade.py`: verify facade emits CLI commands and parses status. +- `l4d2web/tests/test_job_worker.py`: update cancellation imports. +- `l4d2host/README.md`, `l4d2web/README.md`, existing implementation plans: document the relaxed CLI boundary. + +## Tasks + +### Task 1: Add host CLI read commands + +- [x] Write failing tests for `l4d2ctl status --json` and `l4d2ctl logs --no-follow`. +- [x] Run `pytest l4d2host/tests/test_cli.py -q` and confirm the new tests fail because commands do not exist. +- [x] Add the `status` and `logs` commands to `l4d2host/cli.py` using existing `get_instance_status` and `stream_instance_logs` APIs. +- [x] Run `pytest l4d2host/tests/test_cli.py -q` and confirm it passes. + +### Task 2: Add web host command runner + +- [x] Write failing tests for streaming stdout/stderr callbacks, non-zero exit propagation, and cancellation. +- [x] Run `pytest l4d2web/tests/test_host_commands.py -q` and confirm failures are for the missing module. +- [x] Implement `l4d2web/services/host_commands.py` with `run_command`, `HostCommandError`, and `CommandCancelledError`. +- [x] Run `pytest l4d2web/tests/test_host_commands.py -q` and confirm it passes. + +### Task 3: Switch web facade to CLI calls + +- [x] Update facade tests so they monkeypatch `host_commands.run_command` and assert emitted `l4d2ctl` commands. +- [x] Run `pytest l4d2web/tests/test_l4d2_facade.py -q` and confirm failures show the facade still imports/calls `l4d2host` internals. +- [x] Replace direct `l4d2host` imports in `l4d2web/services/l4d2_facade.py` with CLI command calls. +- [x] Run `pytest l4d2web/tests/test_l4d2_facade.py -q` and confirm it passes. + +### Task 4: Update worker cancellation boundary + +- [x] Update job worker tests to import `CommandCancelledError` from `l4d2web.services.host_commands`. +- [x] Run `pytest l4d2web/tests/test_job_worker.py -q` and confirm failures identify the old boundary. +- [x] Update `l4d2web/services/job_worker.py` to catch the web-side cancellation exception. +- [x] Run `pytest l4d2web/tests/test_job_worker.py -q` and confirm it passes. + +### Task 5: Update docs and verify + +- [x] Update README/plan language from “fixed write commands only” to “fixed write commands plus read commands”. +- [x] Run `pytest l4d2host/tests -q` and confirm pass. +- [x] Run `pytest l4d2web/tests -q` and confirm pass. +- [x] Run `ccc index` if available so the code index reflects the boundary change. + +## Self-Review + +- Spec coverage: covers CLI read commands, web-side CLI execution, status/log parsing, cancellation, docs, and verification. +- Scope: hosts table, SSH transport, and overlay sync are explicitly excluded from this change. +- Type consistency: the web-side cancellation type is `l4d2web.services.host_commands.CommandCancelledError`; the host-side process type remains internal to `l4d2host`. diff --git a/docs/superpowers/plans/2026-05-06-l4d2-web-queue-worker.md b/docs/superpowers/plans/2026-05-06-l4d2-web-queue-worker.md index 3041443..cead2d2 100644 --- a/docs/superpowers/plans/2026-05-06-l4d2-web-queue-worker.md +++ b/docs/superpowers/plans/2026-05-06-l4d2-web-queue-worker.md @@ -2,7 +2,7 @@ > **Approval gate:** This plan may be written and refined without further approval. Do not implement code changes from this plan until the user explicitly approves implementation. -**Goal:** Complete the `l4d2web` async lifecycle queue so queued jobs are claimed, executed through the direct `l4d2host` Python APIs, logged to `job_logs`, reflected in server state, and streamed live to the UI. +**Goal:** Complete the `l4d2web` async lifecycle queue so queued jobs are claimed, executed through the `l4d2ctl` host command boundary, logged to `job_logs`, reflected in server state, and streamed live to the UI. **Architecture:** Keep the v1 single-process Flask architecture. Use DB-backed queued jobs as the durable source of truth, worker threads inside the Flask process, SQLite-safe process-local locks, and direct imports through `l4d2web.services.l4d2_facade`. Do not shell out to `l4d2ctl` from the web app. diff --git a/l4d2host/README.md b/l4d2host/README.md index 7969300..fcada2f 100644 --- a/l4d2host/README.md +++ b/l4d2host/README.md @@ -4,7 +4,7 @@ Python host library and CLI for managing L4D2 instances. ## CLI -`l4d2ctl` exposes exactly these commands in v1: +`l4d2ctl` exposes these write commands in v1: - `install` - `initialize -f ` @@ -12,6 +12,11 @@ Python host library and CLI for managing L4D2 instances. - `stop ` - `delete ` +It also exposes read commands used by the web app host boundary: + +- `status --json` +- `logs --lines --follow/--no-follow` + Subprocess failures are fail-fast. Raw stderr is written to stderr and the command exits with the same subprocess return code. ## Runtime Paths @@ -68,9 +73,9 @@ steamcmd +quit `uv` is optional deployment tooling. Debian 13 did not provide an `uv` package during the smoke test, so install it explicitly if you want to use it for faster virtualenv/dependency setup. `l4d2ctl` does not require `uv` at runtime. -## Web App Read APIs +## Host-Local Read APIs -These read APIs are provided for web app integration: +These Python read APIs back the CLI read commands and remain available for host-local callers: - `get_instance_status(name)` - `stream_instance_logs(name, lines=200, follow=True)` diff --git a/l4d2host/cli.py b/l4d2host/cli.py index b8539b1..0326d93 100644 --- a/l4d2host/cli.py +++ b/l4d2host/cli.py @@ -1,9 +1,12 @@ from pathlib import Path +import json import subprocess import typer 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 @@ -54,3 +57,30 @@ def delete(name: str) -> None: delete_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) + if json_output: + typer.echo( + json.dumps( + { + "state": instance_status.state, + "raw_active_state": instance_status.raw_active_state, + "raw_sub_state": instance_status.raw_sub_state, + } + ) + ) + return + typer.echo(instance_status.state) + + +@app.command() +def logs( + name: str, + lines: int = typer.Option(200, "--lines"), + follow: bool = typer.Option(True, "--follow/--no-follow"), +) -> None: + for line in stream_instance_logs(name, lines=lines, follow=follow): + typer.echo(line) diff --git a/l4d2host/tests/test_cli.py b/l4d2host/tests/test_cli.py index 6ae4d2a..c02c783 100644 --- a/l4d2host/tests/test_cli.py +++ b/l4d2host/tests/test_cli.py @@ -1,4 +1,6 @@ import subprocess +from types import SimpleNamespace +import json from typer.testing import CliRunner @@ -24,3 +26,33 @@ def test_cli_propagates_subprocess_return_code(monkeypatch) -> None: assert result.exit_code == 9 assert "boom" in result.stderr + + +def test_status_command_outputs_json(monkeypatch) -> None: + monkeypatch.setattr( + "l4d2host.cli.get_instance_status", + lambda name: SimpleNamespace(state="running", raw_active_state="active", raw_sub_state="running"), + raising=False, + ) + + result = CliRunner().invoke(app, ["status", "alpha", "--json"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "state": "running", + "raw_active_state": "active", + "raw_sub_state": "running", + } + + +def test_logs_command_streams_lines(monkeypatch) -> None: + monkeypatch.setattr( + "l4d2host.cli.stream_instance_logs", + lambda name, *, lines, follow: iter([f"{name}:{lines}:{follow}", "ready"]), + raising=False, + ) + + result = CliRunner().invoke(app, ["logs", "alpha", "--lines", "25", "--no-follow"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["alpha:25:False", "ready"] diff --git a/l4d2web/README.md b/l4d2web/README.md index 2cf2288..950486c 100644 --- a/l4d2web/README.md +++ b/l4d2web/README.md @@ -11,6 +11,7 @@ Flask web app for managing L4D2 servers through user-private blueprints. - Async job model with persisted command logs in `job_logs` - Desired vs actual state model - Live logs for jobs and servers via SSE endpoints +- Host operations go through `l4d2ctl` via a local host command runner, not direct `l4d2host` imports ## Frontend constraints diff --git a/l4d2web/services/host_commands.py b/l4d2web/services/host_commands.py new file mode 100644 index 0000000..340fd2c --- /dev/null +++ b/l4d2web/services/host_commands.py @@ -0,0 +1,166 @@ +from dataclasses import dataclass +import os +import signal +import subprocess +import sys +import threading +import time +from typing import Callable, Iterator, Sequence + + +@dataclass(slots=True) +class CommandResult: + returncode: int + stdout: str + stderr: str + + +class HostCommandError(subprocess.CalledProcessError): + pass + + +class CommandCancelledError(HostCommandError): + pass + + +def run_command( + cmd: Sequence[str], + *, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, + should_cancel: Callable[[], bool] | None = None, + cancel_poll_seconds: float = 0.2, + cancel_terminate_timeout: float = 2.0, +) -> CommandResult: + stdout_lines: list[str] = [] + stderr_lines: list[str] = [] + + proc = subprocess.Popen( + list(cmd), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + start_new_session=should_cancel is not None, + ) + + def emit_stderr_message(line: str) -> None: + stderr_lines.append(line) + if on_stderr is not None: + on_stderr(line) + if passthrough: + print(line, file=sys.stderr) + + def terminate_process() -> None: + emit_stderr_message("cancellation requested; terminating subprocess") + if should_cancel is not None: + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + else: + proc.terminate() + + def kill_process() -> None: + emit_stderr_message("subprocess did not exit after cancellation; killing subprocess") + if should_cancel is not None: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + else: + proc.kill() + + def pump( + stream, + sink: list[str], + callback: Callable[[str], None] | None, + output_stream, + ) -> None: + if stream is None: + return + for raw in iter(stream.readline, ""): + line = raw.rstrip("\n") + sink.append(line) + if callback is not None: + callback(line) + if passthrough: + print(line, file=output_stream) + stream.close() + + stdout_thread = threading.Thread( + target=pump, + args=(proc.stdout, stdout_lines, on_stdout, sys.stdout), + daemon=True, + ) + stderr_thread = threading.Thread( + target=pump, + args=(proc.stderr, stderr_lines, on_stderr, sys.stderr), + daemon=True, + ) + stdout_thread.start() + stderr_thread.start() + + cancelled = False + while True: + returncode = proc.poll() + if returncode is not None: + break + if should_cancel is not None and should_cancel(): + cancelled = True + terminate_process() + try: + returncode = proc.wait(timeout=cancel_terminate_timeout) + except subprocess.TimeoutExpired: + kill_process() + returncode = proc.wait() + break + time.sleep(cancel_poll_seconds) + + stdout_thread.join() + stderr_thread.join() + + result = CommandResult( + returncode=returncode, + stdout="\n".join(stdout_lines), + stderr="\n".join(stderr_lines), + ) + if cancelled: + raise CommandCancelledError( + returncode=returncode, + cmd=list(cmd), + output=result.stdout, + stderr=result.stderr, + ) + if returncode != 0: + raise HostCommandError( + returncode=returncode, + cmd=list(cmd), + output=result.stdout, + stderr=result.stderr, + ) + return result + + +def stream_command(cmd: Sequence[str]) -> Iterator[str]: + proc = subprocess.Popen( + list(cmd), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + try: + if proc.stdout is None: + return + for raw in iter(proc.stdout.readline, ""): + yield raw.rstrip("\n") + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() diff --git a/l4d2web/services/job_worker.py b/l4d2web/services/job_worker.py index 5e9dc89..7a6e81b 100644 --- a/l4d2web/services/job_worker.py +++ b/l4d2web/services/job_worker.py @@ -4,12 +4,12 @@ import subprocess import threading import time -from l4d2host.process import CommandCancelledError from sqlalchemy import func, select from sqlalchemy.orm import Session from l4d2web.db import session_scope from l4d2web.models import Job, JobLog, Server +from l4d2web.services.host_commands import CommandCancelledError TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"} diff --git a/l4d2web/services/l4d2_facade.py b/l4d2web/services/l4d2_facade.py index 6040f07..f015d87 100644 --- a/l4d2web/services/l4d2_facade.py +++ b/l4d2web/services/l4d2_facade.py @@ -1,17 +1,22 @@ +from dataclasses import dataclass import json from pathlib import Path from sqlalchemy import select -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 from l4d2web.db import session_scope from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, Server +from l4d2web.services import host_commands from l4d2web.services.spec_yaml import write_temp_spec +@dataclass(slots=True) +class ServerStatus: + state: str + raw_active_state: str + raw_sub_state: str + + def build_server_spec_payload(server: Server, blueprint: Blueprint, overlay_names: list[str]) -> dict: return { "port": server.port, @@ -42,36 +47,69 @@ def load_server_blueprint_bundle(server_id: int) -> tuple[Server, Blueprint, lis def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None: - SteamInstaller().install_or_update(on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel) + host_commands.run_command( + ["l4d2ctl", "install"], + on_stdout=on_stdout, + on_stderr=on_stderr, + should_cancel=should_cancel, + ) def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: server, blueprint, overlay_names = load_server_blueprint_bundle(server_id) spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_names)) try: - initialize_instance(server.name, spec_path, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel) + host_commands.run_command( + ["l4d2ctl", "initialize", server.name, "-f", str(spec_path)], + on_stdout=on_stdout, + on_stderr=on_stderr, + should_cancel=should_cancel, + ) finally: spec_path.unlink(missing_ok=True) def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: server, _, _ = load_server_blueprint_bundle(server_id) - start_instance(server.name, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel) + host_commands.run_command( + ["l4d2ctl", "start", server.name], + on_stdout=on_stdout, + on_stderr=on_stderr, + should_cancel=should_cancel, + ) def stop_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: server, _, _ = load_server_blueprint_bundle(server_id) - stop_instance(server.name, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel) + host_commands.run_command( + ["l4d2ctl", "stop", server.name], + on_stdout=on_stdout, + on_stderr=on_stderr, + should_cancel=should_cancel, + ) def delete_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: server, _, _ = load_server_blueprint_bundle(server_id) - delete_instance(server.name, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel) + host_commands.run_command( + ["l4d2ctl", "delete", server.name], + on_stdout=on_stdout, + on_stderr=on_stderr, + should_cancel=should_cancel, + ) -def server_status(server_name: str): - return get_instance_status(server_name) +def server_status(server_name: str) -> ServerStatus: + result = host_commands.run_command(["l4d2ctl", "status", server_name, "--json"]) + payload = json.loads(result.stdout or "{}") + return ServerStatus( + state=str(payload.get("state", "unknown")), + raw_active_state=str(payload.get("raw_active_state", "unknown")), + raw_sub_state=str(payload.get("raw_sub_state", "unknown")), + ) def stream_server_logs(server_name: str, *, lines: int = 200, follow: bool = True): - return stream_instance_logs(server_name, lines=lines, follow=follow) + command = ["l4d2ctl", "logs", server_name, "--lines", str(lines)] + command.append("--follow" if follow else "--no-follow") + return host_commands.stream_command(command) diff --git a/l4d2web/tests/test_host_commands.py b/l4d2web/tests/test_host_commands.py new file mode 100644 index 0000000..023792d --- /dev/null +++ b/l4d2web/tests/test_host_commands.py @@ -0,0 +1,55 @@ +import pytest + + +def test_run_command_streams_stdout_and_stderr_callbacks() -> None: + from l4d2web.services.host_commands import run_command + + stdout: list[str] = [] + stderr: list[str] = [] + + result = run_command( + ["python3", "-c", "import sys; print('ok'); print('warn', file=sys.stderr)"], + on_stdout=stdout.append, + on_stderr=stderr.append, + ) + + assert stdout == ["ok"] + assert stderr == ["warn"] + assert result.returncode == 0 + assert result.stdout == "ok" + assert result.stderr == "warn" + + +def test_run_command_raises_host_error_on_nonzero_exit() -> None: + from l4d2web.services.host_commands import HostCommandError, run_command + + with pytest.raises(HostCommandError) as exc_info: + run_command(["python3", "-c", "import sys; print('bad', file=sys.stderr); sys.exit(7)"]) + + assert exc_info.value.returncode == 7 + assert exc_info.value.stderr == "bad" + + +def test_run_command_raises_cancelled_error_when_cancel_requested() -> None: + from l4d2web.services.host_commands import CommandCancelledError, run_command + + stdout: list[str] = [] + + with pytest.raises(CommandCancelledError): + run_command( + ["python3", "-c", "import time; print('ready', flush=True); time.sleep(5)"], + on_stdout=stdout.append, + should_cancel=lambda: bool(stdout), + cancel_poll_seconds=0.01, + cancel_terminate_timeout=0.2, + ) + + assert stdout == ["ready"] + + +def test_stream_command_yields_stdout_lines() -> None: + from l4d2web.services.host_commands import stream_command + + lines = list(stream_command(["python3", "-c", "print('one'); print('two')"])) + + assert lines == ["one", "two"] diff --git a/l4d2web/tests/test_job_worker.py b/l4d2web/tests/test_job_worker.py index 404a5b7..e874be6 100644 --- a/l4d2web/tests/test_job_worker.py +++ b/l4d2web/tests/test_job_worker.py @@ -6,11 +6,11 @@ import subprocess import pytest from sqlalchemy import select -from l4d2host.process import CommandCancelledError from l4d2web.auth import hash_password from l4d2web.db import init_db, session_scope from l4d2web.models import Blueprint, Job, Server, User from l4d2web.services import l4d2_facade +from l4d2web.services.host_commands import CommandCancelledError from l4d2web.services.job_worker import SchedulerState, can_start, recover_stale_jobs, run_worker_once diff --git a/l4d2web/tests/test_l4d2_facade.py b/l4d2web/tests/test_l4d2_facade.py index 22c8d4e..bf11374 100644 --- a/l4d2web/tests/test_l4d2_facade.py +++ b/l4d2web/tests/test_l4d2_facade.py @@ -6,6 +6,7 @@ from l4d2web.app import create_app from l4d2web.auth import hash_password from l4d2web.db import init_db, session_scope from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, Server, User +from l4d2web.services.host_commands import CommandResult @pytest.fixture @@ -44,19 +45,114 @@ def server_with_blueprint(tmp_path, monkeypatch): return server_id -def test_initialize_uses_latest_blueprint_data(monkeypatch: pytest.MonkeyPatch, server_with_blueprint) -> None: - called: dict[str, str] = {} +def test_initialize_uses_l4d2ctl_with_latest_blueprint_data( + monkeypatch: pytest.MonkeyPatch, + server_with_blueprint, +) -> None: + calls: list[list[str]] = [] - def fake_initialize(name, spec_path, **kwargs): + def fake_run_command(cmd, **kwargs): del kwargs - called["name"] = name - called["spec"] = Path(spec_path).read_text() + calls.append(list(cmd)) + spec_path = Path(cmd[cmd.index("-f") + 1]) + assert "sv_consistency 1" in spec_path.read_text() + return CommandResult(returncode=0, stdout="", stderr="") - monkeypatch.setattr("l4d2web.services.l4d2_facade.initialize_instance", fake_initialize) + monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command) + monkeypatch.setattr( + "l4d2web.services.l4d2_facade.initialize_instance", + lambda *args, **kwargs: pytest.fail("facade must not call l4d2host.initialize_instance directly"), + raising=False, + ) from l4d2web.services.l4d2_facade import initialize_server initialize_server(server_with_blueprint) - assert called["name"] == "alpha" - assert "sv_consistency 1" in called["spec"] + assert calls[0][:3] == ["l4d2ctl", "initialize", "alpha"] + assert calls[0][3] == "-f" + + +def test_install_and_lifecycle_commands_use_l4d2ctl( + monkeypatch: pytest.MonkeyPatch, + server_with_blueprint, +) -> None: + calls: list[list[str]] = [] + + def fake_run_command(cmd, **kwargs): + del kwargs + calls.append(list(cmd)) + 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"]: + 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 + + install_runtime() + start_server(server_with_blueprint) + stop_server(server_with_blueprint) + delete_server(server_with_blueprint) + + assert calls == [ + ["l4d2ctl", "install"], + ["l4d2ctl", "start", "alpha"], + ["l4d2ctl", "stop", "alpha"], + ["l4d2ctl", "delete", "alpha"], + ] + + +def test_server_status_parses_l4d2ctl_json(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[list[str]] = [] + + def fake_run_command(cmd, **kwargs): + del kwargs + calls.append(list(cmd)) + return CommandResult( + returncode=0, + stdout='{"state":"running","raw_active_state":"active","raw_sub_state":"running"}', + stderr="", + ) + + monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command) + monkeypatch.setattr( + "l4d2web.services.l4d2_facade.get_instance_status", + lambda *args, **kwargs: pytest.fail("facade must not call l4d2host.get_instance_status directly"), + raising=False, + ) + + from l4d2web.services.l4d2_facade import server_status + + status = server_status("alpha") + + assert calls == [["l4d2ctl", "status", "alpha", "--json"]] + assert status.state == "running" + assert status.raw_active_state == "active" + assert status.raw_sub_state == "running" + + +def test_server_logs_stream_l4d2ctl_logs(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[list[str]] = [] + + def fake_stream_command(cmd): + calls.append(list(cmd)) + return iter(["one", "two"]) + + monkeypatch.setattr("l4d2web.services.host_commands.stream_command", fake_stream_command) + monkeypatch.setattr( + "l4d2web.services.l4d2_facade.stream_instance_logs", + lambda *args, **kwargs: pytest.fail("facade must not call l4d2host.stream_instance_logs directly"), + raising=False, + ) + + from l4d2web.services.l4d2_facade import stream_server_logs + + lines = list(stream_server_logs("alpha", lines=10, follow=False)) + + assert calls == [["l4d2ctl", "logs", "alpha", "--lines", "10", "--no-follow"]] + assert lines == ["one", "two"]