#!/usr/bin/env node import crypto from 'node:crypto'; import { PrismaClient } from '@prisma/client'; const args = new Set(process.argv.slice(2)); const apply = args.has('--apply'); const json = args.has('--json'); const failOnBlockers = args.has('--fail-on-blockers'); if (args.has('--help') || args.has('-h')) { console.log(`Usage: node scripts/phase2-gateway-migration.mjs [--apply] [--json] [--fail-on-blockers] Default mode is dry-run. It audits legacy customer gateway policies and prints which gateways can be safely migrated to the phase-2 single-line-group model. Safe automatic migration requires: - exactly one enabled legacy customer_gateway_policy per customer gateway; - policy calleeMode = ANY; - policy callerMode = ANY, or callerMode = PREFIX with callerValue. All other cases are reported as blockers for manual review.`); process.exit(0); } if (!process.env.DATABASE_URL) { console.error('DATABASE_URL is required. Do not put credentials in command history; load it from the controlled secret environment.'); process.exit(2); } const prisma = new PrismaClient(); function id(prefix) { return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 32 - prefix.length - 1)}`; } function blocker(gateway, code, message, policy = null) { return { gatewayId: gateway.id, gatewayName: gateway.name, customerId: gateway.customerId, code, message, policyId: policy?.id ?? null, policyName: policy?.name ?? null }; } function plannedChange(gateway, policy) { const callerMode = policy.callerMode === 'PREFIX' ? 'PREFIXES' : 'ANY'; return { gatewayId: gateway.id, gatewayName: gateway.name, customerId: gateway.customerId, sourceIp: gateway.sourceIp, lineGroupId: policy.lineGroupId, callerMatchMode: callerMode, callerPrefix: policy.callerMode === 'PREFIX' ? policy.callerValue : null, calleeMatchMode: 'ANY', policyId: policy.id, policyName: policy.name }; } function analyzeGateway(gateway) { const policies = gateway.policies; if (policies.length === 0) { return { plan: null, blocker: blocker(gateway, 'NO_ENABLED_POLICY', 'Gateway has no enabled legacy policy; choose a line group manually before switching hot path.') }; } if (policies.length > 1) { return { plan: null, blocker: blocker(gateway, 'MULTIPLE_ENABLED_POLICIES', 'Gateway has multiple enabled legacy policies; split into multiple customer gateways or choose one rule manually.') }; } const [policy] = policies; if (gateway.lineGroupId && gateway.lineGroupId !== policy.lineGroupId) { return { plan: null, blocker: blocker(gateway, 'LINE_GROUP_CONFLICT', 'Gateway already has a phase-2 lineGroupId different from the legacy policy.', policy) }; } if (policy.calleeMode !== 'ANY') { return { plan: null, blocker: blocker(gateway, 'CALLEE_RULE_REQUIRES_BUSINESS_PREFIX', 'Legacy callee matching cannot be inferred as a business prefix safely.', policy) }; } if (policy.callerMode === 'EQUALS') { return { plan: null, blocker: blocker(gateway, 'CALLER_EQUALS_REQUIRES_MANUAL_PREFIX', 'Phase-2 supports caller ANY or custom prefixes; exact caller match must be reviewed manually.', policy) }; } if (policy.callerMode === 'PREFIX' && !policy.callerValue) { return { plan: null, blocker: blocker(gateway, 'CALLER_PREFIX_EMPTY', 'Legacy caller prefix policy has an empty callerValue.', policy) }; } if (policy.callerMode !== 'ANY' && policy.callerMode !== 'PREFIX') { return { plan: null, blocker: blocker(gateway, 'CALLER_RULE_UNSUPPORTED', `Unsupported legacy callerMode: ${policy.callerMode}`, policy) }; } return { plan: plannedChange(gateway, policy), blocker: null }; } async function loadGateways() { return prisma.customerGateway.findMany({ where: { deletedAt: null }, orderBy: [{ id: 'asc' }], include: { ips: { where: { deletedAt: null }, select: { sourceIp: true, status: true } }, callerPrefixes: { select: { prefix: true } }, policies: { where: { deletedAt: null, status: 'ENABLED' }, orderBy: [{ priority: 'asc' }] } } }); } async function applyPlan(plans) { if (plans.length === 0) { return { updatedGateways: 0, insertedIps: 0, insertedCallerPrefixes: 0, outboxEvents: 0 }; } return prisma.$transaction(async (tx) => { let insertedIps = 0; let insertedCallerPrefixes = 0; for (const plan of plans) { await tx.customerGateway.update({ where: { id: plan.gatewayId }, data: { lineGroupId: plan.lineGroupId, callerMatchMode: plan.callerMatchMode, calleeMatchMode: 'ANY', updatedBy: 'phase2-migration', version: { increment: 1 } } }); if (plan.sourceIp) { const result = await tx.customerGatewayIp.createMany({ data: [ { id: id('cgip'), gatewayId: plan.gatewayId, sourceIp: plan.sourceIp, status: 'ENABLED', createdBy: 'phase2-migration', updatedBy: 'phase2-migration' } ], skipDuplicates: true }); insertedIps += result.count; } if (plan.callerPrefix) { const result = await tx.customerGatewayCallerPrefix.createMany({ data: [ { id: id('cgcp'), gatewayId: plan.gatewayId, prefix: plan.callerPrefix, priority: 100, createdBy: 'phase2-migration' } ], skipDuplicates: true }); insertedCallerPrefixes += result.count; } } await tx.outboxEvent.create({ data: { id: id('out'), aggregateType: 'customer_gateway_config', aggregateId: 'phase2-gateway-migration', eventType: 'phase2.gateway_migration.applied', payload: { gatewayIds: plans.map((plan) => plan.gatewayId), safeGatewayCount: plans.length } } }); return { updatedGateways: plans.length, insertedIps, insertedCallerPrefixes, outboxEvents: 1 }; }); } function printHuman(summary) { console.log(`Phase-2 gateway migration ${apply ? 'APPLY' : 'DRY-RUN'}`); console.log(`Total gateways: ${summary.totalGateways}`); console.log(`Safe to migrate: ${summary.safe.length}`); console.log(`Already compatible: ${summary.alreadyCompatible.length}`); console.log(`Manual blockers: ${summary.blockers.length}`); if (summary.safe.length > 0) { console.log('\nSafe plans:'); for (const plan of summary.safe) { console.log(`- ${plan.gatewayId} ${plan.gatewayName}: lineGroup=${plan.lineGroupId}, caller=${plan.callerMatchMode}${plan.callerPrefix ? `:${plan.callerPrefix}` : ''}, callee=ANY`); } } if (summary.blockers.length > 0) { console.log('\nManual blockers:'); for (const item of summary.blockers) { console.log(`- ${item.gatewayId} ${item.gatewayName}: ${item.code} - ${item.message}`); } } if (summary.applyResult) { console.log('\nApplied:'); console.log(`- updated gateways: ${summary.applyResult.updatedGateways}`); console.log(`- inserted IP rows: ${summary.applyResult.insertedIps}`); console.log(`- inserted caller prefixes: ${summary.applyResult.insertedCallerPrefixes}`); console.log(`- outbox events: ${summary.applyResult.outboxEvents}`); } } try { const gateways = await loadGateways(); const safe = []; const blockers = []; const alreadyCompatible = []; for (const gateway of gateways) { const analysis = analyzeGateway(gateway); if (analysis.blocker) { blockers.push(analysis.blocker); continue; } const plan = analysis.plan; const hasIp = !plan.sourceIp || gateway.ips.some((item) => item.sourceIp === plan.sourceIp); const hasCallerPrefix = !plan.callerPrefix || gateway.callerPrefixes.some((item) => item.prefix === plan.callerPrefix); const compatible = gateway.lineGroupId === plan.lineGroupId && gateway.callerMatchMode === plan.callerMatchMode && gateway.calleeMatchMode === 'ANY' && hasIp && hasCallerPrefix; if (compatible) { alreadyCompatible.push(plan); } else { safe.push(plan); } } const summary = { mode: apply ? 'apply' : 'dry-run', totalGateways: gateways.length, safe, alreadyCompatible, blockers, applyResult: apply ? await applyPlan(safe) : null }; if (json) { console.log(JSON.stringify(summary, null, 2)); } else { printHuman(summary); } if (failOnBlockers && blockers.length > 0) { process.exitCode = 3; } } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } finally { await prisma.$disconnect(); }