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:
parent
a5f7b736a2
commit
0f825686c6
4 changed files with 304 additions and 1 deletions
111
l4d2web/alembic/versions/0010_server_live_state.py
Normal file
111
l4d2web/alembic/versions/0010_server_live_state.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
69
l4d2web/tests/test_migrations.py
Normal file
69
l4d2web/tests/test_migrations.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue