diff --git a/l4d2web/alembic/versions/0010_server_live_state.py b/l4d2web/alembic/versions/0010_server_live_state.py new file mode 100644 index 0000000..ec3d770 --- /dev/null +++ b/l4d2web/alembic/versions/0010_server_live_state.py @@ -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") diff --git a/l4d2web/models.py b/l4d2web/models.py index 601b32e..1086d64 100644 --- a/l4d2web/models.py +++ b/l4d2web/models.py @@ -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) diff --git a/l4d2web/tests/test_migrations.py b/l4d2web/tests/test_migrations.py new file mode 100644 index 0000000..ce1157f --- /dev/null +++ b/l4d2web/tests/test_migrations.py @@ -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() diff --git a/l4d2web/tests/test_models.py b/l4d2web/tests/test_models.py index 61b6cf0..203ce77 100644 --- a/l4d2web/tests/test_models.py +++ b/l4d2web/tests/test_models.py @@ -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()