diff --git a/components/l4d2-web-app/alembic.ini b/components/l4d2-web-app/alembic.ini new file mode 100644 index 0000000..c7c133e --- /dev/null +++ b/components/l4d2-web-app/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = alembic +sqlalchemy.url = sqlite:///l4d2web.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/components/l4d2-web-app/alembic/env.py b/components/l4d2-web-app/alembic/env.py new file mode 100644 index 0000000..1d8f7a4 --- /dev/null +++ b/components/l4d2-web-app/alembic/env.py @@ -0,0 +1,53 @@ +from logging.config import fileConfig +import os + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from l4d2web.models import Base + + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _database_url() -> str: + return os.getenv("DATABASE_URL", "sqlite:///l4d2web.db") + + +def run_migrations_offline() -> None: + context.configure( + url=_database_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section) or {} + configuration["sqlalchemy.url"] = _database_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/components/l4d2-web-app/alembic/versions/0001_initial.py b/components/l4d2-web-app/alembic/versions/0001_initial.py new file mode 100644 index 0000000..56d934d --- /dev/null +++ b/components/l4d2-web-app/alembic/versions/0001_initial.py @@ -0,0 +1,100 @@ +"""initial schema + +Revision ID: 0001_initial +Revises: +Create Date: 2026-04-23 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0001_initial" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("username", sa.String(length=64), nullable=False, unique=True), + sa.Column("password_digest", sa.String(length=255), nullable=False), + sa.Column("admin", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_table( + "overlays", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=128), nullable=False, unique=True), + sa.Column("path", sa.String(length=512), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_table( + "blueprints", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("arguments", sa.Text(), nullable=False), + sa.Column("config", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_table( + "blueprint_overlays", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("blueprint_id", sa.Integer(), sa.ForeignKey("blueprints.id"), nullable=False), + sa.Column("overlay_id", sa.Integer(), sa.ForeignKey("overlays.id"), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_table( + "servers", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("blueprint_id", sa.Integer(), sa.ForeignKey("blueprints.id"), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False, unique=True), + sa.Column("port", sa.Integer(), nullable=False), + sa.Column("desired_state", sa.String(length=16), nullable=False), + sa.Column("actual_state", sa.String(length=16), nullable=False), + sa.Column("actual_state_updated_at", sa.DateTime(), nullable=True), + sa.Column("last_error", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_table( + "jobs", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("server_id", sa.Integer(), sa.ForeignKey("servers.id"), nullable=True), + sa.Column("operation", sa.String(length=32), nullable=False), + sa.Column("state", sa.String(length=16), nullable=False), + sa.Column("exit_code", sa.Integer(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_table( + "job_logs", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("job_id", sa.Integer(), sa.ForeignKey("jobs.id"), nullable=False), + sa.Column("seq", sa.Integer(), nullable=False), + sa.Column("stream", sa.String(length=8), nullable=False), + sa.Column("line", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("job_logs") + op.drop_table("jobs") + op.drop_table("servers") + op.drop_table("blueprint_overlays") + op.drop_table("blueprints") + op.drop_table("overlays") + op.drop_table("users") diff --git a/components/l4d2-web-app/src/l4d2web/db.py b/components/l4d2-web-app/src/l4d2web/db.py new file mode 100644 index 0000000..78647de --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/db.py @@ -0,0 +1,51 @@ +from contextlib import contextmanager +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + + +_engine = None +_engine_url = None +_Session = None + + +def get_database_url() -> str: + return os.getenv("DATABASE_URL", "sqlite:///l4d2web.db") + + +def get_engine(): + global _engine + global _engine_url + global _Session + + db_url = get_database_url() + if _engine is None or _engine_url != db_url: + connect_args = {"check_same_thread": False} if db_url.startswith("sqlite") else {} + _engine = create_engine(db_url, connect_args=connect_args) + _engine_url = db_url + _Session = sessionmaker(bind=_engine, expire_on_commit=False) + return _engine + + +def init_db() -> None: + from l4d2web.models import Base + + Base.metadata.create_all(bind=get_engine()) + + +@contextmanager +def session_scope() -> Session: + global _Session + if _Session is None: + get_engine() + assert _Session is not None + session = _Session() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/components/l4d2-web-app/src/l4d2web/models.py b/components/l4d2-web-app/src/l4d2web/models.py new file mode 100644 index 0000000..dd6fa0d --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/models.py @@ -0,0 +1,98 @@ +from datetime import UTC, datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +def now_utc() -> datetime: + return datetime.now(UTC) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + password_digest: Mapped[str] = mapped_column(String(255), nullable=False) + admin: Mapped[bool] = mapped_column(Boolean, default=False, 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) + + +class Overlay(Base): + __tablename__ = "overlays" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) + path: Mapped[str] = mapped_column(String(512), 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) + + +class Blueprint(Base): + __tablename__ = "blueprints" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + arguments: Mapped[str] = mapped_column(Text, default="[]", nullable=False) + config: Mapped[str] = mapped_column(Text, default="[]", 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) + + +class BlueprintOverlay(Base): + __tablename__ = "blueprint_overlays" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False) + overlay_id: Mapped[int] = mapped_column(ForeignKey("overlays.id"), nullable=False) + position: Mapped[int] = mapped_column(Integer, 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) + + +class Server(Base): + __tablename__ = "servers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False) + name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) + port: Mapped[int] = mapped_column(Integer, nullable=False) + desired_state: Mapped[str] = mapped_column(String(16), default="stopped", 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) + last_error: Mapped[str] = mapped_column(Text, default="", 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) + + +class Job(Base): + __tablename__ = "jobs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + server_id: Mapped[int | None] = mapped_column(ForeignKey("servers.id"), nullable=True) + operation: Mapped[str] = mapped_column(String(32), nullable=False) + state: Mapped[str] = mapped_column(String(16), default="queued", nullable=False) + exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True) + started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) + + +class JobLog(Base): + __tablename__ = "job_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + job_id: Mapped[int] = mapped_column(ForeignKey("jobs.id"), nullable=False) + seq: Mapped[int] = mapped_column(Integer, nullable=False) + 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) diff --git a/components/l4d2-web-app/tests/test_models.py b/components/l4d2-web-app/tests/test_models.py new file mode 100644 index 0000000..1bdd253 --- /dev/null +++ b/components/l4d2-web-app/tests/test_models.py @@ -0,0 +1,20 @@ +from l4d2web.db import init_db, session_scope +from l4d2web.models import Blueprint, User + + +def test_create_user_and_blueprint(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'app.db'}") + + init_db() + + with session_scope() as session: + user = User(username="alice", password_digest="digest", admin=False) + session.add(user) + session.flush() + + blueprint = Blueprint(user_id=user.id, name="default", arguments="[]", config="[]") + session.add(blueprint) + session.flush() + + assert user.id is not None + assert blueprint.id is not None