1import * as zoneRrsets from "@distilled.cloud/hetzner/zone_rrsets";
2import * as Hetzner from "@/Hetzner";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as zones from "@distilled.cloud/hetzner/zones";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: Hetzner.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18const hasHetznerCreds = !!process.env.HCLOUD_TOKEN;
19
20const waitUntilGone = (zoneId: number, name: string, type: string) =>
21 zoneRrsets
22 .getZoneRrset({
23 id_or_name: String(zoneId),
24 rr_name: name,
25 rr_type: type,
26 })
27 .pipe(
28 Effect.as("found" as const),
29 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
30 Effect.repeat({
31 schedule: Schedule.spaced("1 second"),
32 until: (status) => status === "gone",
33 times: 10,
34 }),
35 );
36
37const waitUntilZoneGone = (zoneId: number) =>
38 zones.getZone({ id_or_name: String(zoneId) }).pipe(
39 Effect.as("found" as const),
40 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
41 Effect.repeat({
42 schedule: Schedule.spaced("1 second"),
43 until: (status) => status === "gone",
44 times: 10,
45 }),
46 );
47
48test.provider.skipIf(!hasHetznerCreds)(
49 "create, update, and delete a record set",
50 (stack) =>
51 Effect.gen(function* () {
52 yield* stack.destroy();
53
54 const created = yield* stack.deploy(
55 Effect.gen(function* () {
56 const zone = yield* Hetzner.Zone("DnsZone", {
57 ttl: 3600,
58 });
59 const recordSet = yield* Hetzner.RecordSet("Www", {
60 zone,
61 name: "www",
62 type: "A",
63 records: [{ value: "192.0.2.1" }],
64 ttl: 300,
65 labels: { env: "test" },
66 });
67 return { zone, recordSet };
68 }),
69 );
70
71 expect(created.recordSet.id).toEqual("www/A");
72 expect(created.recordSet.zoneId).toEqual(created.zone.zoneId);
73 expect(created.recordSet.name).toEqual("www");
74 expect(created.recordSet.type).toEqual("A");
75 expect(created.recordSet.ttl).toEqual(300);
76 expect(created.recordSet.changeProtection).toEqual(false);
77 expect(created.recordSet.labels).toMatchObject({ env: "test" });
78 expect(created.recordSet.records).toEqual(
79 expect.arrayContaining([
80 expect.objectContaining({ value: "192.0.2.1" }),
81 ]),
82 );
83 expect(created.recordSet.records).toHaveLength(1);
84
85 const fetched = yield* zoneRrsets.getZoneRrset({
86 id_or_name: String(created.zone.zoneId),
87 rr_name: "www",
88 rr_type: "A",
89 });
90 expect(fetched.rrset.id).toEqual("www/A");
91 expect(fetched.rrset.zone).toEqual(created.zone.zoneId);
92 expect(fetched.rrset.ttl).toEqual(300);
93 expect(fetched.rrset.protection.change).toEqual(false);
94 expect(fetched.rrset.labels.env).toEqual("test");
95 expect(
96 fetched.rrset.records.map((record) => record.value).sort(),
97 ).toEqual(["192.0.2.1"]);
98
99 const updated = yield* stack.deploy(
100 Effect.gen(function* () {
101 const zone = yield* Hetzner.Zone("DnsZone", {
102 name: created.zone.name,
103 ttl: 3600,
104 });
105 const recordSet = yield* Hetzner.RecordSet("Www", {
106 zone,
107 name: "www",
108 type: "A",
109 records: [{ value: "192.0.2.1" }, { value: "192.0.2.2" }],
110 ttl: 600,
111 labels: { env: "prod", role: "dns" },
112 changeProtection: true,
113 });
114 return { zone, recordSet };
115 }),
116 );
117
118 expect(updated.recordSet.id).toEqual("www/A");
119 expect(updated.recordSet.zoneId).toEqual(created.zone.zoneId);
120 expect(updated.recordSet.ttl).toEqual(600);
121 expect(updated.recordSet.changeProtection).toEqual(true);
122 expect(updated.recordSet.labels).toMatchObject({
123 env: "prod",
124 role: "dns",
125 });
126 expect(updated.recordSet.records).toHaveLength(2);
127 expect(
128 updated.recordSet.records.map((record) => record.value).sort(),
129 ).toEqual(["192.0.2.1", "192.0.2.2"]);
130
131 const refetched = yield* zoneRrsets.getZoneRrset({
132 id_or_name: String(updated.zone.zoneId),
133 rr_name: "www",
134 rr_type: "A",
135 });
136 expect(refetched.rrset.ttl).toEqual(600);
137 expect(refetched.rrset.protection.change).toEqual(true);
138 expect(refetched.rrset.labels.env).toEqual("prod");
139 expect(refetched.rrset.labels.role).toEqual("dns");
140 expect(
141 refetched.rrset.records.map((record) => record.value).sort(),
142 ).toEqual(["192.0.2.1", "192.0.2.2"]);
143
144 yield* stack.destroy();
145
146 const gone = yield* waitUntilGone(created.zone.zoneId, "www", "A");
147 expect(gone).toEqual("gone");
148 const zoneGone = yield* waitUntilZoneGone(created.zone.zoneId);
149 expect(zoneGone).toEqual("gone");
150 }).pipe(logLevel),
151 {
152 tags: [
153 "provider:hetzner",
154 "provider:hetzner:recordset",
155 "provider:hetzner:service",
156 "provider:hetzner:zone",
157 "live",
158 ],
159 timeout: 120_000,
160 },
161);
162
163test.provider.skipIf(!hasHetznerCreds)(
164 "replace when name changes",
165 (stack) =>
166 Effect.gen(function* () {
167 yield* stack.destroy();
168
169 const created = yield* stack.deploy(
170 Effect.gen(function* () {
171 const zone = yield* Hetzner.Zone("ReplaceZone", {
172 ttl: 3600,
173 });
174 const recordSet = yield* Hetzner.RecordSet("Host", {
175 zone,
176 name: "www",
177 type: "A",
178 records: [{ value: "192.0.2.10" }],
179 ttl: 300,
180 });
181 return { zone, recordSet };
182 }),
183 );
184
185 expect(created.recordSet.id).toEqual("www/A");
186
187 const replaced = yield* stack.deploy(
188 Effect.gen(function* () {
189 const zone = yield* Hetzner.Zone("ReplaceZone", {
190 name: created.zone.name,
191 ttl: 3600,
192 });
193 const recordSet = yield* Hetzner.RecordSet("Host", {
194 zone,
195 name: "api",
196 type: "A",
197 records: [{ value: "192.0.2.10" }],
198 ttl: 300,
199 });
200 return { zone, recordSet };
201 }),
202 );
203
204 expect(replaced.recordSet.id).toEqual("api/A");
205 expect(replaced.recordSet.id).not.toEqual(created.recordSet.id);
206 expect(replaced.recordSet.zoneId).toEqual(created.zone.zoneId);
207
208 const fetched = yield* zoneRrsets.getZoneRrset({
209 id_or_name: String(replaced.zone.zoneId),
210 rr_name: "api",
211 rr_type: "A",
212 });
213 expect(fetched.rrset.id).toEqual("api/A");
214 expect(fetched.rrset.records.map((record) => record.value)).toEqual([
215 "192.0.2.10",
216 ]);
217
218 const oldGone = yield* waitUntilGone(created.zone.zoneId, "www", "A");
219 expect(oldGone).toEqual("gone");
220
221 yield* stack.destroy();
222
223 const gone = yield* waitUntilGone(replaced.zone.zoneId, "api", "A");
224 expect(gone).toEqual("gone");
225 const zoneGone = yield* waitUntilZoneGone(created.zone.zoneId);
226 expect(zoneGone).toEqual("gone");
227 }).pipe(logLevel),
228 {
229 tags: [
230 "provider:hetzner",
231 "provider:hetzner:recordset",
232 "provider:hetzner:service",
233 "provider:hetzner:zone",
234 "live",
235 ],
236 timeout: 180_000,
237 },
238);
239
240test.provider.skipIf(!hasHetznerCreds)(
241 "list enumerates the deployed record set",
242 (stack) =>
243 Effect.gen(function* () {
244 yield* stack.destroy();
245
246 const deployed = yield* stack.deploy(
247 Effect.gen(function* () {
248 const zone = yield* Hetzner.Zone("ListZone", {
249 ttl: 3600,
250 });
251 const recordSet = yield* Hetzner.RecordSet("Www", {
252 zone,
253 name: "www",
254 type: "A",
255 records: [{ value: "192.0.2.20" }],
256 });
257 return { zone, recordSet };
258 }),
259 );
260
261 const provider = yield* Provider.findProvider(Hetzner.RecordSet);
262 const all = yield* provider.list();
263 const found = all.find(
264 (rrset) =>
265 rrset.zoneId === deployed.zone.zoneId && rrset.id === "www/A",
266 );
267 expect(found).toBeDefined();
268 expect(found?.name).toEqual("www");
269 expect(found?.type).toEqual("A");
270 expect(found?.records.map((record) => record.value)).toEqual([
271 "192.0.2.20",
272 ]);
273
274 yield* stack.destroy();
275
276 const gone = yield* waitUntilGone(deployed.zone.zoneId, "www", "A");
277 expect(gone).toEqual("gone");
278 const zoneGone = yield* waitUntilZoneGone(deployed.zone.zoneId);
279 expect(zoneGone).toEqual("gone");
280 }).pipe(logLevel),
281 {
282 tags: [
283 "provider:hetzner",
284 "provider:hetzner:recordset",
285 "provider:hetzner:service",
286 "provider:hetzner:zone",
287 "live",
288 ],
289 timeout: 120_000,
290 },
291);