feat(l4d2-web): add sqlite schema including blueprints and job logs
This commit is contained in:
parent
4193ce3b4e
commit
4e9c0172ef
6 changed files with 357 additions and 0 deletions
35
components/l4d2-web-app/alembic.ini
Normal file
35
components/l4d2-web-app/alembic.ini
Normal 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
|
||||
53
components/l4d2-web-app/alembic/env.py
Normal file
53
components/l4d2-web-app/alembic/env.py
Normal 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()
|
||||
100
components/l4d2-web-app/alembic/versions/0001_initial.py
Normal file
100
components/l4d2-web-app/alembic/versions/0001_initial.py
Normal 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")
|
||||
51
components/l4d2-web-app/src/l4d2web/db.py
Normal file
51
components/l4d2-web-app/src/l4d2web/db.py
Normal 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()
|
||||
98
components/l4d2-web-app/src/l4d2web/models.py
Normal file
98
components/l4d2-web-app/src/l4d2web/models.py
Normal 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)
|
||||
20
components/l4d2-web-app/tests/test_models.py
Normal file
20
components/l4d2-web-app/tests/test_models.py
Normal 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
|
||||
Loading…
Reference in a new issue