1import * as AWS from "@/AWS";
2import { HostedZone } from "@/AWS/Route53";
3import { isResourceState, State, type ResourceState } from "@/State";
4import * as Test from "@/Test/Alchemy";
5import * as route53 from "@distilled.cloud/aws/route-53";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12const normalizeId = (id: string) => id.replace(/^\/hostedzone\//, "");
13
14// Deterministic per-test zone names (reserved-domain-safe TLD `.alchemy`).
15const zoneName = "alchemy-hostedzone-crud.alchemy.";
16
17const assertZoneGone = (id: string) =>
18 route53.getHostedZone({ Id: normalizeId(id) }).pipe(
19 Effect.flatMap(() => Effect.fail(new Error("zone still exists"))),
20 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
21 Effect.retry({
22 while: (e) => e instanceof Error,
23 schedule: Schedule.max([
24 Schedule.fixed("2 seconds"),
25 Schedule.recurs(10),
26 ]),
27 }),
28 );
29
30test.provider(
31 "create, update comment, tag, and delete hosted zone",
32 (stack) =>
33 Effect.gen(function* () {
34 yield* stack.destroy();
35
36 // Create.
37 const zone = yield* stack.deploy(
38 Effect.gen(function* () {
39 return yield* HostedZone("Zone", {
40 name: zoneName,
41 comment: "initial comment",
42 tags: { env: "test" },
43 });
44 }),
45 );
46
47 expect(zone.id).toBeDefined();
48 expect(zone.name).toBe(zoneName);
49 // Public zones get exactly 4 authoritative name servers.
50 expect(zone.nameServers.length).toBe(4);
51 expect(zone.comment).toBe("initial comment");
52
53 // Verify out of band.
54 const observed = yield* route53.getHostedZone({
55 Id: normalizeId(zone.id),
56 });
57 expect(observed.HostedZone.Config?.Comment).toBe("initial comment");
58
59 const tags = yield* route53.listTagsForResource({
60 ResourceType: "hostedzone",
61 ResourceId: normalizeId(zone.id),
62 });
63 const tagMap = Object.fromEntries(
64 (tags.ResourceTagSet.Tags ?? []).map((t) => [t.Key, t.Value]),
65 );
66 expect(tagMap.env).toBe("test");
67 expect(tagMap["alchemy::id"]).toBeDefined();
68
69 // Update comment + tags in place.
70 const updated = yield* stack.deploy(
71 Effect.gen(function* () {
72 return yield* HostedZone("Zone", {
73 name: zoneName,
74 comment: "updated comment",
75 tags: { env: "prod" },
76 });
77 }),
78 );
79 expect(updated.id).toBe(zone.id);
80 expect(updated.comment).toBe("updated comment");
81
82 const observed2 = yield* route53.getHostedZone({
83 Id: normalizeId(zone.id),
84 });
85 expect(observed2.HostedZone.Config?.Comment).toBe("updated comment");
86
87 const tags2 = yield* route53.listTagsForResource({
88 ResourceType: "hostedzone",
89 ResourceId: normalizeId(zone.id),
90 });
91 const tagMap2 = Object.fromEntries(
92 (tags2.ResourceTagSet.Tags ?? []).map((t) => [t.Key, t.Value]),
93 );
94 expect(tagMap2.env).toBe("prod");
95
96 yield* stack.destroy();
97 yield* assertZoneGone(zone.id);
98 }),
99 { tags: ["provider:aws", "provider:aws:route53", "live"], timeout: 180_000 },
100);
101
102const forceZoneName = "alchemy-hostedzone-force.alchemy.";
103
104test.provider(
105 "forceDestroy deletes leftover records before deleting the zone",
106 (stack) =>
107 Effect.gen(function* () {
108 yield* stack.destroy();
109
110 const zone = yield* stack.deploy(
111 Effect.gen(function* () {
112 return yield* HostedZone("ForceZone", {
113 name: forceZoneName,
114 forceDestroy: true,
115 });
116 }),
117 );
118
119 // Seed a leftover record out of band so the zone is non-empty.
120 yield* route53
121 .changeResourceRecordSets({
122 HostedZoneId: normalizeId(zone.id),
123 ChangeBatch: {
124 Comment: "leftover record",
125 Changes: [
126 {
127 Action: "UPSERT",
128 ResourceRecordSet: {
129 Name: `leftover.${forceZoneName}`,
130 Type: "TXT",
131 TTL: 60,
132 ResourceRecords: [{ Value: '"leftover"' }],
133 },
134 },
135 ],
136 },
137 })
138 .pipe(Effect.asVoid);
139
140 // destroy() must purge the record then delete the zone.
141 yield* stack.destroy();
142 yield* assertZoneGone(zone.id);
143 }),
144 { tags: ["provider:aws", "provider:aws:route53", "live"], timeout: 180_000 },
145);
146
147// Regression test for https://github.com/alchemy-run/alchemy/issues/736.
148//
149// An interrupted first deploy persists the zone as `status: "creating"` with
150// no attributes — and an Output-valued `name` does not survive the state
151// round-trip: it deserializes as `undefined`. Plan's recovery branch then
152// calls `provider.read` with those junk props, which crashed in
153// `normalizeName(undefined)` (`undefined is not an object (evaluating
154// 'name.endsWith')`) and wedged the stack. When `read` reports "not found",
155// the same junk `olds` flow into `diff`, whose unguarded
156// `normalizeName(olds.name)` was the second crash site — so this one wedged
157// redeploy exercises both guards.
158//
159// Simulate exactly that state row after a real deploy and assert the next
160// deploy recovers: `read` returns undefined, `diff` falls through to the
161// create recovery path, and reconcile converges on the half-created zone via
162// its stable CallerReference (HostedZoneAlreadyExists → findByName) — SAME
163// zone id, no duplicate created.
164const recoveryZoneName = "pr770-recovery-hz.alchemy.";
165
166test.provider(
167 "recovers a half-created hosted zone whose creating-state lost Output-valued props (#736)",
168 (stack) =>
169 Effect.gen(function* () {
170 yield* stack.destroy();
171
172 const deployZone = () =>
173 stack.deploy(
174 Effect.gen(function* () {
175 return yield* HostedZone("RecoveryZone", {
176 name: recoveryZoneName,
177 comment: "pr770 recovery",
178 });
179 }),
180 );
181
182 const created = yield* deployZone();
183 expect(created.id).toBeDefined();
184
185 // Safety net: if the recovery redeploy defects (the pre-fix crash), the
186 // zone would otherwise leak — a fresh zone only holds its SOA/NS records
187 // so it deletes cleanly; on the happy path it's already gone (ignored).
188 yield* Effect.addFinalizer(() =>
189 route53
190 .deleteHostedZone({ Id: normalizeId(created.id) })
191 .pipe(Effect.ignore),
192 );
193
194 // Rewrite the zone's persisted row into the wedged shape an interrupted
195 // deploy leaves behind: `creating`, no attributes, and the Output-valued
196 // `name` lost in the state round-trip.
197 const state = yield* yield* State;
198 const stage = stack.stage;
199 const fqns = yield* state.list({ stack: stack.name, stage });
200 const rows = yield* Effect.forEach(fqns, (fqn) =>
201 state
202 .get({ stack: stack.name, stage, fqn })
203 .pipe(Effect.map((row) => ({ fqn, row }))),
204 );
205 const wedged = rows.find(
206 (r): r is { fqn: string; row: ResourceState } =>
207 isResourceState(r.row) &&
208 r.row.resourceType === "AWS.Route53.HostedZone",
209 );
210 if (!wedged) {
211 return yield* Effect.die(
212 new Error("no AWS.Route53.HostedZone state row found after deploy"),
213 );
214 }
215 yield* state.set({
216 stack: stack.name,
217 stage,
218 fqn: wedged.fqn,
219 value: {
220 ...wedged.row,
221 status: "creating",
222 attr: undefined,
223 props: { ...wedged.row.props, name: undefined },
224 },
225 });
226
227 // Before the fix this crashed in plan with
228 // `TypeError: undefined is not an object (evaluating 'name.endsWith')`.
229 const recovered = yield* deployZone();
230 expect(recovered.id).toEqual(created.id);
231 expect(recovered.name).toBe(recoveryZoneName);
232
233 yield* stack.destroy();
234 yield* assertZoneGone(created.id);
235 }),
236 { tags: ["provider:aws", "provider:aws:route53", "live"], timeout: 240_000 },
237);
238
239test.provider(
240 "idempotent delete tolerates an already-gone zone",
241 () =>
242 Effect.gen(function* () {
243 // Deleting a non-existent zone id should be a no-op (NoSuchHostedZone).
244 yield* route53.deleteHostedZone({ Id: "Z0000000000000NONEXIST" }).pipe(
245 Effect.asVoid,
246 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
247 Effect.catchTag("InvalidInput", () => Effect.void),
248 );
249 expect(true).toBe(true);
250 }),
251 { tags: ["provider:aws", "provider:aws:route53", "live"], timeout: 60_000 },
252);