Files
JQC_multi_tenant/control/tenant_migrate.py
T

276 lines
9.9 KiB
Python

"""
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')
# Squashed baseline that builds the full schema (migrations/versions root).
BASELINE_REVISION = '0003_add_user_active'
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
def bootstrap_tenant(tenant, script_location=None, version_locations=None,
baseline_rev=BASELINE_REVISION, record_job=True):
"""Build a FRESH tenant database, then mark it current.
Runs the squashed baseline (full schema) only, then `stamp head` — the
historical phase migrations are NOT replayed (several are not idempotent and
would conflict with the full-schema baseline). Use this for brand-new tenant
databases; use upgrade_tenant() for ongoing incremental migrations.
Returns the stamped head revision.
"""
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:
with contextlib.redirect_stdout(io.StringIO()):
command.upgrade(cfg, baseline_rev) # build full schema (baseline only)
command.stamp(cfg, 'head') # mark at head without replaying phases
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'bootstrapped (baseline {baseline_rev}) + stamped {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
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_bootstrap(args):
tenants = _select_tenants(args.tenant)
if not tenants:
print(f'No matching tenants for --tenant {args.tenant}.')
return 0
failures = 0
for t in tenants:
try:
applied = bootstrap_tenant(t)
print(f' [ok] {t.slug} (id={t.id}) bootstrapped -> {applied}')
except Exception as e:
failures += 1
print(f' [FAIL] {t.slug} (id={t.id}): {type(e).__name__}: {e}')
return 1 if failures else 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)
p_boot = sub.add_parser('bootstrap',
help='Build a FRESH tenant DB (baseline + stamp head)')
p_boot.add_argument('--tenant', required=True, help="'all', or a tenant id or slug")
p_boot.set_defaults(func=_cmd_bootstrap)
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()