feat: complete phase2 baseline cdr quality rbac
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
#!/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();
|
||||
}
|
||||
Reference in New Issue
Block a user