Jun 26 MT-2 phase

This commit is contained in:
2026-06-26 16:57:10 -04:00
parent a9d5e5ad3d
commit 07af8ee35a
4 changed files with 289 additions and 1 deletions
+3 -1
View File
@@ -158,7 +158,9 @@ Each phase additive; existing tenant-zero traffic keeps working throughout.
**MT-1 — Tenant resolution + routing. ✅ DONE.** `app/tenancy/` package: `routing.py` (`RoutingSession` subclassing the Flask-SQLAlchemy session), `resolver.py` (Host→tenant via control plane, lazy import), `engine_cache.py` (per-tenant engines), `context.py` (`TenantContext`), `middleware.py` (`init_tenancy` before_request hook + branded unknown-host 404). Edits: `db` init in `app/__init__.py` (+`init_tenancy(app)` call) and `MULTI_TENANT_ENABLED` + pool flags in `config.py`. Gated behind `MULTI_TENANT_ENABLED` (default False) — fully inert until flipped.
**MT-2 — Per-tenant migration runner.** `flask tenant db upgrade --tenant <id|all>`; record `alembic_head` per tenant.
**MT-2 — Per-tenant migration runner. ✅ DONE (with blocker found).** Standalone `migrations_tenant/env.py` (reads URL from config, reuses `migrations/versions` via `version_locations`, no Flask) + `control/tenant_migrate.py` (`upgrade_tenant()`, `current_revision()`, `chain_head()`, CLI: `python -m control.tenant_migrate upgrade|current|heads --tenant <id|slug|all>`). Records `tenants.alembic_head` + a `ProvisioningJob('migrate')` per run. Existing `migrations/env.py` untouched (normal `flask db` still works).
> **⚠ Blocker found by MT-2:** the live `migrations/versions` chain has **no base** — `phase1_projects_roles.down_revision = '0003_add_user_active'`, which is absent, and no revision has `down_revision = None`. Alembic cannot build the revision map, so `upgrade head` fails against any DB (even a no-op on an up-to-date one). The pre-`phase1` baseline migrations must be restored (or a guarded squashed baseline created) before MT-2 can run against real MySQL DBs and before MT-3 can provision fresh tenants.
**MT-3 — Provisioning service.** Create DB → create **per-tenant MySQL user + password** + grant scoped to that DB only → upgrade to head → seed first tenant-admin → invite email. Creds encrypted into the `tenants` row. Idempotent, `provisioning_jobs`-logged.
+202
View File
@@ -0,0 +1,202 @@
"""
control/tenant_migrate.py
-------------------------
Per-tenant migration runner (MT-2).
Runs the EXISTING tenant Alembic chain (migrations/versions) against each
tenant's own database via the standalone migrations_tenant/env.py. Records the
applied head on tenants.alembic_head and writes a ProvisioningJob row per run.
The existing migrations are re-run-safe (INFORMATION_SCHEMA guards, Rule 14),
so upgrading an already-current tenant (e.g. tenant-zero / LT, whose DB is
already at head) is a no-op.
Usage:
python -m control.tenant_migrate upgrade --tenant all
python -m control.tenant_migrate upgrade --tenant lts # id or slug
python -m control.tenant_migrate current --tenant all
python -m control.tenant_migrate heads
`upgrade_tenant()` is also imported by the MT-3 provisioning service to bring a
freshly-created tenant DB up to head.
"""
import argparse
import contextlib
import io
import os
import sys
from collections import namedtuple
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine
from control.base import control_session
from control.models import Tenant, ProvisioningJob
from control.time_utils import now_eastern
# Repo layout: control/ and migrations/ are siblings at the repo root.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_DEFAULT_SCRIPT_LOCATION = os.path.join(_REPO_ROOT, 'migrations_tenant')
_DEFAULT_VERSION_LOCATIONS = os.path.join(_REPO_ROOT, 'migrations', 'versions')
# Everything except a deleted tenant should be kept schema-current.
MIGRATABLE_STATUSES = ('provisioning', 'active', 'suspended')
TenantRef = namedtuple('TenantRef', ['id', 'slug', 'db_uri'])
def _make_config(db_uri, script_location=None, version_locations=None):
cfg = Config()
cfg.set_main_option('script_location', script_location or _DEFAULT_SCRIPT_LOCATION)
cfg.set_main_option('version_locations', version_locations or _DEFAULT_VERSION_LOCATIONS)
# Escape % so ConfigParser interpolation doesn't choke on URL-encoded passwords.
cfg.set_main_option('sqlalchemy.url', db_uri.replace('%', '%%'))
return cfg
def chain_head(script_location=None, version_locations=None):
"""Return the head revision id of the tenant migration chain."""
cfg = _make_config('sqlite://', script_location, version_locations)
script = ScriptDirectory.from_config(cfg)
heads = script.get_heads()
return heads[0] if len(heads) == 1 else ','.join(sorted(heads))
def current_revision(db_uri):
"""Return the alembic revision currently stamped on the given database."""
engine = create_engine(db_uri, future=True)
try:
with engine.connect() as conn:
return MigrationContext.configure(conn).get_current_revision()
finally:
engine.dispose()
def upgrade_tenant(tenant, script_location=None, version_locations=None, record_job=True):
"""Upgrade one tenant's database to head.
`tenant` must expose `.id` and `.db_uri` (a control ORM Tenant, a TenantRef,
or any object with those attributes). Returns the applied head revision.
Records tenants.alembic_head and a ProvisioningJob('migrate') unless
record_job is False.
"""
db_uri = tenant.db_uri
cfg = _make_config(db_uri, script_location, version_locations)
job_id = None
if record_job:
with control_session() as s:
job = ProvisioningJob(tenant_id=tenant.id, action='migrate',
status='running', created_at=now_eastern())
s.add(job)
s.flush()
job_id = job.id
try:
# Alembic logs progress to stdout; keep the runner output tidy.
with contextlib.redirect_stdout(io.StringIO()):
command.upgrade(cfg, 'head')
applied = current_revision(db_uri)
with control_session() as s:
t = s.get(Tenant, tenant.id)
if t is not None:
t.alembic_head = applied
if job_id is not None:
j = s.get(ProvisioningJob, job_id)
if j is not None:
j.status = 'ok'
j.finished_at = now_eastern()
j.log = f'upgraded to {applied}'
return applied
except Exception as e:
if job_id is not None:
with control_session() as s:
j = s.get(ProvisioningJob, job_id)
if j is not None:
j.status = 'failed'
j.finished_at = now_eastern()
j.log = f'{type(e).__name__}: {e}'
raise
# ── CLI ────────────────────────────────────────────────────────────────────
def _select_tenants(selector):
with control_session() as s:
q = s.query(Tenant).filter(Tenant.status.in_(MIGRATABLE_STATUSES))
if selector != 'all':
if selector.isdigit():
q = q.filter(Tenant.id == int(selector))
else:
q = q.filter(Tenant.slug == selector)
return [TenantRef(t.id, t.slug, t.db_uri) for t in q.order_by(Tenant.id).all()]
def _cmd_upgrade(args):
tenants = _select_tenants(args.tenant)
if not tenants:
print(f'No matching tenants for --tenant {args.tenant}.')
return 0
head = chain_head()
print(f'Target head: {head}')
failures = 0
for t in tenants:
try:
applied = upgrade_tenant(t)
print(f' [ok] {t.slug} (id={t.id}) -> {applied}')
except Exception as e:
failures += 1
print(f' [FAIL] {t.slug} (id={t.id}): {type(e).__name__}: {e}')
print(f'Done. {len(tenants) - failures}/{len(tenants)} upgraded.')
return 1 if failures else 0
def _cmd_current(args):
tenants = _select_tenants(args.tenant)
if not tenants:
print(f'No matching tenants for --tenant {args.tenant}.')
return 0
head = chain_head()
print(f'Chain head: {head}')
for t in tenants:
try:
rev = current_revision(t.db_uri)
except Exception as e:
rev = f'<error: {type(e).__name__}>'
flag = '' if rev == head else ' (BEHIND)'
print(f' {t.slug} (id={t.id}): {rev}{flag}')
return 0
def _cmd_heads(_args):
print(chain_head())
return 0
def main(argv=None):
parser = argparse.ArgumentParser(prog='control.tenant_migrate',
description='Per-tenant migration runner')
sub = parser.add_subparsers(dest='command', required=True)
p_up = sub.add_parser('upgrade', help='Upgrade tenant DB(s) to head')
p_up.add_argument('--tenant', required=True, help="'all', or a tenant id or slug")
p_up.set_defaults(func=_cmd_upgrade)
p_cur = sub.add_parser('current', help='Show each tenant DB current revision')
p_cur.add_argument('--tenant', default='all', help="'all', or a tenant id or slug")
p_cur.set_defaults(func=_cmd_current)
sub.add_parser('heads', help='Show the chain head revision').set_defaults(func=_cmd_heads)
args = parser.parse_args(argv)
sys.exit(args.func(args))
if __name__ == '__main__':
main()
+62
View File
@@ -0,0 +1,62 @@
"""
migrations_tenant/env.py
------------------------
Standalone Alembic environment for running the EXISTING tenant migration chain
(migrations/versions) against an ARBITRARY tenant database.
Unlike migrations/env.py — which is Flask-Migrate-coupled and always targets
the app's default database via current_app — this reads sqlalchemy.url straight
from the Alembic config supplied per-tenant by control/tenant_migrate.py, and
reuses the same version scripts through version_locations. No Flask import, so
it can run for any tenant DB outside an app context.
The normal `flask db ...` workflow is unaffected; it still uses migrations/env.py.
"""
from logging.config import fileConfig
from sqlalchemy import create_engine, pool
from alembic import context
config = context.config
# Only configure logging if an ini file was supplied (the runner builds the
# Config programmatically, so this is typically skipped).
if config.config_file_name is not None:
try:
fileConfig(config.config_file_name)
except Exception:
pass
# Raw-SQL migrations with INFORMATION_SCHEMA guards — autogenerate is not used,
# so no target metadata is required here.
target_metadata = None
def run_migrations_offline():
context.configure(
url=config.get_main_option('sqlalchemy.url'),
target_metadata=target_metadata,
literal_binds=True,
compare_type=False,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
url = config.get_main_option('sqlalchemy.url')
connectable = create_engine(url, poolclass=pool.NullPool, future=True)
try:
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
finally:
connectable.dispose()
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"}