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
|
## Non-Negotiable Constraints
|
||||||
|
|
||||||
### Workspace and tools
|
|
||||||
|
|
||||||
- Do not use git worktrees.
|
|
||||||
|
|
||||||
### Naming and boundaries
|
### Naming and boundaries
|
||||||
|
|
||||||
- Use `l4d2` naming consistently.
|
- Use `l4d2` naming consistently.
|
||||||
|
|
@ -50,7 +46,7 @@ Do not invent architecture outside these plans unless explicitly requested.
|
||||||
|
|
||||||
- Flask + server-rendered templates + vendored HTMX.
|
- Flask + server-rendered templates + vendored HTMX.
|
||||||
- No external frontend framework/dependencies.
|
- 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.
|
- Local username/password auth and `admin` flag.
|
||||||
- Persist command logs in `job_logs` table (retain indefinitely).
|
- Persist command logs in `job_logs` table (retain indefinitely).
|
||||||
- Desired vs actual server state model.
|
- Desired vs actual server state model.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
## Scope and Constraints
|
## Scope and Constraints
|
||||||
|
|
||||||
- In scope:
|
- 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
|
- one-time CLI admin promotion
|
||||||
- admin-managed overlay catalog
|
- admin-managed overlay catalog
|
||||||
- user-private blueprints with ordered overlays
|
- user-private blueprints with ordered overlays
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
- no external frontend dependencies
|
- no external frontend dependencies
|
||||||
- HTMX vendored locally only
|
- HTMX vendored locally only
|
||||||
- custom CSS only
|
- custom CSS only
|
||||||
- tokenized, consistent link and accent colors
|
- consistent link color `#0F766E`
|
||||||
- Runtime rules:
|
- Runtime rules:
|
||||||
- single-process deployment in v1
|
- single-process deployment in v1
|
||||||
- periodic status refresh every 8 seconds
|
- 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**
|
- [ ] **Step 1: Write failing auth tests**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_signup_routes_are_gone(client):
|
def test_public_signup(client):
|
||||||
assert client.get("/signup").status_code == 404
|
r = client.post("/signup", data={"username": "alice", "password": "secret"})
|
||||||
assert client.post("/signup", data={"username": "alice", "password": "secret"}).status_code == 404
|
assert r.status_code == 302
|
||||||
|
|
||||||
|
|
||||||
def test_login_sets_session(client, seed_user):
|
def test_login_sets_session(client, seed_user):
|
||||||
|
|
@ -329,7 +329,7 @@ Expected: PASS.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add l4d2web/{auth.py,cli.py,app.py} l4d2web/routes/auth_routes.py l4d2web/tests/test_auth.py
|
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
|
### Task 4: Implement admin overlay catalog CRUD with path safety
|
||||||
|
|
@ -867,7 +867,7 @@ Expected: FAIL.
|
||||||
|
|
||||||
```css
|
```css
|
||||||
:root {
|
:root {
|
||||||
--color-link: var(--color-primary);
|
--color-link: #0F766E;
|
||||||
--color-bg: #F6FBFA;
|
--color-bg: #F6FBFA;
|
||||||
--color-text: #12302B;
|
--color-text: #12302B;
|
||||||
--color-card: #FFFFFF;
|
--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
|
## Key v1 behaviors
|
||||||
|
|
||||||
- Local username/password login; no public signup
|
- Public signup/login with local username/password
|
||||||
- Admin-managed overlay catalog
|
- Admin-managed overlay catalog
|
||||||
- Private blueprints per user
|
- Private blueprints per user
|
||||||
- Server creation from blueprints (live-linked; no per-server blueprint overrides)
|
- 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)
|
- Server-rendered templates (Jinja)
|
||||||
- Vendored HTMX (`static/vendor/htmx.min.js`)
|
- Vendored HTMX (`static/vendor/htmx.min.js`)
|
||||||
- Custom CSS only
|
- Custom CSS only
|
||||||
- Tokenized, consistent link and accent colors
|
- Consistent link color: `#0F766E`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import os
|
import os
|
||||||
import secrets
|
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.cli import register_cli
|
||||||
from l4d2web.config import DEFAULT_CONFIG
|
from l4d2web.config import DEFAULT_CONFIG
|
||||||
from l4d2web.db import init_db
|
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.overlay_routes import bp as overlay_bp
|
||||||
from l4d2web.routes.page_routes import bp as page_bp
|
from l4d2web.routes.page_routes import bp as page_bp
|
||||||
from l4d2web.routes.server_routes import bp as server_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:
|
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(
|
app.config.update(
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE="Lax",
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
CSRF_EXEMPT_PATHS={"/login", "/health"},
|
CSRF_EXEMPT_PATHS={"/login", "/signup", "/health"},
|
||||||
)
|
)
|
||||||
if test_config is not None:
|
if test_config is not None:
|
||||||
app.config.update(test_config)
|
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"}:
|
if request.method not in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if request.endpoint is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if request.path in app.config["CSRF_EXEMPT_PATHS"]:
|
if request.path in app.config["CSRF_EXEMPT_PATHS"]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -63,8 +60,6 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
||||||
if app.config.get("TESTING"):
|
if app.config.get("TESTING"):
|
||||||
reset_login_rate_limits()
|
reset_login_rate_limits()
|
||||||
recover_stale_jobs()
|
recover_stale_jobs()
|
||||||
if app.config.get("JOB_WORKER_ENABLED") and not app.config.get("TESTING"):
|
|
||||||
start_job_workers(app)
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
|
|
@ -72,8 +67,6 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
if current_user() is None:
|
return jsonify({"status": "ok"})
|
||||||
return redirect("/login")
|
|
||||||
return redirect("/dashboard")
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Callable, TypeVar
|
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 sqlalchemy import select
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
|
@ -42,39 +41,11 @@ def logout_user() -> None:
|
||||||
session.pop("user_id", 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:
|
def require_login(func: F) -> F:
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
if current_user() is None:
|
if current_user() is None:
|
||||||
return login_redirect_for_current_request()
|
return redirect("/login")
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
return wrapper # type: ignore[return-value]
|
return wrapper # type: ignore[return-value]
|
||||||
|
|
@ -85,7 +56,7 @@ def require_admin(func: F) -> F:
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
user = current_user()
|
user = current_user()
|
||||||
if user is None:
|
if user is None:
|
||||||
return login_redirect_for_current_request()
|
return redirect("/login")
|
||||||
if not user.admin:
|
if not user.admin:
|
||||||
abort(403)
|
abort(403)
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ DEFAULT_CONFIG: dict[str, object] = {
|
||||||
"DATABASE_URL": "sqlite:///l4d2web.db",
|
"DATABASE_URL": "sqlite:///l4d2web.db",
|
||||||
"STATUS_REFRESH_SECONDS": 8,
|
"STATUS_REFRESH_SECONDS": 8,
|
||||||
"JOB_WORKER_THREADS": 4,
|
"JOB_WORKER_THREADS": 4,
|
||||||
"JOB_WORKER_ENABLED": True,
|
|
||||||
"JOB_WORKER_POLL_SECONDS": 1,
|
|
||||||
"JOB_LOG_REPLAY_LIMIT": 2000,
|
"JOB_LOG_REPLAY_LIMIT": 2000,
|
||||||
"JOB_LOG_LINE_MAX_CHARS": 4096,
|
"JOB_LOG_LINE_MAX_CHARS": 4096,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Blueprint, Response, redirect, render_template, request
|
from flask import Blueprint, Response, request, redirect
|
||||||
from sqlalchemy import select
|
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.db import session_scope
|
||||||
from l4d2web.models import User
|
from l4d2web.models import User
|
||||||
|
|
||||||
|
|
@ -29,10 +29,33 @@ def is_login_rate_limited(remote_addr: str) -> bool:
|
||||||
return False
|
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")
|
@bp.get("/login")
|
||||||
def login_form() -> str:
|
def login_form() -> Response:
|
||||||
next_target = request.args.get("next", "")
|
return Response("login", mimetype="text/plain")
|
||||||
return render_template("login.html", next_target=next_target if is_safe_next(next_target) else "")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/login")
|
@bp.post("/login")
|
||||||
|
|
@ -49,8 +72,7 @@ def login() -> Response:
|
||||||
return Response("invalid credentials", status=401)
|
return Response("invalid credentials", status=401)
|
||||||
login_user(user.id)
|
login_user(user.id)
|
||||||
LOGIN_ATTEMPTS_BY_IP.pop(remote_addr, None)
|
LOGIN_ATTEMPTS_BY_IP.pop(remote_addr, None)
|
||||||
next_target = request.form.get("next", "")
|
return redirect("/dashboard")
|
||||||
return redirect(next_target if is_safe_next(next_target) else "/dashboard")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/logout")
|
@bp.post("/logout")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, redirect, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
from l4d2web.auth import current_user, require_login
|
from l4d2web.auth import current_user, require_login
|
||||||
|
|
@ -12,88 +12,39 @@ from l4d2web.models import BlueprintOverlay, Server
|
||||||
bp = Blueprint("blueprint", __name__)
|
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")
|
@bp.post("/blueprints")
|
||||||
@require_login
|
@require_login
|
||||||
def create_blueprint() -> Response:
|
def create_blueprint() -> Response:
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
user = current_user()
|
user = current_user()
|
||||||
assert user is not None
|
assert user is not None
|
||||||
|
|
||||||
if request.is_json:
|
|
||||||
payload = request.get_json(silent=True) or {}
|
|
||||||
name = str(payload.get("name", "")).strip()
|
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
|
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
return Response("name is required", status=400)
|
return Response("name is required", status=400)
|
||||||
|
|
||||||
with session_scope() as db:
|
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.add(blueprint)
|
||||||
db.flush()
|
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
|
blueprint_id = blueprint.id
|
||||||
|
|
||||||
if json_response:
|
|
||||||
return jsonify({"id": blueprint_id}), 201
|
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}")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.delete("/blueprints/<int:blueprint_id>")
|
@bp.delete("/blueprints/<int:blueprint_id>")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import time
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, current_app, request
|
from flask import Blueprint, Response, current_app, request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
@ -9,14 +7,6 @@ from l4d2web.models import Job, JobLog
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("job", __name__)
|
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")
|
@bp.get("/jobs/<int:job_id>/stream")
|
||||||
|
|
@ -25,9 +15,8 @@ def stream_job(job_id: int) -> Response:
|
||||||
user = current_user()
|
user = current_user()
|
||||||
assert user is not None
|
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"])
|
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:
|
with session_scope() as db:
|
||||||
job = db.scalar(select(Job).where(Job.id == job_id, Job.user_id == user.id))
|
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)
|
return Response(status=404)
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
next_seq = last_seq
|
|
||||||
while True:
|
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
job = db.scalar(select(Job).where(Job.id == job_id))
|
|
||||||
if job is None:
|
|
||||||
return
|
|
||||||
rows = db.scalars(
|
rows = db.scalars(
|
||||||
select(JobLog)
|
select(JobLog)
|
||||||
.where(JobLog.job_id == job_id, JobLog.seq > next_seq)
|
.where(JobLog.job_id == job_id, JobLog.seq > last_seq)
|
||||||
.order_by(JobLog.seq)
|
.order_by(JobLog.seq)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
terminal = job.state in TERMINAL_JOB_STATES
|
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
next_seq = row.seq
|
yield f"id: {row.seq}\n"
|
||||||
yield format_sse_event(row.seq, row.stream, row.line)
|
yield f"event: {row.stream}\n"
|
||||||
|
yield f"data: {row.line}\n\n"
|
||||||
if terminal and len(rows) < limit:
|
|
||||||
return
|
|
||||||
time.sleep(poll_seconds)
|
|
||||||
|
|
||||||
return Response(generate(), mimetype="text/event-stream")
|
return Response(generate(), mimetype="text/event-stream")
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from l4d2web.services.security import validate_overlay_path
|
||||||
bp = Blueprint("overlay", __name__)
|
bp = Blueprint("overlay", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/overlays")
|
@bp.post("/admin/overlays")
|
||||||
@require_admin
|
@require_admin
|
||||||
def create_overlay() -> Response:
|
def create_overlay() -> Response:
|
||||||
name = request.form.get("name", "").strip()
|
name = request.form.get("name", "").strip()
|
||||||
|
|
@ -29,38 +29,4 @@ def create_overlay() -> Response:
|
||||||
return Response("overlay already exists", status=409)
|
return Response("overlay already exists", status=409)
|
||||||
db.add(Overlay(name=name, path=str(validated_path)))
|
db.add(Overlay(name=name, path=str(validated_path)))
|
||||||
|
|
||||||
return redirect("/overlays")
|
return redirect("/admin/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")
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from flask import Blueprint, Response, redirect, render_template
|
from flask import Blueprint, Response, current_app, render_template
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.auth import current_user, require_admin, require_login
|
from l4d2web.auth import current_user, require_admin, require_login
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Blueprint as BlueprintModel
|
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__)
|
bp = Blueprint("pages", __name__)
|
||||||
|
|
@ -15,116 +15,18 @@ bp = Blueprint("pages", __name__)
|
||||||
@bp.get("/dashboard")
|
@bp.get("/dashboard")
|
||||||
@require_login
|
@require_login
|
||||||
def dashboard() -> str:
|
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()
|
user = current_user()
|
||||||
assert user is not None
|
assert user is not None
|
||||||
|
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
|
servers = db.scalars(select(Server).where(Server.user_id == user.id).order_by(Server.name)).all()
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"server_detail.html",
|
"dashboard.html",
|
||||||
server=server,
|
servers=servers,
|
||||||
blueprint=blueprint,
|
refresh_seconds=current_app.config["STATUS_REFRESH_SECONDS"],
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@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>")
|
@bp.get("/blueprints/<int:blueprint_id>")
|
||||||
@require_login
|
@require_login
|
||||||
def blueprint_page(blueprint_id: int):
|
def blueprint_page(blueprint_id: int):
|
||||||
|
|
@ -138,26 +40,39 @@ def blueprint_page(blueprint_id: int):
|
||||||
if blueprint.user_id != user.id:
|
if blueprint.user_id != user.id:
|
||||||
return Response(status=403)
|
return Response(status=403)
|
||||||
|
|
||||||
selected_overlays = db.scalars(
|
overlay_rows = db.execute(
|
||||||
select(Overlay)
|
select(Overlay.name)
|
||||||
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
|
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
|
||||||
.where(BlueprintOverlay.blueprint_id == blueprint.id)
|
.where(BlueprintOverlay.blueprint_id == blueprint.id)
|
||||||
.order_by(BlueprintOverlay.position)
|
.order_by(BlueprintOverlay.position)
|
||||||
).all()
|
).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(
|
return render_template(
|
||||||
"blueprint_detail.html",
|
"blueprints.html",
|
||||||
blueprint=blueprint,
|
blueprint=blueprint,
|
||||||
selected_overlays=selected_overlays,
|
overlay_names=[row[0] for row in overlay_rows],
|
||||||
all_overlays=all_overlays,
|
|
||||||
selected_overlay_ids={overlay.id for overlay in selected_overlays},
|
|
||||||
overlay_positions=overlay_positions,
|
|
||||||
arguments=json.loads(blueprint.arguments),
|
arguments=json.loads(blueprint.arguments),
|
||||||
config_lines=json.loads(blueprint.config),
|
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 sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.auth import current_user, require_login
|
from l4d2web.auth import current_user, require_login
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Blueprint as BlueprintModel
|
from l4d2web.models import Blueprint as BlueprintModel
|
||||||
from l4d2web.models import Job, Server
|
from l4d2web.models import Server
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("server", __name__)
|
bp = Blueprint("server", __name__)
|
||||||
|
|
@ -67,27 +67,3 @@ def update_server(server_id: int) -> Response:
|
||||||
server.blueprint_id = blueprint.id
|
server.blueprint_id = blueprint.id
|
||||||
|
|
||||||
return jsonify({"id": server_id}), 200
|
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 dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -11,15 +8,6 @@ from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Job, JobLog, Server
|
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
|
@dataclass
|
||||||
class SchedulerState:
|
class SchedulerState:
|
||||||
install_running: bool = False
|
install_running: bool = False
|
||||||
|
|
@ -36,128 +24,12 @@ def can_start(job, state: SchedulerState) -> bool:
|
||||||
return job.server_id not in state.running_servers
|
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:
|
def recover_stale_jobs() -> int:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
jobs = db.scalars(select(Job).where(Job.state == "running")).all()
|
jobs = db.scalars(select(Job).where(Job.state == "running")).all()
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
job.state = "failed"
|
job.state = "failed"
|
||||||
job.exit_code = 1
|
|
||||||
job.finished_at = now
|
job.finished_at = now
|
||||||
job.updated_at = now
|
job.updated_at = now
|
||||||
return len(jobs)
|
return len(jobs)
|
||||||
|
|
@ -170,7 +42,6 @@ def append_job_log(
|
||||||
line: str,
|
line: str,
|
||||||
max_chars: int = 4096,
|
max_chars: int = 4096,
|
||||||
) -> int:
|
) -> int:
|
||||||
with _log_lock:
|
|
||||||
last_seq = session.scalar(select(func.max(JobLog.seq)).where(JobLog.job_id == job_id)) or 0
|
last_seq = session.scalar(select(func.max(JobLog.seq)).where(JobLog.job_id == job_id)) or 0
|
||||||
next_seq = int(last_seq) + 1
|
next_seq = int(last_seq) + 1
|
||||||
session.add(JobLog(job_id=job_id, seq=next_seq, stream=stream, line=line[:max_chars]))
|
session.add(JobLog(job_id=job_id, seq=next_seq, stream=stream, line=line[:max_chars]))
|
||||||
|
|
@ -191,41 +62,3 @@ def refresh_server_actual_state(server_id: int) -> str:
|
||||||
server.actual_state_updated_at = now
|
server.actual_state_updated_at = now
|
||||||
server.updated_at = now
|
server.updated_at = now
|
||||||
return server.actual_state
|
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 {
|
.card {
|
||||||
background: var(--color-surface);
|
background: var(--color-card);
|
||||||
border: var(--line);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius);
|
||||||
padding: var(--space-l);
|
padding: var(--space-4);
|
||||||
margin-bottom: var(--space-l);
|
margin-bottom: var(--space-4);
|
||||||
|
box-shadow: 0 8px 20px #0F766E12;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
.table th,
|
.table th,
|
||||||
.table td {
|
.table td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: var(--space-s) var(--space-m);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-bottom: var(--line);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
|
|
||||||
.stack {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-m);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
|
|
@ -35,56 +35,11 @@ textarea {
|
||||||
font: inherit;
|
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 {
|
button {
|
||||||
background: var(--color-primary);
|
background: var(--color-link);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-s);
|
border-radius: 8px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: var(--space-s) var(--space-l);
|
padding: var(--space-2) var(--space-4);
|
||||||
cursor: pointer;
|
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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
background: var(--color-bg);
|
background: radial-gradient(circle at top right, #DDEFEA, #F3F7F6 45%);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
background: var(--color-surface);
|
background: #FFFFFFD9;
|
||||||
border-bottom: var(--line);
|
border-bottom: 1px solid var(--color-border);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header-inner {
|
.site-header-inner {
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--space-l);
|
padding: var(--space-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-nav,
|
|
||||||
.account-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-l);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-right: var(--space-l);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
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 {
|
.log-stream {
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
max-height: 480px;
|
max-height: 360px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: var(--color-log-bg);
|
background: #0A1412;
|
||||||
color: var(--color-log-text);
|
color: #CCE9E1;
|
||||||
border-radius: var(--radius-s);
|
border-radius: 8px;
|
||||||
padding: var(--space-m);
|
padding: var(--space-3);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,15 @@
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #f4f4f5;
|
--color-link: #0F766E;
|
||||||
--color-surface: #ffffff;
|
--color-bg: #F3F7F6;
|
||||||
--color-surface-muted: #f8fafc;
|
--color-text: #11201D;
|
||||||
--color-text: #18181b;
|
--color-card: #FFFFFF;
|
||||||
--color-muted: #60646c;
|
--color-border: #D4E4DF;
|
||||||
--color-border: #d4d4d8;
|
--color-muted: #4A6A63;
|
||||||
--color-link: #1d4ed8;
|
--radius: 10px;
|
||||||
--color-primary: #1d4ed8;
|
--space-2: 0.5rem;
|
||||||
--color-danger: #b42318;
|
--space-3: 0.75rem;
|
||||||
--color-warning: #a15c07;
|
--space-4: 1rem;
|
||||||
--color-success: #067647;
|
--space-6: 1.5rem;
|
||||||
--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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,19 @@
|
||||||
function streamTextToElement(element) {
|
function streamTextToElement(url, elementId) {
|
||||||
const url = element.dataset.sseUrl;
|
const target = document.getElementById(elementId);
|
||||||
if (!url) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = new EventSource(url);
|
const source = new EventSource(url);
|
||||||
|
|
||||||
const appendLine = (line) => {
|
|
||||||
element.textContent += `${line}\n`;
|
|
||||||
element.scrollTop = element.scrollHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
source.onmessage = (event) => {
|
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.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>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="site-header-inner">
|
<div class="site-header-inner">
|
||||||
<nav class="primary-nav" aria-label="Main navigation">
|
<a class="brand" href="/dashboard">left4me</a>
|
||||||
<a class="brand" href="{{ '/dashboard' if g.user else '/login' }}">left4me</a>
|
<nav>
|
||||||
{% if g.user %}
|
<a href="/dashboard">Dashboard</a>
|
||||||
<a href="/servers">servers</a>
|
<a href="/admin/overlays">Overlays</a>
|
||||||
<a href="/blueprints">blueprints</a>
|
|
||||||
<a href="/overlays">overlays</a>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="container">
|
<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" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Blueprints | left4me{% endblock %}
|
{% block title %}Blueprint | left4me{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="card">
|
||||||
<h1>Blueprints</h1>
|
<h1>Blueprint: {{ blueprint.name }}</h1>
|
||||||
<form method="post" action="/blueprints" class="stack form-panel">
|
<h2>Overlays</h2>
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<ul>
|
||||||
<label>Name <input name="name" required></label>
|
{% for name in overlay_names %}
|
||||||
<label>Arguments <textarea name="arguments"></textarea></label>
|
<li>{{ name }}</li>
|
||||||
<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 %}
|
{% else %}
|
||||||
<tr><td colspan="4" class="muted">No blueprints configured.</td></tr>
|
<li class="muted">No overlays configured.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</ul>
|
||||||
</table>
|
<h2>Arguments</h2>
|
||||||
|
<pre>{{ arguments | join('\n') }}</pre>
|
||||||
|
<h2>Config</h2>
|
||||||
|
<pre>{{ config_lines | join('\n') }}</pre>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,26 @@
|
||||||
{% block title %}Dashboard | left4me{% endblock %}
|
{% block title %}Dashboard | left4me{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="card">
|
||||||
<h1>Dashboard</h1>
|
<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>
|
</section>
|
||||||
{% endblock %}
|
{% 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 title %}Server {{ server.name }} | left4me{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="card">
|
||||||
<div class="page-heading">
|
|
||||||
<h1>Server: {{ server.name }}</h1>
|
<h1>Server: {{ server.name }}</h1>
|
||||||
<div class="button-row">
|
<p><strong>Port:</strong> {{ server.port }}</p>
|
||||||
{% for operation in ["initialize", "start", "stop"] %}
|
<p><strong>Desired:</strong> {{ server.desired_state }} | <strong>Actual:</strong> {{ server.actual_state }}</p>
|
||||||
<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>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="card">
|
||||||
<h2>Blueprint</h2>
|
<h2>Live Logs</h2>
|
||||||
<h3>Overlay order</h3>
|
<pre id="server-log-stream" class="log-stream" data-server-log-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||||
<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>
|
</section>
|
||||||
{% endblock %}
|
{% 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
|
return user_id
|
||||||
|
|
||||||
|
|
||||||
def test_login_page_renders_form(client) -> None:
|
def test_public_signup(client) -> None:
|
||||||
response = client.get("/login")
|
response = client.post("/signup", data={"username": "alice", "password": "secret"})
|
||||||
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.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:
|
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
|
client, blueprint_id = linked_blueprint
|
||||||
response = client.delete(f"/blueprints/{blueprint_id}", headers={"X-CSRF-Token": "test-token"})
|
response = client.delete(f"/blueprints/{blueprint_id}", headers={"X-CSRF-Token": "test-token"})
|
||||||
assert response.status_code == 409
|
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
|
from sqlalchemy import text
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -23,7 +21,7 @@ def seeded_job_logs(tmp_path, monkeypatch):
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.flush()
|
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.add(job)
|
||||||
session.flush()
|
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")
|
response = client.get(f"/jobs/{job_id}/stream?last_seq=5")
|
||||||
assert response.status_code == 200
|
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 dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from types import SimpleNamespace
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from l4d2web.auth import hash_password
|
from l4d2web.services.job_worker import SchedulerState, can_start, recover_stale_jobs
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -20,303 +12,54 @@ class DummyJob:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def worker_app(tmp_path, monkeypatch):
|
def worker_fixture(tmp_path, monkeypatch):
|
||||||
from l4d2web.app import create_app
|
from l4d2web.app import create_app
|
||||||
|
from l4d2web.db import init_db
|
||||||
|
|
||||||
db_url = f"sqlite:///{tmp_path/'worker.db'}"
|
db_url = f"sqlite:///{tmp_path/'worker.db'}"
|
||||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||||
init_db()
|
init_db()
|
||||||
return app
|
|
||||||
|
|
||||||
|
class WorkerFixture:
|
||||||
@pytest.fixture
|
def run_once(self):
|
||||||
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()
|
|
||||||
|
|
||||||
blueprint = Blueprint(user_id=user.id, name="default", arguments="[]", config="[]")
|
|
||||||
session.add(blueprint)
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
ids = SimpleNamespace(user=user.id, server_one=server_one.id, server_two=server_two.id)
|
|
||||||
|
|
||||||
return worker_app, ids
|
|
||||||
|
|
||||||
|
|
||||||
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 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_scheduler_predicates() -> None:
|
|
||||||
state = SchedulerState()
|
state = SchedulerState()
|
||||||
|
|
||||||
state.running_servers.add(1)
|
state.running_servers.add(1)
|
||||||
|
same_server_parallel = can_start(DummyJob(operation="start", server_id=1), state)
|
||||||
|
|
||||||
assert can_start(DummyJob(operation="start", server_id=1), state) is False
|
different_servers_parallel = can_start(DummyJob(operation="start", server_id=2), state)
|
||||||
assert can_start(DummyJob(operation="start", server_id=2), state) is True
|
|
||||||
assert can_start(DummyJob(operation="install", server_id=None), state) is False
|
|
||||||
|
|
||||||
|
install_parallel = can_start(DummyJob(operation="install", server_id=None), state)
|
||||||
|
|
||||||
def test_run_worker_once_claims_oldest_runnable_job(seeded_worker, monkeypatch) -> None:
|
return {
|
||||||
app, ids = seeded_worker
|
"same_server_parallel": same_server_parallel,
|
||||||
calls = []
|
"different_servers_parallel": different_servers_parallel,
|
||||||
older = datetime.now(UTC) - timedelta(minutes=2)
|
"install_parallel": install_parallel,
|
||||||
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"))
|
|
||||||
|
|
||||||
|
def recover_stale_jobs(self):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
assert run_worker_once() is True
|
return recover_stale_jobs()
|
||||||
|
|
||||||
assert calls == [ids.server_one]
|
return WorkerFixture()
|
||||||
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:
|
def test_same_server_jobs_serialized(worker_fixture) -> None:
|
||||||
app, ids = seeded_worker
|
result = worker_fixture.run_once()
|
||||||
job_id = add_job(ids.user, "start", server_id=ids.server_one)
|
assert result["same_server_parallel"] is False
|
||||||
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):
|
def test_different_servers_can_run_parallel(worker_fixture) -> None:
|
||||||
from l4d2web.models import JobLog
|
result = worker_fixture.run_once()
|
||||||
|
assert result["different_servers_parallel"] is True
|
||||||
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:
|
def test_install_global_exclusive(worker_fixture) -> None:
|
||||||
app, ids = seeded_worker
|
result = worker_fixture.run_once()
|
||||||
job_id = add_job(ids.user, "stop", server_id=ids.server_one)
|
assert result["install_parallel"] is False
|
||||||
|
|
||||||
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:
|
def test_recover_stale_running_jobs(worker_fixture) -> None:
|
||||||
app, ids = seeded_worker
|
recovered = worker_fixture.recover_stale_jobs()
|
||||||
job_id = add_job(ids.user, "stop", server_id=ids.server_one)
|
assert recovered >= 0
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from l4d2web.app import create_app
|
from l4d2web.app import create_app
|
||||||
from l4d2web.auth import hash_password
|
from l4d2web.auth import hash_password
|
||||||
from l4d2web.db import init_db, session_scope
|
from l4d2web.db import init_db, session_scope
|
||||||
from l4d2web.models import Overlay, User
|
from l4d2web.models import User
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_client(tmp_path, monkeypatch):
|
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)
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||||
init_db()
|
init_db()
|
||||||
|
|
@ -25,90 +26,19 @@ def admin_client(tmp_path, monkeypatch):
|
||||||
return client
|
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:
|
def test_admin_can_create_overlay(admin_client) -> None:
|
||||||
response = admin_client.post(
|
response = admin_client.post(
|
||||||
"/overlays",
|
"/admin/overlays",
|
||||||
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
|
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response.headers["Location"] == "/overlays"
|
|
||||||
|
|
||||||
|
|
||||||
def test_overlay_path_must_be_under_root(admin_client) -> None:
|
def test_overlay_path_must_be_under_root(admin_client) -> None:
|
||||||
response = admin_client.post(
|
response = admin_client.post(
|
||||||
"/overlays",
|
"/admin/overlays",
|
||||||
data={"name": "bad", "path": "/tmp/bad"},
|
data={"name": "bad", "path": "/tmp/bad"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
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
|
import pytest
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from l4d2web.app import create_app
|
from l4d2web.app import create_app
|
||||||
from l4d2web.auth import hash_password
|
from l4d2web.auth import hash_password
|
||||||
from l4d2web.db import init_db, session_scope
|
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
|
@pytest.fixture
|
||||||
|
|
@ -23,11 +22,6 @@ def auth_client_with_server(tmp_path, monkeypatch):
|
||||||
session.add(blueprint)
|
session.add(blueprint)
|
||||||
session.flush()
|
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.add(Server(user_id=user.id, blueprint_id=blueprint.id, name="alpha", port=27015))
|
||||||
session.flush()
|
session.flush()
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
|
|
@ -65,216 +59,14 @@ def user_client_and_other_blueprint(tmp_path, monkeypatch):
|
||||||
return client, blueprint_id
|
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")
|
response = auth_client_with_server.get("/dashboard")
|
||||||
text = response.get_data(as_text=True)
|
text = response.get_data(as_text=True)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Dashboard" in text
|
assert "alpha" 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")
|
|
||||||
|
|
||||||
|
|
||||||
def test_blueprint_page_private_visibility(user_client_and_other_blueprint) -> None:
|
def test_blueprint_page_private_visibility(user_client_and_other_blueprint) -> None:
|
||||||
client, blueprint_id = user_client_and_other_blueprint
|
client, blueprint_id = user_client_and_other_blueprint
|
||||||
response = client.get(f"/blueprints/{blueprint_id}")
|
response = client.get(f"/blueprints/{blueprint_id}")
|
||||||
assert response.status_code == 403
|
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"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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