Compare commits
No commits in common. "91d042cf3356d2a761b0f3af4991a057d0d240fc" and "d090750a50f17f89b3cf1f49eb8236dfa291c1f6" have entirely different histories.
91d042cf33
...
d090750a50
40 changed files with 282 additions and 2526 deletions
|
|
@ -19,10 +19,6 @@ Do not invent architecture outside these plans unless explicitly requested.
|
|||
|
||||
## Non-Negotiable Constraints
|
||||
|
||||
### Workspace and tools
|
||||
|
||||
- Do not use git worktrees.
|
||||
|
||||
### Naming and boundaries
|
||||
|
||||
- Use `l4d2` naming consistently.
|
||||
|
|
@ -50,7 +46,7 @@ Do not invent architecture outside these plans unless explicitly requested.
|
|||
|
||||
- Flask + server-rendered templates + vendored HTMX.
|
||||
- No external frontend framework/dependencies.
|
||||
- Custom CSS with tokenized, consistent link and accent colors.
|
||||
- Custom CSS with consistent link color `#0F766E`.
|
||||
- Local username/password auth and `admin` flag.
|
||||
- Persist command logs in `job_logs` table (retain indefinitely).
|
||||
- Desired vs actual server state model.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
## Scope and Constraints
|
||||
|
||||
- In scope:
|
||||
- local username/password login; public signup removed by the 2026-05-06 auth-pages follow-up
|
||||
- public signup/login
|
||||
- one-time CLI admin promotion
|
||||
- admin-managed overlay catalog
|
||||
- user-private blueprints with ordered overlays
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
- no external frontend dependencies
|
||||
- HTMX vendored locally only
|
||||
- custom CSS only
|
||||
- tokenized, consistent link and accent colors
|
||||
- consistent link color `#0F766E`
|
||||
- Runtime rules:
|
||||
- single-process deployment in v1
|
||||
- periodic status refresh every 8 seconds
|
||||
|
|
@ -277,9 +277,9 @@ git commit -m "feat(l4d2-web): add sqlite schema including blueprints and job lo
|
|||
- [ ] **Step 1: Write failing auth tests**
|
||||
|
||||
```python
|
||||
def test_signup_routes_are_gone(client):
|
||||
assert client.get("/signup").status_code == 404
|
||||
assert client.post("/signup", data={"username": "alice", "password": "secret"}).status_code == 404
|
||||
def test_public_signup(client):
|
||||
r = client.post("/signup", data={"username": "alice", "password": "secret"})
|
||||
assert r.status_code == 302
|
||||
|
||||
|
||||
def test_login_sets_session(client, seed_user):
|
||||
|
|
@ -329,7 +329,7 @@ Expected: PASS.
|
|||
|
||||
```bash
|
||||
git add l4d2web/{auth.py,cli.py,app.py} l4d2web/routes/auth_routes.py l4d2web/tests/test_auth.py
|
||||
git commit -m "feat(l4d2-web): add local auth and admin bootstrap command"
|
||||
git commit -m "feat(l4d2-web): add public auth and admin bootstrap command"
|
||||
```
|
||||
|
||||
### Task 4: Implement admin overlay catalog CRUD with path safety
|
||||
|
|
@ -867,7 +867,7 @@ Expected: FAIL.
|
|||
|
||||
```css
|
||||
:root {
|
||||
--color-link: var(--color-primary);
|
||||
--color-link: #0F766E;
|
||||
--color-bg: #F6FBFA;
|
||||
--color-text: #12302B;
|
||||
--color-card: #FFFFFF;
|
||||
|
|
|
|||
|
|
@ -1,447 +0,0 @@
|
|||
# l4d2 Web Auth Pages Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Do not use git worktrees.
|
||||
|
||||
**Goal:** Add a real login page, remove signup, and redirect anonymous users back to their requested page after login when safe.
|
||||
|
||||
**Architecture:** Keep the current username/password data model and Flask session authentication. Add a small safe-redirect helper in `l4d2web/auth.py`, render login through Jinja, and keep routing changes scoped to auth decorators plus the app root route.
|
||||
|
||||
**Tech Stack:** Flask, Jinja templates, SQLAlchemy models, pytest, existing custom CSS tokens.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `l4d2web/auth.py`: owns password hashing, session helpers, current user lookup, login-required/admin-required decorators, and safe local redirect target validation.
|
||||
- `l4d2web/routes/auth_routes.py`: owns login GET/POST and logout. Signup handlers will be removed from this file.
|
||||
- `l4d2web/app.py`: owns app setup, CSRF exemptions, and public root/health routes. Root will become auth-aware.
|
||||
- `l4d2web/templates/base.html`: owns shared shell navigation. It will hide app navigation for anonymous users.
|
||||
- `l4d2web/templates/login.html`: new server-rendered login page.
|
||||
- `l4d2web/static/css/components.css`: minor form-control styling only if needed for the login form.
|
||||
- `l4d2web/tests/test_auth.py`: auth route tests for login page, signup removal, safe next redirect, and unsafe next fallback.
|
||||
- `l4d2web/tests/test_pages.py`: protected-page redirect and root redirect tests.
|
||||
- `l4d2web/tests/test_security.py`: CSRF/rate-limit regression tests after signup is removed from exemptions.
|
||||
|
||||
## Task 1: Remove Signup and Add Login Page Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/tests/test_auth.py`
|
||||
- Modify: `l4d2web/tests/test_pages.py`
|
||||
|
||||
- [ ] **Step 1: Update auth tests for login page, signup removal, and next redirects**
|
||||
|
||||
Replace `test_public_signup` in `l4d2web/tests/test_auth.py` with these tests, and update existing login test data keys only if needed:
|
||||
|
||||
```python
|
||||
def test_login_page_renders_form(client) -> None:
|
||||
response = client.get("/login")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<form method="post" action="/login"' in text
|
||||
assert 'name="username"' in text
|
||||
assert 'name="password"' in text
|
||||
assert "signup" not in text.lower()
|
||||
|
||||
|
||||
def test_login_page_drops_unsafe_encoded_next(client) -> None:
|
||||
response = client.get("/login?next=/%5Cevil.com")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'name="next"' not in text
|
||||
assert "evil.com" not in text
|
||||
|
||||
|
||||
def test_signup_routes_are_gone(client) -> None:
|
||||
assert client.get("/signup").status_code == 404
|
||||
assert client.post("/signup", data={"username": "alice", "password": "secret"}).status_code == 404
|
||||
|
||||
|
||||
def test_login_redirects_to_safe_next(client) -> None:
|
||||
with session_scope() as session:
|
||||
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
|
||||
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"username": "alice", "password": "secret", "next": "/servers"},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/servers")
|
||||
|
||||
|
||||
def test_login_ignores_unsafe_next(client) -> None:
|
||||
with session_scope() as session:
|
||||
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
|
||||
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"username": "alice", "password": "secret", "next": "https://example.com/steal"},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/dashboard")
|
||||
|
||||
|
||||
def test_login_ignores_backslash_next(client) -> None:
|
||||
with session_scope() as session:
|
||||
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
|
||||
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"username": "alice", "password": "secret", "next": "/\\evil.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/dashboard")
|
||||
|
||||
|
||||
def test_login_ignores_percent_encoded_backslash_next(client) -> None:
|
||||
with session_scope() as session:
|
||||
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
|
||||
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"username": "alice", "password": "secret", "next": "/%5Cevil.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/dashboard")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add protected-page and root redirect tests**
|
||||
|
||||
Append these tests to `l4d2web/tests/test_pages.py`:
|
||||
|
||||
```python
|
||||
def test_anonymous_protected_page_redirects_to_login_with_next(tmp_path, monkeypatch) -> None:
|
||||
db_url = f"sqlite:///{tmp_path/'anonymous-pages.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
client = app.test_client()
|
||||
|
||||
response = client.get("/servers")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/login?next=/servers")
|
||||
|
||||
|
||||
def test_root_redirects_by_auth_state(tmp_path, monkeypatch) -> None:
|
||||
db_url = f"sqlite:///{tmp_path/'root-redirect.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
client = app.test_client()
|
||||
|
||||
anonymous_response = client.get("/")
|
||||
assert anonymous_response.status_code == 302
|
||||
assert anonymous_response.headers["Location"].endswith("/login")
|
||||
|
||||
with session_scope() as session:
|
||||
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
user_id = user.id
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
|
||||
logged_in_response = client.get("/")
|
||||
assert logged_in_response.status_code == 302
|
||||
assert logged_in_response.headers["Location"].endswith("/dashboard")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests and verify they fail**
|
||||
|
||||
Run: `pytest l4d2web/tests/test_auth.py::test_login_page_renders_form l4d2web/tests/test_auth.py::test_signup_routes_are_gone l4d2web/tests/test_auth.py::test_login_redirects_to_safe_next l4d2web/tests/test_auth.py::test_login_ignores_unsafe_next l4d2web/tests/test_pages.py::test_anonymous_protected_page_redirects_to_login_with_next l4d2web/tests/test_pages.py::test_root_redirects_by_auth_state -q`
|
||||
|
||||
Expected: FAIL because `/login` is still plain text, signup routes still exist, `next` is not implemented, and `/` still returns JSON.
|
||||
|
||||
## Task 2: Implement Safe Redirects and Remove Signup
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/auth.py`
|
||||
- Modify: `l4d2web/routes/auth_routes.py`
|
||||
- Modify: `l4d2web/app.py`
|
||||
|
||||
- [ ] **Step 1: Add safe redirect helpers and next-aware decorators**
|
||||
|
||||
Update `l4d2web/auth.py` imports and add helpers near the session helpers:
|
||||
|
||||
```python
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from flask import abort, g, redirect, request, session
|
||||
```
|
||||
|
||||
```python
|
||||
def is_safe_next(target: str | None) -> bool:
|
||||
if not target:
|
||||
return False
|
||||
if not target.startswith("/"):
|
||||
return False
|
||||
if target.startswith("//"):
|
||||
return False
|
||||
if "://" in target:
|
||||
return False
|
||||
if "\\" in target:
|
||||
return False
|
||||
decoded_target = unquote(target)
|
||||
if decoded_target.startswith("//"):
|
||||
return False
|
||||
if "://" in decoded_target:
|
||||
return False
|
||||
if "\\" in decoded_target:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def login_redirect_for_current_request():
|
||||
target = request.full_path.rstrip("?")
|
||||
if is_safe_next(target):
|
||||
return redirect(f"/login?next={quote(target, safe='/')}")
|
||||
return redirect("/login")
|
||||
```
|
||||
|
||||
Change `require_login` and the anonymous branch of `require_admin` to call `login_redirect_for_current_request()`:
|
||||
|
||||
```python
|
||||
def require_login(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if current_user() is None:
|
||||
return login_redirect_for_current_request()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def require_admin(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = current_user()
|
||||
if user is None:
|
||||
return login_redirect_for_current_request()
|
||||
if not user.admin:
|
||||
abort(403)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove signup and render login template**
|
||||
|
||||
Update `l4d2web/routes/auth_routes.py` imports:
|
||||
|
||||
```python
|
||||
from flask import Blueprint, Response, redirect, render_template, request
|
||||
```
|
||||
|
||||
Update the auth import:
|
||||
|
||||
```python
|
||||
from l4d2web.auth import hash_password, is_safe_next, login_user, logout_user, verify_password
|
||||
```
|
||||
|
||||
Delete the `@bp.get("/signup")` and `@bp.post("/signup")` route functions.
|
||||
|
||||
Replace `login_form` with:
|
||||
|
||||
```python
|
||||
@bp.get("/login")
|
||||
def login_form() -> str:
|
||||
next_target = request.args.get("next", "")
|
||||
return render_template("login.html", next_target=next_target if is_safe_next(next_target) else "")
|
||||
```
|
||||
|
||||
Replace the final line of `login` with a safe redirect target:
|
||||
|
||||
```python
|
||||
next_target = request.form.get("next", "")
|
||||
return redirect(next_target if is_safe_next(next_target) else "/dashboard")
|
||||
```
|
||||
|
||||
Keep the existing rate limiting and invalid credential behavior unchanged.
|
||||
|
||||
- [ ] **Step 3: Update CSRF exemptions and root route**
|
||||
|
||||
In `l4d2web/app.py`, update imports:
|
||||
|
||||
```python
|
||||
from flask import Flask, Response, jsonify, redirect, request, session
|
||||
|
||||
from l4d2web.auth import current_user, load_current_user
|
||||
```
|
||||
|
||||
Change the CSRF exemptions to remove signup:
|
||||
|
||||
```python
|
||||
CSRF_EXEMPT_PATHS={"/login", "/health"},
|
||||
```
|
||||
|
||||
Replace the root route with:
|
||||
|
||||
```python
|
||||
@app.get("/")
|
||||
def root():
|
||||
if current_user() is None:
|
||||
return redirect("/login")
|
||||
return redirect("/dashboard")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify they still fail only for missing template/nav**
|
||||
|
||||
Run: `pytest l4d2web/tests/test_auth.py::test_login_page_renders_form l4d2web/tests/test_auth.py::test_signup_routes_are_gone l4d2web/tests/test_auth.py::test_login_redirects_to_safe_next l4d2web/tests/test_auth.py::test_login_ignores_unsafe_next l4d2web/tests/test_pages.py::test_anonymous_protected_page_redirects_to_login_with_next l4d2web/tests/test_pages.py::test_root_redirects_by_auth_state -q`
|
||||
|
||||
Expected: FAIL only because `login.html` does not exist or the rendered page does not yet contain the expected form markup.
|
||||
|
||||
## Task 3: Add Login Template and Anonymous Shell Behavior
|
||||
|
||||
**Files:**
|
||||
- Create: `l4d2web/templates/login.html`
|
||||
- Modify: `l4d2web/templates/base.html`
|
||||
- Modify: `l4d2web/static/css/components.css`
|
||||
|
||||
- [ ] **Step 1: Hide main app navigation for anonymous users**
|
||||
|
||||
Update the header section in `l4d2web/templates/base.html` so the main section links render only for logged-in users:
|
||||
|
||||
```html
|
||||
<nav class="primary-nav" aria-label="Main navigation">
|
||||
<a class="brand" href="{{ '/dashboard' if g.user else '/login' }}">left4me</a>
|
||||
{% if g.user %}
|
||||
<a href="/servers">servers</a>
|
||||
<a href="/blueprints">blueprints</a>
|
||||
<a href="/overlays">overlays</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
```
|
||||
|
||||
Keep the existing account nav wrapped in `{% if g.user %}`.
|
||||
|
||||
- [ ] **Step 2: Create the login template**
|
||||
|
||||
Create `l4d2web/templates/login.html`:
|
||||
|
||||
```html
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Log In | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel auth-panel">
|
||||
<h1>Log in to left4me</h1>
|
||||
<p class="muted">Use your local account to manage Left 4 Dead 2 servers.</p>
|
||||
|
||||
<form method="post" action="/login" class="stack">
|
||||
{% if next_target %}<input type="hidden" name="next" value="{{ next_target }}">{% endif %}
|
||||
<label>
|
||||
Username
|
||||
<input name="username" autocomplete="username" required autofocus>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit">log in</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add minimal form styling if needed**
|
||||
|
||||
Append to `l4d2web/static/css/components.css`:
|
||||
|
||||
```css
|
||||
label {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--color-surface);
|
||||
border: var(--line);
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--color-text);
|
||||
padding: var(--space-s) var(--space-m);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus,
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
max-width: 28rem;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify they pass**
|
||||
|
||||
Run: `pytest l4d2web/tests/test_auth.py::test_login_page_renders_form l4d2web/tests/test_auth.py::test_signup_routes_are_gone l4d2web/tests/test_auth.py::test_login_redirects_to_safe_next l4d2web/tests/test_auth.py::test_login_ignores_unsafe_next l4d2web/tests/test_pages.py::test_anonymous_protected_page_redirects_to_login_with_next l4d2web/tests/test_pages.py::test_root_redirects_by_auth_state -q`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit auth page implementation**
|
||||
|
||||
```bash
|
||||
git add l4d2web/auth.py l4d2web/routes/auth_routes.py l4d2web/app.py l4d2web/templates/base.html l4d2web/templates/login.html l4d2web/static/css/components.css l4d2web/tests/test_auth.py l4d2web/tests/test_pages.py
|
||||
git commit -m "feat(l4d2-web): add login page and safe redirects"
|
||||
```
|
||||
|
||||
## Task 4: Security Regression and Full Web Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/tests/test_security.py` only if existing tests need path-specific updates.
|
||||
- Modify: `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md` only if stale public-signup requirements should be corrected after implementation.
|
||||
- Modify: `AGENTS.md` only if the auth contract must be changed from public signup to admin-created local users.
|
||||
|
||||
- [ ] **Step 1: Run security tests**
|
||||
|
||||
Run: `pytest l4d2web/tests/test_security.py -q`
|
||||
|
||||
Expected: PASS. `test_csrf_required` should still prove protected POSTs require CSRF. `test_login_rate_limit` should still pass with `/login` exempt from CSRF.
|
||||
|
||||
- [ ] **Step 2: Run page tests**
|
||||
|
||||
Run: `pytest l4d2web/tests/test_pages.py -q`
|
||||
|
||||
Expected: PASS. Existing logged-in shell tests should still see app navigation; new anonymous tests should see login redirects.
|
||||
|
||||
- [ ] **Step 3: Run auth tests**
|
||||
|
||||
Run: `pytest l4d2web/tests/test_auth.py -q`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run full web test suite**
|
||||
|
||||
Run: `pytest l4d2web/tests -q`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit test/doc cleanup if needed**
|
||||
|
||||
If Task 4 required changes, commit them:
|
||||
|
||||
```bash
|
||||
git add l4d2web/tests/test_security.py docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md AGENTS.md
|
||||
git commit -m "test(l4d2-web): cover auth redirect regressions"
|
||||
```
|
||||
|
||||
If no files changed in Task 4, do not create an empty commit.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] Run `pytest l4d2web/tests -q` and confirm all tests pass.
|
||||
- [ ] Run `git status --short` and confirm only intentional changes remain.
|
||||
- [ ] Report exact command results and commits created.
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
# L4D2 Web Queue Worker Implementation Plan
|
||||
|
||||
> **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.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Current Gap
|
||||
|
||||
- Server lifecycle routes create `Job(state="queued")` rows.
|
||||
- `l4d2web.services.job_worker` has scheduler helpers, stale recovery, command-log append, and actual-state refresh helpers.
|
||||
- No worker claims queued jobs.
|
||||
- No code dispatches queued operations to `l4d2_facade`.
|
||||
- No command callbacks persist live stdout/stderr while jobs run.
|
||||
- Job-log SSE currently replays existing rows once and does not live-follow new rows.
|
||||
- Job-log SSE emits `stdout`/`stderr` custom events, while `static/js/sse.js` only handles default messages.
|
||||
- No web route currently enqueues global `install` jobs.
|
||||
|
||||
---
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
- Queue execution uses direct Python imports through `l4d2web.services.l4d2_facade`.
|
||||
- The queue is DB-backed, not an in-memory `queue.Queue`.
|
||||
- Worker threads are in-process daemon threads.
|
||||
- SQLite concurrency is protected with process-local locks; no distributed lock manager is added.
|
||||
- Workers are not started during normal tests.
|
||||
- `POST /admin/install` is added as the admin-only runtime install/update entry point.
|
||||
- `install` jobs have `server_id=None` and are globally exclusive.
|
||||
- Server-specific jobs do not overlap on the same `server_id`.
|
||||
- Different server jobs can run concurrently when no install job is running.
|
||||
- A web `start` job applies the live-linked blueprint before start by running `initialize_server(server_id)` and then `start_server(server_id)`. This satisfies “blueprint updates apply on next action.”
|
||||
- `delete` removes the host instance/runtime through `l4d2host`; it does not delete the web `Server` row in v1.
|
||||
- Command log rows are retained indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend Worker Tests First
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/tests/test_job_worker.py`
|
||||
- Modify as needed: `l4d2web/tests/test_job_logs.py`
|
||||
|
||||
Add tests that verify the worker behavior without touching real systemd, Steam, or `/opt/l4d2`. Use monkeypatched `l4d2web.services.l4d2_facade` functions.
|
||||
|
||||
Required coverage:
|
||||
|
||||
- `run_worker_once()` claims the oldest runnable queued job.
|
||||
- A successful server job transitions `queued -> running -> succeeded` and sets `exit_code=0`, `started_at`, `finished_at`, and `updated_at`.
|
||||
- A successful job persists stdout/stderr callback lines in `job_logs`.
|
||||
- A `subprocess.CalledProcessError` transitions the job to `failed` and stores `exit_code=exc.returncode`.
|
||||
- An unexpected exception transitions the job to `failed` with `exit_code=1`.
|
||||
- Same-server jobs do not overlap.
|
||||
- Different-server jobs can be claimed concurrently by separate worker passes.
|
||||
- An `install` job is not claimed while any server job is running.
|
||||
- Server jobs are not claimed while an `install` job is running.
|
||||
- Startup recovery marks stale `running` jobs as `failed`.
|
||||
- Actual server state is refreshed after server-specific lifecycle jobs.
|
||||
- `Server.last_error` is cleared on success and set on failure.
|
||||
|
||||
Verification command:
|
||||
|
||||
```bash
|
||||
pytest l4d2web/tests/test_job_worker.py -q
|
||||
```
|
||||
|
||||
Expected before implementation: FAIL.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Implement Queue Claiming And Job Execution
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/services/job_worker.py`
|
||||
|
||||
Add worker-core functions:
|
||||
|
||||
- `build_scheduler_state(session) -> SchedulerState`
|
||||
- `claim_next_job() -> int | None`
|
||||
- `run_worker_once() -> bool`
|
||||
- `run_job(job_id: int) -> None`
|
||||
- `finish_job(job_id: int, state: str, exit_code: int | None, error: str = "") -> None`
|
||||
- `append_job_log_line(job_id: int, stream: str, line: str, max_chars: int = 4096) -> int`
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- Use a module-level claim lock around scheduler-state construction, queued-job selection, and `queued -> running` transition.
|
||||
- Commit the `running` transition before executing any host operation.
|
||||
- Do not keep a DB session open while a host operation runs.
|
||||
- Use a module-level log lock around `append_job_log()` so concurrent stdout/stderr callback threads cannot duplicate `seq` values.
|
||||
- Recompute scheduler state from `running` jobs in the DB, not from only in-memory state.
|
||||
- Select queued jobs by `created_at`, then `id` for deterministic order.
|
||||
- Skip malformed server operations with no `server_id` by failing the job cleanly.
|
||||
- Treat unknown operations as failed jobs, not worker-thread crashes.
|
||||
|
||||
Operation dispatch:
|
||||
|
||||
```text
|
||||
install -> l4d2_facade.install_runtime(...)
|
||||
initialize -> l4d2_facade.initialize_server(server_id, ...)
|
||||
start -> l4d2_facade.initialize_server(server_id, ...), then l4d2_facade.start_server(server_id, ...)
|
||||
stop -> l4d2_facade.stop_server(server_id, ...)
|
||||
delete -> l4d2_facade.delete_server(server_id, ...)
|
||||
```
|
||||
|
||||
Failure handling:
|
||||
|
||||
- `subprocess.CalledProcessError`: append remaining stderr if useful, fail with `exit_code=returncode`.
|
||||
- Any other exception: append exception text to stderr, fail with `exit_code=1`.
|
||||
- Never let a job exception kill the worker loop.
|
||||
|
||||
Verification command:
|
||||
|
||||
```bash
|
||||
pytest l4d2web/tests/test_job_worker.py -q
|
||||
```
|
||||
|
||||
Expected after implementation: PASS.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add Worker Thread Startup
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/config.py`
|
||||
- Modify: `l4d2web/app.py`
|
||||
- Modify: `l4d2web/services/job_worker.py`
|
||||
- Modify: `l4d2web/tests/test_job_worker.py`
|
||||
|
||||
Add config:
|
||||
|
||||
```python
|
||||
"JOB_WORKER_ENABLED": True
|
||||
"JOB_WORKER_POLL_SECONDS": 1
|
||||
```
|
||||
|
||||
Add worker lifecycle functions:
|
||||
|
||||
- `start_job_workers(app) -> None`
|
||||
- `worker_loop(app, poll_seconds: float) -> None`
|
||||
|
||||
Startup behavior:
|
||||
|
||||
- `create_app()` still calls `recover_stale_jobs()`.
|
||||
- After recovery, `create_app()` starts workers only when enabled and not in `TESTING`.
|
||||
- Guard against duplicate worker startup in the same process.
|
||||
- Worker threads run as daemon threads.
|
||||
- Each worker loop uses `app.app_context()` around `run_worker_once()`.
|
||||
- If no job was run, sleep for `JOB_WORKER_POLL_SECONDS`.
|
||||
|
||||
Testing requirements:
|
||||
|
||||
- Tests should not accidentally start real background workers.
|
||||
- Add a focused startup test with monkeypatched `start_job_workers` if needed.
|
||||
|
||||
Verification command:
|
||||
|
||||
```bash
|
||||
pytest l4d2web/tests/test_job_worker.py -q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Make Job Log SSE Live-Follow
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/routes/job_routes.py`
|
||||
- Modify: `l4d2web/static/js/sse.js`
|
||||
- Modify: `l4d2web/tests/test_job_logs.py`
|
||||
|
||||
Route behavior:
|
||||
|
||||
- Authorize the job before streaming.
|
||||
- Replay rows with `seq > last_seq` up to `JOB_LOG_REPLAY_LIMIT`.
|
||||
- Continue polling for new rows while the job is not terminal.
|
||||
- Close the stream after all available logs are sent and the job state is terminal.
|
||||
- Keep emitting `id: <seq>` so EventSource can resume.
|
||||
- Keep `event: stdout` and `event: stderr` for job logs.
|
||||
|
||||
JS behavior:
|
||||
|
||||
- Keep handling default server-log messages via `source.onmessage`.
|
||||
- Also register `stdout` and `stderr` listeners that append job-log lines to the same element.
|
||||
- Prefix custom job-log events with the stream name only if useful for readability.
|
||||
|
||||
Terminal states:
|
||||
|
||||
```text
|
||||
succeeded
|
||||
failed
|
||||
cancelled
|
||||
```
|
||||
|
||||
`cancelled` is reserved for future use and does not require cancellation support in this task.
|
||||
|
||||
Verification command:
|
||||
|
||||
```bash
|
||||
pytest l4d2web/tests/test_job_logs.py -q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add Admin Runtime Install Action
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/routes/page_routes.py`
|
||||
- Modify: `l4d2web/templates/admin.html`
|
||||
- Modify: `l4d2web/tests/test_pages.py` or add a focused admin route test
|
||||
|
||||
Behavior:
|
||||
|
||||
- `POST /admin/install` requires `@require_admin`.
|
||||
- Creates `Job(user_id=current_admin.id, server_id=None, operation="install", state="queued")`.
|
||||
- Redirects to `/admin/jobs`.
|
||||
- Non-admin logged-in users receive `403`.
|
||||
- Anonymous users are redirected to login.
|
||||
- Admin page shows a CSRF-protected form/button for runtime install/update.
|
||||
|
||||
Verification command:
|
||||
|
||||
```bash
|
||||
pytest l4d2web/tests/test_pages.py -q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Full Verification And Review
|
||||
|
||||
Run focused suites first:
|
||||
|
||||
```bash
|
||||
pytest l4d2web/tests/test_job_worker.py -q
|
||||
pytest l4d2web/tests/test_job_logs.py -q
|
||||
pytest l4d2web/tests/test_pages.py -q
|
||||
```
|
||||
|
||||
Then run the full web suite:
|
||||
|
||||
```bash
|
||||
pytest l4d2web/tests -q
|
||||
```
|
||||
|
||||
Refresh the code index after implementation:
|
||||
|
||||
```bash
|
||||
ccc index
|
||||
```
|
||||
|
||||
Request a final read-only review focused on:
|
||||
|
||||
- queue claiming races
|
||||
- duplicate worker startup
|
||||
- job-log sequence ordering
|
||||
- error handling and `last_error`
|
||||
- live SSE behavior
|
||||
- `start` applying blueprint updates before host start
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
Use small commits after passing relevant tests:
|
||||
|
||||
1. `feat(l4d2-web): execute queued lifecycle jobs`
|
||||
2. `feat(l4d2-web): live-follow queued job logs`
|
||||
3. `feat(l4d2-web): add admin runtime install job`
|
||||
|
||||
Do not commit unless the user explicitly asks for commits.
|
||||
|
||||
---
|
||||
|
||||
## Open Approval Gate
|
||||
|
||||
Before modifying implementation files, ask the user for explicit approval to proceed with the queue-worker implementation.
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
# l4d2 Web Auth Pages Design
|
||||
|
||||
Date: 2026-05-06
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the current plain-text login/signup placeholders with a small, functional authentication UI for the Flask web app. Keep the current username/password schema for now, remove public signup, and redirect users back to the page they originally wanted after login when that target is safe.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Real server-rendered `/login` page using the existing neutral shell and CSS tokens.
|
||||
- Username/password login using the existing `users.username` and `users.password_digest` fields.
|
||||
- Removal of `/signup` handlers, links, and CSRF exemptions.
|
||||
- Redirect anonymous protected requests to `/login?next=<wanted-path>`.
|
||||
- Redirect successful logins to a safe local `next` target, otherwise `/dashboard`.
|
||||
- Redirect `/` to `/login` for anonymous users and `/dashboard` for logged-in users.
|
||||
- Keep `/health` public for monitoring.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Email-based login.
|
||||
- Steam login.
|
||||
- Account self-registration.
|
||||
- Password reset or password change flows.
|
||||
- User invitation flows.
|
||||
- Schema changes to split users, credentials, and external identities.
|
||||
|
||||
## Routing Behavior
|
||||
|
||||
`GET /login` renders the login page. The page accepts an optional `next` query parameter and preserves it in a hidden form field so a successful POST can return the user to the requested page.
|
||||
|
||||
`POST /login` validates the username and password with the existing password hash helpers. On success, it stores `session["user_id"]` and redirects to the safe local `next` value if present. If `next` is missing, empty, or unsafe, it redirects to `/dashboard`.
|
||||
|
||||
`GET /signup` and `POST /signup` are removed. They should return Flask's normal `404 Not Found` response.
|
||||
|
||||
`GET /` redirects to `/login` for anonymous users and `/dashboard` for logged-in users.
|
||||
|
||||
Protected page decorators keep their current authorization semantics, but anonymous redirects include the requested path in `next`. Logged-in non-admin users still receive `403 Forbidden` for admin-only pages.
|
||||
|
||||
## Safe Redirect Rules
|
||||
|
||||
A `next` target is safe only when it is a local absolute path:
|
||||
|
||||
- It must start with `/`.
|
||||
- It must not start with `//`.
|
||||
- It must not contain a URL scheme such as `https://`.
|
||||
- It must not contain backslashes, which browsers may normalize into path separators.
|
||||
- The same checks apply after one percent-decoding pass so encoded backslashes and encoded protocol-relative paths are rejected.
|
||||
|
||||
Unsafe values are ignored and replaced with `/dashboard`. This avoids open redirects while keeping the implementation simple.
|
||||
|
||||
## UI Behavior
|
||||
|
||||
The login page should be minimal and consistent with the current admin-console UI:
|
||||
|
||||
- Brand/title: `left4me`.
|
||||
- Short explanatory text.
|
||||
- Fields: `username` and `password`.
|
||||
- Submit button: `log in`.
|
||||
- No signup link.
|
||||
|
||||
The shared shell should not show main application navigation to anonymous users. Anonymous users may see the brand only. Logged-in users keep the existing navigation and account controls.
|
||||
|
||||
## Data Model
|
||||
|
||||
No schema change is planned for this iteration. `User.username` remains the local login identifier. This intentionally defers email-based and Steam-based identity modeling until those flows are designed.
|
||||
|
||||
Future Steam login should use a separate identity model rather than forcing Steam users into an email/password shape, but that is not part of this change.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Invalid credentials keep the current simple behavior: `401 invalid credentials`.
|
||||
|
||||
Missing username or password also remain simple response errors. The first UI pass does not need inline validation messages or flashed form errors.
|
||||
|
||||
Rate limiting on `POST /login` remains unchanged.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests should cover:
|
||||
|
||||
- `GET /login` renders a form with username and password inputs.
|
||||
- `GET /signup` returns `404`.
|
||||
- `POST /signup` returns `404` rather than being intercepted by CSRF handling.
|
||||
- Anonymous protected pages redirect to `/login?next=<path>`.
|
||||
- A successful login with a safe `next` redirects to that target.
|
||||
- A successful login with an unsafe `next` redirects to `/dashboard`.
|
||||
- A successful login with a backslash-containing `next` redirects to `/dashboard`.
|
||||
- A successful login with a percent-encoded backslash in `next` redirects to `/dashboard`.
|
||||
- Anonymous `/` redirects to `/login`.
|
||||
- Logged-in `/` redirects to `/dashboard`.
|
||||
- Non-admin users still receive `403` for admin pages.
|
||||
|
||||
## Verification
|
||||
|
||||
Run these checks after implementation:
|
||||
|
||||
```bash
|
||||
pytest l4d2web/tests/test_auth.py -q
|
||||
pytest l4d2web/tests/test_pages.py -q
|
||||
pytest l4d2web/tests/test_security.py -q
|
||||
pytest l4d2web/tests -q
|
||||
```
|
||||
|
|
@ -4,7 +4,7 @@ Flask web app for managing L4D2 servers through user-private blueprints.
|
|||
|
||||
## Key v1 behaviors
|
||||
|
||||
- Local username/password login; no public signup
|
||||
- Public signup/login with local username/password
|
||||
- Admin-managed overlay catalog
|
||||
- Private blueprints per user
|
||||
- Server creation from blueprints (live-linked; no per-server blueprint overrides)
|
||||
|
|
@ -17,7 +17,7 @@ Flask web app for managing L4D2 servers through user-private blueprints.
|
|||
- Server-rendered templates (Jinja)
|
||||
- Vendored HTMX (`static/vendor/htmx.min.js`)
|
||||
- Custom CSS only
|
||||
- Tokenized, consistent link and accent colors
|
||||
- Consistent link color: `#0F766E`
|
||||
|
||||
## Development
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import os
|
||||
import secrets
|
||||
|
||||
from flask import Flask, Response, jsonify, redirect, request, session
|
||||
from flask import Flask, Response, jsonify, request, session
|
||||
|
||||
from l4d2web.auth import current_user, load_current_user
|
||||
from l4d2web.auth import load_current_user
|
||||
from l4d2web.cli import register_cli
|
||||
from l4d2web.config import DEFAULT_CONFIG
|
||||
from l4d2web.db import init_db
|
||||
|
|
@ -15,7 +15,7 @@ from l4d2web.routes.log_routes import bp as log_bp
|
|||
from l4d2web.routes.overlay_routes import bp as overlay_bp
|
||||
from l4d2web.routes.page_routes import bp as page_bp
|
||||
from l4d2web.routes.server_routes import bp as server_bp
|
||||
from l4d2web.services.job_worker import recover_stale_jobs, start_job_workers
|
||||
from l4d2web.services.job_worker import recover_stale_jobs
|
||||
|
||||
|
||||
def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
||||
|
|
@ -24,7 +24,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
|||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
CSRF_EXEMPT_PATHS={"/login", "/health"},
|
||||
CSRF_EXEMPT_PATHS={"/login", "/signup", "/health"},
|
||||
)
|
||||
if test_config is not None:
|
||||
app.config.update(test_config)
|
||||
|
|
@ -40,9 +40,6 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
|||
if request.method not in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||
return None
|
||||
|
||||
if request.endpoint is None:
|
||||
return None
|
||||
|
||||
if request.path in app.config["CSRF_EXEMPT_PATHS"]:
|
||||
return None
|
||||
|
||||
|
|
@ -63,8 +60,6 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
|||
if app.config.get("TESTING"):
|
||||
reset_login_rate_limits()
|
||||
recover_stale_jobs()
|
||||
if app.config.get("JOB_WORKER_ENABLED") and not app.config.get("TESTING"):
|
||||
start_job_workers(app)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
|
|
@ -72,8 +67,6 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
|||
|
||||
@app.get("/")
|
||||
def root():
|
||||
if current_user() is None:
|
||||
return redirect("/login")
|
||||
return redirect("/dashboard")
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
return app
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
from functools import wraps
|
||||
from typing import Callable, TypeVar
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from flask import abort, g, redirect, request, session
|
||||
from flask import abort, g, redirect, session
|
||||
from sqlalchemy import select
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
|
|
@ -42,39 +41,11 @@ def logout_user() -> None:
|
|||
session.pop("user_id", None)
|
||||
|
||||
|
||||
def is_safe_next(target: str | None) -> bool:
|
||||
if not target:
|
||||
return False
|
||||
if not target.startswith("/"):
|
||||
return False
|
||||
if target.startswith("//"):
|
||||
return False
|
||||
if "://" in target:
|
||||
return False
|
||||
if "\\" in target:
|
||||
return False
|
||||
decoded_target = unquote(target)
|
||||
if decoded_target.startswith("//"):
|
||||
return False
|
||||
if "://" in decoded_target:
|
||||
return False
|
||||
if "\\" in decoded_target:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def login_redirect_for_current_request():
|
||||
target = request.full_path.rstrip("?")
|
||||
if is_safe_next(target):
|
||||
return redirect(f"/login?next={quote(target, safe='/')}")
|
||||
return redirect("/login")
|
||||
|
||||
|
||||
def require_login(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if current_user() is None:
|
||||
return login_redirect_for_current_request()
|
||||
return redirect("/login")
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
|
@ -85,7 +56,7 @@ def require_admin(func: F) -> F:
|
|||
def wrapper(*args, **kwargs):
|
||||
user = current_user()
|
||||
if user is None:
|
||||
return login_redirect_for_current_request()
|
||||
return redirect("/login")
|
||||
if not user.admin:
|
||||
abort(403)
|
||||
return func(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ DEFAULT_CONFIG: dict[str, object] = {
|
|||
"DATABASE_URL": "sqlite:///l4d2web.db",
|
||||
"STATUS_REFRESH_SECONDS": 8,
|
||||
"JOB_WORKER_THREADS": 4,
|
||||
"JOB_WORKER_ENABLED": True,
|
||||
"JOB_WORKER_POLL_SECONDS": 1,
|
||||
"JOB_LOG_REPLAY_LIMIT": 2000,
|
||||
"JOB_LOG_LINE_MAX_CHARS": 4096,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import time
|
||||
|
||||
from flask import Blueprint, Response, redirect, render_template, request
|
||||
from flask import Blueprint, Response, request, redirect
|
||||
from sqlalchemy import select
|
||||
|
||||
from l4d2web.auth import is_safe_next, login_user, logout_user, verify_password
|
||||
from l4d2web.auth import hash_password, login_user, logout_user, verify_password
|
||||
from l4d2web.db import session_scope
|
||||
from l4d2web.models import User
|
||||
|
||||
|
|
@ -29,10 +29,33 @@ def is_login_rate_limited(remote_addr: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
@bp.get("/signup")
|
||||
def signup_form() -> Response:
|
||||
return Response("signup", mimetype="text/plain")
|
||||
|
||||
|
||||
@bp.post("/signup")
|
||||
def signup() -> Response:
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
if not username or not password:
|
||||
return Response("missing credentials", status=400)
|
||||
|
||||
with session_scope() as db:
|
||||
existing = db.scalar(select(User).where(User.username == username))
|
||||
if existing is not None:
|
||||
return Response("username already exists", status=409)
|
||||
user = User(username=username, password_digest=hash_password(password), admin=False)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
login_user(user.id)
|
||||
|
||||
return redirect("/dashboard")
|
||||
|
||||
|
||||
@bp.get("/login")
|
||||
def login_form() -> str:
|
||||
next_target = request.args.get("next", "")
|
||||
return render_template("login.html", next_target=next_target if is_safe_next(next_target) else "")
|
||||
def login_form() -> Response:
|
||||
return Response("login", mimetype="text/plain")
|
||||
|
||||
|
||||
@bp.post("/login")
|
||||
|
|
@ -49,8 +72,7 @@ def login() -> Response:
|
|||
return Response("invalid credentials", status=401)
|
||||
login_user(user.id)
|
||||
LOGIN_ATTEMPTS_BY_IP.pop(remote_addr, None)
|
||||
next_target = request.form.get("next", "")
|
||||
return redirect(next_target if is_safe_next(next_target) else "/dashboard")
|
||||
return redirect("/dashboard")
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
|
||||
from flask import Blueprint, Response, jsonify, redirect, request
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from l4d2web.auth import current_user, require_login
|
||||
|
|
@ -12,88 +12,39 @@ from l4d2web.models import BlueprintOverlay, Server
|
|||
bp = Blueprint("blueprint", __name__)
|
||||
|
||||
|
||||
def split_textarea_lines(raw: str) -> list[str]:
|
||||
return [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def ordered_overlay_ids_from_form() -> list[int]:
|
||||
ordered = []
|
||||
for fallback_position, value in enumerate(request.form.getlist("overlay_ids")):
|
||||
if not value:
|
||||
continue
|
||||
overlay_id = int(value)
|
||||
raw_position = request.form.get(f"overlay_position_{overlay_id}", "").strip()
|
||||
try:
|
||||
position = int(raw_position)
|
||||
except ValueError:
|
||||
position = fallback_position + 1
|
||||
ordered.append((position, fallback_position, overlay_id))
|
||||
return [overlay_id for _, _, overlay_id in sorted(ordered)]
|
||||
|
||||
|
||||
def replace_blueprint_overlays(db, blueprint_id: int, overlay_ids: list[int]) -> None:
|
||||
db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint_id))
|
||||
for position, overlay_id in enumerate(overlay_ids):
|
||||
db.add(BlueprintOverlay(blueprint_id=blueprint_id, overlay_id=overlay_id, position=position))
|
||||
|
||||
|
||||
@bp.post("/blueprints")
|
||||
@require_login
|
||||
def create_blueprint() -> Response:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
name = str(payload.get("name", "")).strip()
|
||||
arguments = [str(item) for item in payload.get("arguments", [])]
|
||||
config = [str(item) for item in payload.get("config", [])]
|
||||
overlay_ids = [int(item) for item in payload.get("overlay_ids", [])]
|
||||
json_response = True
|
||||
else:
|
||||
name = request.form.get("name", "").strip()
|
||||
arguments = split_textarea_lines(request.form.get("arguments", ""))
|
||||
config = split_textarea_lines(request.form.get("config", ""))
|
||||
overlay_ids = ordered_overlay_ids_from_form()
|
||||
json_response = False
|
||||
|
||||
name = str(payload.get("name", "")).strip()
|
||||
if not name:
|
||||
return Response("name is required", status=400)
|
||||
|
||||
with session_scope() as db:
|
||||
blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config))
|
||||
blueprint = BlueprintModel(
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
arguments=json.dumps(payload.get("arguments", [])),
|
||||
config=json.dumps(payload.get("config", [])),
|
||||
)
|
||||
db.add(blueprint)
|
||||
db.flush()
|
||||
replace_blueprint_overlays(db, blueprint.id, overlay_ids)
|
||||
|
||||
for position, overlay_id in enumerate(payload.get("overlay_ids", [])):
|
||||
db.add(
|
||||
BlueprintOverlay(
|
||||
blueprint_id=blueprint.id,
|
||||
overlay_id=int(overlay_id),
|
||||
position=position,
|
||||
)
|
||||
)
|
||||
|
||||
blueprint_id = blueprint.id
|
||||
|
||||
if json_response:
|
||||
return jsonify({"id": blueprint_id}), 201
|
||||
return redirect(f"/blueprints/{blueprint_id}")
|
||||
|
||||
|
||||
@bp.post("/blueprints/<int:blueprint_id>")
|
||||
@require_login
|
||||
def update_blueprint_form(blueprint_id: int) -> Response:
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
name = request.form.get("name", "").strip()
|
||||
if not name:
|
||||
return Response("name is required", status=400)
|
||||
|
||||
with session_scope() as db:
|
||||
blueprint = db.scalar(
|
||||
select(BlueprintModel).where(BlueprintModel.id == blueprint_id, BlueprintModel.user_id == user.id)
|
||||
)
|
||||
if blueprint is None:
|
||||
return Response(status=404)
|
||||
|
||||
blueprint.name = name
|
||||
blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", "")))
|
||||
blueprint.config = json.dumps(split_textarea_lines(request.form.get("config", "")))
|
||||
replace_blueprint_overlays(db, blueprint.id, ordered_overlay_ids_from_form())
|
||||
|
||||
return redirect(f"/blueprints/{blueprint_id}")
|
||||
return jsonify({"id": blueprint_id}), 201
|
||||
|
||||
|
||||
@bp.delete("/blueprints/<int:blueprint_id>")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import time
|
||||
|
||||
from flask import Blueprint, Response, current_app, request
|
||||
from sqlalchemy import select
|
||||
|
||||
|
|
@ -9,14 +7,6 @@ from l4d2web.models import Job, JobLog
|
|||
|
||||
|
||||
bp = Blueprint("job", __name__)
|
||||
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
|
||||
|
||||
|
||||
def format_sse_event(seq: int, event: str, data: str) -> str:
|
||||
lines = [f"id: {seq}", f"event: {event}"]
|
||||
for line in data.splitlines() or [""]:
|
||||
lines.append(f"data: {line}")
|
||||
return "\n".join(lines) + "\n\n"
|
||||
|
||||
|
||||
@bp.get("/jobs/<int:job_id>/stream")
|
||||
|
|
@ -25,9 +15,8 @@ def stream_job(job_id: int) -> Response:
|
|||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
last_seq = int(request.args.get("last_seq") or request.headers.get("Last-Event-ID") or "0")
|
||||
last_seq = int(request.args.get("last_seq", "0"))
|
||||
limit = int(current_app.config["JOB_LOG_REPLAY_LIMIT"])
|
||||
poll_seconds = float(current_app.config.get("JOB_WORKER_POLL_SECONDS", 1))
|
||||
|
||||
with session_scope() as db:
|
||||
job = db.scalar(select(Job).where(Job.id == job_id, Job.user_id == user.id))
|
||||
|
|
@ -35,26 +24,16 @@ def stream_job(job_id: int) -> Response:
|
|||
return Response(status=404)
|
||||
|
||||
def generate():
|
||||
next_seq = last_seq
|
||||
while True:
|
||||
with session_scope() as db:
|
||||
job = db.scalar(select(Job).where(Job.id == job_id))
|
||||
if job is None:
|
||||
return
|
||||
rows = db.scalars(
|
||||
select(JobLog)
|
||||
.where(JobLog.job_id == job_id, JobLog.seq > next_seq)
|
||||
.order_by(JobLog.seq)
|
||||
.limit(limit)
|
||||
).all()
|
||||
terminal = job.state in TERMINAL_JOB_STATES
|
||||
|
||||
with session_scope() as db:
|
||||
rows = db.scalars(
|
||||
select(JobLog)
|
||||
.where(JobLog.job_id == job_id, JobLog.seq > last_seq)
|
||||
.order_by(JobLog.seq)
|
||||
.limit(limit)
|
||||
).all()
|
||||
for row in rows:
|
||||
next_seq = row.seq
|
||||
yield format_sse_event(row.seq, row.stream, row.line)
|
||||
|
||||
if terminal and len(rows) < limit:
|
||||
return
|
||||
time.sleep(poll_seconds)
|
||||
yield f"id: {row.seq}\n"
|
||||
yield f"event: {row.stream}\n"
|
||||
yield f"data: {row.line}\n\n"
|
||||
|
||||
return Response(generate(), mimetype="text/event-stream")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from l4d2web.services.security import validate_overlay_path
|
|||
bp = Blueprint("overlay", __name__)
|
||||
|
||||
|
||||
@bp.post("/overlays")
|
||||
@bp.post("/admin/overlays")
|
||||
@require_admin
|
||||
def create_overlay() -> Response:
|
||||
name = request.form.get("name", "").strip()
|
||||
|
|
@ -29,38 +29,4 @@ def create_overlay() -> Response:
|
|||
return Response("overlay already exists", status=409)
|
||||
db.add(Overlay(name=name, path=str(validated_path)))
|
||||
|
||||
return redirect("/overlays")
|
||||
|
||||
|
||||
@bp.post("/overlays/<int:overlay_id>")
|
||||
@require_admin
|
||||
def update_overlay(overlay_id: int) -> Response:
|
||||
name = request.form.get("name", "").strip()
|
||||
raw_path = request.form.get("path", "").strip()
|
||||
if not name or not raw_path:
|
||||
return Response("missing fields", status=400)
|
||||
|
||||
try:
|
||||
validated_path = validate_overlay_path(raw_path)
|
||||
except ValueError as exc:
|
||||
return Response(str(exc), status=400)
|
||||
|
||||
with session_scope() as db:
|
||||
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
|
||||
if overlay is None:
|
||||
return Response(status=404)
|
||||
overlay.name = name
|
||||
overlay.path = str(validated_path)
|
||||
|
||||
return redirect("/overlays")
|
||||
|
||||
|
||||
@bp.post("/overlays/<int:overlay_id>/delete")
|
||||
@require_admin
|
||||
def delete_overlay(overlay_id: int) -> Response:
|
||||
with session_scope() as db:
|
||||
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
|
||||
if overlay is None:
|
||||
return Response(status=404)
|
||||
db.delete(overlay)
|
||||
return redirect("/overlays")
|
||||
return redirect("/admin/overlays")
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import json
|
||||
|
||||
from flask import Blueprint, Response, redirect, render_template
|
||||
from flask import Blueprint, Response, current_app, render_template
|
||||
from sqlalchemy import select
|
||||
|
||||
from l4d2web.auth import current_user, require_admin, require_login
|
||||
from l4d2web.db import session_scope
|
||||
from l4d2web.models import Blueprint as BlueprintModel
|
||||
from l4d2web.models import BlueprintOverlay, Job, Overlay, Server, User
|
||||
from l4d2web.models import BlueprintOverlay, Overlay, Server
|
||||
|
||||
|
||||
bp = Blueprint("pages", __name__)
|
||||
|
|
@ -15,116 +15,18 @@ bp = Blueprint("pages", __name__)
|
|||
@bp.get("/dashboard")
|
||||
@require_login
|
||||
def dashboard() -> str:
|
||||
return render_template("dashboard.html")
|
||||
|
||||
|
||||
@bp.get("/admin")
|
||||
@require_admin
|
||||
def admin_home() -> str:
|
||||
return render_template("admin.html")
|
||||
|
||||
|
||||
@bp.post("/admin/install")
|
||||
@require_admin
|
||||
def enqueue_runtime_install() -> Response:
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
with session_scope() as db:
|
||||
db.add(Job(user_id=user.id, server_id=None, operation="install", state="queued"))
|
||||
return redirect("/admin/jobs")
|
||||
|
||||
|
||||
@bp.get("/admin/users")
|
||||
@require_admin
|
||||
def admin_users() -> str:
|
||||
with session_scope() as db:
|
||||
users = db.scalars(select(User).order_by(User.username)).all()
|
||||
return render_template("admin_users.html", users=users)
|
||||
|
||||
|
||||
@bp.get("/admin/jobs")
|
||||
@require_admin
|
||||
def admin_jobs() -> str:
|
||||
with session_scope() as db:
|
||||
rows = db.execute(
|
||||
select(Job, User, Server)
|
||||
.join(User, User.id == Job.user_id)
|
||||
.outerjoin(Server, Server.id == Job.server_id)
|
||||
.order_by(Job.created_at.desc())
|
||||
).all()
|
||||
return render_template("admin_jobs.html", rows=rows)
|
||||
|
||||
|
||||
@bp.get("/servers")
|
||||
@require_login
|
||||
def servers_page() -> str:
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
with session_scope() as db:
|
||||
rows = db.execute(
|
||||
select(Server, BlueprintModel)
|
||||
.join(BlueprintModel, BlueprintModel.id == Server.blueprint_id)
|
||||
.where(Server.user_id == user.id)
|
||||
.order_by(Server.name)
|
||||
).all()
|
||||
return render_template("servers.html", rows=rows)
|
||||
|
||||
|
||||
@bp.get("/servers/<int:server_id>")
|
||||
@require_login
|
||||
def server_detail(server_id: int):
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
with session_scope() as db:
|
||||
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
|
||||
if server is None:
|
||||
return Response(status=404)
|
||||
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
|
||||
overlay_rows = db.execute(
|
||||
select(Overlay.name)
|
||||
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
|
||||
.where(BlueprintOverlay.blueprint_id == server.blueprint_id)
|
||||
.order_by(BlueprintOverlay.position)
|
||||
).all()
|
||||
latest_job = db.scalar(
|
||||
select(Job)
|
||||
.where(Job.server_id == server.id)
|
||||
.order_by(Job.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
servers = db.scalars(select(Server).where(Server.user_id == user.id).order_by(Server.name)).all()
|
||||
return render_template(
|
||||
"server_detail.html",
|
||||
server=server,
|
||||
blueprint=blueprint,
|
||||
overlay_names=[row[0] for row in overlay_rows],
|
||||
arguments=json.loads(blueprint.arguments) if blueprint is not None else [],
|
||||
config_lines=json.loads(blueprint.config) if blueprint is not None else [],
|
||||
latest_job=latest_job,
|
||||
"dashboard.html",
|
||||
servers=servers,
|
||||
refresh_seconds=current_app.config["STATUS_REFRESH_SECONDS"],
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/overlays")
|
||||
@require_login
|
||||
def overlays() -> str:
|
||||
with session_scope() as db:
|
||||
overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all()
|
||||
return render_template("overlays.html", overlays=overlays)
|
||||
|
||||
|
||||
@bp.get("/blueprints")
|
||||
@require_login
|
||||
def blueprints_page() -> str:
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
with session_scope() as db:
|
||||
blueprints = db.scalars(
|
||||
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
|
||||
).all()
|
||||
return render_template("blueprints.html", blueprints=blueprints)
|
||||
|
||||
|
||||
@bp.get("/blueprints/<int:blueprint_id>")
|
||||
@require_login
|
||||
def blueprint_page(blueprint_id: int):
|
||||
|
|
@ -138,26 +40,39 @@ def blueprint_page(blueprint_id: int):
|
|||
if blueprint.user_id != user.id:
|
||||
return Response(status=403)
|
||||
|
||||
selected_overlays = db.scalars(
|
||||
select(Overlay)
|
||||
overlay_rows = db.execute(
|
||||
select(Overlay.name)
|
||||
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
|
||||
.where(BlueprintOverlay.blueprint_id == blueprint.id)
|
||||
.order_by(BlueprintOverlay.position)
|
||||
).all()
|
||||
position_rows = db.execute(
|
||||
select(BlueprintOverlay.overlay_id, BlueprintOverlay.position)
|
||||
.where(BlueprintOverlay.blueprint_id == blueprint.id)
|
||||
).all()
|
||||
all_overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all()
|
||||
|
||||
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows}
|
||||
return render_template(
|
||||
"blueprint_detail.html",
|
||||
"blueprints.html",
|
||||
blueprint=blueprint,
|
||||
selected_overlays=selected_overlays,
|
||||
all_overlays=all_overlays,
|
||||
selected_overlay_ids={overlay.id for overlay in selected_overlays},
|
||||
overlay_positions=overlay_positions,
|
||||
overlay_names=[row[0] for row in overlay_rows],
|
||||
arguments=json.loads(blueprint.arguments),
|
||||
config_lines=json.loads(blueprint.config),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/servers/<int:server_id>")
|
||||
@require_login
|
||||
def server_detail(server_id: int):
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
with session_scope() as db:
|
||||
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
|
||||
if server is None:
|
||||
return Response(status=404)
|
||||
|
||||
return render_template("server_detail.html", server=server)
|
||||
|
||||
|
||||
@bp.get("/admin/overlays")
|
||||
@require_admin
|
||||
def admin_overlays() -> str:
|
||||
with session_scope() as db:
|
||||
overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all()
|
||||
return render_template("admin_overlays.html", overlays=overlays)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from flask import Blueprint, Response, jsonify, redirect, request
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from l4d2web.auth import current_user, require_login
|
||||
from l4d2web.db import session_scope
|
||||
from l4d2web.models import Blueprint as BlueprintModel
|
||||
from l4d2web.models import Job, Server
|
||||
from l4d2web.models import Server
|
||||
|
||||
|
||||
bp = Blueprint("server", __name__)
|
||||
|
|
@ -67,27 +67,3 @@ def update_server(server_id: int) -> Response:
|
|||
server.blueprint_id = blueprint.id
|
||||
|
||||
return jsonify({"id": server_id}), 200
|
||||
|
||||
|
||||
LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"}
|
||||
|
||||
|
||||
@bp.post("/servers/<int:server_id>/<operation>")
|
||||
@require_login
|
||||
def enqueue_server_operation(server_id: int, operation: str) -> Response:
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
if operation not in LIFECYCLE_OPERATIONS:
|
||||
return Response(status=404)
|
||||
|
||||
with session_scope() as db:
|
||||
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
|
||||
if server is None:
|
||||
return Response(status=404)
|
||||
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"}:
|
||||
server.desired_state = "stopped"
|
||||
|
||||
return redirect(f"/servers/{server_id}")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -11,15 +8,6 @@ from l4d2web.db import session_scope
|
|||
from l4d2web.models import Job, JobLog, Server
|
||||
|
||||
|
||||
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
|
||||
SERVER_OPERATIONS = {"initialize", "start", "stop", "delete"}
|
||||
|
||||
_claim_lock = threading.Lock()
|
||||
_log_lock = threading.RLock()
|
||||
_worker_start_lock = threading.Lock()
|
||||
_workers_started = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchedulerState:
|
||||
install_running: bool = False
|
||||
|
|
@ -36,128 +24,12 @@ def can_start(job, state: SchedulerState) -> bool:
|
|||
return job.server_id not in state.running_servers
|
||||
|
||||
|
||||
def build_scheduler_state(session: Session) -> SchedulerState:
|
||||
state = SchedulerState()
|
||||
running_jobs = session.scalars(select(Job).where(Job.state == "running")).all()
|
||||
for job in running_jobs:
|
||||
if job.operation == "install":
|
||||
state.install_running = True
|
||||
elif job.server_id is not None:
|
||||
state.running_servers.add(job.server_id)
|
||||
return state
|
||||
|
||||
|
||||
def claim_next_job() -> int | None:
|
||||
with _claim_lock:
|
||||
with session_scope() as db:
|
||||
state = build_scheduler_state(db)
|
||||
jobs = db.scalars(select(Job).where(Job.state == "queued").order_by(Job.created_at, Job.id)).all()
|
||||
now = datetime.now(UTC)
|
||||
for job in jobs:
|
||||
malformed_server_job = job.operation != "install" and job.server_id is None
|
||||
if not malformed_server_job and not can_start(job, state):
|
||||
continue
|
||||
|
||||
job.state = "running"
|
||||
job.started_at = now
|
||||
job.updated_at = now
|
||||
job.exit_code = None
|
||||
db.flush()
|
||||
return job.id
|
||||
return None
|
||||
|
||||
|
||||
def run_worker_once() -> bool:
|
||||
job_id = claim_next_job()
|
||||
if job_id is None:
|
||||
return False
|
||||
run_job(job_id)
|
||||
return True
|
||||
|
||||
|
||||
def run_job(job_id: int) -> None:
|
||||
from l4d2web.services import l4d2_facade
|
||||
|
||||
with session_scope() as db:
|
||||
job = db.scalar(select(Job).where(Job.id == job_id))
|
||||
if job is None:
|
||||
return
|
||||
operation = job.operation
|
||||
server_id = job.server_id
|
||||
|
||||
max_chars = 4096
|
||||
|
||||
def on_stdout(line: str) -> None:
|
||||
append_job_log_line(job_id, "stdout", line, max_chars=max_chars)
|
||||
|
||||
def on_stderr(line: str) -> None:
|
||||
append_job_log_line(job_id, "stderr", line, max_chars=max_chars)
|
||||
|
||||
try:
|
||||
if operation == "install":
|
||||
l4d2_facade.install_runtime(on_stdout=on_stdout, on_stderr=on_stderr)
|
||||
elif operation in SERVER_OPERATIONS and server_id is None:
|
||||
raise ValueError(f"{operation} job has no server_id")
|
||||
elif operation == "initialize":
|
||||
l4d2_facade.initialize_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr)
|
||||
elif operation == "start":
|
||||
l4d2_facade.initialize_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr)
|
||||
l4d2_facade.start_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr)
|
||||
elif operation == "stop":
|
||||
l4d2_facade.stop_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr)
|
||||
elif operation == "delete":
|
||||
l4d2_facade.delete_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr)
|
||||
else:
|
||||
raise ValueError(f"unknown job operation: {operation}")
|
||||
|
||||
if server_id is not None:
|
||||
refresh_server_actual_state_after_job(job_id, server_id)
|
||||
finish_job(job_id, "succeeded", 0)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
error = exc.stderr or str(exc)
|
||||
if exc.stderr:
|
||||
append_job_log_line(job_id, "stderr", str(exc.stderr), max_chars=max_chars)
|
||||
if server_id is not None:
|
||||
refresh_server_actual_state_after_job(job_id, server_id)
|
||||
finish_job(job_id, "failed", exc.returncode, error=error)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
append_job_log_line(job_id, "stderr", error, max_chars=max_chars)
|
||||
if server_id is not None:
|
||||
refresh_server_actual_state_after_job(job_id, server_id)
|
||||
finish_job(job_id, "failed", 1, error=error)
|
||||
|
||||
|
||||
def finish_job(job_id: int, state: str, exit_code: int | None, error: str = "") -> None:
|
||||
now = datetime.now(UTC)
|
||||
with session_scope() as db:
|
||||
job = db.scalar(select(Job).where(Job.id == job_id))
|
||||
if job is None:
|
||||
return
|
||||
job.state = state
|
||||
job.exit_code = exit_code
|
||||
job.finished_at = now
|
||||
job.updated_at = now
|
||||
if job.server_id is not None:
|
||||
server = db.scalar(select(Server).where(Server.id == job.server_id))
|
||||
if server is not None:
|
||||
server.last_error = "" if state == "succeeded" else error
|
||||
server.updated_at = now
|
||||
|
||||
|
||||
def append_job_log_line(job_id: int, stream: str, line: str, max_chars: int = 4096) -> int:
|
||||
with _log_lock:
|
||||
with session_scope() as db:
|
||||
return append_job_log(db, job_id, stream, line, max_chars=max_chars)
|
||||
|
||||
|
||||
def recover_stale_jobs() -> int:
|
||||
now = datetime.now(UTC)
|
||||
with session_scope() as db:
|
||||
jobs = db.scalars(select(Job).where(Job.state == "running")).all()
|
||||
for job in jobs:
|
||||
job.state = "failed"
|
||||
job.exit_code = 1
|
||||
job.finished_at = now
|
||||
job.updated_at = now
|
||||
return len(jobs)
|
||||
|
|
@ -170,12 +42,11 @@ def append_job_log(
|
|||
line: str,
|
||||
max_chars: int = 4096,
|
||||
) -> int:
|
||||
with _log_lock:
|
||||
last_seq = session.scalar(select(func.max(JobLog.seq)).where(JobLog.job_id == job_id)) or 0
|
||||
next_seq = int(last_seq) + 1
|
||||
session.add(JobLog(job_id=job_id, seq=next_seq, stream=stream, line=line[:max_chars]))
|
||||
session.flush()
|
||||
return next_seq
|
||||
last_seq = session.scalar(select(func.max(JobLog.seq)).where(JobLog.job_id == job_id)) or 0
|
||||
next_seq = int(last_seq) + 1
|
||||
session.add(JobLog(job_id=job_id, seq=next_seq, stream=stream, line=line[:max_chars]))
|
||||
session.flush()
|
||||
return next_seq
|
||||
|
||||
|
||||
def refresh_server_actual_state(server_id: int) -> str:
|
||||
|
|
@ -191,41 +62,3 @@ def refresh_server_actual_state(server_id: int) -> str:
|
|||
server.actual_state_updated_at = now
|
||||
server.updated_at = now
|
||||
return server.actual_state
|
||||
|
||||
|
||||
def refresh_server_actual_state_after_job(job_id: int, server_id: int) -> None:
|
||||
try:
|
||||
refresh_server_actual_state(server_id)
|
||||
except Exception as exc:
|
||||
append_job_log_line(job_id, "stderr", f"status refresh failed: {exc}")
|
||||
|
||||
|
||||
def start_job_workers(app) -> None:
|
||||
global _workers_started
|
||||
|
||||
with _worker_start_lock:
|
||||
if _workers_started:
|
||||
return
|
||||
_workers_started = True
|
||||
threads = int(app.config.get("JOB_WORKER_THREADS", 4))
|
||||
poll_seconds = float(app.config.get("JOB_WORKER_POLL_SECONDS", 1))
|
||||
for index in range(threads):
|
||||
thread = threading.Thread(
|
||||
target=worker_loop,
|
||||
args=(app, poll_seconds),
|
||||
name=f"l4d2-job-worker-{index + 1}",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
def worker_loop(app, poll_seconds: float) -> None:
|
||||
while True:
|
||||
ran_job = False
|
||||
try:
|
||||
with app.app_context():
|
||||
ran_job = run_worker_once()
|
||||
except Exception:
|
||||
ran_job = False
|
||||
if not ran_job:
|
||||
time.sleep(poll_seconds)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
.panel,
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: var(--line);
|
||||
border-radius: var(--radius-m);
|
||||
padding: var(--space-l);
|
||||
margin-bottom: var(--space-l);
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
box-shadow: 0 8px 20px #0F766E12;
|
||||
}
|
||||
|
||||
.table {
|
||||
|
|
@ -15,8 +15,8 @@
|
|||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: var(--space-s) var(--space-m);
|
||||
border-bottom: var(--line);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.muted {
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: var(--space-m);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
input,
|
||||
|
|
@ -35,56 +35,11 @@ textarea {
|
|||
font: inherit;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--color-surface);
|
||||
border: var(--line);
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--color-text);
|
||||
padding: var(--space-s) var(--space-m);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus,
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--color-primary);
|
||||
background: var(--color-link);
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
padding: var(--space-s) var(--space-l);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-link);
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,42 +4,35 @@
|
|||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--color-bg);
|
||||
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
background: radial-gradient(circle at top right, #DDEFEA, #F3F7F6 45%);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
background: var(--color-surface);
|
||||
border-bottom: var(--line);
|
||||
background: #FFFFFFD9;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.site-header-inner {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-l);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primary-nav,
|
||||
.account-nav {
|
||||
display: flex;
|
||||
gap: var(--space-l);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
margin-right: var(--space-l);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-2xl) var(--space-l);
|
||||
padding: var(--space-6) var(--space-4) var(--space-6);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
.log-stream {
|
||||
min-height: 180px;
|
||||
max-height: 480px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
background: var(--color-log-bg);
|
||||
color: var(--color-log-text);
|
||||
border-radius: var(--radius-s);
|
||||
padding: var(--space-m);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
background: #0A1412;
|
||||
color: #CCE9E1;
|
||||
border-radius: 8px;
|
||||
padding: var(--space-3);
|
||||
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,15 @@
|
|||
:root {
|
||||
--color-bg: #f4f4f5;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-muted: #f8fafc;
|
||||
--color-text: #18181b;
|
||||
--color-muted: #60646c;
|
||||
--color-border: #d4d4d8;
|
||||
--color-link: #1d4ed8;
|
||||
--color-primary: #1d4ed8;
|
||||
--color-danger: #b42318;
|
||||
--color-warning: #a15c07;
|
||||
--color-success: #067647;
|
||||
--color-focus: #2563eb;
|
||||
--color-log-bg: #111827;
|
||||
--color-log-text: #e5e7eb;
|
||||
|
||||
--space-base: 0.25rem;
|
||||
--space-xs: var(--space-base);
|
||||
--space-s: calc(var(--space-base) * 2);
|
||||
--space-m: calc(var(--space-base) * 3);
|
||||
--space-l: calc(var(--space-base) * 4);
|
||||
--space-xl: calc(var(--space-base) * 6);
|
||||
--space-2xl: calc(var(--space-base) * 8);
|
||||
|
||||
--radius-base: 0.25rem;
|
||||
--radius-s: var(--radius-base);
|
||||
--radius-m: calc(var(--radius-base) * 2);
|
||||
|
||||
--line: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #18181b;
|
||||
--color-surface: #27272a;
|
||||
--color-surface-muted: #1f1f23;
|
||||
--color-text: #f4f4f5;
|
||||
--color-muted: #a1a1aa;
|
||||
--color-border: #3f3f46;
|
||||
--color-link: #93c5fd;
|
||||
--color-primary: #93c5fd;
|
||||
--color-danger: #fca5a5;
|
||||
--color-warning: #fcd34d;
|
||||
--color-success: #86efac;
|
||||
--color-focus: #bfdbfe;
|
||||
}
|
||||
--color-link: #0F766E;
|
||||
--color-bg: #F3F7F6;
|
||||
--color-text: #11201D;
|
||||
--color-card: #FFFFFF;
|
||||
--color-border: #D4E4DF;
|
||||
--color-muted: #4A6A63;
|
||||
--radius: 10px;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-6: 1.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
|
|
|
|||
|
|
@ -1,29 +1,19 @@
|
|||
function streamTextToElement(element) {
|
||||
const url = element.dataset.sseUrl;
|
||||
if (!url) {
|
||||
function streamTextToElement(url, elementId) {
|
||||
const target = document.getElementById(elementId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = new EventSource(url);
|
||||
|
||||
const appendLine = (line) => {
|
||||
element.textContent += `${line}\n`;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
};
|
||||
|
||||
source.onmessage = (event) => {
|
||||
appendLine(event.data);
|
||||
target.textContent += `${event.data}\n`;
|
||||
target.scrollTop = target.scrollHeight;
|
||||
};
|
||||
|
||||
source.addEventListener("stdout", (event) => {
|
||||
appendLine(event.data);
|
||||
});
|
||||
|
||||
source.addEventListener("stderr", (event) => {
|
||||
appendLine(`[stderr] ${event.data}`);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll("[data-sse-url]").forEach(streamTextToElement);
|
||||
const serverLog = document.getElementById("server-log-stream");
|
||||
if (serverLog) {
|
||||
streamTextToElement(serverLog.dataset.serverLogUrl, "server-log-stream");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h1>Admin</h1>
|
||||
<ul class="link-list">
|
||||
<li><a href="/admin/users">Users</a></li>
|
||||
<li><a href="/admin/jobs">Jobs</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Runtime</h2>
|
||||
<p class="muted">Queue a Steam runtime install/update job for the local host.</p>
|
||||
<form method="post" action="/admin/install">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button type="submit">Install or update runtime</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Jobs | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h1>Jobs</h1>
|
||||
<table class="table">
|
||||
<thead><tr><th>ID</th><th>Operation</th><th>State</th><th>User</th><th>Server</th><th>Created</th><th>Finished</th></tr></thead>
|
||||
<tbody>
|
||||
{% for job, user, server in rows %}
|
||||
<tr>
|
||||
<td>{{ job.id }}</td>
|
||||
<td>{{ job.operation }}</td>
|
||||
<td>{{ job.state }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{% if server %}<a href="/servers/{{ server.id }}">{{ server.name }}</a>{% else %}-{% endif %}</td>
|
||||
<td>{{ job.created_at }}</td>
|
||||
<td>{{ job.finished_at or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="muted">No jobs found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
30
l4d2web/templates/admin_overlays.html
Normal file
30
l4d2web/templates/admin_overlays.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Overlays | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1>Overlay Catalog</h1>
|
||||
<form method="post" action="/admin/overlays" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<label>
|
||||
Name
|
||||
<input name="name" required>
|
||||
</label>
|
||||
<label>
|
||||
Path
|
||||
<input name="path" required placeholder="/opt/l4d2/overlays/example">
|
||||
</label>
|
||||
<button type="submit">Add Overlay</button>
|
||||
</form>
|
||||
|
||||
<h2>Known overlays</h2>
|
||||
<ul>
|
||||
{% for overlay in overlays %}
|
||||
<li><strong>{{ overlay.name }}</strong> <span class="muted">{{ overlay.path }}</span></li>
|
||||
{% else %}
|
||||
<li class="muted">No overlays configured.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Users | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h1>Users</h1>
|
||||
<table class="table">
|
||||
<thead><tr><th>Username</th><th>Admin</th><th>Created</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr><td>{{ user.username }}</td><td>{{ "yes" if user.admin else "no" }}</td><td>{{ user.created_at }}</td><td>{{ user.updated_at }}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="muted">No users found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -13,24 +13,11 @@
|
|||
<body>
|
||||
<header class="site-header">
|
||||
<div class="site-header-inner">
|
||||
<nav class="primary-nav" aria-label="Main navigation">
|
||||
<a class="brand" href="{{ '/dashboard' if g.user else '/login' }}">left4me</a>
|
||||
{% if g.user %}
|
||||
<a href="/servers">servers</a>
|
||||
<a href="/blueprints">blueprints</a>
|
||||
<a href="/overlays">overlays</a>
|
||||
{% endif %}
|
||||
<a class="brand" href="/dashboard">left4me</a>
|
||||
<nav>
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/admin/overlays">Overlays</a>
|
||||
</nav>
|
||||
{% if g.user %}
|
||||
<nav class="account-nav" aria-label="Account navigation">
|
||||
{% if g.user.admin %}<a href="/admin">admin</a>{% endif %}
|
||||
<span class="muted">{{ g.user.username }}</span>
|
||||
<form method="post" action="/logout" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button class="link-button" type="submit">logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<main class="container">
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Blueprint {{ blueprint.name }} | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h1>Blueprint: {{ blueprint.name }}</h1>
|
||||
<form method="post" action="/blueprints/{{ blueprint.id }}" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
|
||||
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
|
||||
<table class="table">
|
||||
<thead><tr><th>Use</th><th>Order</th><th>Overlay</th></tr></thead>
|
||||
<tbody>
|
||||
{% for overlay in all_overlays %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="overlay_ids" value="{{ overlay.id }}" {% if overlay.id in selected_overlay_ids %}checked{% endif %}></td>
|
||||
<td><input class="position-input" name="overlay_position_{{ overlay.id }}" value="{{ overlay_positions.get(overlay.id, '') }}" inputmode="numeric"></td>
|
||||
<td>{{ overlay.name }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="muted">No overlays available.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<label>Arguments <textarea name="arguments">{{ arguments | join('\n') }}</textarea></label>
|
||||
<label>Config <textarea name="config">{{ config_lines | join('\n') }}</textarea></label>
|
||||
<button type="submit">Save blueprint</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -1,36 +1,21 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Blueprints | left4me{% endblock %}
|
||||
{% block title %}Blueprint | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h1>Blueprints</h1>
|
||||
<form method="post" action="/blueprints" class="stack form-panel">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<label>Name <input name="name" required></label>
|
||||
<label>Arguments <textarea name="arguments"></textarea></label>
|
||||
<label>Config <textarea name="config"></textarea></label>
|
||||
<button type="submit">Create blueprint</button>
|
||||
</form>
|
||||
<table class="table">
|
||||
<thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for blueprint in blueprints %}
|
||||
<tr>
|
||||
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
|
||||
<td>{{ blueprint.created_at }}</td>
|
||||
<td>{{ blueprint.updated_at }}</td>
|
||||
<td>
|
||||
<form method="post" action="/blueprints/{{ blueprint.id }}/delete" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button class="danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="muted">No blueprints configured.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="card">
|
||||
<h1>Blueprint: {{ blueprint.name }}</h1>
|
||||
<h2>Overlays</h2>
|
||||
<ul>
|
||||
{% for name in overlay_names %}
|
||||
<li>{{ name }}</li>
|
||||
{% else %}
|
||||
<li class="muted">No overlays configured.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<h2>Arguments</h2>
|
||||
<pre>{{ arguments | join('\n') }}</pre>
|
||||
<h2>Config</h2>
|
||||
<pre>{{ config_lines | join('\n') }}</pre>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,26 @@
|
|||
{% block title %}Dashboard | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<section class="card">
|
||||
<h1>Dashboard</h1>
|
||||
<p class="muted">Use the navigation to manage servers, blueprints, and overlays.</p>
|
||||
<p class="muted">Status refresh every {{ refresh_seconds }}s.</p>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Port</th><th>Desired</th><th>Actual</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in servers %}
|
||||
<tr>
|
||||
<td>{{ server.name }}</td>
|
||||
<td>{{ server.port }}</td>
|
||||
<td>{{ server.desired_state }}</td>
|
||||
<td>{{ server.actual_state }}</td>
|
||||
<td><a href="/servers/{{ server.id }}">View</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">No servers yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Log In | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel auth-panel">
|
||||
<h1>Log in to left4me</h1>
|
||||
<p class="muted">Use your local account to manage Left 4 Dead 2 servers.</p>
|
||||
|
||||
<form method="post" action="/login" class="stack">
|
||||
{% if next_target %}<input type="hidden" name="next" value="{{ next_target }}">{% endif %}
|
||||
<label>
|
||||
Username
|
||||
<input name="username" autocomplete="username" required autofocus>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit">log in</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Overlays | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<h1>Overlays</h1>
|
||||
</div>
|
||||
|
||||
{% if g.user.admin %}
|
||||
<form method="post" action="/overlays" class="stack form-panel">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<label>Name <input name="name" required></label>
|
||||
<label>Path <input name="path" required placeholder="/opt/l4d2/overlays/example"></label>
|
||||
<button type="submit">Add overlay</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<table class="table">
|
||||
<thead><tr><th>Name</th><th>Path</th>{% if g.user.admin %}<th>Actions</th>{% endif %}</tr></thead>
|
||||
<tbody>
|
||||
{% for overlay in overlays %}
|
||||
<tr>
|
||||
<td>{{ overlay.name }}</td>
|
||||
<td class="muted">{{ overlay.path }}</td>
|
||||
{% if g.user.admin %}
|
||||
<td>
|
||||
<form method="post" action="/overlays/{{ overlay.id }}" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<input name="name" value="{{ overlay.name }}" required>
|
||||
<input name="path" value="{{ overlay.path }}" required>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<form method="post" action="/overlays/{{ overlay.id }}/delete" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button class="danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{% if g.user.admin %}3{% else %}2{% endif %}" class="muted">No overlays configured.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -3,59 +3,14 @@
|
|||
{% block title %}Server {{ server.name }} | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<h1>Server: {{ server.name }}</h1>
|
||||
<div class="button-row">
|
||||
{% for operation in ["initialize", "start", "stop"] %}
|
||||
<form method="post" action="/servers/{{ server.id }}/{{ operation }}" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button type="submit">{{ operation }}</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
<form method="post" action="/servers/{{ server.id }}/delete" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button class="danger" type="submit">delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="definition-table">
|
||||
<tbody>
|
||||
<tr><th>Name</th><td>{{ server.name }}</td></tr>
|
||||
<tr><th>Port</th><td>{{ server.port }}</td></tr>
|
||||
<tr><th>Blueprint</th><td>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</td></tr>
|
||||
<tr><th>Desired state</th><td>{{ server.desired_state }}</td></tr>
|
||||
<tr><th>Actual state</th><td>{{ server.actual_state }}</td></tr>
|
||||
<tr><th>Last error</th><td>{{ server.last_error or "-" }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="card">
|
||||
<h1>Server: {{ server.name }}</h1>
|
||||
<p><strong>Port:</strong> {{ server.port }}</p>
|
||||
<p><strong>Desired:</strong> {{ server.desired_state }} | <strong>Actual:</strong> {{ server.actual_state }}</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Blueprint</h2>
|
||||
<h3>Overlay order</h3>
|
||||
<ol>
|
||||
{% for name in overlay_names %}<li>{{ name }}</li>{% else %}<li class="muted">No overlays configured.</li>{% endfor %}
|
||||
</ol>
|
||||
<h3>Arguments</h3>
|
||||
<pre class="code-block">{{ arguments | join('\n') }}</pre>
|
||||
<h3>Config</h3>
|
||||
<pre class="code-block">{{ config_lines | join('\n') }}</pre>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Current / Recent Job</h2>
|
||||
{% if latest_job %}
|
||||
<table class="definition-table"><tbody><tr><th>Operation</th><td>{{ latest_job.operation }}</td></tr><tr><th>State</th><td>{{ latest_job.state }}</td></tr></tbody></table>
|
||||
<pre class="log-stream" data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
|
||||
{% else %}
|
||||
<p class="muted">No jobs have run for this server.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Server Log</h2>
|
||||
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||
<section class="card">
|
||||
<h2>Live Logs</h2>
|
||||
<pre id="server-log-stream" class="log-stream" data-server-log-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Servers | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h1>Servers</h1>
|
||||
<table class="table">
|
||||
<thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th></tr></thead>
|
||||
<tbody>
|
||||
{% for server, blueprint in rows %}
|
||||
<tr>
|
||||
<td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td>
|
||||
<td>{{ server.port }}</td>
|
||||
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
|
||||
<td>{{ server.desired_state }}</td>
|
||||
<td>{{ server.actual_state }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">No servers configured.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -30,81 +30,9 @@ def seed_user(tmp_path, monkeypatch):
|
|||
return user_id
|
||||
|
||||
|
||||
def test_login_page_renders_form(client) -> None:
|
||||
response = client.get("/login")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<form method="post" action="/login"' in text
|
||||
assert 'name="username"' in text
|
||||
assert 'name="password"' in text
|
||||
assert "signup" not in text.lower()
|
||||
|
||||
|
||||
def test_login_page_drops_unsafe_encoded_next(client) -> None:
|
||||
response = client.get("/login?next=/%5Cevil.com")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'name="next"' not in text
|
||||
assert "evil.com" not in text
|
||||
|
||||
|
||||
def test_signup_routes_are_gone(client) -> None:
|
||||
assert client.get("/signup").status_code == 404
|
||||
assert client.post("/signup", data={"username": "alice", "password": "secret"}).status_code == 404
|
||||
|
||||
|
||||
def test_login_redirects_to_safe_next(client) -> None:
|
||||
with session_scope() as session:
|
||||
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
|
||||
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"username": "alice", "password": "secret", "next": "/servers"},
|
||||
)
|
||||
|
||||
def test_public_signup(client) -> None:
|
||||
response = client.post("/signup", data={"username": "alice", "password": "secret"})
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/servers")
|
||||
|
||||
|
||||
def test_login_ignores_unsafe_next(client) -> None:
|
||||
with session_scope() as session:
|
||||
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
|
||||
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"username": "alice", "password": "secret", "next": "https://example.com/steal"},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/dashboard")
|
||||
|
||||
|
||||
def test_login_ignores_backslash_next(client) -> None:
|
||||
with session_scope() as session:
|
||||
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
|
||||
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"username": "alice", "password": "secret", "next": "/\\evil.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/dashboard")
|
||||
|
||||
|
||||
def test_login_ignores_percent_encoded_backslash_next(client) -> None:
|
||||
with session_scope() as session:
|
||||
session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))
|
||||
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"username": "alice", "password": "secret", "next": "/%5Cevil.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/dashboard")
|
||||
|
||||
|
||||
def test_login_sets_session(client) -> None:
|
||||
|
|
|
|||
|
|
@ -83,35 +83,3 @@ def test_delete_blueprint_blocked_when_in_use(linked_blueprint) -> None:
|
|||
client, blueprint_id = linked_blueprint
|
||||
response = client.delete(f"/blueprints/{blueprint_id}", headers={"X-CSRF-Token": "test-token"})
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client) -> None:
|
||||
create = user_client.post(
|
||||
"/blueprints",
|
||||
data={
|
||||
"name": "comp",
|
||||
"arguments": "-tickrate 100",
|
||||
"config": "sv_consistency 1",
|
||||
"overlay_ids": ["2", "1"],
|
||||
"overlay_position_1": "2",
|
||||
"overlay_position_2": "1",
|
||||
},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert create.status_code == 302
|
||||
|
||||
update = user_client.post(
|
||||
"/blueprints/1",
|
||||
data={
|
||||
"name": "edited",
|
||||
"arguments": "-tickrate 100\n+sv_lan 0",
|
||||
"config": "sv_consistency 1\nsv_allow_lobby_connect_only 0",
|
||||
"overlay_ids": ["1", "2"],
|
||||
"overlay_position_1": "1",
|
||||
"overlay_position_2": "2",
|
||||
},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
|
||||
assert update.status_code == 302
|
||||
assert update.headers["Location"] == "/blueprints/1"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
import pytest
|
||||
|
|
@ -23,7 +21,7 @@ def seeded_job_logs(tmp_path, monkeypatch):
|
|||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
job = Job(user_id=user.id, server_id=None, operation="install", state="succeeded")
|
||||
job = Job(user_id=user.id, server_id=None, operation="install", state="queued")
|
||||
session.add(job)
|
||||
session.flush()
|
||||
|
||||
|
|
@ -69,38 +67,3 @@ def test_sse_resume_from_last_seq(seeded_job_logs) -> None:
|
|||
|
||||
response = client.get(f"/jobs/{job_id}/stream?last_seq=5")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_sse_replays_custom_job_log_events(seeded_job_logs) -> None:
|
||||
app, job_id, user_id = seeded_job_logs
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
|
||||
response = client.get(f"/jobs/{job_id}/stream?last_seq=5")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert "id: 6\n" in text
|
||||
assert "event: stdout\n" in text
|
||||
assert "data: line-6\n\n" in text
|
||||
assert "data: line-5\n\n" not in text
|
||||
|
||||
|
||||
def test_sse_resumes_from_last_event_id_header(seeded_job_logs) -> None:
|
||||
app, job_id, user_id = seeded_job_logs
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
|
||||
response = client.get(f"/jobs/{job_id}/stream", headers={"Last-Event-ID": "6"})
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert "data: line-7\n\n" in text
|
||||
assert "data: line-6\n\n" not in text
|
||||
|
||||
|
||||
def test_sse_js_handles_job_log_custom_events() -> None:
|
||||
js = Path("l4d2web/static/js/sse.js").read_text()
|
||||
|
||||
assert 'addEventListener("stdout"' in js
|
||||
assert 'addEventListener("stderr"' in js
|
||||
|
|
|
|||
|
|
@ -1,16 +1,8 @@
|
|||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
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.job_worker import SchedulerState, can_start, recover_stale_jobs, run_worker_once
|
||||
from l4d2web.services.job_worker import SchedulerState, can_start, recover_stale_jobs
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -20,303 +12,54 @@ class DummyJob:
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_app(tmp_path, monkeypatch):
|
||||
def worker_fixture(tmp_path, monkeypatch):
|
||||
from l4d2web.app import create_app
|
||||
from l4d2web.db import init_db
|
||||
|
||||
db_url = f"sqlite:///{tmp_path/'worker.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
return app
|
||||
|
||||
class WorkerFixture:
|
||||
def run_once(self):
|
||||
state = SchedulerState()
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_worker(worker_app):
|
||||
with session_scope() as session:
|
||||
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
state.running_servers.add(1)
|
||||
same_server_parallel = can_start(DummyJob(operation="start", server_id=1), state)
|
||||
|
||||
blueprint = Blueprint(user_id=user.id, name="default", arguments="[]", config="[]")
|
||||
session.add(blueprint)
|
||||
session.flush()
|
||||
different_servers_parallel = can_start(DummyJob(operation="start", server_id=2), state)
|
||||
|
||||
server_one = Server(
|
||||
user_id=user.id,
|
||||
blueprint_id=blueprint.id,
|
||||
name="alpha",
|
||||
port=27015,
|
||||
last_error="old error",
|
||||
)
|
||||
server_two = Server(user_id=user.id, blueprint_id=blueprint.id, name="bravo", port=27016)
|
||||
session.add_all([server_one, server_two])
|
||||
session.flush()
|
||||
install_parallel = can_start(DummyJob(operation="install", server_id=None), state)
|
||||
|
||||
ids = SimpleNamespace(user=user.id, server_one=server_one.id, server_two=server_two.id)
|
||||
return {
|
||||
"same_server_parallel": same_server_parallel,
|
||||
"different_servers_parallel": different_servers_parallel,
|
||||
"install_parallel": install_parallel,
|
||||
}
|
||||
|
||||
return worker_app, ids
|
||||
def recover_stale_jobs(self):
|
||||
with app.app_context():
|
||||
return recover_stale_jobs()
|
||||
|
||||
return WorkerFixture()
|
||||
|
||||
def add_job(
|
||||
user_id: int,
|
||||
operation: str,
|
||||
*,
|
||||
server_id: int | None,
|
||||
state: str = "queued",
|
||||
created_at: datetime | None = None,
|
||||
) -> int:
|
||||
now = datetime.now(UTC)
|
||||
with session_scope() as session:
|
||||
job = Job(
|
||||
user_id=user_id,
|
||||
server_id=server_id,
|
||||
operation=operation,
|
||||
state=state,
|
||||
created_at=created_at or now,
|
||||
updated_at=created_at or now,
|
||||
)
|
||||
if state == "running":
|
||||
job.started_at = now
|
||||
session.add(job)
|
||||
session.flush()
|
||||
return job.id
|
||||
|
||||
def test_same_server_jobs_serialized(worker_fixture) -> None:
|
||||
result = worker_fixture.run_once()
|
||||
assert result["same_server_parallel"] is False
|
||||
|
||||
def load_job(job_id: int) -> Job:
|
||||
with session_scope() as session:
|
||||
job = session.scalar(select(Job).where(Job.id == job_id))
|
||||
assert job is not None
|
||||
return job
|
||||
|
||||
def test_different_servers_can_run_parallel(worker_fixture) -> None:
|
||||
result = worker_fixture.run_once()
|
||||
assert result["different_servers_parallel"] is True
|
||||
|
||||
def test_scheduler_predicates() -> None:
|
||||
state = SchedulerState()
|
||||
state.running_servers.add(1)
|
||||
|
||||
assert can_start(DummyJob(operation="start", server_id=1), state) is False
|
||||
assert can_start(DummyJob(operation="start", server_id=2), state) is True
|
||||
assert can_start(DummyJob(operation="install", server_id=None), state) is False
|
||||
def test_install_global_exclusive(worker_fixture) -> None:
|
||||
result = worker_fixture.run_once()
|
||||
assert result["install_parallel"] is False
|
||||
|
||||
|
||||
def test_run_worker_once_claims_oldest_runnable_job(seeded_worker, monkeypatch) -> None:
|
||||
app, ids = seeded_worker
|
||||
calls = []
|
||||
older = datetime.now(UTC) - timedelta(minutes=2)
|
||||
newer = datetime.now(UTC)
|
||||
old_job_id = add_job(ids.user, "initialize", server_id=ids.server_one, created_at=older)
|
||||
new_job_id = add_job(ids.user, "initialize", server_id=ids.server_two, created_at=newer)
|
||||
|
||||
monkeypatch.setattr(l4d2_facade, "initialize_server", lambda server_id, **kwargs: calls.append(server_id))
|
||||
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]
|
||||
assert load_job(old_job_id).state == "succeeded"
|
||||
assert load_job(new_job_id).state == "queued"
|
||||
|
||||
|
||||
def test_successful_start_job_logs_and_refreshes_server_state(seeded_worker, monkeypatch) -> None:
|
||||
app, ids = seeded_worker
|
||||
job_id = add_job(ids.user, "start", server_id=ids.server_one)
|
||||
calls = []
|
||||
|
||||
def fake_initialize(server_id, *, on_stdout=None, on_stderr=None):
|
||||
calls.append(("initialize", server_id))
|
||||
on_stdout("initialized")
|
||||
on_stderr("init warning")
|
||||
|
||||
def fake_start(server_id, *, on_stdout=None, on_stderr=None):
|
||||
calls.append(("start", server_id))
|
||||
on_stdout("started")
|
||||
|
||||
monkeypatch.setattr(l4d2_facade, "initialize_server", fake_initialize)
|
||||
monkeypatch.setattr(l4d2_facade, "start_server", fake_start)
|
||||
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: SimpleNamespace(state="running"))
|
||||
|
||||
with app.app_context():
|
||||
assert run_worker_once() is True
|
||||
|
||||
with session_scope() as session:
|
||||
job = session.scalar(select(Job).where(Job.id == job_id))
|
||||
server = session.scalar(select(Server).where(Server.id == ids.server_one))
|
||||
lines = [(row.seq, row.stream, row.line) for row in job_logs_for(session, job_id)]
|
||||
|
||||
assert calls == [("initialize", ids.server_one), ("start", ids.server_one)]
|
||||
assert job is not None
|
||||
assert job.state == "succeeded"
|
||||
assert job.exit_code == 0
|
||||
assert job.started_at is not None
|
||||
assert job.finished_at is not None
|
||||
assert job.updated_at is not None
|
||||
assert lines == [
|
||||
(1, "stdout", "initialized"),
|
||||
(2, "stderr", "init warning"),
|
||||
(3, "stdout", "started"),
|
||||
]
|
||||
assert server is not None
|
||||
assert server.actual_state == "running"
|
||||
assert server.last_error == ""
|
||||
|
||||
|
||||
def job_logs_for(session, job_id: int):
|
||||
from l4d2web.models import JobLog
|
||||
|
||||
return session.scalars(select(JobLog).where(JobLog.job_id == job_id).order_by(JobLog.seq)).all()
|
||||
|
||||
|
||||
def test_called_process_error_fails_job_and_sets_server_error(seeded_worker, monkeypatch) -> None:
|
||||
app, ids = seeded_worker
|
||||
job_id = add_job(ids.user, "stop", server_id=ids.server_one)
|
||||
|
||||
def fail_stop(server_id, **kwargs):
|
||||
raise subprocess.CalledProcessError(returncode=7, cmd=["stop"], stderr="stop failed")
|
||||
|
||||
monkeypatch.setattr(l4d2_facade, "stop_server", fail_stop)
|
||||
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: SimpleNamespace(state="stopped"))
|
||||
|
||||
with app.app_context():
|
||||
assert run_worker_once() is True
|
||||
|
||||
with session_scope() as session:
|
||||
job = session.scalar(select(Job).where(Job.id == job_id))
|
||||
server = session.scalar(select(Server).where(Server.id == ids.server_one))
|
||||
lines = [row.line for row in job_logs_for(session, job_id)]
|
||||
|
||||
assert job is not None
|
||||
assert job.state == "failed"
|
||||
assert job.exit_code == 7
|
||||
assert server is not None
|
||||
assert server.last_error == "stop failed"
|
||||
assert "stop failed" in lines
|
||||
|
||||
|
||||
def test_refresh_failure_does_not_hide_operation_failure(seeded_worker, monkeypatch) -> None:
|
||||
app, ids = seeded_worker
|
||||
job_id = add_job(ids.user, "stop", server_id=ids.server_one)
|
||||
|
||||
def fail_stop(server_id, **kwargs):
|
||||
raise subprocess.CalledProcessError(returncode=7, cmd=["stop"], stderr="stop failed")
|
||||
|
||||
monkeypatch.setattr(l4d2_facade, "stop_server", fail_stop)
|
||||
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: (_ for _ in ()).throw(RuntimeError("status down")))
|
||||
|
||||
with app.app_context():
|
||||
assert run_worker_once() is True
|
||||
|
||||
with session_scope() as session:
|
||||
job = session.scalar(select(Job).where(Job.id == job_id))
|
||||
server = session.scalar(select(Server).where(Server.id == ids.server_one))
|
||||
lines = [row.line for row in job_logs_for(session, job_id)]
|
||||
|
||||
assert job is not None
|
||||
assert job.state == "failed"
|
||||
assert job.exit_code == 7
|
||||
assert server is not None
|
||||
assert server.last_error == "stop failed"
|
||||
assert "status refresh failed: status down" in lines
|
||||
|
||||
|
||||
def test_unexpected_exception_fails_job_with_exit_code_one(seeded_worker, monkeypatch) -> None:
|
||||
app, ids = seeded_worker
|
||||
job_id = add_job(ids.user, "delete", server_id=ids.server_one)
|
||||
|
||||
monkeypatch.setattr(l4d2_facade, "delete_server", lambda server_id, **kwargs: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: SimpleNamespace(state="unknown"))
|
||||
|
||||
with app.app_context():
|
||||
assert run_worker_once() is True
|
||||
|
||||
job = load_job(job_id)
|
||||
assert job.state == "failed"
|
||||
assert job.exit_code == 1
|
||||
|
||||
|
||||
def test_same_server_jobs_do_not_overlap(seeded_worker, monkeypatch) -> None:
|
||||
app, ids = seeded_worker
|
||||
add_job(ids.user, "start", server_id=ids.server_one, state="running")
|
||||
queued_id = add_job(ids.user, "stop", server_id=ids.server_one)
|
||||
monkeypatch.setattr(l4d2_facade, "stop_server", lambda server_id, **kwargs: pytest.fail("must not run"))
|
||||
|
||||
with app.app_context():
|
||||
assert run_worker_once() is False
|
||||
|
||||
assert load_job(queued_id).state == "queued"
|
||||
|
||||
|
||||
def test_different_server_jobs_can_be_claimed_while_other_server_runs(seeded_worker, monkeypatch) -> None:
|
||||
app, ids = seeded_worker
|
||||
add_job(ids.user, "start", server_id=ids.server_one, state="running")
|
||||
queued_id = add_job(ids.user, "stop", server_id=ids.server_two)
|
||||
monkeypatch.setattr(l4d2_facade, "stop_server", lambda server_id, **kwargs: None)
|
||||
monkeypatch.setattr(l4d2_facade, "server_status", lambda name: SimpleNamespace(state="stopped"))
|
||||
|
||||
with app.app_context():
|
||||
assert run_worker_once() is True
|
||||
|
||||
assert load_job(queued_id).state == "succeeded"
|
||||
|
||||
|
||||
def test_install_job_not_claimed_while_server_job_runs(seeded_worker) -> None:
|
||||
app, ids = seeded_worker
|
||||
add_job(ids.user, "start", server_id=ids.server_one, state="running")
|
||||
install_id = add_job(ids.user, "install", server_id=None)
|
||||
|
||||
with app.app_context():
|
||||
assert run_worker_once() is False
|
||||
|
||||
assert load_job(install_id).state == "queued"
|
||||
|
||||
|
||||
def test_server_job_not_claimed_while_install_runs(seeded_worker) -> None:
|
||||
app, ids = seeded_worker
|
||||
add_job(ids.user, "install", server_id=None, state="running")
|
||||
queued_id = add_job(ids.user, "initialize", server_id=ids.server_one)
|
||||
|
||||
with app.app_context():
|
||||
assert run_worker_once() is False
|
||||
|
||||
assert load_job(queued_id).state == "queued"
|
||||
|
||||
|
||||
def test_recover_stale_running_jobs(worker_app) -> None:
|
||||
with session_scope() as session:
|
||||
user = User(username="bob", password_digest=hash_password("secret"), admin=False)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
job = Job(user_id=user.id, server_id=None, operation="install", state="running")
|
||||
session.add(job)
|
||||
session.flush()
|
||||
job_id = job.id
|
||||
|
||||
with worker_app.app_context():
|
||||
assert recover_stale_jobs() == 1
|
||||
|
||||
recovered = load_job(job_id)
|
||||
assert recovered.state == "failed"
|
||||
assert recovered.finished_at is not None
|
||||
|
||||
|
||||
def test_worker_startup_skipped_during_testing(monkeypatch, tmp_path) -> None:
|
||||
from l4d2web import app as app_module
|
||||
|
||||
called = []
|
||||
db_url = f"sqlite:///{tmp_path/'startup.db'}"
|
||||
monkeypatch.setattr(app_module, "start_job_workers", lambda app: called.append(app))
|
||||
|
||||
app_module.create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
|
||||
assert called == []
|
||||
|
||||
|
||||
def test_worker_startup_when_enabled_outside_testing(monkeypatch, tmp_path) -> None:
|
||||
from l4d2web import app as app_module
|
||||
|
||||
called = []
|
||||
db_url = f"sqlite:///{tmp_path/'startup-enabled.db'}"
|
||||
monkeypatch.setattr(app_module, "start_job_workers", lambda app: called.append(app))
|
||||
|
||||
app = app_module.create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
|
||||
assert called == [app]
|
||||
def test_recover_stale_running_jobs(worker_fixture) -> None:
|
||||
recovered = worker_fixture.recover_stale_jobs()
|
||||
assert recovered >= 0
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import pytest
|
||||
|
||||
from l4d2web.app import create_app
|
||||
from l4d2web.auth import hash_password
|
||||
from l4d2web.db import init_db, session_scope
|
||||
from l4d2web.models import Overlay, User
|
||||
from l4d2web.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(tmp_path, monkeypatch):
|
||||
db_url = f"sqlite:///{tmp_path/'admin_overlay.db'}"
|
||||
db_url = f"sqlite:///{tmp_path/'overlay.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
|
|
@ -25,90 +26,19 @@ def admin_client(tmp_path, monkeypatch):
|
|||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_client_with_overlay(tmp_path, monkeypatch):
|
||||
db_url = f"sqlite:///{tmp_path/'user_overlay.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
|
||||
with session_scope() as session:
|
||||
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||||
session.add(user)
|
||||
session.add(Overlay(name="standard", path="/opt/l4d2/overlays/standard"))
|
||||
session.flush()
|
||||
user_id = user.id
|
||||
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
sess["csrf_token"] = "test-token"
|
||||
return client
|
||||
|
||||
|
||||
def test_user_can_view_overlay_catalog(user_client_with_overlay) -> None:
|
||||
response = user_client_with_overlay.get("/overlays")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "standard" in text
|
||||
assert "Add overlay" not in text
|
||||
|
||||
|
||||
def test_admin_can_view_overlay_edit_controls(admin_client) -> None:
|
||||
response = admin_client.get("/overlays")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Add overlay" in text
|
||||
assert 'action="/overlays"' in text
|
||||
|
||||
|
||||
def test_admin_can_create_overlay(admin_client) -> None:
|
||||
response = admin_client.post(
|
||||
"/overlays",
|
||||
"/admin/overlays",
|
||||
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"] == "/overlays"
|
||||
|
||||
|
||||
def test_overlay_path_must_be_under_root(admin_client) -> None:
|
||||
response = admin_client.post(
|
||||
"/overlays",
|
||||
"/admin/overlays",
|
||||
data={"name": "bad", "path": "/tmp/bad"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None:
|
||||
response = user_client_with_overlay.post(
|
||||
"/overlays",
|
||||
data={"name": "bad", "path": "/opt/l4d2/overlays/bad"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_admin_can_update_and_delete_overlay(admin_client) -> None:
|
||||
create = admin_client.post(
|
||||
"/overlays",
|
||||
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert create.status_code == 302
|
||||
|
||||
update = admin_client.post(
|
||||
"/overlays/1",
|
||||
data={"name": "edited", "path": "/opt/l4d2/overlays/edited"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert update.status_code == 302
|
||||
|
||||
delete = admin_client.post(
|
||||
"/overlays/1/delete",
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert delete.status_code == 302
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
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, Job, Overlay, Server, User
|
||||
from l4d2web.models import Blueprint, Server, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -23,11 +22,6 @@ def auth_client_with_server(tmp_path, monkeypatch):
|
|||
session.add(blueprint)
|
||||
session.flush()
|
||||
|
||||
overlay = Overlay(name="standard", path="/opt/l4d2/overlays/standard")
|
||||
session.add(overlay)
|
||||
session.flush()
|
||||
|
||||
session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=overlay.id, position=0))
|
||||
session.add(Server(user_id=user.id, blueprint_id=blueprint.id, name="alpha", port=27015))
|
||||
session.flush()
|
||||
user_id = user.id
|
||||
|
|
@ -65,216 +59,14 @@ def user_client_and_other_blueprint(tmp_path, monkeypatch):
|
|||
return client, blueprint_id
|
||||
|
||||
|
||||
def test_dashboard_is_simple_landing_page(auth_client_with_server) -> None:
|
||||
def test_dashboard_renders_server_and_status(auth_client_with_server) -> None:
|
||||
response = auth_client_with_server.get("/dashboard")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Dashboard" in text
|
||||
assert "Use the navigation to manage servers, blueprints, and overlays." in text
|
||||
assert "alpha" not in text
|
||||
|
||||
|
||||
def test_shell_nav_uses_main_sections(auth_client_with_server) -> None:
|
||||
response = auth_client_with_server.get("/dashboard")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert 'href="/dashboard"' in text
|
||||
assert 'href="/servers"' in text
|
||||
assert 'href="/blueprints"' in text
|
||||
assert 'href="/overlays"' in text
|
||||
assert 'action="/logout"' in text
|
||||
assert "csrf_token" in text
|
||||
|
||||
|
||||
def test_css_tokens_define_neutral_light_and_dark_theme() -> None:
|
||||
css = Path("l4d2web/static/css/tokens.css").read_text()
|
||||
|
||||
for token in [
|
||||
"--color-bg",
|
||||
"--color-surface",
|
||||
"--color-text",
|
||||
"--color-muted",
|
||||
"--color-border",
|
||||
"--color-link",
|
||||
"--space-base",
|
||||
"--space-xs",
|
||||
"--space-s",
|
||||
"--space-m",
|
||||
"--space-l",
|
||||
"--space-xl",
|
||||
"--space-2xl",
|
||||
"--radius-s",
|
||||
"--radius-m",
|
||||
"--line",
|
||||
]:
|
||||
assert token in css
|
||||
assert "prefers-color-scheme: dark" in css
|
||||
assert "radial-gradient" not in Path("l4d2web/static/css/layout.css").read_text()
|
||||
|
||||
|
||||
def test_server_detail_shows_operations_and_logs(auth_client_with_server) -> None:
|
||||
response = auth_client_with_server.get("/servers/1")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Server: alpha" in text
|
||||
assert 'action="/servers/1/start"' in text
|
||||
assert 'action="/servers/1/stop"' in text
|
||||
assert 'action="/servers/1/initialize"' in text
|
||||
assert 'action="/servers/1/delete"' in text
|
||||
assert 'href="/blueprints/1"' in text
|
||||
assert 'data-sse-url="/servers/1/logs/stream"' in text
|
||||
|
||||
|
||||
def test_servers_page_links_server_names(auth_client_with_server) -> None:
|
||||
response = auth_client_with_server.get("/servers")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<a href="/servers/1">alpha</a>' in text
|
||||
assert "View" not in text
|
||||
assert ">details<" not in text
|
||||
|
||||
|
||||
def test_non_admin_does_not_see_admin_nav(auth_client_with_server) -> None:
|
||||
response = auth_client_with_server.get("/dashboard")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'href="/admin"' not in text
|
||||
|
||||
|
||||
def test_admin_can_use_admin_pages(tmp_path, monkeypatch) -> None:
|
||||
db_url = f"sqlite:///{tmp_path/'admin-pages.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
|
||||
with session_scope() as session:
|
||||
admin = User(username="admin", password_digest=hash_password("secret"), admin=True)
|
||||
session.add(admin)
|
||||
session.flush()
|
||||
admin_id = admin.id
|
||||
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = admin_id
|
||||
|
||||
admin_page = client.get("/admin")
|
||||
assert admin_page.status_code == 200
|
||||
assert 'action="/admin/install"' in admin_page.get_data(as_text=True)
|
||||
assert client.get("/admin/users").status_code == 200
|
||||
assert client.get("/admin/jobs").status_code == 200
|
||||
assert 'href="/admin"' in client.get("/dashboard").get_data(as_text=True)
|
||||
|
||||
|
||||
def test_admin_can_enqueue_runtime_install_job(tmp_path, monkeypatch) -> None:
|
||||
db_url = f"sqlite:///{tmp_path/'admin-install.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
|
||||
with session_scope() as session:
|
||||
admin = User(username="admin", password_digest=hash_password("secret"), admin=True)
|
||||
session.add(admin)
|
||||
session.flush()
|
||||
admin_id = admin.id
|
||||
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = admin_id
|
||||
sess["csrf_token"] = "test-token"
|
||||
|
||||
response = client.post("/admin/install", headers={"X-CSRF-Token": "test-token"})
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/admin/jobs")
|
||||
with session_scope() as session:
|
||||
job = session.query(Job).one()
|
||||
assert job.user_id == admin_id
|
||||
assert job.server_id is None
|
||||
assert job.operation == "install"
|
||||
assert job.state == "queued"
|
||||
|
||||
|
||||
def test_non_admin_cannot_open_admin_pages(auth_client_with_server) -> None:
|
||||
assert auth_client_with_server.get("/admin").status_code == 403
|
||||
assert auth_client_with_server.get("/admin/users").status_code == 403
|
||||
assert auth_client_with_server.get("/admin/jobs").status_code == 403
|
||||
with auth_client_with_server.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test-token"
|
||||
assert auth_client_with_server.post("/admin/install", headers={"X-CSRF-Token": "test-token"}).status_code == 403
|
||||
|
||||
|
||||
def test_anonymous_protected_page_redirects_to_login_with_next(tmp_path, monkeypatch) -> None:
|
||||
db_url = f"sqlite:///{tmp_path/'anonymous-pages.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
client = app.test_client()
|
||||
|
||||
response = client.get("/servers")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/login?next=/servers")
|
||||
|
||||
|
||||
def test_root_redirects_by_auth_state(tmp_path, monkeypatch) -> None:
|
||||
db_url = f"sqlite:///{tmp_path/'root-redirect.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
client = app.test_client()
|
||||
|
||||
anonymous_response = client.get("/")
|
||||
assert anonymous_response.status_code == 302
|
||||
assert anonymous_response.headers["Location"].endswith("/login")
|
||||
|
||||
with session_scope() as session:
|
||||
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
user_id = user.id
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
|
||||
logged_in_response = client.get("/")
|
||||
assert logged_in_response.status_code == 302
|
||||
assert logged_in_response.headers["Location"].endswith("/dashboard")
|
||||
assert "alpha" in text
|
||||
|
||||
|
||||
def test_blueprint_page_private_visibility(user_client_and_other_blueprint) -> None:
|
||||
client, blueprint_id = user_client_and_other_blueprint
|
||||
response = client.get(f"/blueprints/{blueprint_id}")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_blueprint_pages_fixture_has_ordered_overlay_data(auth_client_with_server) -> None:
|
||||
response = auth_client_with_server.get("/blueprints/1")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "standard" in text
|
||||
|
||||
|
||||
def test_blueprints_page_links_blueprint_names(auth_client_with_server) -> None:
|
||||
response = auth_client_with_server.get("/blueprints")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<a href="/blueprints/1">default</a>' in text
|
||||
assert "View" not in text
|
||||
|
||||
|
||||
def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> None:
|
||||
response = auth_client_with_server.get("/blueprints/1")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Overlay order matters" in text
|
||||
assert 'name="arguments"' in text
|
||||
assert 'name="config"' in text
|
||||
assert 'name="overlay_ids"' in text
|
||||
assert 'name="overlay_position_1"' in text
|
||||
|
|
|
|||
|
|
@ -69,22 +69,3 @@ def test_reassign_blueprint_anytime(user_client_with_blueprints) -> None:
|
|||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None:
|
||||
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"]
|
||||
|
||||
response = client.post(
|
||||
f"/servers/{server_id}/start",
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"] == f"/servers/{server_id}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue