1import * as Hetzner from "@distilled.cloud/hetzner";
2import type {
3 ZonePrimary,
4 ZoneSecondary,
5} from "@distilled.cloud/hetzner/zones";
6import * as Data from "effect/Data";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as Stream from "effect/Stream";
11import { Unowned } from "../AdoptPolicy.ts";
12import { isResolved } from "../Diff.ts";
13import { createPhysicalName } from "../PhysicalName.ts";
14import * as Provider from "../Provider.ts";
15import { Resource } from "../Resource.ts";
16import { tagRecord } from "../Tags.ts";
17import { recordsEqual } from "../Util/equal.ts";
18import { waitForZoneAction } from "./actions.ts";
19import {
20 alchemyLabelKeys,
21 alchemyStackSelector,
22 createInternalLabels,
23 hasAlchemyLabels,
24 labelSelector,
25 stripInternalLabels,
26 toLabels,
27} from "./Labels.ts";
28import type { Providers } from "./Providers.ts";
29
30export type ZoneMode = "primary" | "secondary";
31export type ZoneStatus = "ok" | "updating" | "error";
32export type ZoneRegistrar = "hetzner" | "other" | "unknown";
33export type ZoneDelegationStatus =
34 | "valid"
35 | "partially-valid"
36 | "invalid"
37 | "lame"
38 | "unregistered"
39 | "unknown"
40 | (string & {});
41
42export interface ZoneProps {
43 /**
44 * Apex domain of the zone (e.g. `example.com`). Must be lowercase, must
45 * not end with a dot, and must use a well-known public suffix. Subdomains
46 * are not supported. Changing this replaces the zone.
47 *
48 * If omitted, a unique `*.com` name is generated from the stack, stage,
49 * and logical ID.
50 */
51 name?: string;
52 /**
53 * Zone mode. Primary zones are edited via the Cloud API (RRSets);
54 * secondary zones are transferred from primary nameservers via AXFR.
55 * Cannot be changed after creation — triggers a replacement.
56 *
57 * @default "primary"
58 */
59 mode?: ZoneMode;
60 /**
61 * Default TTL in seconds for RRSets that do not set their own TTL.
62 * Must be between 60 and 2147483647. Omit to keep Hetzner's default on
63 * create and leave the live value alone on update.
64 */
65 ttl?: number;
66 /**
67 * User-defined labels. Alchemy ownership labels (`alchemy.stack`,
68 * `alchemy.stage`, `alchemy.id`) are always merged in.
69 */
70 labels?: Record<string, string>;
71 /**
72 * Prevent the zone from being deleted via the API.
73 *
74 * @default false
75 */
76 deleteProtection?: boolean;
77}
78
79export interface ZoneAttributes {
80 /** Numeric Cloud API id of the zone. Stable across updates. */
81 zoneId: number;
82 /** Apex domain name. */
83 name: string;
84 /** Zone mode (`primary` or `secondary`). */
85 mode: ZoneMode;
86 /** Default TTL in seconds. */
87 ttl: number;
88 /** User-defined labels (Alchemy ownership labels stripped). */
89 labels: Record<string, string>;
90 /** Whether delete protection is enabled. */
91 deleteProtection: boolean;
92 /** Live zone status. */
93 status: ZoneStatus;
94 /** Number of resource records in the zone. */
95 recordCount: number;
96 /** Domain registrar as reported by Hetzner. */
97 registrar: ZoneRegistrar;
98 /** RFC3339 creation timestamp. */
99 created: string;
100 /** Authoritative Hetzner nameservers assigned to this zone. */
101 assignedNameservers: string[];
102 /** Nameservers currently delegated by the parent DNS zone. */
103 delegatedNameservers: string[];
104 /** Delegation check status, when Hetzner has reported one. */
105 delegationStatus: ZoneDelegationStatus | undefined;
106}
107
108export type Zone = Resource<
109 "Hetzner.Zone",
110 ZoneProps,
111 ZoneAttributes,
112 never,
113 Providers
114>;
115
116/**
117 * A Hetzner Cloud DNS zone — an apex domain hosted on Hetzner's
118 * authoritative nameservers.
119 *
120 * The zone `name` is the identity: changing it replaces the zone. Default
121 * TTL, labels, and delete protection update in place. Resource record sets
122 * are a separate resource (`RecordSet`).
123 * @see https://docs.hetzner.cloud/reference/cloud#zones
124 *
125 * ### Creating a Zone
126 * **Example:** Primary zone with a default TTL
127 * ```typescript
128 * const zone = yield* Hetzner.Zone("example", {
129 * name: "example.com",
130 * ttl: 3600,
131 * });
132 * ```
133 *
134 * **Example:** Zone with labels and delete protection
135 * ```typescript
136 * const zone = yield* Hetzner.Zone("example", {
137 * name: "example.com",
138 * labels: { env: "prod" },
139 * deleteProtection: true,
140 * });
141 * ```
142 *
143 * @resource
144 * @product DNS
145 */
146export const Zone = Resource<Zone>("Hetzner.Zone");
147
148type CloudZone = ZonePrimary | ZoneSecondary;
149
150const DEFAULT_MODE: ZoneMode = "primary";
151
152const normalizeName = (name: string): string =>
153 name.toLowerCase().replace(/\.$/, "");
154
155const generateZoneName = (id: string) =>
156 createPhysicalName({ id, lowercase: true, maxLength: 63 }).pipe(
157 Effect.map((physical) => `${physical}.com`),
158 );
159
160const resolveZoneName = (
161 id: string,
162 news: Pick<ZoneProps, "name">,
163 output: Pick<ZoneAttributes, "name"> | undefined,
164) =>
165 Effect.gen(function* () {
166 if (news.name !== undefined) {
167 return normalizeName(news.name);
168 }
169 if (output?.name !== undefined) {
170 return output.name;
171 }
172 return yield* generateZoneName(id);
173 });
174
175const desiredLabels = Effect.fn(function* (
176 id: string,
177 user: Record<string, string> | undefined,
178) {
179 return {
180 ...toLabels(user),
181 ...(yield* createInternalLabels(id)),
182 };
183});
184
185const toAttrs = (zone: CloudZone): ZoneAttributes => ({
186 zoneId: zone.id,
187 name: zone.name,
188 mode: zone.mode,
189 ttl: zone.ttl,
190 labels: stripInternalLabels(tagRecord(zone.labels)),
191 deleteProtection: zone.protection.delete,
192 status: zone.status,
193 recordCount: zone.record_count,
194 registrar: zone.registrar,
195 created: zone.created,
196 assignedNameservers: zone.authoritative_nameservers.assigned,
197 delegatedNameservers: zone.authoritative_nameservers.delegated,
198 delegationStatus: zone.authoritative_nameservers.delegation_status,
199});
200
201const getZoneBy = (idOrName: string) =>
202 Hetzner.zones.getZone({ id_or_name: idOrName }).pipe(
203 Effect.map(({ zone }) => zone),
204 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
205 );
206
207class ZoneStillExists extends Data.TaggedError("ZoneStillExists")<{
208 idOrName: string;
209}> {}
210
211const backoff = Schedule.min([
212 Schedule.exponential(Duration.millis(500), 1.5),
213 Schedule.spaced(Duration.seconds(5)),
214]);
215
216const retryLocked = <A, E extends { readonly _tag: string }, R>(
217 effect: Effect.Effect<A, E, R>,
218) =>
219 effect.pipe(
220 Effect.retry({
221 while: (e) => e._tag === "Locked",
222 times: 8,
223 schedule: backoff,
224 }),
225 );
226
227export const ZoneProvider = () =>
228 Provider.succeed(Zone, {
229 stables: ["zoneId", "name", "mode"],
230 list: Effect.fn(function* () {
231 const zones = yield* Hetzner.zones.listZones
232 .items({ label_selector: alchemyStackSelector, per_page: 50 })
233 .pipe(Stream.runCollect);
234 return [...zones].map(toAttrs);
235 }),
236 diff: Effect.fn(function* ({ news, output }) {
237 if (!isResolved(news)) return undefined;
238 if (!output) return undefined;
239 if (news.name !== undefined && normalizeName(news.name) !== output.name) {
240 return { action: "replace" } as const;
241 }
242 if ((news.mode ?? DEFAULT_MODE) !== output.mode) {
243 return { action: "replace" } as const;
244 }
245 const desiredProtection = news.deleteProtection ?? false;
246 const labelsChanged = !recordsEqual(news.labels ?? {}, output.labels);
247 const ttlChanged = news.ttl !== undefined && news.ttl !== output.ttl;
248 if (
249 ttlChanged ||
250 labelsChanged ||
251 desiredProtection !== output.deleteProtection
252 ) {
253 return { action: "update" } as const;
254 }
255 return undefined;
256 }),
257 read: Effect.fn(function* ({ id, olds, output }) {
258 let zone: CloudZone | undefined;
259 if (output?.zoneId !== undefined) {
260 zone = yield* getZoneBy(String(output.zoneId));
261 }
262 if (zone === undefined) {
263 const name = output?.name ?? olds?.name;
264 if (name !== undefined) {
265 zone = yield* getZoneBy(normalizeName(name));
266 }
267 }
268 if (zone === undefined) {
269 const expected = yield* createInternalLabels(id);
270 const selector = labelSelector(expected);
271 if (selector.length > 0) {
272 zone = yield* Hetzner.zones.listZones
273 .items({
274 label_selector: selector,
275 per_page: 50,
276 })
277 .pipe(
278 Stream.take(1),
279 Stream.runHead,
280 Effect.map((option) =>
281 option._tag === "Some" ? option.value : undefined,
282 ),
283 );
284 }
285 }
286 if (zone === undefined) return undefined;
287 const attrs = toAttrs(zone);
288 return (yield* hasAlchemyLabels(id, tagRecord(zone.labels)))
289 ? attrs
290 : Unowned(attrs);
291 }),
292 reconcile: Effect.fn(function* ({ id, news, output }) {
293 const name = yield* resolveZoneName(id, news, output);
294 const mode = news.mode ?? output?.mode ?? DEFAULT_MODE;
295 const idOrName =
296 output?.zoneId !== undefined ? String(output.zoneId) : name;
297
298 // 1. Observe
299 let current = yield* getZoneBy(idOrName);
300 if (current === undefined && output?.zoneId !== undefined) {
301 current = yield* getZoneBy(name);
302 }
303
304 // 2. Ensure
305 if (current === undefined) {
306 const labels = yield* desiredLabels(id, news.labels);
307 const created = yield* retryLocked(
308 Hetzner.zones
309 .createZone({
310 name,
311 mode,
312 ...(news.ttl !== undefined ? { ttl: news.ttl } : {}),
313 labels,
314 })
315 .pipe(
316 Effect.catchTag("Conflict", () =>
317 Hetzner.zones
318 .getZone({ id_or_name: name })
319 .pipe(
320 Effect.map(({ zone }) => ({ zone, action: undefined })),
321 ),
322 ),
323 ),
324 );
325 if (created.action !== undefined) {
326 yield* waitForZoneAction(created.action);
327 }
328 current =
329 created.action === undefined
330 ? created.zone
331 : ((yield* getZoneBy(name)) ?? created.zone);
332 }
333
334 const zoneRef = String(current.id);
335
336 // 3. Sync — labels (PUT overwrites the full set)
337 const labels = yield* desiredLabels(id, news.labels);
338 const observedLabels = tagRecord(current.labels);
339 if (!recordsEqual(observedLabels, labels)) {
340 const updated = yield* retryLocked(
341 Hetzner.zones.updateZone({
342 id_or_name: zoneRef,
343 labels,
344 }),
345 );
346 current = updated.zone;
347 }
348
349 // Sync — default TTL (primary zones only; secondary ignores it)
350 if (
351 news.ttl !== undefined &&
352 news.ttl !== current.ttl &&
353 current.mode === "primary"
354 ) {
355 const { action } = yield* retryLocked(
356 Hetzner.zoneActions.changeZoneTtl({
357 id_or_name: zoneRef,
358 ttl: news.ttl,
359 }),
360 );
361 yield* waitForZoneAction(action);
362 current = (yield* getZoneBy(zoneRef)) ?? current;
363 }
364
365 // Sync — delete protection
366 const desiredProtection = news.deleteProtection ?? false;
367 if (current.protection.delete !== desiredProtection) {
368 const { action } = yield* retryLocked(
369 Hetzner.zoneActions.changeZoneProtection({
370 id_or_name: zoneRef,
371 delete: desiredProtection,
372 }),
373 );
374 yield* waitForZoneAction(action);
375 current = (yield* getZoneBy(zoneRef)) ?? current;
376 }
377
378 return toAttrs(current);
379 }),
380 delete: Effect.fn(function* ({ output }) {
381 const idOrName = String(output.zoneId);
382 const current = yield* getZoneBy(idOrName);
383 if (current === undefined) return;
384
385 if (current.protection.delete) {
386 const { action } = yield* retryLocked(
387 Hetzner.zoneActions.changeZoneProtection({
388 id_or_name: idOrName,
389 delete: false,
390 }),
391 );
392 yield* waitForZoneAction(action);
393 }
394
395 const deleted = yield* retryLocked(
396 Hetzner.zones
397 .deleteZone({ id_or_name: idOrName })
398 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))),
399 );
400 if (deleted !== undefined) {
401 yield* waitForZoneAction(deleted.action);
402 }
403
404 yield* getZoneBy(idOrName).pipe(
405 Effect.flatMap((zone) =>
406 zone === undefined ? Effect.void : new ZoneStillExists({ idOrName }),
407 ),
408 Effect.retry({
409 while: (e) => e._tag === "ZoneStillExists",
410 times: 8,
411 schedule: backoff,
412 }),
413 Effect.catchTag("ZoneStillExists", () => Effect.void),
414 );
415 }),
416 });