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