diff --git a/lib/services/route53-sync-service.ts b/lib/services/route53-sync-service.ts new file mode 100644 index 0000000..60d4da5 --- /dev/null +++ b/lib/services/route53-sync-service.ts @@ -0,0 +1,449 @@ +/** + * 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. + 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. + 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; +}