diff --git a/meridian_control/README.md b/meridian_control/README.md index 38d3926..080b9c4 100644 --- a/meridian_control/README.md +++ b/meridian_control/README.md @@ -20,7 +20,9 @@ pip install -e ".[control]" # from the Meridian repo root # Run the control plane (SQLite by default) meridian-control run --host 0.0.0.0 --port 8443 -# Or point at Postgres: +# Or point at Postgres (apply the schema with Alembic first): +MERIDIAN_CONTROL_DB_URL=postgresql+psycopg://user:pass@host/meridian_control \ + meridian-control migrate MERIDIAN_CONTROL_DB_URL=postgresql+psycopg://user:pass@host/meridian_control \ meridian-control run @@ -28,6 +30,10 @@ MERIDIAN_CONTROL_DB_URL=postgresql+psycopg://user:pass@host/meridian_control \ meridian-control mint-token --auto-approve ``` +Production manages the schema with **Alembic** (`meridian-control migrate`, or +`alembic -c meridian_control/alembic.ini upgrade head`). `create_all` remains the +zero-config default for local dev and tests. + ## Endpoints | Method + path | Purpose | @@ -76,6 +82,5 @@ python scripts/verify_connection.py # prints a numbers report, exits 0 on PASS ## Not yet included -- Alembic migrations (schema is created via `create_all` for now). - Certificate **revocation** automation / CRL distribution (rotation is implemented: `POST /control/v1/nodes/{id}/certificate`). diff --git a/meridian_control/alembic.ini b/meridian_control/alembic.ini new file mode 100644 index 0000000..b5b8d13 --- /dev/null +++ b/meridian_control/alembic.ini @@ -0,0 +1,6 @@ +# Alembic config for meridian-control. The database URL is resolved in env.py +# from MERIDIAN_CONTROL_DB_URL (ControlConfig), or an -x db_url=... override. +[alembic] +script_location = %(here)s/migrations +prepend_sys_path = . +path_separator = os diff --git a/meridian_control/cli.py b/meridian_control/cli.py index c40c444..1599581 100644 --- a/meridian_control/cli.py +++ b/meridian_control/cli.py @@ -18,8 +18,19 @@ def main(argv: list[str] | None = None) -> int: tok.add_argument("--auto-approve", action="store_true") tok.add_argument("--ttl", type=int, default=3600) + sub.add_parser("migrate", help="apply Alembic migrations up to head") + args = parser.parse_args(argv) + if args.command == "migrate": + from .config import ControlConfig + from .db import run_migrations + + db_url = ControlConfig.from_env().db_url + run_migrations(db_url) + print(f"migrated {db_url} to head") + return 0 + if args.command == "run": import uvicorn diff --git a/meridian_control/db.py b/meridian_control/db.py index 8287478..b17a3e5 100644 --- a/meridian_control/db.py +++ b/meridian_control/db.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + from sqlalchemy import create_engine from sqlalchemy.orm import DeclarativeBase, sessionmaker @@ -15,7 +17,23 @@ def make_engine(db_url: str): return create_engine(db_url, connect_args=connect_args, future=True) -def make_session_factory(db_url: str): +def make_session_factory(db_url: str, create_schema: bool = True): + """Session factory. `create_schema` uses `create_all` for dev/tests; set it + False in production and manage the schema with Alembic (`meridian-control + migrate`, or `run_migrations`).""" engine = make_engine(db_url) - Base.metadata.create_all(engine) + if create_schema: + Base.metadata.create_all(engine) return sessionmaker(bind=engine, expire_on_commit=False, future=True) + + +def run_migrations(db_url: str) -> None: + """Apply Alembic migrations up to head (the production schema path).""" + from alembic import command + from alembic.config import Config + + here = Path(__file__).parent + cfg = Config(str(here / "alembic.ini")) + cfg.set_main_option("script_location", str(here / "migrations")) + cfg.set_main_option("sqlalchemy.url", db_url) + command.upgrade(cfg, "head") diff --git a/meridian_control/migrations/env.py b/meridian_control/migrations/env.py new file mode 100644 index 0000000..62ff07c --- /dev/null +++ b/meridian_control/migrations/env.py @@ -0,0 +1,44 @@ +"""Alembic environment for meridian-control. + +The DB URL comes from ControlConfig (MERIDIAN_CONTROL_DB_URL) unless overridden +with `-x db_url=...`. target_metadata is the ORM Base so autogenerate stays in +sync with meridian_control.models. +""" + +from __future__ import annotations + +from alembic import context + +import meridian_control.models # noqa: F401 - import registers all tables on Base.metadata +from meridian_control.config import ControlConfig +from meridian_control.db import Base, make_engine + +target_metadata = Base.metadata + + +def _url() -> str: + override = context.get_x_argument(as_dictionary=True).get("db_url") + if override: + return override + ini = context.config.get_main_option("sqlalchemy.url") + return ini or ControlConfig.from_env().db_url + + +def run_migrations_offline() -> None: + context.configure(url=_url(), target_metadata=target_metadata, literal_binds=True, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + engine = make_engine(_url()) + with engine.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/meridian_control/migrations/script.py.mako b/meridian_control/migrations/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/meridian_control/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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: 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/meridian_control/migrations/versions/dc240ff6c192_initial_schema.py b/meridian_control/migrations/versions/dc240ff6c192_initial_schema.py new file mode 100644 index 0000000..1ee0aea --- /dev/null +++ b/meridian_control/migrations/versions/dc240ff6c192_initial_schema.py @@ -0,0 +1,103 @@ +"""initial schema + +Revision ID: dc240ff6c192 +Revises: +Create Date: 2026-08-04 00:59:38.132114 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'dc240ff6c192' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_events', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('node_id', sa.String(length=128), nullable=False), + sa.Column('kind', sa.String(length=64), nullable=False), + sa.Column('detail', sa.String(length=1024), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('claims', + sa.Column('claim_id', sa.String(length=128), nullable=False), + sa.Column('node_id', sa.String(length=128), nullable=False), + sa.Column('public_key', sa.LargeBinary(), nullable=False), + sa.Column('nonce', sa.String(length=128), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('claim_id') + ) + op.create_table('enrollment_tokens', + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('auto_approve', sa.Boolean(), nullable=False), + sa.Column('used', sa.Boolean(), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('token_hash') + ) + op.create_table('incarnation', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('value', sa.Integer(), nullable=False), + sa.Column('epoch_floor', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('nodes', + sa.Column('node_id', sa.String(length=128), nullable=False), + sa.Column('public_key', sa.LargeBinary(), nullable=False), + sa.Column('certificate_pem', sa.String(), nullable=False), + sa.Column('display_name', sa.String(length=253), nullable=False), + sa.Column('labels', sa.JSON(), nullable=False), + sa.Column('active_session_id', sa.String(length=128), nullable=True), + sa.Column('fencing_epoch', sa.Integer(), nullable=False), + sa.Column('incarnation', sa.Integer(), nullable=False), + sa.Column('highest_sequence', sa.Integer(), nullable=False), + sa.Column('lease_expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('desired_generation', sa.Integer(), nullable=False), + sa.Column('revoked', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('node_id') + ) + op.create_table('stop_authorizations', + sa.Column('node_id', sa.String(length=128), nullable=False), + sa.Column('engine_id', sa.String(length=128), nullable=False), + sa.PrimaryKeyConstraint('node_id', 'engine_id') + ) + op.create_table('desired_snapshots', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('node_id', sa.String(length=128), nullable=False), + sa.Column('generation', sa.Integer(), nullable=False), + sa.Column('snapshot', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['node_id'], ['nodes.node_id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('node_id', 'generation', name='uq_node_generation') + ) + op.create_table('observations', + sa.Column('node_id', sa.String(length=128), nullable=False), + sa.Column('sequence', sa.Integer(), nullable=False), + sa.Column('observation', sa.JSON(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['node_id'], ['nodes.node_id'], ), + sa.PrimaryKeyConstraint('node_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('observations') + op.drop_table('desired_snapshots') + op.drop_table('stop_authorizations') + op.drop_table('nodes') + op.drop_table('incarnation') + op.drop_table('enrollment_tokens') + op.drop_table('claims') + op.drop_table('audit_events') + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index f2d3107..62e659c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ docs = [ # service. SQLite by default, Postgres via a connection URL. control = [ "sqlalchemy>=2.0", + "alembic>=1.13", ] dev = [ "ruff", @@ -59,6 +60,8 @@ packages = ["meridian", "meridian_control"] [tool.ruff] target-version = "py39" line-length = 120 +# Alembic autogenerates migration scripts; don't lint their generated style. +extend-exclude = ["meridian_control/migrations/versions"] [tool.ruff.lint] select = ["E", "F", "I", "W"] diff --git a/tests/control/test_migrations.py b/tests/control/test_migrations.py new file mode 100644 index 0000000..99cbe92 --- /dev/null +++ b/tests/control/test_migrations.py @@ -0,0 +1,29 @@ +"""Alembic migrations build the full schema (the production schema path). + +Proves `run_migrations` (used by `meridian-control migrate`) applies cleanly to a +fresh database and that the initial migration stays in sync with the ORM models. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import create_engine, inspect + +from meridian_control.db import Base, run_migrations + +pytest.importorskip("alembic") # only in the [control] extra; skip in the gateway-only CI job + + +def test_migrations_create_full_schema(tmp_path): + db_url = f"sqlite:///{tmp_path}/m.db" + run_migrations(db_url) # apply head to an empty DB + tables = set(inspect(create_engine(db_url)).get_table_names()) + expected = set(Base.metadata.tables) | {"alembic_version"} + missing = expected - tables + assert not missing, f"migration is missing tables: {missing}" + + +def test_migrations_are_idempotent(tmp_path): + db_url = f"sqlite:///{tmp_path}/m.db" + run_migrations(db_url) + run_migrations(db_url) # second upgrade to head is a no-op, must not error