Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions meridian_control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,20 @@ 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

# Mint a one-time enrollment token for a node
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 |
Expand Down Expand Up @@ -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`).
6 changes: 6 additions & 0 deletions meridian_control/alembic.ini
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions meridian_control/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 20 additions & 2 deletions meridian_control/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from pathlib import Path

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker

Expand All @@ -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")
44 changes: 44 additions & 0 deletions meridian_control/migrations/env.py
Original file line number Diff line number Diff line change
@@ -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()
24 changes: 24 additions & 0 deletions meridian_control/migrations/script.py.mako
Original file line number Diff line number Diff line change
@@ -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"}
103 changes: 103 additions & 0 deletions meridian_control/migrations/versions/dc240ff6c192_initial_schema.py
Original file line number Diff line number Diff line change
@@ -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 ###
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ docs = [
# service. SQLite by default, Postgres via a connection URL.
control = [
"sqlalchemy>=2.0",
"alembic>=1.13",
]
dev = [
"ruff",
Expand All @@ -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"]
Expand Down
29 changes: 29 additions & 0 deletions tests/control/test_migrations.py
Original file line number Diff line number Diff line change
@@ -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
Loading