1import * as route53 from "@distilled.cloud/aws/route-53";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource, type ResourceBinding } from "../../Resource.ts";
8import { durationToSeconds } from "../IAM/common.ts";
9import type { Providers } from "../Providers.ts";
10import { resolveHostedZoneId } from "./HostedZoneLookup.ts";
11import {
12 normalizeHostedZoneId,
13 normalizeName,
14 toAliasTarget,
15 toRecordSet,
16 type RecordAliasTarget,
17 type ResolvedRecordAliasTarget,
18} from "./Record.ts";
19
20/**
21 * Binding contract of {@link Records}: composites (e.g. an
22 * `AWS.Website.StaticSite` attached to an `AWS.Website.Router`) contribute
23 * additional record names without a circular input prop.
24 */
25export type RecordsBinding = {
26 /**
27 * Additional record names to manage alongside {@link RecordsProps.names}.
28 */
29 names?: string[];
30};
31
32export interface RecordsProps {
33 /**
34 * Hosted zone that owns the records. When omitted, the most specific
35 * PUBLIC hosted zone in the account containing the set's first name is
36 * inferred by walking its parent domains; the deploy fails actionably
37 * when no zone matches. Every name in the set lives in the one zone
38 * either way.
39 */
40 hostedZoneId?: string;
41 /**
42 * Record type shared by every record in the set.
43 */
44 type: route53.RRType;
45 /**
46 * Record names managed by this set. Names contributed through the binding
47 * contract (see {@link RecordsBinding}) are merged in at reconcile time.
48 */
49 names?: string[];
50 /**
51 * TTL for non-alias records, e.g. `"60 seconds"` (a bare number is
52 * milliseconds). Rounded to whole seconds on the wire.
53 */
54 ttl?: Duration.Input;
55 /**
56 * Record values for non-alias records (shared by every name in the set).
57 */
58 records?: string[];
59 /**
60 * Alias target for alias records (shared by every name in the set).
61 */
62 aliasTarget?: RecordAliasTarget;
63}
64
65export interface Records extends Resource<
66 "AWS.Route53.Records",
67 RecordsProps,
68 {
69 /**
70 * Hosted zone that owns the records. `undefined` only while the set
71 * is empty with no explicit zone — resolved (explicit or inferred) on
72 * the first reconcile that has a name.
73 */
74 hostedZoneId: string | undefined;
75 /**
76 * Record type shared by every record in the set.
77 */
78 type: route53.RRType;
79 /**
80 * Fully qualified names of every record currently managed by this set
81 * (declared `names` plus bound names, as of the last reconcile).
82 */
83 names: string[];
84 /**
85 * Current TTL for non-alias records.
86 */
87 ttl: number | undefined;
88 /**
89 * Current non-alias record values.
90 */
91 records: string[] | undefined;
92 /**
93 * Current alias target, when the set manages alias records.
94 */
95 aliasTarget: ResolvedRecordAliasTarget | undefined;
96 },
97 RecordsBinding,
98 Providers
99> {}
100
101/**
102 * A dynamic set of identically-configured Route 53 records that differ only
103 * by name.
104 *
105 * Unlike {@link Record} (one resource per record), `Records` reconciles a
106 * whole name set against the hosted zone — names added to the set are
107 * upserted, names removed from the set are deleted. The set is the union of
108 * the declared `names` prop and names contributed through the
109 * {@link RecordsBinding} binding contract, which is how composites (e.g. a
110 * site attached to an `AWS.Website.Router`) register hostnames on a
111 * distribution's DNS without a circular input prop.
112 * ### Managing Record Sets
113 * **Example:** Alias Records For Several Hostnames
114 * ```typescript
115 * const records = yield* Records("AliasRecords", {
116 * hostedZoneId: "Z1234567890",
117 * type: "A",
118 * names: ["www.example.com", "docs.example.com"],
119 * aliasTarget: {
120 * hostedZoneId: distribution.hostedZoneId,
121 * dnsName: distribution.domainName,
122 * },
123 * });
124 * ```
125 *
126 * **Example:** Binding Target For Composite-Contributed Names
127 * ```typescript
128 * // Names may also arrive through the binding contract — an
129 * // `AWS.Website.Router` declares an empty set and attached sites bind
130 * // their hostnames onto it:
131 * const records = yield* Records("SiteAliasRecords", {
132 * hostedZoneId: "Z1234567890",
133 * type: "A",
134 * aliasTarget: {
135 * hostedZoneId: distribution.hostedZoneId,
136 * dnsName: distribution.domainName,
137 * },
138 * });
139 * // elsewhere:
140 * yield* records.bind`MySite`({ names: ["docs.example.com"] });
141 * ```
142 *
143 * @resource
144 */
145export const Records = Resource<Records>("AWS.Route53.Records");
146
147/**
148 * Union of declared and bound record names, deduped (case-insensitively via
149 * trailing-dot-insensitive normalization) and sorted for stable comparisons.
150 * Tolerates both `{ sid, data }` rows (provider lifecycle) and bare binding
151 * payloads.
152 * @internal
153 */
154const resolveDesiredNames = (
155 declared: string[] | undefined,
156 bindings: ReadonlyArray<RecordsBinding | ResourceBinding<RecordsBinding>>,
157): string[] => {
158 const bound = bindings.flatMap((binding) =>
159 "data" in binding && binding.data !== undefined
160 ? ((binding as ResourceBinding<RecordsBinding>).data.names ?? [])
161 : ((binding as RecordsBinding).names ?? []),
162 );
163 const byNormalized = new Map<string, string>();
164 for (const name of [...(declared ?? []), ...bound]) {
165 const key = normalizeName(name).toLowerCase();
166 if (!byNormalized.has(key)) {
167 byNormalized.set(key, name);
168 }
169 }
170 return [...byNormalized.values()].sort((a, b) => a.localeCompare(b));
171};
172
173export const RecordsProvider = () =>
174 Provider.effect(
175 Records,
176 Effect.gen(function* () {
177 const waitForChange = Effect.fn(function* (changeId: string) {
178 return yield* route53
179 .getChange({ Id: changeId.replace(/^\/change\//, "") })
180 .pipe(
181 Effect.map((response) => response.ChangeInfo.Status),
182 Effect.catchTag("NoSuchChange", () => Effect.succeed("PENDING")),
183 Effect.repeat({
184 schedule: Schedule.max([
185 Schedule.fixed("2 seconds"),
186 Schedule.recurs(60),
187 ]),
188 until: (status) => status === "INSYNC",
189 }),
190 );
191 });
192
193 const findRecord = Effect.fn(function* (
194 hostedZoneId: string,
195 name: string,
196 type: route53.RRType,
197 ) {
198 const response = yield* route53
199 .listResourceRecordSets({
200 HostedZoneId: normalizeHostedZoneId(hostedZoneId),
201 StartRecordName: normalizeName(name),
202 StartRecordType: type,
203 MaxItems: 10,
204 })
205 .pipe(
206 Effect.catchTag("NoSuchHostedZone", () =>
207 Effect.succeed(undefined),
208 ),
209 );
210
211 return (response?.ResourceRecordSets ?? []).find(
212 (recordSet) =>
213 recordSet.Name.toLowerCase() ===
214 normalizeName(name).toLowerCase() &&
215 recordSet.Type === type &&
216 recordSet.SetIdentifier === undefined,
217 );
218 });
219
220 const desiredRecordSet = (news: RecordsProps, name: string) =>
221 toRecordSet({
222 name,
223 type: news.type,
224 ttl: durationToSeconds(news.ttl),
225 records: news.records,
226 aliasTarget: news.aliasTarget,
227 });
228
229 /** Trailing-dot/case-insensitive comparison of a live record set
230 * against the desired wire shape for one name. */
231 const matchesDesired = (
232 live: route53.ResourceRecordSet,
233 desired: route53.ResourceRecordSet,
234 ): boolean => {
235 const normalizeDns = (value: string | undefined) =>
236 value?.toLowerCase().replace(/\.$/, "");
237 if (desired.AliasTarget) {
238 return (
239 normalizeDns(live.AliasTarget?.DNSName) ===
240 normalizeDns(desired.AliasTarget.DNSName) &&
241 live.AliasTarget?.HostedZoneId ===
242 desired.AliasTarget.HostedZoneId &&
243 (live.AliasTarget?.EvaluateTargetHealth ?? false) ===
244 (desired.AliasTarget.EvaluateTargetHealth ?? false)
245 );
246 }
247 const liveValues = (live.ResourceRecords ?? [])
248 .map((record) => record.Value)
249 .sort();
250 const desiredValues = (desired.ResourceRecords ?? [])
251 .map((record) => record.Value)
252 .sort();
253 return (
254 live.TTL === desired.TTL &&
255 liveValues.length === desiredValues.length &&
256 liveValues.every((value, index) => value === desiredValues[index])
257 );
258 };
259
260 /** Delete `names` (exact live shape) tolerantly — missing records,
261 * missing zones, and already-deleted batches are all no-ops. */
262 const deleteNames = Effect.fn(function* (
263 hostedZoneId: string,
264 type: route53.RRType,
265 names: string[],
266 ) {
267 const changes: route53.Change[] = [];
268 for (const name of names) {
269 const live = yield* findRecord(hostedZoneId, name, type);
270 if (live) {
271 changes.push({ Action: "DELETE", ResourceRecordSet: live });
272 }
273 }
274 if (changes.length === 0) {
275 return;
276 }
277 yield* route53
278 .changeResourceRecordSets({
279 HostedZoneId: normalizeHostedZoneId(hostedZoneId),
280 ChangeBatch: {
281 Comment: "Alchemy Route53 record-set delete",
282 Changes: changes,
283 },
284 })
285 .pipe(
286 Effect.flatMap((response) => waitForChange(response.ChangeInfo.Id)),
287 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
288 Effect.catchTag("InvalidChangeBatch", () => Effect.void),
289 );
290 });
291
292 return {
293 stables: ["hostedZoneId", "type"],
294 diff: Effect.fn(function* ({ olds, news }) {
295 if (!isResolved(news)) return undefined;
296 // Identity change → replace (same rule as `Record`); the names
297 // themselves are mutable — prop/binding deltas fall through to
298 // the engine's default update logic.
299 if (
300 // An undefined side means "inferred" — reconcile resolves it
301 // against the live account, so only two explicit, differing
302 // zones are a replacement.
303 (olds.hostedZoneId !== undefined &&
304 news.hostedZoneId !== undefined &&
305 normalizeHostedZoneId(olds.hostedZoneId) !==
306 normalizeHostedZoneId(news.hostedZoneId)) ||
307 olds.type !== news.type
308 ) {
309 return { action: "replace" } as const;
310 }
311 }),
312 read: Effect.fn(function* ({ output }) {
313 if (output === undefined) {
314 // Without the previously-managed name set there is no identity
315 // to look up — report "not found" so the engine re-drives the
316 // reconcile (upserts converge on any half-created records).
317 return undefined;
318 }
319 if (output.hostedZoneId === undefined) {
320 // An empty set that never resolved a zone — nothing to observe.
321 return output;
322 }
323 const found: string[] = [];
324 let recordSet: route53.ResourceRecordSet | undefined;
325 for (const name of output.names) {
326 const live = yield* findRecord(
327 output.hostedZoneId,
328 name,
329 output.type,
330 );
331 if (live) {
332 found.push(name);
333 recordSet ??= live;
334 }
335 }
336 return {
337 ...output,
338 names: found,
339 ttl: recordSet?.TTL ?? output.ttl,
340 records:
341 recordSet?.ResourceRecords?.map((record) => record.Value) ??
342 output.records,
343 aliasTarget:
344 toAliasTarget(recordSet?.AliasTarget) ?? output.aliasTarget,
345 };
346 }),
347 reconcile: Effect.fn(function* ({ news, output, session, bindings }) {
348 const desired = resolveDesiredNames(news.names, bindings);
349 // Resolve the zone: explicit prop, else the zone this set already
350 // resolved to, else infer from the first name in the set. A set
351 // that is still empty with no explicit zone stays unresolved —
352 // the zone materializes on the first reconcile that has a name.
353 const explicitZoneId = news.hostedZoneId ?? output?.hostedZoneId;
354 if (explicitZoneId === undefined && desired.length === 0) {
355 yield* session.note(`${news.type} × 0 record(s)`);
356 return {
357 hostedZoneId: undefined,
358 type: news.type,
359 names: [],
360 ttl: undefined,
361 records: undefined,
362 aliasTarget: undefined,
363 };
364 }
365 const hostedZoneId = yield* resolveHostedZoneId(
366 explicitZoneId,
367 desired[0] ?? "",
368 );
369 // `output.names` is the cache of which records this resource
370 // managed before — the only way to know what to garbage-collect
371 // (Route 53 records carry no tags to observe ownership from).
372 const previous = output?.names ?? [];
373 const desiredKeys = new Set(
374 desired.map((name) => normalizeName(name).toLowerCase()),
375 );
376 const stale = previous.filter(
377 (name) => !desiredKeys.has(normalizeName(name).toLowerCase()),
378 );
379
380 // Sync — observe each desired name and upsert only the delta.
381 const changes: route53.Change[] = [];
382 for (const name of desired) {
383 const target = desiredRecordSet(news, name);
384 const live = yield* findRecord(hostedZoneId, name, news.type);
385 if (!live || !matchesDesired(live, target)) {
386 changes.push({ Action: "UPSERT", ResourceRecordSet: target });
387 }
388 }
389 if (changes.length > 0) {
390 const response = yield* route53.changeResourceRecordSets({
391 HostedZoneId: normalizeHostedZoneId(hostedZoneId),
392 ChangeBatch: {
393 Comment: "Alchemy Route53 record-set upsert",
394 Changes: changes,
395 },
396 });
397 yield* waitForChange(response.ChangeInfo.Id);
398 }
399
400 // Garbage-collect names that left the set.
401 yield* deleteNames(hostedZoneId, news.type, stale);
402
403 yield* session.note(
404 `${news.type} × ${desired.length} record(s)` +
405 (stale.length > 0 ? ` (-${stale.length})` : ""),
406 );
407
408 const sample =
409 desired.length > 0
410 ? yield* findRecord(hostedZoneId, desired[0]!, news.type)
411 : undefined;
412 return {
413 hostedZoneId: normalizeHostedZoneId(hostedZoneId),
414 type: news.type,
415 names: desired.map((name) => normalizeName(name)),
416 ttl: sample?.TTL,
417 records: sample?.ResourceRecords?.map((record) => record.Value),
418 aliasTarget: toAliasTarget(sample?.AliasTarget),
419 };
420 }),
421 delete: Effect.fn(function* ({ output }) {
422 if (output.hostedZoneId === undefined) {
423 // The set never resolved a zone — it never created records.
424 return;
425 }
426 yield* deleteNames(output.hostedZoneId, output.type, output.names);
427 }),
428 };
429 }),
430 );