1import * as route53 from "@distilled.cloud/aws/route-53";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, diffTags } from "../../Tags.ts";
9import type { Providers } from "../Providers.ts";
10
11export interface HostedZoneProps {
12 /**
13 * Fully qualified domain name for the zone (e.g. `"example.com"`). A trailing
14 * dot is added automatically. Changing the name forces replacement.
15 */
16 name: string;
17 /**
18 * Optional comment describing the zone. Updated in place.
19 */
20 comment?: string;
21 /**
22 * Whether this is a private hosted zone. Requires `vpc`. Changing this forces
23 * replacement.
24 * @default false
25 */
26 privateZone?: boolean;
27 /**
28 * VPC to associate with a private hosted zone at create time. Changing the
29 * initial VPC forces replacement.
30 */
31 vpc?: {
32 /** VPC ID. */
33 vpcId: string;
34 /** Region the VPC lives in. */
35 vpcRegion: string;
36 };
37 /**
38 * ID of a reusable delegation set to associate with the zone. Changing this
39 * forces replacement.
40 */
41 delegationSetId?: string;
42 /**
43 * Whether to delete all non-SOA/NS records before deleting the zone.
44 * @default false
45 */
46 forceDestroy?: boolean;
47 /**
48 * Tags applied to the hosted zone.
49 */
50 tags?: Record<string, string>;
51}
52
53export interface HostedZone extends Resource<
54 "AWS.Route53.HostedZone",
55 HostedZoneProps,
56 {
57 /**
58 * Hosted zone ID (without the `/hostedzone/` prefix).
59 */
60 id: string;
61 /**
62 * Fully qualified zone name (with trailing dot).
63 */
64 name: string;
65 /**
66 * Authoritative name servers for the zone.
67 */
68 nameServers: string[];
69 /**
70 * Current zone comment.
71 */
72 comment: string | undefined;
73 },
74 never,
75 Providers
76> {}
77
78/**
79 * A Route 53 hosted zone.
80 *
81 * `HostedZone` manages the lifecycle of a public or private hosted zone,
82 * including its comment and tags. For public zones, the four authoritative
83 * name servers are exposed as `nameServers`.
84 * ### Creating a Hosted Zone
85 * **Example:** Public Hosted Zone
86 * ```typescript
87 * const zone = yield* HostedZone("MyZone", {
88 * name: "example.com",
89 * comment: "Primary zone",
90 * });
91 * // zone.nameServers -> the 4 NS records to set at your registrar
92 * ```
93 *
94 * **Example:** Force Destroy
95 * ```typescript
96 * const zone = yield* HostedZone("MyZone", {
97 * name: "example.com",
98 * forceDestroy: true, // delete leftover records on destroy
99 * });
100 * ```
101 *
102 * @resource
103 */
104export const HostedZone = Resource<HostedZone>("AWS.Route53.HostedZone");
105
106const normalizeId = (id: string) => id.replace(/^\/hostedzone\//, "");
107
108const normalizeName = (name: string) =>
109 name.endsWith(".") ? name : `${name}.`;
110
111export const HostedZoneProvider = () =>
112 Provider.effect(
113 HostedZone,
114 Effect.gen(function* () {
115 // Poll `getChange` until the change reaches INSYNC. `getChange` is
116 // eventually consistent and can briefly return `NoSuchChange` right after
117 // submit, so coalesce that to a non-INSYNC status and keep polling.
118 // `ChangeInfo.Id` comes back as "/change/C..." but `getChange` only
119 // accepts the bare id — the prefixed form returns `NoSuchChange`
120 // forever, silently burning the full repeat cap on every change.
121 const waitForChange = Effect.fn(function* (changeId: string) {
122 return yield* route53
123 .getChange({ Id: changeId.replace(/^\/change\//, "") })
124 .pipe(
125 Effect.map((r) => r.ChangeInfo.Status),
126 Effect.catchTag("NoSuchChange", () => Effect.succeed("PENDING")),
127 Effect.repeat({
128 schedule: Schedule.max([
129 Schedule.fixed("2 seconds"),
130 Schedule.recurs(60),
131 ]),
132 until: (status) => status === "INSYNC",
133 }),
134 );
135 });
136
137 const findByName = Effect.fn(function* (name: string) {
138 const response = yield* route53.listHostedZonesByName({
139 DNSName: normalizeName(name),
140 MaxItems: 1,
141 });
142 return (response.HostedZones ?? []).find(
143 (zone) => zone.Name === normalizeName(name),
144 );
145 });
146
147 const observe = Effect.fn(function* (id: string) {
148 return yield* route53
149 .getHostedZone({ Id: normalizeId(id) })
150 .pipe(
151 Effect.catchTag("NoSuchHostedZone", () =>
152 Effect.succeed(undefined),
153 ),
154 );
155 });
156
157 const observedTags = Effect.fn(function* (id: string) {
158 const response = yield* route53.listTagsForResource({
159 ResourceType: "hostedzone",
160 ResourceId: normalizeId(id),
161 });
162 const record: Record<string, string> = {};
163 for (const tag of response.ResourceTagSet.Tags ?? []) {
164 if (tag.Key !== undefined && tag.Value !== undefined) {
165 record[tag.Key] = tag.Value;
166 }
167 }
168 return record;
169 });
170
171 const syncTags = Effect.fn(function* (
172 id: string,
173 logicalId: string,
174 userTags: Record<string, string> | undefined,
175 ) {
176 const internalTags = yield* createInternalTags(logicalId);
177 const newTags = { ...userTags, ...internalTags };
178 const oldTags = yield* observedTags(id);
179 const { upsert, removed } = diffTags(oldTags, newTags);
180 if (upsert.length === 0 && removed.length === 0) {
181 return;
182 }
183 yield* route53.changeTagsForResource({
184 ResourceType: "hostedzone",
185 ResourceId: normalizeId(id),
186 AddTags: upsert.length > 0 ? upsert : undefined,
187 RemoveTagKeys: removed.length > 0 ? removed : undefined,
188 });
189 });
190
191 // Delete every non-SOA/NS record set so the zone can be deleted.
192 const purgeRecords = Effect.fn(function* (id: string) {
193 const sets: route53.ResourceRecordSet[] = [];
194 let request: route53.ListResourceRecordSetsRequest = {
195 HostedZoneId: normalizeId(id),
196 MaxItems: 300,
197 };
198 while (true) {
199 const response = yield* route53.listResourceRecordSets(request);
200 sets.push(...(response.ResourceRecordSets ?? []));
201 if (!response.IsTruncated || response.NextRecordName === undefined) {
202 break;
203 }
204 request = {
205 HostedZoneId: normalizeId(id),
206 StartRecordName: response.NextRecordName,
207 StartRecordType: response.NextRecordType,
208 StartRecordIdentifier: response.NextRecordIdentifier,
209 MaxItems: 300,
210 };
211 }
212 const deletable = sets.filter(
213 (set) => set.Type !== "SOA" && set.Type !== "NS",
214 );
215 if (deletable.length === 0) {
216 return;
217 }
218 yield* route53
219 .changeResourceRecordSets({
220 HostedZoneId: normalizeId(id),
221 ChangeBatch: {
222 Comment: "Alchemy HostedZone forceDestroy",
223 Changes: deletable.map((set) => ({
224 Action: "DELETE" as const,
225 ResourceRecordSet: set,
226 })),
227 },
228 })
229 .pipe(
230 Effect.flatMap((response) => waitForChange(response.ChangeInfo.Id)),
231 );
232 });
233
234 return {
235 stables: ["id", "name"],
236 list: () =>
237 route53.listHostedZones.pages({}).pipe(
238 Stream.runCollect,
239 Effect.map((chunk) =>
240 Array.from(chunk).flatMap((page) =>
241 (page.HostedZones ?? []).map((zone) => ({
242 id: normalizeId(zone.Id),
243 name: zone.Name,
244 nameServers: [] as string[],
245 comment: zone.Config?.Comment,
246 })),
247 ),
248 ),
249 ),
250 diff: Effect.fn(function* ({ olds, news }) {
251 if (!isResolved(news)) return undefined;
252 // Name change → replace, but only when the old name is known — a
253 // half-created state row can't round-trip an Output-valued `name`
254 // (it deserializes as `undefined`), and an unknown old name must
255 // fall through to the create/update recovery path.
256 if (
257 (olds.name !== undefined &&
258 normalizeName(olds.name) !== normalizeName(news.name)) ||
259 (olds.privateZone ?? false) !== (news.privateZone ?? false) ||
260 olds.delegationSetId !== news.delegationSetId ||
261 olds.vpc?.vpcId !== news.vpc?.vpcId
262 ) {
263 return { action: "replace" } as const;
264 }
265 }),
266 read: Effect.fn(function* ({ olds, output }) {
267 // Resolve the zone id: prefer the stored output, else look up by name
268 // (adoption / state-loss recovery). The persisted `name` may be
269 // `undefined` when a `creating` row was written before upstream
270 // Outputs resolved — report "not found" and let the engine
271 // re-drive the create.
272 const zoneId =
273 output?.id ??
274 (olds?.name !== undefined
275 ? (yield* findByName(olds.name))?.Id
276 : undefined);
277 if (zoneId === undefined) {
278 return undefined;
279 }
280 const detail = yield* observe(zoneId);
281 if (!detail) {
282 return undefined;
283 }
284 return {
285 id: normalizeId(detail.HostedZone.Id),
286 name: detail.HostedZone.Name,
287 nameServers: detail.DelegationSet?.NameServers ?? [],
288 comment: detail.HostedZone.Config?.Comment,
289 };
290 }),
291 reconcile: Effect.fn(function* ({ id, instanceId, news, output }) {
292 // Observe.
293 let zone = output?.id ? yield* observe(output.id) : undefined;
294
295 // Ensure.
296 if (!zone) {
297 const created = yield* route53
298 .createHostedZone({
299 Name: normalizeName(news.name),
300 CallerReference: instanceId,
301 HostedZoneConfig:
302 news.comment !== undefined || news.privateZone
303 ? {
304 Comment: news.comment,
305 PrivateZone: news.privateZone,
306 }
307 : undefined,
308 VPC: news.vpc
309 ? { VPCId: news.vpc.vpcId, VPCRegion: news.vpc.vpcRegion }
310 : undefined,
311 DelegationSetId: news.delegationSetId,
312 })
313 .pipe(
314 Effect.map((response) => ({
315 HostedZone: response.HostedZone,
316 DelegationSet: response.DelegationSet,
317 })),
318 // Re-running with the same CallerReference races; re-observe.
319 Effect.catchTag("HostedZoneAlreadyExists", () =>
320 Effect.gen(function* () {
321 const existing = yield* findByName(news.name);
322 if (!existing) {
323 return yield* Effect.die(
324 new Error(
325 "hosted zone not found after HostedZoneAlreadyExists",
326 ),
327 );
328 }
329 return yield* observe(existing.Id).pipe(
330 Effect.map((z) =>
331 z
332 ? {
333 HostedZone: z.HostedZone,
334 DelegationSet: z.DelegationSet,
335 }
336 : undefined,
337 ),
338 );
339 }),
340 ),
341 );
342 if (!created) {
343 return yield* Effect.die(
344 new Error("hosted zone could not be observed after create"),
345 );
346 }
347 zone = {
348 HostedZone: created.HostedZone,
349 DelegationSet: created.DelegationSet,
350 VPCs: undefined,
351 };
352 }
353
354 const zoneId = zone.HostedZone.Id;
355
356 // Sync comment.
357 if ((zone.HostedZone.Config?.Comment ?? undefined) !== news.comment) {
358 yield* route53.updateHostedZoneComment({
359 Id: normalizeId(zoneId),
360 Comment: news.comment ?? "",
361 });
362 }
363
364 // Sync tags.
365 yield* syncTags(zoneId, id, news.tags);
366
367 // Re-read for fresh name servers + comment.
368 const detail = yield* observe(zoneId);
369 return {
370 id: normalizeId(zoneId),
371 name: detail?.HostedZone.Name ?? normalizeName(news.name),
372 nameServers: detail?.DelegationSet?.NameServers ?? [],
373 comment: detail?.HostedZone.Config?.Comment ?? news.comment,
374 };
375 }),
376 delete: Effect.fn(function* ({ olds, output }) {
377 if (olds.forceDestroy) {
378 yield* purgeRecords(output.id).pipe(
379 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
380 );
381 }
382 yield* route53.deleteHostedZone({ Id: normalizeId(output.id) }).pipe(
383 Effect.asVoid,
384 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
385 // A still-non-empty zone (without forceDestroy) is retried briefly
386 // in case a referencing record's delete is still propagating —
387 // records looked up by zone NAME (e.g. `ECS.Service`'s `domain`)
388 // have no dependency edge on the zone, so their deletes run
389 // concurrently with ours (a classic Dependency Violation).
390 Effect.retry({
391 while: (e) =>
392 e._tag === "PriorRequestNotComplete" ||
393 e._tag === "HostedZoneNotEmpty",
394 schedule: Schedule.max([
395 Schedule.fixed("3 seconds"),
396 Schedule.recurs(20),
397 ]),
398 }),
399 );
400 }),
401 };
402 }),
403 );