1import * as AWS from "@/AWS";
2import { Record } from "@/AWS/Route53";
3import * as Provider from "@/Provider";
4import { isResourceState, State, type ResourceState } from "@/State";
5import * as Test from "@/Test/Alchemy";
6import * as route53 from "@distilled.cloud/aws/route-53";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as Stream from "effect/Stream";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14// Deterministic zone name so a crashed run's leftover zone is reclaimed by
15// the next run's lookup-first `resolveZone` (and then deleted by `teardownZone`).
16// (`example.com` is reserved by AWS, hence the bespoke name.)
17const zoneName = "alchemy-route53-list-test.com.";
18const callerReference = "alchemy-route53-record-list-test-v2";
19const recordName = `list-record.${zoneName}`;
20const recordValue = '"alchemy-list-test"';
21
22const normalizeId = (id: string) => id.replace(/^\/hostedzone\//, "");
23
24const listZoneIdsByName = route53.listHostedZones.pages({}).pipe(
25 Stream.runCollect,
26 Effect.map((chunk) =>
27 Array.from(chunk).flatMap((page) => page.HostedZones ?? []),
28 ),
29 Effect.map((zones) =>
30 zones
31 .filter((zone) => zone.Name === zoneName)
32 .flatMap((zone) => (zone.Id !== undefined ? [zone.Id] : [])),
33 ),
34);
35
36const findZoneIdByName = listZoneIdsByName.pipe(Effect.map((ids) => ids[0]));
37
38// The hosted zone is a *suite-scoped* fixture — resolved once (lookup-first,
39// create on a genuine miss) and shared by both tests in this file, then
40// deleted by the `teardownZone` finalizer below so a passing run leaves the
41// account clean. A short retry absorbs the brief list eventual-consistency
42// right after a first-time create, when a create can race ahead of the zone
43// appearing in `listHostedZones`.
44const zoneNotYetListable =
45 "hosted zone not found after HostedZoneAlreadyExists";
46
47// List first, create only on a genuine miss. The previous create-first design
48// got permanently poisoned whenever the zone was deleted (as the `teardownZone`
49// teardown now does every run): a stable `CallerReference` stays claimed
50// during a long propagation window, so `createHostedZone` keeps returning
51// `HostedZoneAlreadyExists` while `listHostedZones` still shows nothing.
52// Looking the zone up first (and only creating with a *fresh* CallerReference
53// when it's truly absent) sidesteps that trap — an absent/poisoned zone
54// always re-creates cleanly, and a present zone is reused without ever
55// hitting the conflict path.
56const resolveZone = findZoneIdByName.pipe(
57 Effect.flatMap((existing) =>
58 existing !== undefined
59 ? Effect.succeed(normalizeId(existing))
60 : route53
61 .createHostedZone({
62 Name: zoneName,
63 CallerReference: `${callerReference}-${crypto.randomUUID()}`,
64 })
65 .pipe(
66 Effect.map((response) => normalizeId(response.HostedZone.Id)),
67 // A concurrent attempt won the race — fall back to the lookup.
68 Effect.catchTag("HostedZoneAlreadyExists", () =>
69 findZoneIdByName.pipe(
70 Effect.flatMap((id) =>
71 id !== undefined
72 ? Effect.succeed(normalizeId(id))
73 : Effect.fail(new Error(zoneNotYetListable)),
74 ),
75 ),
76 ),
77 ),
78 ),
79 Effect.retry({
80 while: (e) => e instanceof Error && e.message === zoneNotYetListable,
81 schedule: Schedule.spaced("5 seconds"),
82 times: 24,
83 }),
84);
85
86// Cache the resolved id at module scope: both tests in this file run
87// sequentially in the same fork, and a first-time create can lag
88// `listHostedZones` long enough that the second test's lookup misses and
89// creates a DUPLICATE zone (same name + fresh CallerReference succeeds).
90let standingZoneId: string | undefined;
91const ensureZone = Effect.suspend(() =>
92 standingZoneId !== undefined
93 ? Effect.succeed(standingZoneId)
94 : resolveZone.pipe(
95 Effect.tap((id) =>
96 Effect.sync(() => {
97 standingZoneId = id;
98 }),
99 ),
100 ),
101);
102
103// Suite teardown: purge any test records still in the zone (idempotent — the
104// happy path already deleted them) and delete the zone itself so a passing
105// run leaves ZERO Route53 leftovers. Runs as `Effect.ensuring` on the LAST
106// test in this file (test-body cleanup keeps the provider environment in
107// scope, unlike `afterAll`). Re-creation on the next run is safe because
108// `resolveZone` is lookup-first with a fresh CallerReference — and if a run
109// dies before the teardown fires, the deterministic zone name lets the next
110// run reclaim AND delete the leftover.
111const purgeAndDeleteZone = (zoneId: string) =>
112 Effect.gen(function* () {
113 const sets = yield* route53
114 .listResourceRecordSets({ HostedZoneId: zoneId, MaxItems: 100 })
115 .pipe(
116 Effect.map((r) => r.ResourceRecordSets ?? []),
117 Effect.catchTag("NoSuchHostedZone", () => Effect.succeed([])),
118 );
119 const deletable = sets.filter((s) => s.Type !== "SOA" && s.Type !== "NS");
120 if (deletable.length > 0) {
121 yield* route53
122 .changeResourceRecordSets({
123 HostedZoneId: zoneId,
124 ChangeBatch: {
125 Comment: "Record.test.ts suite teardown",
126 Changes: deletable.map((set) => ({
127 Action: "DELETE" as const,
128 ResourceRecordSet: set,
129 })),
130 },
131 })
132 .pipe(
133 Effect.asVoid,
134 Effect.catchTag("InvalidChangeBatch", () => Effect.void),
135 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
136 );
137 }
138 yield* route53.deleteHostedZone({ Id: zoneId }).pipe(
139 Effect.asVoid,
140 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
141 // Route 53 serializes changes per zone; a delete racing the record
142 // purge above can bounce with PriorRequestNotComplete.
143 Effect.retry({
144 while: (e) => e._tag === "PriorRequestNotComplete",
145 schedule: Schedule.spaced("3 seconds"),
146 times: 8,
147 }),
148 );
149 // Verify the delete actually landed (authoritative read, not the
150 // eventually-consistent list) — a silent no-op here would orphan the zone.
151 yield* route53.getHostedZone({ Id: zoneId }).pipe(
152 Effect.flatMap(() =>
153 Effect.fail(new Error(`teardown: zone ${zoneId} still exists`)),
154 ),
155 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
156 Effect.retry({
157 while: (e): boolean => e instanceof Error,
158 schedule: Schedule.spaced("2 seconds"),
159 times: 10,
160 }),
161 );
162 });
163
164const teardownZone = Effect.gen(function* () {
165 // Delete EVERY incarnation bearing the test zone name, not just one id:
166 // a transient failure (or vitest retry) between a server-side create and
167 // the caller observing it can mint a duplicate same-name zone while the
168 // eventually-consistent list still hides the sibling. Union the in-run
169 // cached id (the list may not show a moments-old zone yet) with every
170 // listed match; anything the stale list hides this run is reclaimed by
171 // the NEXT run's teardown via the same name lookup.
172 const listed = yield* listZoneIdsByName;
173 const candidates = [
174 ...new Set(
175 [
176 ...(standingZoneId !== undefined ? [standingZoneId] : []),
177 ...listed,
178 ].map(normalizeId),
179 ),
180 ];
181 yield* Effect.forEach(candidates, purgeAndDeleteZone);
182 // Drop the module-scope cache so a hypothetical later user re-resolves.
183 standingZoneId = undefined;
184}).pipe(Effect.orDie);
185
186// Create/delete the record set out of band. The Alchemy engine's own deploy
187// path is currently blocked by a distilled schema bug (see the file footer),
188// so we seed the record directly to exercise `list()` against real Route53.
189const changeRecord = (hostedZoneId: string, action: "UPSERT" | "DELETE") =>
190 route53
191 .changeResourceRecordSets({
192 HostedZoneId: hostedZoneId,
193 ChangeBatch: {
194 Comment: "Route53 Record list() test",
195 Changes: [
196 {
197 Action: action,
198 ResourceRecordSet: {
199 Name: recordName,
200 Type: "TXT",
201 TTL: 60,
202 ResourceRecords: [{ Value: recordValue }],
203 },
204 },
205 ],
206 },
207 })
208 .pipe(
209 Effect.asVoid,
210 Effect.catchTag("InvalidChangeBatch", () => Effect.void),
211 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
212 );
213
214test.provider(
215 "list enumerates the deployed record across hosted zones",
216 (stack) =>
217 Effect.gen(function* () {
218 yield* stack.destroy();
219
220 const hostedZoneId = yield* ensureZone;
221 yield* changeRecord(hostedZoneId, "UPSERT");
222
223 const provider = yield* Provider.findProvider(Record);
224
225 // `list()` fans out across every hosted zone; assert our seeded record
226 // appears. Retry briefly to absorb Route53's create eventual consistency.
227 const found = yield* provider.list().pipe(
228 Effect.map((all) =>
229 all.some(
230 (r) =>
231 normalizeId(r.hostedZoneId) === normalizeId(hostedZoneId) &&
232 r.name === recordName &&
233 r.type === "TXT",
234 ),
235 ),
236 Effect.flatMap((present) =>
237 present
238 ? Effect.succeed(true)
239 : Effect.fail(new Error("record not yet listable")),
240 ),
241 Effect.retry({
242 schedule: Schedule.max([
243 Schedule.fixed("3 seconds"),
244 Schedule.recurs(10),
245 ]),
246 }),
247 Effect.catch(() => Effect.succeed(false)),
248 );
249
250 yield* changeRecord(hostedZoneId, "DELETE");
251 // The zone stays up for the next test in this file; the `teardownZone`
252 // teardown deletes it once the suite is done.
253 yield* stack.destroy();
254
255 expect(found).toBe(true);
256 }),
257 { tags: ["provider:aws", "provider:aws:route53", "live"], timeout: 240_000 },
258);
259
260const inferredRecordName = `inferred-record.${zoneName}`;
261const inferredRecordValue = '"alchemy-inference-test"';
262
263test.provider(
264 "infers the hosted zone from the record name when hostedZoneId is omitted",
265 (stack) =>
266 Effect.gen(function* () {
267 yield* stack.destroy();
268
269 const hostedZoneId = yield* ensureZone;
270
271 const deployed = yield* stack.deploy(
272 Effect.gen(function* () {
273 return yield* Record("InferredRecord", {
274 // No `hostedZoneId` — the provider walks the name's parent
275 // domains to the most specific public zone (the standing test
276 // zone) via ListHostedZonesByName.
277 name: inferredRecordName,
278 type: "TXT",
279 ttl: "60 seconds",
280 records: [inferredRecordValue],
281 });
282 }),
283 );
284 // Attributes carry the RESOLVED zone id.
285 expect(normalizeId(deployed.hostedZoneId as string)).toBe(
286 normalizeId(hostedZoneId),
287 );
288
289 // Out-of-band: the record actually landed in the standing zone.
290 const live = yield* route53.listResourceRecordSets({
291 HostedZoneId: hostedZoneId,
292 StartRecordName: inferredRecordName,
293 StartRecordType: "TXT",
294 MaxItems: 5,
295 });
296 expect(
297 (live.ResourceRecordSets ?? []).some(
298 (set) => set.Name === inferredRecordName && set.Type === "TXT",
299 ),
300 ).toBe(true);
301
302 // Destroy resolves the zone from the stored attributes.
303 yield* stack.destroy();
304 const gone = yield* route53
305 .listResourceRecordSets({
306 HostedZoneId: hostedZoneId,
307 StartRecordName: inferredRecordName,
308 StartRecordType: "TXT",
309 MaxItems: 5,
310 })
311 .pipe(
312 Effect.map(
313 (r) =>
314 !(r.ResourceRecordSets ?? []).some(
315 (set) => set.Name === inferredRecordName && set.Type === "TXT",
316 ),
317 ),
318 );
319 expect(gone).toBe(true);
320 }),
321 { tags: ["provider:aws", "provider:aws:route53", "live"], timeout: 240_000 },
322);
323
324// Regression test for https://github.com/alchemy-run/alchemy/issues/736.
325//
326// An interrupted first deploy persists the record as `status: "creating"`
327// with no attributes — and the Output-valued props (`hostedZoneId` flows from
328// the zone resource, `name` is typically derived from it) do not survive the
329// state round-trip: they deserialize as `undefined`. Plan's recovery branch
330// then calls `provider.read` with those junk props, which crashed in
331// `normalizeHostedZoneId(undefined)` (`undefined is not an object (evaluating
332// 'hostedZoneId.replace')`) and wedged the stack. When `read` reports "not
333// found", the same junk `olds` flow into `diff`, whose unguarded
334// `normalizeHostedZoneId(olds.hostedZoneId)` / `normalizeName(olds.name)`
335// were the next crash sites — so one wedged redeploy exercises all the guards.
336//
337// Deploy into the standing zone (see `ensureZone`), wedge the persisted row
338// into exactly that shape, and assert the next deploy recovers: `read`
339// returns undefined, `diff` falls through to the create recovery path, and
340// reconcile's UPSERT converges on the half-created record.
341const recoveryRecordName = `pr770-recovery.${zoneName}`;
342const recoveryRecordValue = '"pr770-recovery"';
343
344const deleteRecoveryRecord = (hostedZoneId: string) =>
345 route53
346 .changeResourceRecordSets({
347 HostedZoneId: hostedZoneId,
348 ChangeBatch: {
349 Comment: "pr770 recovery test cleanup",
350 Changes: [
351 {
352 Action: "DELETE",
353 ResourceRecordSet: {
354 Name: recoveryRecordName,
355 Type: "TXT",
356 TTL: 60,
357 ResourceRecords: [{ Value: recoveryRecordValue }],
358 },
359 },
360 ],
361 },
362 })
363 .pipe(Effect.ignore);
364
365test.provider(
366 "recovers a half-created record whose creating-state lost Output-valued props (#736)",
367 (stack) =>
368 Effect.gen(function* () {
369 yield* stack.destroy();
370
371 const hostedZoneId = yield* ensureZone;
372
373 // Safety net: if the recovery redeploy defects (the pre-fix crash), the
374 // half-created record would otherwise leak into the standing zone; on
375 // the happy path the DELETE finds nothing (InvalidChangeBatch, ignored).
376 yield* Effect.addFinalizer(() => deleteRecoveryRecord(hostedZoneId));
377
378 const deployRecord = () =>
379 stack.deploy(
380 Effect.gen(function* () {
381 return yield* Record("RecoveryRecord", {
382 hostedZoneId,
383 name: recoveryRecordName,
384 type: "TXT",
385 ttl: "60 seconds",
386 records: [recoveryRecordValue],
387 });
388 }),
389 );
390
391 const created = yield* deployRecord();
392 expect(created.name).toBe(recoveryRecordName);
393
394 // Rewrite the record's persisted row into the wedged shape: `creating`,
395 // no attributes, and the Output-valued identity props lost in the
396 // state round-trip.
397 const state = yield* yield* State;
398 const stage = stack.stage;
399 const fqns = yield* state.list({ stack: stack.name, stage });
400 const rows = yield* Effect.forEach(fqns, (fqn) =>
401 state
402 .get({ stack: stack.name, stage, fqn })
403 .pipe(Effect.map((row) => ({ fqn, row }))),
404 );
405 const wedged = rows.find(
406 (r): r is { fqn: string; row: ResourceState } =>
407 isResourceState(r.row) && r.row.resourceType === "AWS.Route53.Record",
408 );
409 if (!wedged) {
410 return yield* Effect.die(
411 new Error("no AWS.Route53.Record state row found after deploy"),
412 );
413 }
414 yield* state.set({
415 stack: stack.name,
416 stage,
417 fqn: wedged.fqn,
418 value: {
419 ...wedged.row,
420 status: "creating",
421 attr: undefined,
422 props: {
423 ...wedged.row.props,
424 hostedZoneId: undefined,
425 name: undefined,
426 },
427 },
428 });
429
430 // Before the fix this crashed in plan with
431 // `TypeError: undefined is not an object (evaluating 'hostedZoneId.replace')`.
432 const recovered = yield* deployRecord();
433 expect(normalizeId(recovered.hostedZoneId)).toBe(
434 normalizeId(hostedZoneId),
435 );
436 expect(recovered.name).toBe(recoveryRecordName);
437 expect(recovered.type).toBe("TXT");
438
439 // Verify out of band the record actually exists.
440 const observed = yield* route53.listResourceRecordSets({
441 HostedZoneId: hostedZoneId,
442 StartRecordName: recoveryRecordName,
443 StartRecordType: "TXT",
444 MaxItems: 1,
445 });
446 expect(
447 (observed.ResourceRecordSets ?? []).some(
448 (set) => set.Name === recoveryRecordName && set.Type === "TXT",
449 ),
450 ).toBe(true);
451
452 // destroy() removes the record; the zone itself is deleted by the
453 // `teardownZone` ensuring below.
454 yield* stack.destroy();
455 }).pipe(
456 // This is the LAST test in the file, so it owns the shared zone's
457 // teardown — success, failure, or interruption all leave the account
458 // Route53-clean.
459 Effect.ensuring(teardownZone),
460 ),
461 { tags: ["provider:aws", "provider:aws:route53", "live"], timeout: 240_000 },
462);