From bee6b835562ed8d8030af08a4332c1c533efcdd3 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Sat, 28 Feb 2026 19:57:35 +0100 Subject: [PATCH] feat: add Alembic migration with schema and seed data Co-Authored-By: Claude Sonnet 4.6 --- backend/alembic.ini | 37 ++++++ backend/alembic/env.py | 42 ++++++ backend/alembic/script.py.mako | 26 ++++ backend/alembic/versions/0001_initial.py | 162 +++++++++++++++++++++++ 4 files changed, 267 insertions(+) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/0001_initial.py diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..a89bed3 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql://shorefront:changeme@localhost:5432/shorefront + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[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 +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..3f4a15a --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,42 @@ +import os +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Override sqlalchemy.url from environment +database_url = os.environ.get("DATABASE_URL") +if database_url: + config.set_main_option("sqlalchemy.url", database_url) + +from app.models import Base # noqa: E402 +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + 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/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${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, 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: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py new file mode 100644 index 0000000..fb469cc --- /dev/null +++ b/backend/alembic/versions/0001_initial.py @@ -0,0 +1,162 @@ +"""initial schema and seed data + +Revision ID: 0001 +Revises: +Create Date: 2026-02-28 +""" +from alembic import op +import sqlalchemy as sa +from passlib.context import CryptContext + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def upgrade() -> None: + # --- Schema --- + op.create_table( + "users", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("username", sa.String(64), nullable=False, unique=True), + sa.Column("email", sa.String(255), nullable=False, unique=True), + sa.Column("hashed_password", sa.String(255), nullable=False), + sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"), + ) + op.create_table( + "configs", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("description", sa.Text, server_default=""), + sa.Column("is_active", sa.Boolean, server_default="true"), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("owner_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + ) + op.create_table( + "zones", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False), + sa.Column("name", sa.String(32), nullable=False), + sa.Column("type", sa.String(16), nullable=False), + sa.Column("options", sa.Text, server_default=""), + sa.UniqueConstraint("config_id", "name", name="uq_zone_name_per_config"), + ) + op.create_table( + "interfaces", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False), + sa.Column("name", sa.String(32), nullable=False), + sa.Column("zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False), + sa.Column("options", sa.Text, server_default=""), + ) + op.create_table( + "policies", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False), + sa.Column("src_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False), + sa.Column("dst_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=False), + sa.Column("policy", sa.String(16), nullable=False), + sa.Column("log_level", sa.String(16), server_default=""), + sa.Column("comment", sa.Text, server_default=""), + sa.Column("position", sa.Integer, server_default="0"), + ) + op.create_table( + "rules", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False), + sa.Column("action", sa.String(32), nullable=False), + sa.Column("src_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=True), + sa.Column("dst_zone_id", sa.Integer, sa.ForeignKey("zones.id"), nullable=True), + sa.Column("src_ip", sa.String(64), server_default=""), + sa.Column("dst_ip", sa.String(64), server_default=""), + sa.Column("proto", sa.String(16), server_default=""), + sa.Column("dport", sa.String(64), server_default=""), + sa.Column("sport", sa.String(64), server_default=""), + sa.Column("comment", sa.Text, server_default=""), + sa.Column("position", sa.Integer, server_default="0"), + ) + op.create_table( + "masq", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("config_id", sa.Integer, sa.ForeignKey("configs.id"), nullable=False), + sa.Column("source_network", sa.String(64), nullable=False), + sa.Column("out_interface", sa.String(32), nullable=False), + sa.Column("to_address", sa.String(64), server_default=""), + sa.Column("comment", sa.Text, server_default=""), + ) + + # --- Seed data --- + conn = op.get_bind() + + # Admin user + conn.execute( + sa.text( + "INSERT INTO users (username, email, hashed_password, is_active) " + "VALUES (:u, :e, :p, true)" + ), + {"u": "admin", "e": "admin@localhost", "p": pwd_context.hash("admin")}, + ) + user_id = conn.execute(sa.text("SELECT id FROM users WHERE username='admin'")).scalar() + + # Sample config + conn.execute( + sa.text( + "INSERT INTO configs (name, description, is_active, owner_id) " + "VALUES (:n, :d, true, :o)" + ), + {"n": "homelab", "d": "Sample homelab Shorewall config", "o": user_id}, + ) + config_id = conn.execute(sa.text("SELECT id FROM configs WHERE name='homelab'")).scalar() + + # Zones + for z_name, z_type in [("fw", "firewall"), ("net", "ipv4"), ("loc", "ipv4")]: + conn.execute( + sa.text("INSERT INTO zones (config_id, name, type, options) VALUES (:c, :n, :t, '')"), + {"c": config_id, "n": z_name, "t": z_type}, + ) + fw_id = conn.execute(sa.text("SELECT id FROM zones WHERE config_id=:c AND name='fw'"), {"c": config_id}).scalar() + net_id = conn.execute(sa.text("SELECT id FROM zones WHERE config_id=:c AND name='net'"), {"c": config_id}).scalar() + loc_id = conn.execute(sa.text("SELECT id FROM zones WHERE config_id=:c AND name='loc'"), {"c": config_id}).scalar() + + # Interface + conn.execute( + sa.text("INSERT INTO interfaces (config_id, name, zone_id, options) VALUES (:c, :n, :z, '')"), + {"c": config_id, "n": "eth0", "z": net_id}, + ) + + # Policies + policies = [ + (loc_id, net_id, "ACCEPT", "", "loc to net", 1), + (net_id, fw_id, "DROP", "info", "net to fw", 2), + (net_id, loc_id, "DROP", "info", "net to loc", 3), + (fw_id, net_id, "ACCEPT", "", "fw to net", 4), + (fw_id, loc_id, "ACCEPT", "", "fw to loc", 5), + ] + for src, dst, pol, log, comment, pos in policies: + conn.execute( + sa.text( + "INSERT INTO policies (config_id, src_zone_id, dst_zone_id, policy, log_level, comment, position) " + "VALUES (:c, :s, :d, :p, :l, :cm, :pos)" + ), + {"c": config_id, "s": src, "d": dst, "p": pol, "l": log, "cm": comment, "pos": pos}, + ) + + # Masq + conn.execute( + sa.text("INSERT INTO masq (config_id, source_network, out_interface, to_address, comment) VALUES (:c, :s, :o, '', :cm)"), + {"c": config_id, "s": "192.168.1.0/24", "o": "eth0", "cm": "LAN masquerade"}, + ) + + +def downgrade() -> None: + op.drop_table("masq") + op.drop_table("rules") + op.drop_table("policies") + op.drop_table("interfaces") + op.drop_table("zones") + op.drop_table("configs") + op.drop_table("users")