1import * as Hetzner from "@distilled.cloud/hetzner";
2import type { GetPrimaryIpResponsePrimaryIp } from "@distilled.cloud/hetzner/primary_ips";
3import * as Data from "effect/Data";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { Unowned } from "../AdoptPolicy.ts";
9import { isResolved } from "../Diff.ts";
10import { createPhysicalName } from "../PhysicalName.ts";
11import * as Provider from "../Provider.ts";
12import { Resource } from "../Resource.ts";
13import { tagRecord } from "../Tags.ts";
14import { waitForAction } from "./actions.ts";
15import { findLocation } from "./Catalog.ts";
16import {
17 alchemyStackSelector,
18 createInternalLabels,
19 diffLabels,
20 hasAlchemyLabels,
21 stripInternalLabels,
22 toLabels,
23} from "./Labels.ts";
24import type { Providers } from "./Providers.ts";
25
26export type PrimaryIpType = "ipv4" | "ipv6";
27
28export interface PrimaryIpProps {
29 /**
30 * Address family. Cannot be changed after creation — changing it
31 * triggers a replacement.
32 */
33 type: PrimaryIpType;
34 /**
35 * Location name (`nbg1`, `fsn1`, `hel1`, …) or numeric Location ID.
36 * Required unless `datacenter` is set. Cannot be changed after
37 * creation — changing it triggers a replacement.
38 */
39 location?: string | number;
40 /**
41 * Datacenter name (`nbg1-dc3`, `fsn1-dc14`, …) or numeric Datacenter
42 * ID. Alternative to `location`; a name is mapped onto the parent
43 * Location (`nbg1-dc3` → `nbg1`). Cannot be changed after creation —
44 * changing it triggers a replacement.
45 */
46 datacenter?: string | number;
47 /**
48 * Name of the Primary IP. Must be unique per Hetzner project. If
49 * omitted, a unique name is generated from the stack, stage, and
50 * logical ID.
51 */
52 name?: string;
53 /**
54 * If enabled, Hetzner deletes this Primary IP when its assigned
55 * resource is deleted.
56 * @default false
57 */
58 autoDelete?: boolean;
59 /**
60 * User-defined labels (`key`/`value` pairs) applied to the Primary IP.
61 * Alchemy ownership labels are added automatically.
62 */
63 labels?: Record<string, string>;
64 /**
65 * Prevent the Primary IP from being deleted via the API.
66 * @default false
67 */
68 deleteProtection?: boolean;
69}
70
71export type PrimaryIp = Resource<
72 "Hetzner.PrimaryIp",
73 PrimaryIpProps,
74 {
75 /**
76 * Numeric Hetzner ID of the Primary IP.
77 */
78 id: number;
79 /**
80 * Name of the Primary IP.
81 */
82 name: string;
83 /**
84 * Address family of the Primary IP.
85 */
86 type: PrimaryIpType;
87 /**
88 * Assigned address. For `ipv6` this is the `/64` network.
89 */
90 ip: string;
91 /**
92 * Location name the IP is bound to (`nbg1`, `fsn1`, …).
93 */
94 location: string;
95 /**
96 * Numeric Location id.
97 */
98 locationId: number;
99 /**
100 * Datacenter name or id from props, when the resource was placed via
101 * `datacenter`. Not returned by the API.
102 */
103 datacenter: string | undefined;
104 /**
105 * Whether Hetzner has blocked the Primary IP.
106 */
107 blocked: boolean;
108 /**
109 * Auto-delete flag.
110 */
111 autoDelete: boolean;
112 /**
113 * Assigned resource id, or `null` if unassigned.
114 */
115 assigneeId: number | null;
116 /**
117 * Assigned resource type (`server`), when assigned.
118 */
119 assigneeType: string | undefined;
120 /**
121 * RFC3339 timestamp when the Primary IP was created.
122 */
123 created: string;
124 /**
125 * User-defined labels (Alchemy ownership labels are stripped).
126 */
127 labels: Record<string, string>;
128 /**
129 * Whether delete protection is enabled.
130 */
131 deleteProtection: boolean;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * An unassigned Hetzner Cloud Primary IP. Provide `type` and either a
139 * `location` or a `datacenter`; Alchemy generates a unique name unless
140 * you set `name`. Assignee wiring is a later resource.
141 *
142 * `type`, `location`, and `datacenter` are immutable — changing any of
143 * them replaces the Primary IP (new address). `name`, `autoDelete`,
144 * `labels`, and `deleteProtection` update in place.
145 *
146 * @see https://docs.hetzner.cloud/reference/cloud#primary-ips
147 *
148 * ### Creating a Primary IP
149 * **Example:** IPv6 in Nuremberg
150 * ```typescript
151 * const ip = yield* Hetzner.PrimaryIp("web-ipv6", {
152 * type: "ipv6",
153 * location: "nbg1",
154 * });
155 * ```
156 *
157 * **Example:** IPv4 placed by datacenter
158 * ```typescript
159 * const ip = yield* Hetzner.PrimaryIp("web-ipv4", {
160 * type: "ipv4",
161 * datacenter: "nbg1-dc3",
162 * });
163 * ```
164 *
165 * ### Labels and auto-delete
166 * **Example:** Named IP with labels
167 * ```typescript
168 * const ip = yield* Hetzner.PrimaryIp("mail", {
169 * name: "mail-ipv4",
170 * type: "ipv4",
171 * location: "fsn1",
172 * autoDelete: false,
173 * labels: { role: "mail" },
174 * });
175 * ```
176 *
177 * @resource
178 * @product IP Address
179 */
180export const PrimaryIp = Resource<PrimaryIp>("Hetzner.PrimaryIp");
181
182export class PrimaryIpPlacementRequired extends Data.TaggedError(
183 "Hetzner.PrimaryIpPlacementRequired",
184)<{
185 message: string;
186}> {}
187
188type CloudPrimaryIp = GetPrimaryIpResponsePrimaryIp;
189
190const asType = (type: string): PrimaryIpType =>
191 type === "ipv6" ? "ipv6" : "ipv4";
192
193const userLabels = (
194 labels: Record<string, string | undefined> | null | undefined,
195): Record<string, string> => stripInternalLabels(tagRecord(labels));
196
197/**
198 * Hetzner datacenter names are `{location}-dc{n}` (e.g. `nbg1-dc3`).
199 * Numeric ids are passed through — Locations use a different id space.
200 */
201export const locationFromDatacenter = (
202 datacenter: string | number,
203): string | number => {
204 if (typeof datacenter === "number") return datacenter;
205 const match = /^([a-z0-9]+)-dc\d+$/i.exec(datacenter);
206 return match ? match[1]!.toLowerCase() : datacenter;
207};
208
209const resolvePlacement = (
210 props: Pick<PrimaryIpProps, "location" | "datacenter">,
211): string | number | undefined => {
212 if (props.location !== undefined) return props.location;
213 if (props.datacenter !== undefined) {
214 return locationFromDatacenter(props.datacenter);
215 }
216 return undefined;
217};
218
219const samePlacement = (
220 news: Pick<PrimaryIpProps, "location" | "datacenter">,
221 attrs: PrimaryIp["Attributes"],
222): boolean => {
223 const desired = resolvePlacement(news);
224 if (desired === undefined) return true;
225 return (
226 desired === attrs.location ||
227 desired === attrs.locationId ||
228 String(desired) === String(attrs.locationId)
229 );
230};
231
232const toAttrs = (
233 ip: CloudPrimaryIp,
234 extras?: { datacenter?: string | number },
235): PrimaryIp["Attributes"] => ({
236 id: ip.id,
237 name: ip.name,
238 type: asType(ip.type),
239 ip: ip.ip,
240 location: ip.location.name,
241 locationId: ip.location.id,
242 datacenter:
243 extras?.datacenter !== undefined ? String(extras.datacenter) : undefined,
244 blocked: ip.blocked,
245 autoDelete: ip.auto_delete,
246 assigneeId: ip.assignee_id,
247 assigneeType: ip.assignee_type,
248 created: ip.created,
249 labels: userLabels(ip.labels),
250 deleteProtection: ip.protection.delete,
251});
252
253const createPrimaryIpName = (
254 id: string,
255 name: string | undefined,
256 existing?: string,
257) =>
258 Effect.gen(function* () {
259 return (
260 name ?? existing ?? (yield* createPhysicalName({ id, maxLength: 63 }))
261 );
262 });
263
264const getById = (id: number) =>
265 Hetzner.primaryIps.getPrimaryIp({ id }).pipe(
266 Effect.map(({ primary_ip }) => primary_ip),
267 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
268 );
269
270const getByName = (name: string) =>
271 Hetzner.primaryIps
272 .listPrimaryIps({ name, per_page: 50 })
273 .pipe(Effect.map(({ primary_ips }) => primary_ips[0]));
274
275const observe = Effect.fn(function* ({
276 name,
277 outputId,
278}: {
279 id: string;
280 name?: string;
281 outputId?: number;
282}) {
283 // Identity is the numeric id (cached on `output`) or the unique
284 // project-scoped name. Do NOT fall back to alchemy.* labels: a
285 // replacement shares the logical id with the old generation, so a
286 // label lookup would adopt the IP we are replacing.
287 if (outputId !== undefined) {
288 const byId = yield* getById(outputId);
289 if (byId !== undefined) return byId;
290 }
291 if (name !== undefined) {
292 return yield* getByName(name);
293 }
294 return undefined;
295});
296
297const refresh = (id: number) =>
298 Hetzner.primaryIps.getPrimaryIp({ id }).pipe(
299 Effect.map(({ primary_ip }) => primary_ip),
300 Effect.retry({
301 while: (e) => e._tag === "NotFound",
302 times: 5,
303 schedule: Schedule.min([
304 Schedule.exponential(Duration.millis(200), 1.5),
305 Schedule.spaced(Duration.seconds(2)),
306 ]),
307 }),
308 );
309
310const disableProtection = (id: number) =>
311 Hetzner.primaryIpActions
312 .changePrimaryIpProtection({ id, delete: false })
313 .pipe(Effect.flatMap(({ action }) => waitForAction(action)));
314
315export const PrimaryIpProvider = () =>
316 Provider.succeed(PrimaryIp, {
317 stables: ["id", "ip", "type", "location", "locationId", "created"],
318 nuke: { dependsOn: ["Hetzner.Server"] },
319 list: Effect.fn(function* () {
320 const items = yield* Hetzner.primaryIps.listPrimaryIps
321 .items({ label_selector: alchemyStackSelector, per_page: 50 })
322 .pipe(
323 Stream.runCollect,
324 Effect.map((chunk) => Array.from(chunk)),
325 );
326 return items.map((ip) => toAttrs(ip));
327 }),
328 diff: Effect.fn(function* ({ news, output }) {
329 if (!isResolved(news)) return undefined;
330 if (output !== undefined) {
331 if (news.type !== output.type) {
332 return { action: "replace" } as const;
333 }
334 if (!samePlacement(news, output)) {
335 return { action: "replace" } as const;
336 }
337 }
338 return undefined;
339 }),
340 read: Effect.fn(function* ({ id, olds, output }) {
341 const found = yield* observe({
342 id,
343 name: olds?.name ?? output?.name,
344 outputId: output?.id,
345 });
346 if (found === undefined) return undefined;
347 const attrs = toAttrs(found, {
348 datacenter: olds?.datacenter ?? output?.datacenter,
349 });
350 const owned = yield* hasAlchemyLabels(id, tagRecord(found.labels));
351 return owned ? attrs : Unowned(attrs);
352 }),
353 reconcile: Effect.fn(function* ({ id, news, output }) {
354 const name = yield* createPrimaryIpName(id, news.name, output?.name);
355 const internalLabels = yield* createInternalLabels(id);
356 const desiredLabels = {
357 ...toLabels(news.labels),
358 ...internalLabels,
359 };
360 const desiredAutoDelete = news.autoDelete ?? false;
361 const desiredProtection = news.deleteProtection ?? false;
362
363 // Observe — cloud state is authoritative. `output.id` is a cache
364 // for the stable identifier; if the IP is gone, we recreate.
365 let current = yield* observe({
366 id,
367 name,
368 outputId: output?.id,
369 });
370
371 // Ensure — create only when missing. A Conflict is a race with a
372 // peer reconciler or a name that just became visible; re-observe.
373 if (current === undefined) {
374 const placement = resolvePlacement(news);
375 if (placement === undefined) {
376 return yield* new PrimaryIpPlacementRequired({
377 message:
378 "PrimaryIp requires `location` or `datacenter` when creating",
379 });
380 }
381 const location = yield* findLocation(placement);
382 const created = yield* Hetzner.primaryIps
383 .createPrimaryIp({
384 name,
385 type: news.type,
386 location: location.name,
387 labels: desiredLabels,
388 auto_delete: desiredAutoDelete,
389 })
390 .pipe(
391 Effect.catchTag("Conflict", () =>
392 observe({ id, name }).pipe(
393 Effect.flatMap((hit) =>
394 hit !== undefined
395 ? Effect.succeed({ primary_ip: hit, action: undefined })
396 : Hetzner.primaryIps.createPrimaryIp({
397 name,
398 type: news.type,
399 location: location.name,
400 labels: desiredLabels,
401 auto_delete: desiredAutoDelete,
402 }),
403 ),
404 ),
405 ),
406 );
407 if (created.action) {
408 yield* waitForAction(created.action);
409 }
410 current = created.primary_ip;
411 }
412
413 // Sync — name / auto_delete / labels via PUT (labels overwrite the
414 // full set). Protection is a separate Action.
415 const observedLabels = tagRecord(current.labels);
416 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
417 const needsUpdate =
418 current.name !== name ||
419 current.auto_delete !== desiredAutoDelete ||
420 upsert.length > 0 ||
421 removed.length > 0;
422 if (needsUpdate) {
423 const updated = yield* Hetzner.primaryIps.updatePrimaryIp({
424 id: current.id,
425 name,
426 auto_delete: desiredAutoDelete,
427 labels: desiredLabels,
428 });
429 current = updated.primary_ip;
430 }
431
432 if (current.protection.delete !== desiredProtection) {
433 const { action } =
434 yield* Hetzner.primaryIpActions.changePrimaryIpProtection({
435 id: current.id,
436 delete: desiredProtection,
437 });
438 yield* waitForAction(action);
439 }
440
441 return toAttrs(yield* refresh(current.id), {
442 datacenter: news.datacenter,
443 });
444 }),
445 delete: Effect.fn(function* ({ output }) {
446 const current = yield* getById(output.id);
447 if (current === undefined) return;
448 if (current.protection.delete) {
449 yield* disableProtection(current.id);
450 }
451 if (current.assignee_id !== null) {
452 const { action } = yield* Hetzner.primaryIpActions.unassignPrimaryIp({
453 id: current.id,
454 });
455 yield* waitForAction(action);
456 }
457 yield* Hetzner.primaryIps
458 .deletePrimaryIp({ id: current.id })
459 .pipe(Effect.catchTag("NotFound", () => Effect.void));
460 }),
461 });