Jun 26 MT-0 phase

This commit is contained in:
2026-06-26 16:19:48 -04:00
parent 5d95cdbbfc
commit fc39491891
13 changed files with 1094 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# Alembic config for the JQC CONTROL plane (separate from the tenant chain).
# The database URL is injected at runtime from CONTROL_DATABASE_URL in env.py —
# never hard-coded here.
[alembic]
script_location = control/migrations
prepend_sys_path = .
sqlalchemy.url =
[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
+67
View File
@@ -0,0 +1,67 @@
"""
control/migrations/env.py
-------------------------
Standalone Alembic environment for the control plane. Unlike the data-plane
env (which pulls its engine from the Flask app), this reads the URL directly
from CONTROL_DATABASE_URL and targets ControlBase.metadata. No Flask import.
"""
import os
import sys
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
# Make the repo root importable so `import control.*` resolves when Alembic
# is invoked as `alembic -c control/migrations/alembic.ini ...`.
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
)
from control.base import ControlBase, get_control_database_url # noqa: E402
import control.models # noqa: E402,F401 (registers tables on ControlBase.metadata)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Inject the URL from the environment (escape % for ConfigParser).
config.set_main_option(
'sqlalchemy.url', get_control_database_url().replace('%', '%%')
)
target_metadata = ControlBase.metadata
def run_migrations_offline():
context.configure(
url=config.get_main_option('sqlalchemy.url'),
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
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,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+22
View File
@@ -0,0 +1,22 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
@@ -0,0 +1,171 @@
"""control0001 — initial control-plane schema
Creates the control-plane registry tables (MULTI_TENANT_PLAN.md §3):
plans, plan_features, superadmins, tenants, tenant_domains,
provisioning_jobs, tenant_audit.
Raw MySQL DDL with INFORMATION_SCHEMA existence checks so the migration is
safe to re-run (CLAUDE.md Rule 14). InnoDB + utf8mb4 throughout. Targets the
CONTROL database only — never a tenant DB.
"""
import sqlalchemy as sa
from alembic import op
revision = 'control0001_init'
down_revision = None
branch_labels = None
depends_on = None
def _table_exists(bind, table: str) -> bool:
result = bind.execute(sa.text(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema = DATABASE() AND table_name = :t"
), {'t': table})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, 'plans'):
op.execute(sa.text("""
CREATE TABLE plans (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL,
name VARCHAR(100) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
max_users INT NULL,
max_facilities INT NULL,
max_inspections_month INT NULL,
max_issues_month INT NULL,
allow_custom_domain TINYINT(1) NOT NULL DEFAULT 0,
allow_mobile_api TINYINT(1) NOT NULL DEFAULT 0,
allow_scheduled_reports TINYINT(1) NOT NULL DEFAULT 0,
allow_branding TINYINT(1) NOT NULL DEFAULT 0,
price_cents INT NULL,
billing_period VARCHAR(16) NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uq_plans_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
if not _table_exists(bind, 'plan_features'):
op.execute(sa.text("""
CREATE TABLE plan_features (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
plan_id INT NOT NULL,
feature_key VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
CONSTRAINT fk_plan_features_plan FOREIGN KEY (plan_id)
REFERENCES plans(id) ON DELETE CASCADE,
UNIQUE KEY uq_plan_feature (plan_id, feature_key),
INDEX ix_plan_features_plan (plan_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
if not _table_exists(bind, 'superadmins'):
op.execute(sa.text("""
CREATE TABLE superadmins (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
UNIQUE KEY uq_superadmins_username (username),
UNIQUE KEY uq_superadmins_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
if not _table_exists(bind, 'tenants'):
op.execute(sa.text("""
CREATE TABLE tenants (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(63) NOT NULL,
name VARCHAR(150) NOT NULL,
plan_id INT NOT NULL,
status ENUM('provisioning','active','suspended','deleted')
NOT NULL DEFAULT 'provisioning',
db_host VARCHAR(255) NOT NULL,
db_port INT NOT NULL DEFAULT 3306,
db_name VARCHAR(64) NOT NULL,
db_user VARCHAR(64) NOT NULL,
db_password_enc TEXT NULL,
alembic_head VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
suspended_at DATETIME NULL,
notes TEXT NULL,
CONSTRAINT fk_tenants_plan FOREIGN KEY (plan_id)
REFERENCES plans(id),
UNIQUE KEY uq_tenants_slug (slug),
UNIQUE KEY uq_tenants_db_name (db_name),
INDEX ix_tenants_plan (plan_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
if not _table_exists(bind, 'tenant_domains'):
op.execute(sa.text("""
CREATE TABLE tenant_domains (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
domain VARCHAR(255) NOT NULL,
kind ENUM('subdomain','custom') NOT NULL,
is_primary TINYINT(1) NOT NULL DEFAULT 0,
verified TINYINT(1) NOT NULL DEFAULT 0,
verification_token VARCHAR(64) NULL,
tls_status ENUM('pending','active','failed')
NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
CONSTRAINT fk_tenant_domains_tenant FOREIGN KEY (tenant_id)
REFERENCES tenants(id) ON DELETE CASCADE,
UNIQUE KEY uq_tenant_domains_domain (domain),
INDEX ix_tenant_domains_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
if not _table_exists(bind, 'provisioning_jobs'):
op.execute(sa.text("""
CREATE TABLE provisioning_jobs (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NULL,
action ENUM('create_db','migrate','seed','suspend','resume','delete')
NOT NULL,
status ENUM('queued','running','ok','failed')
NOT NULL DEFAULT 'queued',
log TEXT NULL,
created_at DATETIME NOT NULL,
finished_at DATETIME NULL,
CONSTRAINT fk_provisioning_jobs_tenant FOREIGN KEY (tenant_id)
REFERENCES tenants(id) ON DELETE SET NULL,
INDEX ix_provisioning_jobs_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
if not _table_exists(bind, 'tenant_audit'):
op.execute(sa.text("""
CREATE TABLE tenant_audit (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
superadmin_id INT NULL,
action VARCHAR(50) NOT NULL,
tenant_id INT NULL,
details TEXT NULL,
ip_address VARCHAR(45) NULL,
created_at DATETIME NOT NULL,
CONSTRAINT fk_tenant_audit_superadmin FOREIGN KEY (superadmin_id)
REFERENCES superadmins(id) ON DELETE SET NULL,
CONSTRAINT fk_tenant_audit_tenant FOREIGN KEY (tenant_id)
REFERENCES tenants(id) ON DELETE SET NULL,
INDEX ix_tenant_audit_tenant (tenant_id),
INDEX ix_tenant_audit_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
def downgrade():
bind = op.get_bind()
for table in ('tenant_audit', 'provisioning_jobs', 'tenant_domains',
'tenants', 'superadmins', 'plan_features', 'plans'):
if _table_exists(bind, table):
op.execute(sa.text(f'DROP TABLE {table}'))