From c18271dda907917f9aef63ee8fab6334e9760e52 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 20:21:05 -0400 Subject: [PATCH 1/4] test(24-02): add Route 53 record-key, normalization, and drift-classification helpers - buildRecordKey, normalizeRecordSet, recordSetsEqual, classifyDrift, toHistoryPayload - Pure, dependency-free module (no pg, no AWS client construction) - 16 unit tests covering every behavior bullet from the plan --- lib/services/route53-record-key.test.ts | 174 ++++++++++++++++++++++++ lib/services/route53-record-key.ts | 117 ++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 lib/services/route53-record-key.test.ts create mode 100644 lib/services/route53-record-key.ts diff --git a/lib/services/route53-record-key.test.ts b/lib/services/route53-record-key.test.ts new file mode 100644 index 0000000..fc36c19 --- /dev/null +++ b/lib/services/route53-record-key.test.ts @@ -0,0 +1,174 @@ +/** + * lib/services/route53-record-key.ts unit tests. + * + * Pure functions, no mocking required — no `pg`, no AWS client construction. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildRecordKey, + normalizeRecordSet, + recordSetsEqual, + classifyDrift, + toHistoryPayload, + type NormalizedRecordSet, +} from './route53-record-key'; +import type { ResourceRecordSet } from '@aws-sdk/client-route-53'; + +describe('buildRecordKey', () => { + it('builds a key with an empty setIdentifier segment when none is given', () => { + expect( + buildRecordKey({ zoneId: 'Z123', name: 'www.example.com.', type: 'A', setIdentifier: null }) + ).toBe('Z123:www.example.com.:A:'); + }); + + it('appends a non-null setIdentifier after the final colon', () => { + expect( + buildRecordKey({ zoneId: 'Z123', name: 'www.example.com.', type: 'A', setIdentifier: 'primary' }) + ).toBe('Z123:www.example.com.:A:primary'); + }); +}); + +describe('normalizeRecordSet', () => { + it('lowercases name, preserves trailing dot, uppercases type, coerces missing TTL to null, sorts resourceRecords', () => { + const rs: ResourceRecordSet = { + Name: 'WWW.Example.COM.', + Type: 'a' as ResourceRecordSet['Type'], + ResourceRecords: [{ Value: '10.0.0.2' }, { Value: '10.0.0.1' }], + }; + const normalized = normalizeRecordSet(rs, 'Z123'); + + expect(normalized.name).toBe('www.example.com.'); + expect(normalized.type).toBe('A'); + expect(normalized.ttl).toBeNull(); + expect(normalized.resourceRecords).toEqual([{ value: '10.0.0.1' }, { value: '10.0.0.2' }]); + }); + + it('normalizes an alias record with no TTL/ResourceRecords but a populated AliasTarget', () => { + const rs: ResourceRecordSet = { + Name: 'alias.example.com.', + Type: 'A' as ResourceRecordSet['Type'], + AliasTarget: { + HostedZoneId: 'Z2FDTNDATAQYW2', + DNSName: 'd123.cloudfront.net.', + EvaluateTargetHealth: false, + }, + }; + const normalized = normalizeRecordSet(rs, 'Z123'); + + expect(normalized.ttl).toBeNull(); + expect(normalized.resourceRecords).toEqual([]); + expect(normalized.aliasTarget).toEqual({ + HostedZoneId: 'Z2FDTNDATAQYW2', + DNSName: 'd123.cloudfront.net.', + EvaluateTargetHealth: false, + }); + }); +}); + +describe('recordSetsEqual', () => { + const base: NormalizedRecordSet = { + recordKey: 'Z123:www.example.com.:A:', + zoneId: 'Z123', + name: 'www.example.com.', + type: 'A', + setIdentifier: null, + ttl: 300, + resourceRecords: [{ value: '10.0.0.1' }, { value: '10.0.0.2' }], + aliasTarget: null, + }; + + it('is true when compared with itself', () => { + expect(recordSetsEqual(base, base)).toBe(true); + }); + + it('is false when a single resourceRecords value changes', () => { + const changed: NormalizedRecordSet = { + ...base, + resourceRecords: [{ value: '10.0.0.1' }, { value: '10.0.0.3' }], + }; + expect(recordSetsEqual(base, changed)).toBe(false); + }); + + it('is true when only the order of resourceRecords changes', () => { + const reordered: NormalizedRecordSet = { + ...base, + resourceRecords: [{ value: '10.0.0.2' }, { value: '10.0.0.1' }], + }; + // Both are pre-sorted by normalizeRecordSet in real use; simulate that + // order doesn't matter by sorting here too, matching normalization. + const sorted = [...reordered.resourceRecords].sort((a, b) => a.value.localeCompare(b.value)); + expect(recordSetsEqual(base, { ...reordered, resourceRecords: sorted })).toBe(true); + }); + + it('is false when TTL differs', () => { + const changed: NormalizedRecordSet = { ...base, ttl: 600 }; + expect(recordSetsEqual(base, changed)).toBe(false); + }); + + it('is true when both are null', () => { + expect(recordSetsEqual(null, null)).toBe(true); + }); + + it('is false when only one side is null', () => { + expect(recordSetsEqual(base, null)).toBe(false); + expect(recordSetsEqual(null, base)).toBe(false); + }); +}); + +describe('classifyDrift', () => { + const prev: NormalizedRecordSet = { + recordKey: 'Z123:www.example.com.:A:', + zoneId: 'Z123', + name: 'www.example.com.', + type: 'A', + setIdentifier: null, + ttl: 300, + resourceRecords: [{ value: '10.0.0.1' }], + aliasTarget: null, + }; + + it('returns create when prev is null', () => { + expect(classifyDrift(null, prev)).toBe('create'); + }); + + it('returns delete when next is null', () => { + expect(classifyDrift(prev, null)).toBe('delete'); + }); + + it('returns update when normalized sets differ', () => { + const next: NormalizedRecordSet = { ...prev, ttl: 600 }; + expect(classifyDrift(prev, next)).toBe('update'); + }); + + it('returns null when normalized sets are equal (no history row)', () => { + expect(classifyDrift(prev, { ...prev })).toBeNull(); + }); +}); + +describe('toHistoryPayload', () => { + it('returns null for a null input', () => { + expect(toHistoryPayload(null)).toBeNull(); + }); + + it('returns the whole-recordset snapshot shape for a non-null input', () => { + const ns: NormalizedRecordSet = { + recordKey: 'Z123:www.example.com.:A:', + zoneId: 'Z123', + name: 'www.example.com.', + type: 'A', + setIdentifier: null, + ttl: 300, + resourceRecords: [{ value: '10.0.0.1' }], + aliasTarget: null, + }; + expect(toHistoryPayload(ns)).toEqual({ + name: 'www.example.com.', + type: 'A', + setIdentifier: null, + ttl: 300, + resourceRecords: [{ value: '10.0.0.1' }], + aliasTarget: null, + }); + }); +}); diff --git a/lib/services/route53-record-key.ts b/lib/services/route53-record-key.ts new file mode 100644 index 0000000..60c7dcb --- /dev/null +++ b/lib/services/route53-record-key.ts @@ -0,0 +1,117 @@ +/** + * Pure, dependency-free helpers for Route 53 record-key derivation, + * recordset normalization, and drift classification. + * + * No `pg` import, no AWS SDK client construction — only types are imported + * from `@aws-sdk/client-route-53`. This keeps the module unit-testable + * without mocking anything (see 24-RESEARCH.md Anti-Patterns: no per-value + * diffing; Route 53 models an update as a whole-recordset replace). + */ + +import type { ResourceRecordSet } from '@aws-sdk/client-route-53'; + +export interface NormalizedRecordSet { + recordKey: string; + zoneId: string; + name: string; + type: string; + setIdentifier: string | null; + ttl: number | null; + resourceRecords: Array<{ value: string }>; + aliasTarget: Record | null; +} + +/** + * Build the `route53_records.record_key` primary key / `recordId` URL + * segment: `${zoneId}:${name}:${type}:${setIdentifier ?? ''}`. + */ +export function buildRecordKey(input: { + zoneId: string; + name: string; + type: string; + setIdentifier?: string | null; +}): string { + return `${input.zoneId}:${input.name}:${input.type}:${input.setIdentifier ?? ''}`; +} + +/** + * Normalize a raw AWS `ResourceRecordSet` into a comparable, stable shape. + * + * - `name` is lowercased with its trailing dot preserved + * - `type` is uppercased + * - `ttl` is `rs.TTL ?? null` (alias records have no TTL) + * - `resourceRecords` is sorted by value so AWS's unordered list doesn't + * register as drift on every sync + * - `aliasTarget` is `rs.AliasTarget ?? null` + */ +export function normalizeRecordSet(rs: ResourceRecordSet, zoneId: string): NormalizedRecordSet { + const name = (rs.Name ?? '').toLowerCase(); + const type = (rs.Type ?? '').toUpperCase(); + const setIdentifier = rs.SetIdentifier ?? null; + const ttl = rs.TTL ?? null; + const resourceRecords = (rs.ResourceRecords ?? []) + .map((r) => ({ value: r.Value ?? '' })) + .sort((a, b) => a.value.localeCompare(b.value)); + const aliasTarget = (rs.AliasTarget as unknown as Record | undefined) ?? null; + + return { + recordKey: buildRecordKey({ zoneId, name, type, setIdentifier }), + zoneId, + name, + type, + setIdentifier, + ttl, + resourceRecords, + aliasTarget, + }; +} + +/** + * Compare two normalized recordsets for material equality: TTL, the + * serialized (already-sorted) resourceRecords array, and the serialized + * aliasTarget. Both null returns true; exactly one null returns false. + */ +export function recordSetsEqual(a: NormalizedRecordSet | null, b: NormalizedRecordSet | null): boolean { + if (a === null && b === null) return true; + if (a === null || b === null) return false; + + if (a.ttl !== b.ttl) return false; + if (JSON.stringify(a.resourceRecords) !== JSON.stringify(b.resourceRecords)) return false; + if (JSON.stringify(a.aliasTarget) !== JSON.stringify(b.aliasTarget)) return false; + + return true; +} + +/** + * Classify the drift between a previous (mirror) and next (AWS-live) + * normalized recordset, mapping to `route53_record_history.change_action`. + * Returns `null` when there is no material difference, so the sync does not + * write a no-op history row. + */ +export function classifyDrift( + prev: NormalizedRecordSet | null, + next: NormalizedRecordSet | null +): 'create' | 'update' | 'delete' | null { + if (prev === null && next === null) return null; + if (prev === null) return 'create'; + if (next === null) return 'delete'; + return recordSetsEqual(prev, next) ? null : 'update'; +} + +/** + * Build the JSONB payload stored in `before_value`/`after_value`: the whole + * recordset snapshot. Route 53 has no partial-value primitive, so the unit + * of change recorded in history is always the whole recordset, never a + * per-field delta. + */ +export function toHistoryPayload(ns: NormalizedRecordSet | null): unknown { + if (ns === null) return null; + return { + name: ns.name, + type: ns.type, + setIdentifier: ns.setIdentifier, + ttl: ns.ttl, + resourceRecords: ns.resourceRecords, + aliasTarget: ns.aliasTarget, + }; +} From d8c0912f4b2d3abc781dd7e497715f660f1cab96 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 20:22:34 -0400 Subject: [PATCH 2/4] =?UTF-8?q?feat(24-02):=20add=20Route53SyncService=20?= =?UTF-8?q?=E2=80=94=20zones=20and=20records=20mirror=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fullSync/incrementalSync + getRoute53SyncService() singleton - Paginated ListHostedZonesCommand + GetHostedZoneCommand (delegation set for D-12) - Paginated ListResourceRecordSetsCommand per live zone - Soft-delete reconciliation for zones and records (never hard-delete) - sync_history bookkeeping with entity_type='route53', literal full/incremental sync_type - Drift detection wired via buildDriftHistoryRows, writing sync_detected_drift history rows - No integration_settings gating anywhere (D-10) --- lib/services/route53-sync-service.ts | 449 +++++++++++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 lib/services/route53-sync-service.ts 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; +} From 0acf1fa24f6a6e4d94d9a810116fea088515c7fa Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 20:23:16 -0400 Subject: [PATCH 3/4] test(24-02): cover buildDriftHistoryRows update/delete/create/no-change cases - 6 unit tests exercising the pure drift-classification wiring in Route53SyncService - Confirms whole-recordset before/after payloads, not per-field deltas - Confirms CRUD-originated changes still get tagged sync_detected_drift (sync cannot distinguish actor) - Full suite green aside from 2 pre-existing itglue-search.test.ts failures logged in deferred-items.md (unrelated to this plan) --- lib/services/route53-sync-service.test.ts | 126 ++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 lib/services/route53-sync-service.test.ts diff --git a/lib/services/route53-sync-service.test.ts b/lib/services/route53-sync-service.test.ts new file mode 100644 index 0000000..c8347fa --- /dev/null +++ b/lib/services/route53-sync-service.test.ts @@ -0,0 +1,126 @@ +/** + * lib/services/route53-sync-service.ts unit tests. + * + * Only buildDriftHistoryRows is exercised here — a pure function taking + * hand-constructed NormalizedRecordSet maps, no AWS or Postgres mocking + * required (matches the plan's stated test scope for this file). + */ + +import { describe, it, expect } from 'vitest'; +import { buildDriftHistoryRows } from './route53-sync-service'; +import type { NormalizedRecordSet } from './route53-record-key'; + +function makeRecordSet(overrides: Partial = {}): NormalizedRecordSet { + return { + recordKey: 'Z123:www.example.com.:A:', + zoneId: 'Z123', + name: 'www.example.com.', + type: 'A', + setIdentifier: null, + ttl: 300, + resourceRecords: [{ value: '10.0.0.1' }], + aliasTarget: null, + ...overrides, + }; +} + +describe('buildDriftHistoryRows', () => { + it('produces one update row with non-null before/after when TTL changes', () => { + const key = 'Z123:www.example.com.:A:'; + const prev = makeRecordSet({ ttl: 300 }); + const next = makeRecordSet({ ttl: 600 }); + + const rows = buildDriftHistoryRows( + new Map([[key, prev]]), + new Map([[key, next]]), + 'Z123' + ); + + expect(rows).toHaveLength(1); + expect(rows[0].changeAction).toBe('update'); + expect(rows[0].zoneId).toBe('Z123'); + expect(rows[0].recordKey).toBe(key); + expect(rows[0].beforeValue).not.toBeNull(); + expect(rows[0].afterValue).not.toBeNull(); + }); + + it('produces a delete row with non-null before and null after when the mirror row has no AWS match', () => { + const key = 'Z123:gone.example.com.:A:'; + const prev = makeRecordSet({ recordKey: key, name: 'gone.example.com.' }); + + const rows = buildDriftHistoryRows(new Map([[key, prev]]), new Map(), 'Z123'); + + expect(rows).toHaveLength(1); + expect(rows[0].changeAction).toBe('delete'); + expect(rows[0].beforeValue).not.toBeNull(); + expect(rows[0].afterValue).toBeNull(); + }); + + it('produces a create row with null before and non-null after when AWS has no matching mirror row', () => { + const key = 'Z123:new.example.com.:A:'; + const next = makeRecordSet({ recordKey: key, name: 'new.example.com.' }); + + const rows = buildDriftHistoryRows(new Map(), new Map([[key, next]]), 'Z123'); + + expect(rows).toHaveLength(1); + expect(rows[0].changeAction).toBe('create'); + expect(rows[0].beforeValue).toBeNull(); + expect(rows[0].afterValue).not.toBeNull(); + }); + + it('produces zero rows when mirror and AWS recordsets are identical', () => { + const key = 'Z123:www.example.com.:A:'; + const prev = makeRecordSet(); + const next = makeRecordSet(); + + const rows = buildDriftHistoryRows( + new Map([[key, prev]]), + new Map([[key, next]]), + 'Z123' + ); + + expect(rows).toHaveLength(0); + }); + + it('stores the whole recordset (name, type, setIdentifier, ttl, resourceRecords, aliasTarget) in before/after, not a per-field delta', () => { + const key = 'Z123:www.example.com.:A:'; + const prev = makeRecordSet({ ttl: 300 }); + const next = makeRecordSet({ ttl: 600 }); + + const rows = buildDriftHistoryRows( + new Map([[key, prev]]), + new Map([[key, next]]), + 'Z123' + ); + + expect(rows[0].afterValue).toEqual({ + name: 'www.example.com.', + type: 'A', + setIdentifier: null, + ttl: 600, + resourceRecords: [{ value: '10.0.0.1' }], + aliasTarget: null, + }); + }); + + it('a record changed by Pulse CRUD in the same window is still tagged as drift by the sync (sync cannot distinguish actor)', () => { + // The sync has no notion of "who changed this" — it only compares + // mirror vs. AWS-live state. A CRUD-originated change looks identical + // to an out-of-band AWS console edit from the sync's perspective, so it + // still produces a drift row here. The CRUD route's own pulse_crud row + // (written elsewhere, not by this function) is the authoritative one; + // both rows are expected to coexist in the ledger. + const key = 'Z123:www.example.com.:A:'; + const prev = makeRecordSet({ ttl: 300 }); + const next = makeRecordSet({ ttl: 900 }); // as if Pulse CRUD had just updated this + + const rows = buildDriftHistoryRows( + new Map([[key, prev]]), + new Map([[key, next]]), + 'Z123' + ); + + expect(rows).toHaveLength(1); + expect(rows[0].changeAction).toBe('update'); + }); +}); From 6df084cb98c5d5be610f1c46de11a3d604b126f0 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 20:24:18 -0400 Subject: [PATCH 4/4] docs(24-02): create plan summary Route 53 sync + drift detection plan complete: 3 tasks, 4 files, all verification passing (22 new tests, tsc clean, full suite green aside from pre-existing unrelated itglue-search failures). --- .../24-02-SUMMARY.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-02-SUMMARY.md diff --git a/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-02-SUMMARY.md b/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-02-SUMMARY.md new file mode 100644 index 0000000..92ab85c --- /dev/null +++ b/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-02-SUMMARY.md @@ -0,0 +1,92 @@ +--- +phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud +plan: 02 +subsystem: aws-route53 +tags: [route53, sync, drift-detection, soft-delete, sync_history] +dependency-graph: + requires: + - "lib/services/route53-factory.ts (getRoute53Client / isRoute53Configured)" + - "lib/types/route53.ts (Route53Record, Route53SyncResult, Route53HistorySource)" + - "migrations/102_route53_tables.sql (route53_zones / route53_records / route53_record_history)" + provides: + - "lib/services/route53-record-key.ts (buildRecordKey, normalizeRecordSet, recordSetsEqual, classifyDrift, toHistoryPayload)" + - "lib/services/route53-sync-service.ts (Route53SyncService, getRoute53SyncService(), buildDriftHistoryRows)" + affects: + - "plan 24-05 (CRUD routes import getRoute53SyncService for post-write history/audit patterns)" + - "plan 24-06 (scheduler wiring calls fullSync()/incrementalSync())" +tech-stack: + added: [] + patterns: + - "Sync-service class shape mirrors lib/services/veeam-sync-service.ts: constructor(client?), isSyncing guard, executeSync() step loop with per-step error isolation, sync_history bookkeeping" + - "Pure drift-classification logic extracted into an injectable, unit-testable function (buildDriftHistoryRows) rather than inlined in the DB-touching sync method" +key-files: + created: + - lib/services/route53-record-key.ts + - lib/services/route53-record-key.test.ts + - lib/services/route53-sync-service.ts + - lib/services/route53-sync-service.test.ts + modified: [] +decisions: + - "Combined Task 2 (sync mirror) and Task 3 (drift detection) into a single write of route53-sync-service.ts rather than writing sync-only first and retrofitting drift after — the plan's own Task 2 action already anticipated buildDriftHistoryRows()'s existence (referencing it in Task 3's read_first), so building both together avoided a throwaway intermediate version of syncRecords()." + - "Symlinked node_modules from the main repo checkout into this worktree (git-ignored, not committed) — the worktree had no node_modules of its own and @aws-sdk/client-route-53 types were required for both writing and type-checking this plan's code." +metrics: + duration: "~45 minutes, 3 tasks, 4 files" + completed: "2026-08-05" +--- + +# Phase 24 Plan 2: Route 53 Sync + Drift Detection Summary + +Built the Route 53 → Postgres mirror sync (`Route53SyncService`) with paginated +zone/record ingestion, soft-delete reconciliation, and whole-recordset drift +detection that writes `sync_detected_drift` history rows whenever a mirrored +record's live AWS value no longer matches Postgres. + +## What Was Built + +**`lib/services/route53-record-key.ts`** — pure, dependency-free helpers: +- `buildRecordKey({ zoneId, name, type, setIdentifier })` → `${zoneId}:${name}:${type}:${setIdentifier ?? ''}`, the `route53_records.record_key` primary key +- `normalizeRecordSet(rs, zoneId)` — lowercases name (preserving trailing dot), uppercases type, coerces missing TTL to `null`, sorts `resourceRecords` by value (so AWS's unordered list never produces phantom drift), passes through `aliasTarget` +- `recordSetsEqual(a, b)` — TTL + serialized resourceRecords + serialized aliasTarget comparison; both-null is equal, one-null is not +- `classifyDrift(prev, next)` → `'create' | 'update' | 'delete' | null`, returning `null` for no material difference (no-op history row avoided) +- `toHistoryPayload(ns)` — whole-recordset JSONB snapshot for `before_value`/`after_value` + +**`lib/services/route53-sync-service.ts`** — `Route53SyncService` class: +- `fullSync()` / `incrementalSync()` both delegate to `executeSync()`, which runs the same two steps in both cases (Route 53 exposes no modification cursor, so "incremental" is the same full diff run more often — documented in a code comment per D-11) +- `syncZones()` — paginates `ListHostedZonesCommand` (Marker/IsTruncated/NextMarker), strips the `/hostedzone/` prefix from each zone id, fetches `GetHostedZoneCommand` per zone for `DelegationSet.NameServers` (backs plan 24-04's D-12 NS-delegation check), upserts into `route53_zones`, then soft-deletes zones no longer returned by AWS +- `syncRecords()` — for each live zone, paginates `ListResourceRecordSetsCommand` (StartRecordName/Type/Identifier), normalizes each recordset, loads the zone's current mirror rows, computes drift via `buildDriftHistoryRows()` **before** upserting (so the pre-sync state is still available), inserts drift history rows in a transaction, then upserts records and soft-deletes anything no longer present +- `buildDriftHistoryRows(prevByKey, nextByKey, zoneId)` — exported pure function; unions both keyed maps, classifies drift per key via `classifyDrift`, skips `null` (no-change) keys, builds `DriftHistoryRow[]` with whole-recordset before/after payloads +- Initial-import guard: if a zone's mirror had zero rows before this sync, drift history generation is skipped entirely for that zone (logged once) — prevents flooding the append-only ledger with meaningless `create` rows on first import +- `sync_history` bookkeeping: `entity_type='route53'`, `sync_type` written as the literal `'full'`/`'incremental'` (matching the table's CHECK constraint), catastrophic-failure catch marks the row `status='failed'` +- All error logging uses `error instanceof Error ? error.message : String(error)` — never the raw AWS SDK error object (T-24-03: avoids leaking `$metadata`/request headers into logs) +- No `integration_settings` check anywhere in the file (D-10: Route 53's disable toggle is display-only, must never gate sync) +- `getRoute53SyncService()` module-level singleton, following `pax8-sync-service.ts`'s lazy-instance pattern, exported for plans 24-05/24-06 + +## Deviations from Plan + +None — plan executed as written. Tasks 2 and 3 were combined into a single write of `route53-sync-service.ts` (see Decisions above) since the file only exists once either way; both tasks' acceptance criteria are independently verified below. + +## Verification + +- `npx vitest run lib/services/route53-record-key.test.ts` — 16 tests passed +- `npx vitest run lib/services/route53-sync-service.test.ts` — 6 tests passed +- `npx tsc --noEmit --pretty` — exits 0 +- `npm test` (full suite) — 489 passed, 2 pre-existing failures in `lib/services/analyzer/itglue-search.test.ts`, unrelated to this plan's files and already logged in `deferred-items.md` from plan 24-01 (neither `itglue-search.ts` nor its test were touched by this plan) +- Acceptance-criteria greps all confirmed: `integration_settings` count 0, `entity_type` present, both pagination loops present (`IsTruncated` count 2), both soft-delete statements present (`is_deleted = true` count 2), `sync_detected_drift` literal present, `changed_by_user_id` NULL for drift rows, initial-import guard present (`prevByKey.size === 0`), `buildDriftHistoryRows` call precedes the `INSERT INTO route53_records` statement in source order + +## Environment Note (not a code deviation) + +This worktree had no `node_modules` directory. Symlinked it from the main +repo checkout (`ln -s /opt/stacks/pulse/node_modules ./node_modules`) so +`@aws-sdk/client-route-53` types and `vitest`/`tsc` were available. This is a +local filesystem convenience, not a git-tracked change — `node_modules` is +`.gitignore`d and no commit references it. + +## Self-Check: PASSED + +- `lib/services/route53-record-key.ts` — FOUND +- `lib/services/route53-record-key.test.ts` — FOUND +- `lib/services/route53-sync-service.ts` — FOUND +- `lib/services/route53-sync-service.test.ts` — FOUND +- Commit `c18271d` (Task 1) — FOUND in `git log --oneline --all` +- Commit `d8c0912` (Task 2) — FOUND in `git log --oneline --all` +- Commit `0acf1fa` (Task 3) — FOUND in `git log --oneline --all`