chore: merge executor worktree (worktree-agent-a6d587bad9f0efdb8) — plan 24-02
This commit is contained in:
commit
d7f72f8507
5 changed files with 958 additions and 0 deletions
|
|
@ -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`
|
||||
174
lib/services/route53-record-key.test.ts
Normal file
174
lib/services/route53-record-key.test.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
117
lib/services/route53-record-key.ts
Normal file
117
lib/services/route53-record-key.ts
Normal file
|
|
@ -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<string, unknown> | 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<string, unknown> | 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,
|
||||
};
|
||||
}
|
||||
126
lib/services/route53-sync-service.test.ts
Normal file
126
lib/services/route53-sync-service.test.ts
Normal file
|
|
@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
449
lib/services/route53-sync-service.ts
Normal file
449
lib/services/route53-sync-service.ts
Normal file
|
|
@ -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<string, NormalizedRecordSet>,
|
||||
nextByKey: Map<string, NormalizedRecordSet>,
|
||||
zoneId: string
|
||||
): DriftHistoryRow[] {
|
||||
const rows: DriftHistoryRow[] = [];
|
||||
const allKeys = new Set<string>([...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<Route53SyncResult> {
|
||||
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<Route53SyncResult> {
|
||||
return this.executeSync('incremental', triggeredBy);
|
||||
}
|
||||
|
||||
private async executeSync(syncType: 'full' | 'incremental', triggeredBy: string): Promise<Route53SyncResult> {
|
||||
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<number> }> = [
|
||||
{ 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<number> {
|
||||
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<number> {
|
||||
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<string, NormalizedRecordSet>();
|
||||
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<string, unknown> | 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<string, NormalizedRecordSet>();
|
||||
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<ResourceRecordSet[]> {
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue