1import * as Hetzner from "@distilled.cloud/hetzner";
2import type {
3 CreateZoneRrsetRequestRecordsItem,
4 GetZoneRrsetResponseRrset,
5 ListZoneRrsetsResponseRrsetsItem,
6} from "@distilled.cloud/hetzner/zone_rrsets";
7import * as Data from "effect/Data";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import { Unowned } from "../AdoptPolicy.ts";
13import { deepEqual, isResolved } from "../Diff.ts";
14import { createPhysicalName } from "../PhysicalName.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import { tagRecord } from "../Tags.ts";
18import { recordsEqual as labelsEqual } from "../Util/equal.ts";
19import { waitForZoneAction } from "./actions.ts";
20import {
21 alchemyLabelKeys,
22 alchemyStackSelector,
23 createInternalLabels,
24 hasAlchemyLabels,
25 labelSelector,
26 stripInternalLabels,
27 toLabels,
28} from "./Labels.ts";
29import type { Providers } from "./Providers.ts";
30
31/**
32 * A resource-valued prop: the resource itself, or an Effect that produces
33 * it (so `yield* Zone(...)` and `Zone(...)` both type-check).
34 */
35type Ref<T> = T | Effect.Effect<T, never, Providers>;
36
37/**
38 * Zone identity an RRSet belongs to. A `Hetzner.Zone` resource satisfies
39 * this via `zoneId`.
40 */
41export type RecordSetZone = {
42 readonly zoneId: number;
43};
44
45export type RecordSetType =
46 | "A"
47 | "AAAA"
48 | "CAA"
49 | "CNAME"
50 | "DS"
51 | "HINFO"
52 | "HTTPS"
53 | "MX"
54 | "NS"
55 | "PTR"
56 | "RP"
57 | "SOA"
58 | "SRV"
59 | "SVCB"
60 | "TLSA"
61 | "TXT"
62 | (string & {});
63
64export interface RecordSetRecord {
65 /**
66 * Record value. Interpretation depends on `type` — an A record is an
67 * IPv4 address, AAAA an IPv6 address, CNAME a hostname, etc. Must be
68 * unique within the RRSet. Accepts a primitive Output (e.g. a Primary
69 * IP's `ip`).
70 */
71 value: string;
72 /**
73 * Optional comment shown in the Hetzner Cloud Console. Does not affect
74 * DNS responses.
75 */
76 comment?: string;
77}
78
79export interface RecordSetProps {
80 /**
81 * Zone this RRSet lives in. Accepts a `Hetzner.Zone` or `{ zoneId }`.
82 * Changing the zone replaces the RRSet.
83 */
84 zone: Ref<RecordSetZone>;
85 /**
86 * DNS name of the RRSet, relative to the zone. Lowercase, must not end
87 * with a dot or the zone apex. Use `@` for the zone apex. Changing this
88 * replaces the RRSet.
89 *
90 * If omitted, a unique label is generated from the stack, stage, and
91 * logical ID.
92 */
93 name?: string;
94 /**
95 * DNS record type. All `records` share this type — two A values are one
96 * RRSet, not two resources. Changing this replaces the RRSet.
97 */
98 type: RecordSetType;
99 /**
100 * Records in this RRSet. Must be non-empty and contain distinct values.
101 * Order is not significant. Mutable — updates in place.
102 */
103 records: RecordSetRecord[];
104 /**
105 * TTL in seconds (`60`–`2147483647`). Omit to use the Zone's default TTL
106 * on create and leave the live value alone on update.
107 */
108 ttl?: number;
109 /**
110 * User-defined labels. Alchemy ownership labels (`alchemy.stack`,
111 * `alchemy.stage`, `alchemy.id`) are always merged in.
112 */
113 labels?: Record<string, string>;
114 /**
115 * Prevent the RRSet from being changed or deleted via the API.
116 *
117 * @default false
118 */
119 changeProtection?: boolean;
120}
121
122export interface RecordSetAttributes {
123 /**
124 * RRSet id (`{name}/{type}`, e.g. `www/A`). Stable across updates.
125 */
126 id: string;
127 /**
128 * Numeric Cloud API id of the parent Zone.
129 */
130 zoneId: number;
131 /**
132 * DNS name of the RRSet (`@` for the apex).
133 */
134 name: string;
135 /**
136 * DNS record type.
137 */
138 type: RecordSetType;
139 /**
140 * TTL in seconds, or `undefined` when the Zone default applies.
141 */
142 ttl: number | undefined;
143 /**
144 * Records currently published. Order is not significant.
145 */
146 records: RecordSetRecord[];
147 /**
148 * User-defined labels (Alchemy ownership labels stripped).
149 */
150 labels: Record<string, string>;
151 /**
152 * Whether change protection is enabled.
153 */
154 changeProtection: boolean;
155}
156
157export type RecordSet = Resource<
158 "Hetzner.RecordSet",
159 RecordSetProps,
160 RecordSetAttributes,
161 never,
162 Providers
163>;
164
165/**
166 * A Hetzner Cloud DNS resource record set (RRSet) — one `(name, type)`
167 * with one or more records. Two A values are a single resource, not two.
168 *
169 * Identity is `(zone, name, type)`: changing any of those replaces the
170 * RRSet. Records, TTL, labels, and change protection update in place.
171 * Only primary Zones accept RRSet edits.
172 *
173 * @see https://docs.hetzner.cloud/reference/cloud#zone-rrsets
174 *
175 * ### Creating an RRSet
176 * **Example:** A records on a subdomain
177 * ```typescript
178 * const zone = yield* Hetzner.Zone("example", {
179 * name: "example.com",
180 * });
181 * const www = yield* Hetzner.RecordSet("www", {
182 * zone,
183 * name: "www",
184 * type: "A",
185 * records: [
186 * { value: "192.0.2.1" },
187 * { value: "192.0.2.2" },
188 * ],
189 * ttl: 300,
190 * });
191 * ```
192 *
193 * **Example:** Apex A record from a Primary IP
194 * ```typescript
195 * const ip = yield* Hetzner.PrimaryIp("web-ip", {
196 * type: "ipv4",
197 * location: "nbg1",
198 * });
199 * const apex = yield* Hetzner.RecordSet("apex", {
200 * zone,
201 * name: "@",
202 * type: "A",
203 * records: [{ value: ip.ip }],
204 * });
205 * ```
206 *
207 * ### Updating records
208 * **Example:** Replace the record set and TTL
209 * ```typescript
210 * const www = yield* Hetzner.RecordSet("www", {
211 * zone,
212 * name: "www",
213 * type: "A",
214 * records: [{ value: "192.0.2.10" }],
215 * ttl: 600,
216 * });
217 * ```
218 *
219 * @resource
220 * @product DNS
221 */
222export const RecordSet = Resource<RecordSet>("Hetzner.RecordSet");
223
224export class RecordSetZoneMissing extends Data.TaggedError(
225 "Hetzner.RecordSetZoneMissing",
226)<{
227 name: string;
228 type: string;
229}> {}
230
231class RecordSetStillExists extends Data.TaggedError("RecordSetStillExists")<{
232 zoneId: number;
233 name: string;
234 type: string;
235}> {}
236
237type CloudRrset = GetZoneRrsetResponseRrset | ListZoneRrsetsResponseRrsetsItem;
238
239const NAME_MAX_LENGTH = 63;
240
241const normalizeName = (name: string): string =>
242 name.toLowerCase().replace(/\.$/, "");
243
244const normalizeType = (type: string): RecordSetType =>
245 type.toUpperCase() as RecordSetType;
246
247const compactRecord = (record: {
248 value: string;
249 comment?: string | null;
250}): RecordSetRecord => {
251 const comment = record.comment ?? undefined;
252 return comment !== undefined && comment.length > 0
253 ? { value: record.value, comment }
254 : { value: record.value };
255};
256
257const sortRecords = (
258 records: ReadonlyArray<RecordSetRecord>,
259): RecordSetRecord[] =>
260 [...records]
261 .map(compactRecord)
262 .sort((a, b) => a.value.localeCompare(b.value));
263
264const recordsEqual = (
265 a: ReadonlyArray<RecordSetRecord>,
266 b: ReadonlyArray<RecordSetRecord>,
267): boolean => deepEqual(sortRecords(a), sortRecords(b), { stripNullish: true });
268
269const toWireRecords = (
270 records: ReadonlyArray<RecordSetRecord>,
271): CreateZoneRrsetRequestRecordsItem[] =>
272 records.map((record) =>
273 record.comment !== undefined
274 ? { value: record.value, comment: record.comment }
275 : { value: record.value },
276 );
277
278const zoneIdOf = (value: unknown): number | undefined => {
279 if (value === null || typeof value !== "object") return undefined;
280 const rec = value as { zoneId?: unknown; id?: unknown };
281 if (typeof rec.zoneId === "number") return rec.zoneId;
282 if (typeof rec.id === "number") return rec.id;
283 return undefined;
284};
285
286const resolveName = (
287 id: string,
288 news: Pick<RecordSetProps, "name">,
289 output: Pick<RecordSetAttributes, "name"> | undefined,
290) =>
291 Effect.gen(function* () {
292 if (news.name !== undefined) {
293 return normalizeName(news.name);
294 }
295 if (output?.name !== undefined) {
296 return output.name;
297 }
298 return yield* createPhysicalName({
299 id,
300 lowercase: true,
301 maxLength: NAME_MAX_LENGTH,
302 });
303 });
304
305const desiredLabels = Effect.fn(function* (
306 id: string,
307 user: Record<string, string> | undefined,
308) {
309 return {
310 ...toLabels(user),
311 ...(yield* createInternalLabels(id)),
312 };
313});
314
315const toAttrs = (rrset: CloudRrset): RecordSetAttributes => ({
316 id: rrset.id,
317 zoneId: rrset.zone,
318 name: rrset.name,
319 type: rrset.type,
320 ttl: rrset.ttl ?? undefined,
321 records: sortRecords(rrset.records.map(compactRecord)),
322 labels: stripInternalLabels(tagRecord(rrset.labels)),
323 changeProtection: rrset.protection.change,
324});
325
326const backoff = Schedule.min([
327 Schedule.exponential(Duration.millis(500), 1.5),
328 Schedule.spaced(Duration.seconds(5)),
329]);
330
331const retryLocked = <A, E extends { readonly _tag: string }, R>(
332 effect: Effect.Effect<A, E, R>,
333) =>
334 effect.pipe(
335 Effect.retry({
336 while: (e) => e._tag === "Locked",
337 times: 8,
338 schedule: backoff,
339 }),
340 );
341
342const getRrset = (zoneId: number, name: string, type: string) =>
343 Hetzner.zoneRrsets
344 .getZoneRrset({
345 id_or_name: String(zoneId),
346 rr_name: name,
347 rr_type: type,
348 })
349 .pipe(
350 Effect.map(({ rrset }) => rrset),
351 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
352 );
353
354const findByLabels = (zoneId: number, id: string) =>
355 Effect.gen(function* () {
356 const selector = labelSelector(yield* createInternalLabels(id));
357 if (selector.length === 0) return undefined;
358 return yield* Hetzner.zoneRrsets.listZoneRrsets
359 .items({
360 id_or_name: String(zoneId),
361 label_selector: selector,
362 per_page: 100,
363 })
364 .pipe(
365 Stream.take(1),
366 Stream.runHead,
367 Effect.map((option) =>
368 option._tag === "Some" ? option.value : undefined,
369 ),
370 );
371 });
372
373export const RecordSetProvider = () =>
374 Provider.succeed(RecordSet, {
375 stables: ["id", "zoneId", "name", "type"],
376 nuke: { dependsOn: ["Hetzner.Zone"] },
377 list: Effect.fn(function* () {
378 const zones = yield* Hetzner.zones.listZones
379 .items({ per_page: 50 })
380 .pipe(Stream.runCollect);
381 const rows = yield* Effect.forEach(
382 [...zones],
383 (zone) =>
384 Hetzner.zoneRrsets.listZoneRrsets
385 .items({
386 id_or_name: String(zone.id),
387 label_selector: alchemyStackSelector,
388 per_page: 100,
389 })
390 .pipe(
391 Stream.runCollect,
392 Effect.map((chunk) => [...chunk].map(toAttrs)),
393 Effect.catchTag(
394 ["NotFound", "BadRequest", "UnprocessableEntity"],
395 () => Effect.succeed([] as RecordSetAttributes[]),
396 ),
397 ),
398 { concurrency: 5 },
399 );
400 return rows.flat();
401 }),
402 diff: Effect.fn(function* ({ news, output }) {
403 if (!isResolved(news)) return undefined;
404 if (!output) return undefined;
405 const zoneId = zoneIdOf(news.zone);
406 if (zoneId !== undefined && zoneId !== output.zoneId) {
407 return { action: "replace" } as const;
408 }
409 if (news.name !== undefined && normalizeName(news.name) !== output.name) {
410 return { action: "replace" } as const;
411 }
412 if (normalizeType(news.type) !== output.type) {
413 return { action: "replace" } as const;
414 }
415 return undefined;
416 }),
417 read: Effect.fn(function* ({ id, olds, output }) {
418 const zoneId = output?.zoneId ?? zoneIdOf(olds?.zone);
419 const name =
420 output?.name ??
421 (olds?.name !== undefined ? normalizeName(olds.name) : undefined);
422 const type =
423 output?.type ??
424 (olds?.type !== undefined ? normalizeType(olds.type) : undefined);
425
426 let current: CloudRrset | undefined;
427 if (zoneId !== undefined && name !== undefined && type !== undefined) {
428 current = yield* getRrset(zoneId, name, type);
429 }
430 if (current === undefined && zoneId !== undefined) {
431 current = yield* findByLabels(zoneId, id);
432 }
433 if (current === undefined) return undefined;
434 const attrs = toAttrs(current);
435 return (yield* hasAlchemyLabels(id, tagRecord(current.labels)))
436 ? attrs
437 : Unowned(attrs);
438 }),
439 reconcile: Effect.fn(function* ({ id, news, output }) {
440 const zoneId = zoneIdOf(news.zone) ?? output?.zoneId;
441 const name = yield* resolveName(id, news, output);
442 const type = normalizeType(news.type);
443 if (zoneId === undefined) {
444 return yield* new RecordSetZoneMissing({ name, type });
445 }
446
447 const desiredRecords = sortRecords(news.records);
448 const labels = yield* desiredLabels(id, news.labels);
449 const desiredProtection = news.changeProtection ?? false;
450
451 // 1. Observe
452 let current =
453 output !== undefined &&
454 output.zoneId === zoneId &&
455 output.name === name &&
456 output.type === type
457 ? yield* getRrset(output.zoneId, output.name, output.type)
458 : yield* getRrset(zoneId, name, type);
459
460 // 2. Ensure
461 if (current === undefined) {
462 const created = yield* retryLocked(
463 Hetzner.zoneRrsets
464 .createZoneRrset({
465 id_or_name: String(zoneId),
466 name,
467 type,
468 records: toWireRecords(desiredRecords),
469 labels,
470 ...(news.ttl !== undefined ? { ttl: news.ttl } : {}),
471 })
472 .pipe(
473 Effect.catchTag("Conflict", () =>
474 getRrset(zoneId, name, type).pipe(
475 Effect.map((rrset) =>
476 rrset !== undefined
477 ? { rrset, action: undefined }
478 : undefined,
479 ),
480 ),
481 ),
482 ),
483 );
484 if (created === undefined) {
485 current = yield* getRrset(zoneId, name, type);
486 } else {
487 if (created.action !== undefined) {
488 yield* waitForZoneAction(created.action.id);
489 }
490 current = (yield* getRrset(zoneId, name, type)) ?? created.rrset;
491 }
492 }
493
494 if (current === undefined) {
495 return yield* new RecordSetZoneMissing({ name, type });
496 }
497
498 const zoneRef = String(zoneId);
499
500 // 3. Sync — labels (PUT overwrites the full set)
501 const observedLabels = tagRecord(current.labels);
502 if (!labelsEqual(observedLabels, labels)) {
503 const updated = yield* retryLocked(
504 Hetzner.zoneRrsets.updateZoneRrset({
505 id_or_name: zoneRef,
506 rr_name: name,
507 rr_type: type,
508 labels,
509 }),
510 );
511 current = updated.rrset;
512 }
513
514 // Sync — TTL
515 if (news.ttl !== undefined && news.ttl !== (current.ttl ?? undefined)) {
516 const { action } = yield* retryLocked(
517 Hetzner.zoneRrsetActions.changeZoneRrsetTtl({
518 id_or_name: zoneRef,
519 rr_name: name,
520 rr_type: type,
521 ttl: news.ttl,
522 }),
523 );
524 yield* waitForZoneAction(action.id);
525 current = (yield* getRrset(zoneId, name, type)) ?? current;
526 }
527
528 // Sync — records (order-insensitive; value identifies a record)
529 if (!recordsEqual(current.records.map(compactRecord), desiredRecords)) {
530 const { action } = yield* retryLocked(
531 Hetzner.zoneRrsetActions.setZoneRrsetRecords({
532 id_or_name: zoneRef,
533 rr_name: name,
534 rr_type: type,
535 records: toWireRecords(desiredRecords),
536 }),
537 );
538 yield* waitForZoneAction(action.id);
539 current = (yield* getRrset(zoneId, name, type)) ?? current;
540 }
541
542 // Sync — change protection
543 if (current.protection.change !== desiredProtection) {
544 const { action } = yield* retryLocked(
545 Hetzner.zoneRrsetActions.changeZoneRrsetProtection({
546 id_or_name: zoneRef,
547 rr_name: name,
548 rr_type: type,
549 change: desiredProtection,
550 }),
551 );
552 yield* waitForZoneAction(action.id);
553 current = (yield* getRrset(zoneId, name, type)) ?? current;
554 }
555
556 return toAttrs(current);
557 }),
558 delete: Effect.fn(function* ({ output }) {
559 const zoneId = output.zoneId;
560 const name = output.name;
561 const type = output.type;
562 const current = yield* getRrset(zoneId, name, type);
563 if (current === undefined) return;
564
565 if (current.protection.change) {
566 const { action } = yield* retryLocked(
567 Hetzner.zoneRrsetActions.changeZoneRrsetProtection({
568 id_or_name: String(zoneId),
569 rr_name: name,
570 rr_type: type,
571 change: false,
572 }),
573 );
574 yield* waitForZoneAction(action.id);
575 }
576
577 const deleted = yield* retryLocked(
578 Hetzner.zoneRrsets
579 .deleteZoneRrset({
580 id_or_name: String(zoneId),
581 rr_name: name,
582 rr_type: type,
583 })
584 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))),
585 );
586 if (deleted !== undefined) {
587 yield* waitForZoneAction(deleted.action.id);
588 }
589
590 yield* getRrset(zoneId, name, type).pipe(
591 Effect.flatMap((rrset) =>
592 rrset === undefined
593 ? Effect.void
594 : new RecordSetStillExists({ zoneId, name, type }),
595 ),
596 Effect.retry({
597 while: (e) => e._tag === "RecordSetStillExists",
598 times: 8,
599 schedule: backoff,
600 }),
601 Effect.catchTag("RecordSetStillExists", () => Effect.void),
602 );
603 }),
604 });