/** * Route 53 Sync Service * * Mirrors AWS Route 53 hosted zones and resource record sets into Postgres * (route53_zones / route53_records), soft-deleting anything AWS no longer * returns, and detects drift — recording a route53_record_history row * (source='sync_detected_drift') for every record whose live AWS value no * longer matches the mirror (D-06). * * Follows lib/services/veeam-sync-service.ts's class shape: constructor * takes an optional client, isSyncing guard, executeSync() step loop with * per-step error isolation, sync_history bookkeeping. */ import { Route53Client, ListHostedZonesCommand, GetHostedZoneCommand, ListResourceRecordSetsCommand, type HostedZone, type ResourceRecordSet, } from '@aws-sdk/client-route-53'; import postgresClient from './postgres-client'; import { getRoute53Client } from './route53-factory'; import type { Route53SyncResult } from '@/lib/types/route53'; import { buildRecordKey, normalizeRecordSet, classifyDrift, toHistoryPayload, type NormalizedRecordSet, } from './route53-record-key'; export interface Route53EntitySyncResult { entity: string; success: boolean; recordsUpserted: number; duration: number; error?: string; } export interface DriftHistoryRow { zoneId: string; recordKey: string; recordName: string; recordType: string; changeAction: 'create' | 'update' | 'delete'; beforeValue: unknown; afterValue: unknown; } /** * Union the previous (mirror) and next (AWS-live) keyed recordset maps, * classify drift per key, and build the row shape destined for * route53_record_history. Pure function — no database handle — so it is * unit-testable without Postgres or AWS. */ export function buildDriftHistoryRows( prevByKey: Map, nextByKey: Map, zoneId: string ): DriftHistoryRow[] { const rows: DriftHistoryRow[] = []; const allKeys = new Set([...prevByKey.keys(), ...nextByKey.keys()]); for (const key of allKeys) { const prev = prevByKey.get(key) ?? null; const next = nextByKey.get(key) ?? null; const action = classifyDrift(prev, next); if (action === null) continue; const source = next ?? prev; if (!source) continue; rows.push({ zoneId, recordKey: key, recordName: source.name, recordType: source.type, changeAction: action, beforeValue: toHistoryPayload(prev), afterValue: toHistoryPayload(next), }); } return rows; } export class Route53SyncService { private client: Route53Client; private isSyncing = false; constructor(client?: Route53Client) { this.client = client || getRoute53Client(); } isSyncInProgress(): boolean { return this.isSyncing; } /** * Full sync — fetches all zones/records and upserts into Postgres. */ async fullSync(triggeredBy: string = 'system'): Promise { return this.executeSync('full', triggeredBy); } /** * Incremental sync — Route 53's list APIs expose no modification cursor * (no lastTrackedModificationDateTime analog), so an "incremental" run * executes the exact same full read against AWS with the same diff logic * as fullSync; the only difference is cadence (D-11). This is not a * missing optimization — Route 53 offers nothing cheaper to poll. */ async incrementalSync(triggeredBy: string = 'system'): Promise { return this.executeSync('incremental', triggeredBy); } private async executeSync(syncType: 'full' | 'incremental', triggeredBy: string): Promise { if (this.isSyncing) { throw new Error('A Route 53 sync operation is already in progress'); } this.isSyncing = true; const syncId = `route53-${Date.now()}`; const startTime = new Date(); const entityResults: Route53EntitySyncResult[] = []; const errors: string[] = []; // Create sync_history record. The CHECK constraint on sync_type only // allows 'full' | 'incremental' | 'entity-specific' — write the literal // syncType value, not a composite string like 'route53-full'. let historyId: number | null = null; try { const histResult = await postgresClient.query<{ id: number }>( `INSERT INTO sync_history (entity_type, sync_type, status, started_at, records_added, records_updated, records_deleted, triggered_by) VALUES ($1, $2, $3, $4, 0, 0, 0, $5) RETURNING id`, ['route53', syncType, 'started', startTime, triggeredBy] ); historyId = histResult.rows[0].id; } catch (e) { console.warn('[ROUTE53-SYNC] Could not create sync history record:', e instanceof Error ? e.message : String(e)); } console.log(`[ROUTE53-SYNC] Starting ${syncType} sync (${syncId})`); try { // Zones must sync before records — route53_records.zone_id has an FK // to route53_zones(id). const steps: Array<{ name: string; fn: () => Promise }> = [ { name: 'zones', fn: () => this.syncZones() }, { name: 'records', fn: () => this.syncRecords() }, ]; for (const step of steps) { const stepStart = Date.now(); try { const count = await step.fn(); const duration = Date.now() - stepStart; entityResults.push({ entity: step.name, success: true, recordsUpserted: count, duration }); console.log(`[ROUTE53-SYNC] ${step.name}: ${count} records in ${duration}ms`); } catch (error) { const duration = Date.now() - stepStart; const msg = error instanceof Error ? error.message : String(error); errors.push(`${step.name}: ${msg}`); entityResults.push({ entity: step.name, success: false, recordsUpserted: 0, duration, error: msg }); console.error(`[ROUTE53-SYNC] ${step.name} failed:`, msg); } } const completedAt = new Date(); const duration = completedAt.getTime() - startTime.getTime(); const status = errors.length === 0 ? 'completed' : 'failed'; const totalRecords = entityResults.reduce((sum, r) => sum + r.recordsUpserted, 0); console.log(`[ROUTE53-SYNC] Sync ${status} in ${duration}ms — ${totalRecords} total records`); if (historyId) { try { await postgresClient.query( `UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4, entity_details = $5 WHERE id = $6`, [status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, JSON.stringify(entityResults), historyId] ); } catch (e) { console.warn('[ROUTE53-SYNC] Could not update sync history:', e instanceof Error ? e.message : String(e)); } } return { syncId, syncType, status, startedAt: startTime.toISOString(), completedAt: completedAt.toISOString(), duration, entities: { results: entityResults }, errors, }; } catch (error) { const completedAt = new Date(); const msg = error instanceof Error ? error.message : String(error); console.error('[ROUTE53-SYNC] Sync failed catastrophically:', msg); if (historyId) { try { await postgresClient.query( `UPDATE sync_history SET status = 'failed', completed_at = $1, error_message = $2 WHERE id = $3`, [completedAt, msg, historyId] ); } catch { /* ignore */ } } return { syncId, syncType, status: 'failed', startedAt: startTime.toISOString(), completedAt: completedAt.toISOString(), duration: completedAt.getTime() - startTime.getTime(), entities: { results: entityResults }, errors: [msg], }; } finally { this.isSyncing = false; } } // ============================================================================ // Zone sync // ============================================================================ private async syncZones(): Promise { const zones: HostedZone[] = []; let marker: string | undefined; do { const response = await this.client.send(new ListHostedZonesCommand({ Marker: marker })); zones.push(...(response.HostedZones ?? [])); marker = response.IsTruncated ? response.NextMarker : undefined; } while (marker); const seenIds: string[] = []; for (const zone of zones) { const id = (zone.Id ?? '').replace('/hostedzone/', ''); if (!id) continue; seenIds.push(id); let nameServers: string[] = []; try { const zoneDetail = await this.client.send(new GetHostedZoneCommand({ Id: id })); nameServers = zoneDetail.DelegationSet?.NameServers ?? []; } catch (error) { console.warn( `[ROUTE53-SYNC] Could not fetch delegation set for zone ${id}:`, error instanceof Error ? error.message : String(error) ); } await postgresClient.query( `INSERT INTO route53_zones (id, name, comment, private_zone, record_count, authoritative_name_servers, raw_payload, synced_at, updated_at, is_deleted, deleted_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW(), false, NULL) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, comment = EXCLUDED.comment, private_zone = EXCLUDED.private_zone, record_count = EXCLUDED.record_count, authoritative_name_servers = EXCLUDED.authoritative_name_servers, raw_payload = EXCLUDED.raw_payload, synced_at = NOW(), updated_at = NOW(), is_deleted = false, deleted_at = NULL`, [ id, zone.Name ?? '', zone.Config?.Comment ?? null, zone.Config?.PrivateZone ?? false, zone.ResourceRecordSetCount ?? 0, JSON.stringify(nameServers), JSON.stringify(zone), ] ); } // Soft-delete zones no longer returned by AWS. Guard against an empty // seenIds — `id <> ALL('{}')` is vacuously true for every row, so a // successful-but-empty AWS response (transient API quirk, not "the // account has zero zones") would otherwise soft-delete every previously // synced zone in one shot. Same bug class already fixed in // pax8-sync-service.ts. if (seenIds.length > 0) { await postgresClient.query( `UPDATE route53_zones SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE is_deleted = false AND id <> ALL($1)`, [seenIds] ); } return seenIds.length; } // ============================================================================ // Record sync (includes drift detection) // ============================================================================ private async syncRecords(): Promise { const liveZones = await postgresClient.query<{ id: string }>( 'SELECT id FROM route53_zones WHERE is_deleted = false' ); let totalUpserted = 0; for (const { id: zoneId } of liveZones.rows) { const recordsets = await this.fetchAllRecordSets(zoneId); const nextByKey = new Map(); for (const rs of recordsets) { const normalized = normalizeRecordSet(rs, zoneId); nextByKey.set(normalized.recordKey, normalized); } // Load the current mirror rows for this zone BEFORE upserting, so we // can diff against the pre-sync state (after the upsert the previous // state is gone). const mirrorRows = await postgresClient.query<{ record_key: string; name: string; type: string; set_identifier: string | null; ttl: number | null; resource_records: Array<{ value: string }> | null; alias_target: Record | null; }>( `SELECT record_key, name, type, set_identifier, ttl, resource_records, alias_target FROM route53_records WHERE zone_id = $1 AND is_deleted = false`, [zoneId] ); const prevByKey = new Map(); for (const row of mirrorRows.rows) { prevByKey.set(row.record_key, { recordKey: row.record_key, zoneId, name: row.name, type: row.type, setIdentifier: row.set_identifier, ttl: row.ttl, resourceRecords: row.resource_records ?? [], aliasTarget: row.alias_target, }); } // Guard the very first sync: an empty mirror means this is the initial // import for the zone. Do NOT emit `create` history rows for every // record — that would flood the append-only ledger with thousands of // meaningless rows. if (prevByKey.size === 0) { console.log(`[ROUTE53-SYNC] Initial import for zone ${zoneId} — skipping drift history`); } else { const driftRows = buildDriftHistoryRows(prevByKey, nextByKey, zoneId); if (driftRows.length > 0) { await postgresClient.transaction(async (client) => { for (const row of driftRows) { await client.query( `INSERT INTO route53_record_history (zone_id, record_key, record_name, record_type, change_action, before_value, after_value, source, changed_by_user_id, changed_by_email) VALUES ($1, $2, $3, $4, $5, $6, $7, 'sync_detected_drift', NULL, NULL)`, [ row.zoneId, row.recordKey, row.recordName, row.recordType, row.changeAction, JSON.stringify(row.beforeValue), JSON.stringify(row.afterValue), ] ); } }); } } const seenKeys: string[] = []; for (const normalized of nextByKey.values()) { seenKeys.push(normalized.recordKey); await postgresClient.query( `INSERT INTO route53_records (record_key, zone_id, name, type, set_identifier, ttl, resource_records, alias_target, raw_payload, synced_at, updated_at, is_deleted, deleted_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), false, NULL) ON CONFLICT (record_key) DO UPDATE SET name = EXCLUDED.name, type = EXCLUDED.type, set_identifier = EXCLUDED.set_identifier, ttl = EXCLUDED.ttl, resource_records = EXCLUDED.resource_records, alias_target = EXCLUDED.alias_target, raw_payload = EXCLUDED.raw_payload, synced_at = NOW(), updated_at = NOW(), is_deleted = false, deleted_at = NULL`, [ normalized.recordKey, zoneId, normalized.name, normalized.type, normalized.setIdentifier, normalized.ttl, JSON.stringify(normalized.resourceRecords), JSON.stringify(normalized.aliasTarget), JSON.stringify(normalized), ] ); totalUpserted++; } // Soft-delete this zone's records no longer present in AWS. Never // hard-delete — the history ledger references record_key. Guard // against an empty seenKeys for the same reason as syncZones() above — // every real zone has at least apex NS/SOA records, so an empty page // here is a transient AWS API quirk, not "this zone has zero records." if (seenKeys.length > 0) { await postgresClient.query( `UPDATE route53_records SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE zone_id = $1 AND is_deleted = false AND record_key <> ALL($2)`, [zoneId, seenKeys] ); } } return totalUpserted; } private async fetchAllRecordSets(zoneId: string): Promise { const recordsets: ResourceRecordSet[] = []; let startRecordName: string | undefined; let startRecordType: ResourceRecordSet['Type'] | undefined; let startRecordIdentifier: string | undefined; do { const response = await this.client.send( new ListResourceRecordSetsCommand({ HostedZoneId: zoneId, StartRecordName: startRecordName, StartRecordType: startRecordType, StartRecordIdentifier: startRecordIdentifier, }) ); recordsets.push(...(response.ResourceRecordSets ?? [])); if (response.IsTruncated) { startRecordName = response.NextRecordName; startRecordType = response.NextRecordType; startRecordIdentifier = response.NextRecordIdentifier; } else { startRecordName = undefined; startRecordType = undefined; startRecordIdentifier = undefined; } } while (startRecordName); return recordsets; } } let _instance: Route53SyncService | null = null; export function getRoute53SyncService(): Route53SyncService { if (!_instance) { _instance = new Route53SyncService(); } return _instance; }