Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
CONFIG_ACTIVE_VERSION_KEY,
|
||||
CONFIG_PREVIOUS_VERSION_KEY,
|
||||
configVersionManifestKey,
|
||||
configVersionPrefix,
|
||||
type RedisClient
|
||||
} from '@lisglosips/redis';
|
||||
|
||||
export interface PublishConfigResult {
|
||||
published: boolean;
|
||||
version?: string;
|
||||
previousVersion?: string | null;
|
||||
outboxIds: string[];
|
||||
manifest?: ConfigManifest;
|
||||
}
|
||||
|
||||
export interface ConfigManifest {
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
customerCount: number;
|
||||
gatewayCount: number;
|
||||
policyCount: number;
|
||||
vendorGatewayCount: number;
|
||||
lineGroupCount: number;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
type ConfigSnapshot = {
|
||||
customers: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
balance: string;
|
||||
creditLimit: string;
|
||||
minBalance: string;
|
||||
}>;
|
||||
gateways: Array<{
|
||||
id: string;
|
||||
customerId: string;
|
||||
authMode: string;
|
||||
sourceIp: string | null;
|
||||
sipUsername: string | null;
|
||||
sipDomain: string | null;
|
||||
sipHa1: string | null;
|
||||
status: string;
|
||||
}>;
|
||||
policies: Array<{
|
||||
id: string;
|
||||
customerId: string;
|
||||
gatewayId: string;
|
||||
lineGroupId: string;
|
||||
priority: number;
|
||||
callerMode: string;
|
||||
callerValue: string | null;
|
||||
calleeMode: string;
|
||||
calleeValue: string | null;
|
||||
status: string;
|
||||
}>;
|
||||
vendorGateways: Array<{
|
||||
id: string;
|
||||
vendorId: string;
|
||||
authMode: string;
|
||||
host: string;
|
||||
port: number;
|
||||
transport: string;
|
||||
sipUsername: string | null;
|
||||
sipHa1: string | null;
|
||||
cpsLimit: number;
|
||||
concurrencyLimit: number;
|
||||
billingCycleSec: number;
|
||||
cycleRate: string;
|
||||
status: string;
|
||||
forbiddenPeriods: Array<{ weekdayMask: number; startTime: string; endTime: string }>;
|
||||
codecs: Array<{ codec: string; priority: number }>;
|
||||
prefixRules: Array<{ direction: string; matchPrefix: string; replacePrefix: string; priority: number }>;
|
||||
}>;
|
||||
lineGroups: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
vendorGatewayId: string;
|
||||
priority: number;
|
||||
weight: number;
|
||||
concurrencyCap: number;
|
||||
status: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function publishPendingConfig(prisma: PrismaClient, redis: RedisClient, now = new Date()): Promise<PublishConfigResult> {
|
||||
const pending = await prisma.outboxEvent.findMany({
|
||||
where: {
|
||||
aggregateType: { in: ['customer_gateway_config', 'vendor_gateway_config', 'line_group_config'] },
|
||||
status: 'PENDING',
|
||||
availableAt: { lte: now }
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }],
|
||||
take: 100
|
||||
});
|
||||
|
||||
if (pending.length === 0) {
|
||||
return { published: false, outboxIds: [] };
|
||||
}
|
||||
|
||||
const lockedAt = now;
|
||||
const locked = await prisma.outboxEvent.updateMany({
|
||||
where: {
|
||||
id: { in: pending.map((event) => event.id) },
|
||||
status: 'PENDING'
|
||||
},
|
||||
data: {
|
||||
status: 'PROCESSING',
|
||||
lockedAt,
|
||||
attempts: { increment: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
if (locked.count === 0) {
|
||||
return { published: false, outboxIds: [] };
|
||||
}
|
||||
|
||||
const processingIds = pending.map((event) => event.id);
|
||||
try {
|
||||
const snapshot = await loadSnapshot(prisma);
|
||||
const version = `${now.getTime()}`;
|
||||
const previousVersion = await redis.get(CONFIG_ACTIVE_VERSION_KEY);
|
||||
const manifest = await writeSnapshot(redis, version, snapshot, previousVersion, now);
|
||||
|
||||
await prisma.outboxEvent.updateMany({
|
||||
where: { id: { in: processingIds }, status: 'PROCESSING' },
|
||||
data: {
|
||||
status: 'PUBLISHED',
|
||||
processedAt: new Date(),
|
||||
lastError: null
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
published: true,
|
||||
version,
|
||||
previousVersion,
|
||||
outboxIds: processingIds,
|
||||
manifest
|
||||
};
|
||||
} catch (error) {
|
||||
await prisma.outboxEvent.updateMany({
|
||||
where: { id: { in: processingIds }, status: 'PROCESSING' },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
lastError: error instanceof Error ? error.message.slice(0, 1000) : 'Unknown publish error'
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function rollbackActiveConfig(redis: RedisClient): Promise<{ rolledBack: boolean; activeVersion: string | null; previousVersion: string | null }> {
|
||||
const activeVersion = await redis.get(CONFIG_ACTIVE_VERSION_KEY);
|
||||
const previousVersion = await redis.get(CONFIG_PREVIOUS_VERSION_KEY);
|
||||
if (!previousVersion) {
|
||||
return { rolledBack: false, activeVersion, previousVersion };
|
||||
}
|
||||
|
||||
await redis
|
||||
.multi()
|
||||
.set(CONFIG_ACTIVE_VERSION_KEY, previousVersion)
|
||||
.set(CONFIG_PREVIOUS_VERSION_KEY, activeVersion ?? '')
|
||||
.exec();
|
||||
|
||||
return { rolledBack: true, activeVersion: previousVersion, previousVersion: activeVersion };
|
||||
}
|
||||
|
||||
async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
const [customers, gateways, policies, vendorGateways, lineGroups] = await prisma.$transaction([
|
||||
prisma.customer.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ id: 'asc' }]
|
||||
}),
|
||||
prisma.customerGateway.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ id: 'asc' }]
|
||||
}),
|
||||
prisma.customerGatewayPolicy.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ gatewayId: 'asc' }, { priority: 'asc' }]
|
||||
}),
|
||||
prisma.vendorGateway.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ id: 'asc' }],
|
||||
include: {
|
||||
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
|
||||
codecs: { orderBy: [{ priority: 'asc' }] },
|
||||
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] }
|
||||
}
|
||||
}),
|
||||
prisma.landingLineGroup.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ id: 'asc' }],
|
||||
include: {
|
||||
items: { orderBy: [{ priority: 'asc' }] }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
customers: customers.map((customer) => ({
|
||||
id: customer.id,
|
||||
status: customer.status,
|
||||
balance: customer.balance.toFixed(6),
|
||||
creditLimit: customer.creditLimit.toFixed(6),
|
||||
minBalance: customer.minBalance.toFixed(6)
|
||||
})),
|
||||
gateways: gateways.map((gateway) => ({
|
||||
id: gateway.id,
|
||||
customerId: gateway.customerId,
|
||||
authMode: gateway.authMode,
|
||||
sourceIp: gateway.sourceIp,
|
||||
sipUsername: gateway.sipUsername,
|
||||
sipDomain: gateway.sipDomain,
|
||||
sipHa1: gateway.sipHa1,
|
||||
status: gateway.status
|
||||
})),
|
||||
policies: policies.map((policy) => ({
|
||||
id: policy.id,
|
||||
customerId: policy.customerId,
|
||||
gatewayId: policy.gatewayId,
|
||||
lineGroupId: policy.lineGroupId,
|
||||
priority: policy.priority,
|
||||
callerMode: policy.callerMode,
|
||||
callerValue: policy.callerValue,
|
||||
calleeMode: policy.calleeMode,
|
||||
calleeValue: policy.calleeValue,
|
||||
status: policy.status
|
||||
})),
|
||||
vendorGateways: vendorGateways.map((gateway) => ({
|
||||
id: gateway.id,
|
||||
vendorId: gateway.vendorId,
|
||||
authMode: gateway.authMode,
|
||||
host: gateway.host,
|
||||
port: gateway.port,
|
||||
transport: gateway.transport,
|
||||
sipUsername: gateway.sipUsername,
|
||||
sipHa1: gateway.sipHa1,
|
||||
cpsLimit: gateway.cpsLimit,
|
||||
concurrencyLimit: gateway.concurrencyLimit,
|
||||
billingCycleSec: gateway.billingCycleSec,
|
||||
cycleRate: gateway.cycleRate.toFixed(6),
|
||||
status: gateway.status,
|
||||
forbiddenPeriods: gateway.forbiddenPeriods.map((period) => ({
|
||||
weekdayMask: period.weekdayMask,
|
||||
startTime: period.startTime,
|
||||
endTime: period.endTime
|
||||
})),
|
||||
codecs: gateway.codecs.map((codec) => ({
|
||||
codec: codec.codec,
|
||||
priority: codec.priority
|
||||
})),
|
||||
prefixRules: gateway.prefixRules.map((rule) => ({
|
||||
direction: rule.direction,
|
||||
matchPrefix: rule.matchPrefix,
|
||||
replacePrefix: rule.replacePrefix,
|
||||
priority: rule.priority
|
||||
}))
|
||||
})),
|
||||
lineGroups: lineGroups.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
status: group.status,
|
||||
items: group.items.map((item) => ({
|
||||
id: item.id,
|
||||
vendorGatewayId: item.vendorGatewayId,
|
||||
priority: item.priority,
|
||||
weight: item.weight,
|
||||
concurrencyCap: item.concurrencyCap,
|
||||
status: item.status
|
||||
}))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async function writeSnapshot(
|
||||
redis: RedisClient,
|
||||
version: string,
|
||||
snapshot: ConfigSnapshot,
|
||||
previousVersion: string | null,
|
||||
now: Date
|
||||
): Promise<ConfigManifest> {
|
||||
const prefix = configVersionPrefix(version);
|
||||
const checksum = crypto.createHash('sha256').update(stableJson(snapshot)).digest('hex');
|
||||
const manifest: ConfigManifest = {
|
||||
version,
|
||||
generatedAt: now.toISOString(),
|
||||
customerCount: snapshot.customers.length,
|
||||
gatewayCount: snapshot.gateways.length,
|
||||
policyCount: snapshot.policies.length,
|
||||
vendorGatewayCount: snapshot.vendorGateways.length,
|
||||
lineGroupCount: snapshot.lineGroups.length,
|
||||
checksum
|
||||
};
|
||||
|
||||
const multi = redis.multi();
|
||||
multi.set(configVersionManifestKey(version), JSON.stringify(manifest));
|
||||
|
||||
for (const customer of snapshot.customers) {
|
||||
multi.set(`${prefix}:customer:${customer.id}`, JSON.stringify(customer));
|
||||
}
|
||||
for (const gateway of snapshot.gateways) {
|
||||
multi.set(`${prefix}:customer_gateway:${gateway.id}`, JSON.stringify(gateway));
|
||||
if ((gateway.authMode === 'IP' || gateway.authMode === 'MIXED') && gateway.sourceIp) {
|
||||
multi.set(`${prefix}:auth:ip:${gateway.sourceIp}`, gateway.id);
|
||||
}
|
||||
if ((gateway.authMode === 'SIP_DIGEST' || gateway.authMode === 'MIXED') && gateway.sipUsername && gateway.sipDomain) {
|
||||
multi.set(`${prefix}:auth:sip:${gateway.sipUsername}@${gateway.sipDomain}`, gateway.id);
|
||||
}
|
||||
}
|
||||
for (const policy of snapshot.policies) {
|
||||
multi.rpush(`${prefix}:customer_gateway:${policy.gatewayId}:policies`, JSON.stringify(policy));
|
||||
}
|
||||
for (const gateway of snapshot.vendorGateways) {
|
||||
multi.set(`${prefix}:vendor_gateway:${gateway.id}`, JSON.stringify(gateway));
|
||||
}
|
||||
for (const lineGroup of snapshot.lineGroups) {
|
||||
multi.set(`${prefix}:line_group:${lineGroup.id}`, JSON.stringify(lineGroup));
|
||||
for (const item of lineGroup.items) {
|
||||
multi.rpush(`${prefix}:line_group:${lineGroup.id}:items`, JSON.stringify(item));
|
||||
}
|
||||
}
|
||||
|
||||
if (previousVersion) {
|
||||
multi.set(CONFIG_PREVIOUS_VERSION_KEY, previousVersion);
|
||||
}
|
||||
multi.set(CONFIG_ACTIVE_VERSION_KEY, version);
|
||||
await multi.exec();
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
return JSON.stringify(value, Object.keys(flattenKeys(value)).sort());
|
||||
}
|
||||
|
||||
function flattenKeys(value: unknown, keys: Record<string, true> = {}): Record<string, true> {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
flattenKeys(item, keys);
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
keys[key] = true;
|
||||
flattenKeys(nested, keys);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
Reference in New Issue
Block a user