206 lines
7.6 KiB
Python
206 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
scratch_bootstrap_test.py
|
|
=========================
|
|
One-shot, self-cleaning test that proves the squashed baseline DDL executes on
|
|
real MySQL and that bootstrap_tenant() builds a complete, head-stamped schema.
|
|
|
|
It mirrors what MT-3 provisioning will do:
|
|
1. (as a MySQL admin) CREATE a throwaway database + user + grant
|
|
2. insert a temporary tenant row in the control DB (encrypted creds)
|
|
3. run control.tenant_migrate.bootstrap_tenant() against it
|
|
4. verify: all expected tables built + alembic_version stamped at chain head
|
|
5. tear everything down (tenant row, provisioning jobs, database, user)
|
|
|
|
Nothing touches your real LT database or any real tenant. Run from the REPO ROOT.
|
|
|
|
Prerequisites in the environment (same as the runner):
|
|
export CONTROL_DATABASE_URL='mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control'
|
|
export CONTROL_FERNET_KEY='...'
|
|
|
|
Example:
|
|
python scratch_bootstrap_test.py \
|
|
--admin-user root --admin-password 'ROOTPW' \
|
|
--host 127.0.0.1 --port 3306
|
|
|
|
Optional overrides: --test-db, --test-user, --test-password, --user-host, --keep
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
|
|
import pymysql
|
|
from sqlalchemy import create_engine, inspect, text
|
|
|
|
from control.base import control_session
|
|
from control.models import Plan, Tenant, ProvisioningJob
|
|
from control.seed import seed_plans
|
|
from control.tenant_migrate import bootstrap_tenant, chain_head
|
|
|
|
EXPECTED_TABLES = {
|
|
'api_device_tokens', 'api_refresh_tokens', 'areas', 'audit_logs', 'broadcasts',
|
|
'checklist_items', 'customer_assignments', 'device_registrations', 'facilities',
|
|
'facility_score_alerts', 'inspection_results', 'inspection_templates',
|
|
'inspections', 'inspector_assignments', 'issue_comments', 'issue_followers',
|
|
'issues', 'notification_matrix', 'notification_preferences', 'notifications',
|
|
'projects', 'scheduled_reports', 'support_ticket_replies', 'support_tickets',
|
|
'users',
|
|
}
|
|
|
|
|
|
def _admin_conn(args):
|
|
return pymysql.connect(
|
|
host=args.host, port=args.port,
|
|
user=args.admin_user, password=args.admin_password,
|
|
autocommit=True,
|
|
)
|
|
|
|
|
|
def _admin_exec(conn, sql):
|
|
with conn.cursor() as cur:
|
|
cur.execute(sql)
|
|
|
|
|
|
def create_test_db(args):
|
|
conn = _admin_conn(args)
|
|
try:
|
|
_admin_exec(conn, f"DROP DATABASE IF EXISTS `{args.test_db}`")
|
|
_admin_exec(conn, f"CREATE DATABASE `{args.test_db}` "
|
|
f"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
|
_admin_exec(conn, f"DROP USER IF EXISTS '{args.test_user}'@'{args.user_host}'")
|
|
_admin_exec(conn, f"CREATE USER '{args.test_user}'@'{args.user_host}' "
|
|
f"IDENTIFIED BY '{args.test_password}'")
|
|
_admin_exec(conn, f"GRANT ALL PRIVILEGES ON `{args.test_db}`.* "
|
|
f"TO '{args.test_user}'@'{args.user_host}'")
|
|
_admin_exec(conn, "FLUSH PRIVILEGES")
|
|
finally:
|
|
conn.close()
|
|
print(f" [setup] created database `{args.test_db}` + user "
|
|
f"'{args.test_user}'@'{args.user_host}'")
|
|
|
|
|
|
def drop_test_db(args):
|
|
conn = _admin_conn(args)
|
|
try:
|
|
_admin_exec(conn, f"DROP DATABASE IF EXISTS `{args.test_db}`")
|
|
_admin_exec(conn, f"DROP USER IF EXISTS '{args.test_user}'@'{args.user_host}'")
|
|
_admin_exec(conn, "FLUSH PRIVILEGES")
|
|
finally:
|
|
conn.close()
|
|
print(f" [cleanup] dropped database + user")
|
|
|
|
|
|
def insert_temp_tenant(args):
|
|
seed_plans() # idempotent — ensures at least one plan exists
|
|
with control_session() as s:
|
|
plan = s.query(Plan).order_by(Plan.id).first()
|
|
t = Tenant(
|
|
slug=args.test_slug, name='SCRATCH TEST (temporary)',
|
|
plan_id=plan.id, status='provisioning',
|
|
db_host=args.host, db_port=args.port,
|
|
db_name=args.test_db, db_user=args.test_user,
|
|
)
|
|
t.set_db_password(args.test_password)
|
|
s.add(t)
|
|
s.flush()
|
|
tid = t.id
|
|
print(f" [setup] inserted temp tenant id={tid} slug='{args.test_slug}'")
|
|
return tid
|
|
|
|
|
|
def delete_temp_tenant(tid):
|
|
with control_session() as s:
|
|
s.query(ProvisioningJob).filter_by(tenant_id=tid).delete()
|
|
t = s.get(Tenant, tid)
|
|
if t is not None:
|
|
s.delete(t)
|
|
print(f" [cleanup] removed temp tenant id={tid} + its provisioning jobs")
|
|
|
|
|
|
def verify(args):
|
|
uri = (f"mysql+pymysql://{args.test_user}:{args.test_password}"
|
|
f"@{args.host}:{args.port}/{args.test_db}")
|
|
engine = create_engine(uri, future=True)
|
|
ok = True
|
|
try:
|
|
insp = inspect(engine)
|
|
built = set(insp.get_table_names())
|
|
built.discard('alembic_version')
|
|
|
|
missing = EXPECTED_TABLES - built
|
|
extra = built - EXPECTED_TABLES
|
|
print(f" tables built: {len(built)} (expected {len(EXPECTED_TABLES)})")
|
|
if missing:
|
|
print(f" !! MISSING tables: {sorted(missing)}"); ok = False
|
|
if extra:
|
|
print(f" ?? unexpected extra tables: {sorted(extra)}")
|
|
|
|
with engine.connect() as c:
|
|
ver = c.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
|
head = chain_head()
|
|
print(f" alembic_version: {ver} (chain head: {head})")
|
|
if ver != head:
|
|
print(" !! alembic_version is not at chain head"); ok = False
|
|
|
|
# spot-check a couple of tables have their columns
|
|
ucols = {c['name'] for c in insp.get_columns('users')}
|
|
for need in ('id', 'username', 'email', 'password_hash', 'role'):
|
|
if need not in ucols:
|
|
print(f" !! users.{need} missing"); ok = False
|
|
print(f" users columns: {len(ucols)} (incl id/username/email/role)")
|
|
finally:
|
|
engine.dispose()
|
|
return ok
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description='Scratch MySQL bootstrap test')
|
|
ap.add_argument('--admin-user', required=True, help='MySQL admin user (can CREATE DB/USER)')
|
|
ap.add_argument('--admin-password', required=True)
|
|
ap.add_argument('--host', default='127.0.0.1')
|
|
ap.add_argument('--port', type=int, default=3306)
|
|
ap.add_argument('--test-db', default='jqc_scratch_test')
|
|
ap.add_argument('--test-user', default='jqc_scratch_user')
|
|
ap.add_argument('--test-password', default='Scratch_pw_123!')
|
|
ap.add_argument('--test-slug', default='scratchtest')
|
|
ap.add_argument('--user-host', default='%', help="MySQL host part for the test user")
|
|
ap.add_argument('--keep', action='store_true', help='Skip cleanup (inspect manually)')
|
|
args = ap.parse_args()
|
|
|
|
print("== JQC scratch bootstrap test ==")
|
|
tid = None
|
|
passed = False
|
|
try:
|
|
create_test_db(args)
|
|
tid = insert_temp_tenant(args)
|
|
print(" [run] bootstrap_tenant() ...")
|
|
with control_session() as s:
|
|
t = s.get(Tenant, tid)
|
|
ref = type('R', (), {'id': t.id, 'db_uri': t.db_uri})()
|
|
applied = bootstrap_tenant(ref)
|
|
print(f" [run] bootstrapped -> {applied}")
|
|
passed = verify(args)
|
|
except Exception as e:
|
|
print(f" !! ERROR: {type(e).__name__}: {e}")
|
|
passed = False
|
|
finally:
|
|
if not args.keep:
|
|
if tid is not None:
|
|
try:
|
|
delete_temp_tenant(tid)
|
|
except Exception as e:
|
|
print(f" !! tenant cleanup failed: {e}")
|
|
try:
|
|
drop_test_db(args)
|
|
except Exception as e:
|
|
print(f" !! db cleanup failed: {e}")
|
|
else:
|
|
print(" [keep] left test DB + tenant in place for inspection")
|
|
|
|
print()
|
|
print("RESULT:", "PASS ✅" if passed else "FAIL ❌")
|
|
sys.exit(0 if passed else 1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main() |