feat(01-02): configure Alembic and create Build model

- Configure Alembic for async migrations with SQLAlchemy 2.0
- Create Build model with UUID primary key, config_hash, status enum
- Add indexes on status (queue queries) and config_hash (cache lookups)
- Generate and apply initial migration creating builds table

Build model fields: id, config_hash, status, iso_path, error_message,
build_log, started_at, completed_at, created_at, updated_at.
This commit is contained in:
Mikkel Georgsen 2026-01-25 20:11:55 +00:00
parent 11fb568354
commit c261664784
8 changed files with 428 additions and 0 deletions

150
backend/alembic.ini Normal file
View file

@ -0,0 +1,150 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# sqlalchemy.url is set from app.core.config in env.py
# sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
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
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View file

@ -0,0 +1 @@
Generic single-database configuration.

80
backend/alembic/env.py Normal file
View file

@ -0,0 +1,80 @@
"""Alembic migration environment configuration for async SQLAlchemy."""
import asyncio
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from backend.app.core.config import settings # noqa: E402
from backend.app.db.base import Base # noqa: E402
# Import all models for autogenerate to discover them
from backend.app.db.models import build # noqa: E402, F401
# Alembic Config object
config = context.config
# Set sqlalchemy.url from application settings
config.set_main_option("sqlalchemy.url", settings.database_url)
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# SQLAlchemy metadata for autogenerate support
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL and not an Engine,
skipping Engine creation so no DBAPI is needed.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
"""Run migrations with the given connection."""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""Run migrations in 'online' mode with async engine.
Creates an async Engine and associates a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View file

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View file

View file

@ -0,0 +1,48 @@
"""Create build table
Revision ID: de1460a760b0
Revises:
Create Date: 2026-01-25 20:11:11.446731
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'de1460a760b0'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('builds',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('config_hash', sa.String(length=64), nullable=False),
sa.Column('status', sa.Enum('PENDING', 'BUILDING', 'COMPLETED', 'FAILED', 'CACHED', name='buildstatus'), nullable=False),
sa.Column('iso_path', sa.String(length=512), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('build_log', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_builds_config_hash'), 'builds', ['config_hash'], unique=True)
op.create_index('ix_builds_status', 'builds', ['status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_builds_status', table_name='builds')
op.drop_index(op.f('ix_builds_config_hash'), table_name='builds')
op.drop_table('builds')
# ### end Alembic commands ###

View file

@ -0,0 +1,8 @@
"""Database models package.
Import all models here for Alembic autogenerate to discover them.
"""
from backend.app.db.models.build import Build
__all__ = ["Build"]

View file

@ -0,0 +1,113 @@
"""Build tracking model for ISO generation."""
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, Index, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.db.base import Base
class BuildStatus(enum.Enum):
"""Status values for build tracking."""
PENDING = "pending"
BUILDING = "building"
COMPLETED = "completed"
FAILED = "failed"
CACHED = "cached"
class Build(Base):
"""Model for tracking ISO build jobs.
Attributes:
id: Unique identifier for the build (UUID)
config_hash: SHA-256 hash of the build configuration (64 chars)
status: Current build status
iso_path: Path to generated ISO file (if completed)
error_message: Error message if build failed
build_log: Full build output log
started_at: Timestamp when build started
completed_at: Timestamp when build completed
created_at: Timestamp when build was created
updated_at: Timestamp of last update
The config_hash enables caching - identical configurations can
return existing ISOs without rebuilding.
"""
__tablename__ = "builds"
# Primary key
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
# Configuration hash for caching (SHA-256 = 64 hex chars)
config_hash: Mapped[str] = mapped_column(
String(64),
unique=True,
index=True,
nullable=False,
)
# Build status
status: Mapped[BuildStatus] = mapped_column(
Enum(BuildStatus),
default=BuildStatus.PENDING,
nullable=False,
)
# Build results
iso_path: Mapped[str | None] = mapped_column(
String(512),
nullable=True,
)
error_message: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)
build_log: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)
# Timing
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
# Audit timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Indexes for common queries
__table_args__ = (
# Index on status for queue queries (find pending builds)
Index("ix_builds_status", "status"),
# Index on config_hash already created via column definition
)
def __repr__(self) -> str:
"""String representation of Build."""
return f"<Build {self.id} status={self.status.value}>"