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: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
|
||||||
actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
||||||
updated_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)
|
stream: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
line: Mapped[str] = mapped_column(Text, nullable=False)
|
line: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, 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.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:
|
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 is not None
|
||||||
assert user.password_changed_at >= before
|
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