feat(l4d2-web): add sqlite schema including blueprints and job logs

This commit is contained in:
mwiegand 2026-04-23 01:05:14 +02:00
parent 4193ce3b4e
commit 4e9c0172ef
No known key found for this signature in database
6 changed files with 357 additions and 0 deletions

View file

@ -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

View file

@ -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()

View file

@ -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")

View file

@ -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()

View file

@ -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)

View file

@ -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