feat(live-state): add schema for snapshots, sessions, steam profiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-12 21:18:24 +02:00
parent a5f7b736a2
commit 0f825686c6
No known key found for this signature in database
4 changed files with 304 additions and 1 deletions

View file

@ -0,0 +1,111 @@
"""server_live_state schema
Revision ID: 0010_server_live_state
Revises: 0009_user_password_changed_at
Create Date: 2026-05-12
"""
from __future__ import annotations
import secrets
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0010_server_live_state"
down_revision: Union[str, Sequence[str], None] = "0009_user_password_changed_at"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Add rcon_password to servers, default ''
with op.batch_alter_table("servers") as batch:
batch.add_column(
sa.Column(
"rcon_password",
sa.String(length=64),
nullable=False,
server_default="",
)
)
# 2. Backfill every existing row with a freshly generated password.
conn = op.get_bind()
rows = conn.execute(sa.text("SELECT id FROM servers")).fetchall()
for row in rows:
conn.execute(
sa.text("UPDATE servers SET rcon_password = :pw WHERE id = :id"),
{"pw": secrets.token_urlsafe(32), "id": row.id},
)
# 3. server_live_state — run-length-encoded snapshots
op.create_table(
"server_live_state",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"server_id",
sa.Integer(),
sa.ForeignKey("servers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("last_seen_at", sa.DateTime(), nullable=False),
sa.Column("players", sa.Integer(), nullable=False),
sa.Column("max_players", sa.Integer(), nullable=False),
sa.Column("bots", sa.Integer(), nullable=False),
sa.Column("map", sa.String(length=64), nullable=False),
sa.Column("hibernating", sa.Boolean(), nullable=False),
sqlite_autoincrement=True,
)
op.create_index(
"ix_sls_server_started",
"server_live_state",
["server_id", "started_at"],
)
# 4. server_player_session — connection intervals
op.create_table(
"server_player_session",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"server_id",
sa.Integer(),
sa.ForeignKey("servers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("steam_id_64", sa.String(length=20), nullable=False),
sa.Column("joined_at", sa.DateTime(), nullable=False),
sa.Column("left_at", sa.DateTime(), nullable=True),
sa.Column("name_at_join", sa.String(length=64), nullable=False),
sa.Column("min_ping", sa.Integer(), nullable=False),
sa.Column("max_ping", sa.Integer(), nullable=False),
sqlite_autoincrement=True,
)
op.create_index("ix_sps_server_open", "server_player_session", ["server_id", "left_at"])
op.create_index("ix_sps_server_recent", "server_player_session", ["server_id", "left_at"])
op.create_index(
"ix_sps_steam_history", "server_player_session", ["steam_id_64", "joined_at"]
)
# 5. steam_user_profile — 24h profile cache
op.create_table(
"steam_user_profile",
sa.Column("steam_id_64", sa.String(length=20), primary_key=True),
sa.Column("persona_name", sa.String(length=64), nullable=False),
sa.Column("avatar_url", sa.Text(), nullable=False),
sa.Column("fetched_at", sa.DateTime(), nullable=False),
)
def downgrade() -> None:
op.drop_table("steam_user_profile")
op.drop_index("ix_sps_steam_history", table_name="server_player_session")
op.drop_index("ix_sps_server_recent", table_name="server_player_session")
op.drop_index("ix_sps_server_open", table_name="server_player_session")
op.drop_table("server_player_session")
op.drop_index("ix_sls_server_started", table_name="server_live_state")
op.drop_table("server_live_state")
with op.batch_alter_table("servers") as batch:
batch.drop_column("rcon_password")

View file

@ -143,6 +143,9 @@ class Server(Base):
actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
rcon_password: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
@ -172,3 +175,53 @@ class JobLog(Base):
stream: Mapped[str] = mapped_column(String(8), nullable=False)
line: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
class ServerLiveState(Base):
__tablename__ = "server_live_state"
__table_args__ = (
Index("ix_sls_server_started", "server_id", "started_at"),
{"sqlite_autoincrement": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
server_id: Mapped[int] = mapped_column(
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
)
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
players: Mapped[int] = mapped_column(Integer, nullable=False)
max_players: Mapped[int] = mapped_column(Integer, nullable=False)
bots: Mapped[int] = mapped_column(Integer, nullable=False)
map: Mapped[str] = mapped_column(String(64), nullable=False)
hibernating: Mapped[bool] = mapped_column(Boolean, nullable=False)
class ServerPlayerSession(Base):
__tablename__ = "server_player_session"
__table_args__ = (
Index("ix_sps_server_open", "server_id", "left_at"),
Index("ix_sps_server_recent", "server_id", "left_at"),
Index("ix_sps_steam_history", "steam_id_64", "joined_at"),
{"sqlite_autoincrement": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
server_id: Mapped[int] = mapped_column(
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
)
steam_id_64: Mapped[str] = mapped_column(String(20), nullable=False)
joined_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
name_at_join: Mapped[str] = mapped_column(String(64), nullable=False)
min_ping: Mapped[int] = mapped_column(Integer, nullable=False)
max_ping: Mapped[int] = mapped_column(Integer, nullable=False)
class SteamUserProfile(Base):
__tablename__ = "steam_user_profile"
steam_id_64: Mapped[str] = mapped_column(String(20), primary_key=True)
persona_name: Mapped[str] = mapped_column(String(64), nullable=False)
avatar_url: Mapped[str] = mapped_column(Text, nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)

View file

@ -0,0 +1,69 @@
"""Tests for migration 0010_server_live_state."""
from pathlib import Path
import sqlalchemy as sa
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect
_ALEMBIC_DIR = Path(__file__).resolve().parents[1] / "alembic"
def _alembic_config(db_url: str) -> Config:
cfg = Config()
cfg.set_main_option("script_location", str(_ALEMBIC_DIR))
cfg.set_main_option("sqlalchemy.url", db_url)
return cfg
def test_migration_0010_backfills_rcon_password(tmp_path: Path, monkeypatch) -> None:
db_path = tmp_path / "t.db"
db_url = f"sqlite:///{db_path}"
monkeypatch.setenv("DATABASE_URL", db_url)
cfg = _alembic_config(db_url)
# Run alembic up to 0009 only.
command.upgrade(cfg, "0009_user_password_changed_at")
engine = create_engine(db_url)
with engine.begin() as conn:
conn.execute(sa.text(
"INSERT INTO users (id, username, password_digest, admin, active, "
"created_at, updated_at, password_changed_at) "
"VALUES (1, 'u', 'x', 0, 1, '2026-01-01', '2026-01-01', '2026-01-01')"
))
conn.execute(sa.text(
"INSERT INTO blueprints (id, user_id, name, arguments, config, "
"created_at, updated_at) "
"VALUES (1, 1, 'bp', '[]', '[]', '2026-01-01', '2026-01-01')"
))
conn.execute(sa.text(
"INSERT INTO servers (id, user_id, blueprint_id, name, port, "
"desired_state, actual_state, last_error, created_at, updated_at) "
"VALUES (1, 1, 1, 's1', 27600, 'stopped', 'unknown', '', "
"'2026-01-01', '2026-01-01')"
))
conn.execute(sa.text(
"INSERT INTO servers (id, user_id, blueprint_id, name, port, "
"desired_state, actual_state, last_error, created_at, updated_at) "
"VALUES (2, 1, 1, 's2', 27601, 'stopped', 'unknown', '', "
"'2026-01-01', '2026-01-01')"
))
# Apply migration 0010.
command.upgrade(cfg, "0010_server_live_state")
with engine.connect() as conn:
rows = conn.execute(sa.text("SELECT id, rcon_password FROM servers ORDER BY id")).fetchall()
assert len(rows) == 2
for row in rows:
assert len(row.rcon_password) >= 32
assert row.rcon_password.replace("_", "").replace("-", "").isalnum()
inspector = inspect(engine)
assert "server_live_state" in inspector.get_table_names()
assert "server_player_session" in inspector.get_table_names()
assert "steam_user_profile" in inspector.get_table_names()

View file

@ -1,5 +1,15 @@
from sqlalchemy import select
from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, User
from l4d2web.models import (
Blueprint,
Server,
ServerLiveState,
ServerPlayerSession,
SteamUserProfile,
User,
now_utc as now_utc_aware,
)
def test_create_user_and_blueprint(tmp_path, monkeypatch) -> None:
@ -38,3 +48,63 @@ def test_user_has_password_changed_at_default(tmp_path, monkeypatch):
assert user.password_changed_at is not None
assert user.password_changed_at >= before
def test_server_has_rcon_password_column(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
init_db()
with session_scope() as db:
u = User(username="u", password_digest="x")
db.add(u); db.flush()
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]")
db.add(bp); db.flush()
s = Server(
user_id=u.id, blueprint_id=bp.id, name="s", port=27500,
rcon_password="abc",
)
db.add(s); db.flush()
assert db.scalar(select(Server.rcon_password).where(Server.id == s.id)) == "abc"
def test_server_live_state_table_columns(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
init_db()
with session_scope() as db:
u = User(username="u", password_digest="x"); db.add(u); db.flush()
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]"); db.add(bp); db.flush()
s = Server(user_id=u.id, blueprint_id=bp.id, name="s", port=27501, rcon_password="x")
db.add(s); db.flush()
row = ServerLiveState(
server_id=s.id, started_at=now_utc_aware(), last_seen_at=now_utc_aware(),
players=2, max_players=4, bots=0, map="c1m1_hotel", hibernating=False,
)
db.add(row); db.flush()
def test_server_player_session_table_columns(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
init_db()
with session_scope() as db:
u = User(username="u", password_digest="x"); db.add(u); db.flush()
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]"); db.add(bp); db.flush()
s = Server(user_id=u.id, blueprint_id=bp.id, name="s", port=27502, rcon_password="x")
db.add(s); db.flush()
row = ServerPlayerSession(
server_id=s.id, steam_id_64="76561197960828710",
joined_at=now_utc_aware(), left_at=None,
name_at_join="Crone", min_ping=42, max_ping=185,
)
db.add(row); db.flush()
def test_steam_user_profile_table_columns(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
init_db()
with session_scope() as db:
row = SteamUserProfile(
steam_id_64="76561197960828710",
persona_name="MrCool42",
avatar_url="https://avatars.cloudflare.steamstatic.com/abc_medium.jpg",
fetched_at=now_utc_aware(),
)
db.add(row); db.flush()