1import * as Hetzner from "@distilled.cloud/hetzner";
2import type { GetFloatingIpResponseFloatingIp } from "@distilled.cloud/hetzner/floating_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 labelSelector,
22 stripInternalLabels,
23 toLabels,
24} from "./Labels.ts";
25import type { Providers } from "./Providers.ts";
26
27export type FloatingIpType = "ipv4" | "ipv6";
28
29export interface FloatingIpProps {
30 /**
31 * Address family of the Floating IP. Cannot be changed after creation —
32 * changing it triggers a replacement.
33 */
34 type: FloatingIpType;
35 /**
36 * Home Location for the Floating IP (`nbg1`, `fsn1`, `hel1`, …) or the
37 * Location's numeric id. Routing is optimized for this Location. Cannot
38 * be changed after creation — changing it triggers a replacement.
39 */
40 homeLocation: string | number;
41 /**
42 * Name of the Floating IP. Must be unique per Hetzner project. If
43 * omitted, a unique name is generated from the stack, stage, and
44 * logical ID.
45 */
46 name?: string;
47 /**
48 * Free-form description shown in the Hetzner Cloud Console.
49 */
50 description?: string | null;
51 /**
52 * User-defined labels (`key`/`value` pairs) applied to the Floating IP.
53 * Alchemy ownership labels are added automatically.
54 */
55 labels?: Record<string, string>;
56 /**
57 * Prevent the Floating IP from being deleted via the API.
58 * @default false
59 */
60 deleteProtection?: boolean;
61}
62
63export type FloatingIp = Resource<
64 "Hetzner.FloatingIp",
65 FloatingIpProps,
66 {
67 /**
68 * Numeric Hetzner ID of the Floating IP.
69 */
70 id: number;
71 /**
72 * Name of the Floating IP.
73 */
74 name: string;
75 /**
76 * Address family of the Floating IP.
77 */
78 type: FloatingIpType;
79 /**
80 * Assigned address. For `ipv6` this is the `/64` network.
81 */
82 ip: string;
83 /**
84 * Home Location name (`nbg1`, `fsn1`, …).
85 */
86 homeLocation: string;
87 /**
88 * Numeric ID of the home Location.
89 */
90 homeLocationId: number;
91 /**
92 * Description of the Floating IP, or `null` if unset.
93 */
94 description: string | null;
95 /**
96 * Whether Hetzner has blocked the Floating IP.
97 */
98 blocked: boolean;
99 /**
100 * Whether delete protection is enabled.
101 */
102 deleteProtection: boolean;
103 /**
104 * User-defined labels (Alchemy ownership labels are stripped).
105 */
106 labels: Record<string, string>;
107 /**
108 * RFC3339 timestamp when the Floating IP was created.
109 */
110 created: string;
111 /**
112 * Numeric Server ID this Floating IP is assigned to, or `null` if
113 * unassigned. Assignment is managed by `FloatingIpAssignment`.
114 */
115 serverId: number | null;
116 },
117 never,
118 Providers
119>;
120
121/**
122 * An unassigned Hetzner Cloud Floating IP. Provide `type` and a
123 * `homeLocation`; Alchemy generates a unique name unless you set `name`.
124 * Assignment to a Server is a separate `FloatingIpAssignment` resource.
125 *
126 * `type` and `homeLocation` are immutable — changing either replaces the
127 * Floating IP (new address). `name`, `description`, `labels`, and
128 * `deleteProtection` update in place.
129 *
130 * @see https://docs.hetzner.cloud/reference/cloud#floating-ips
131 *
132 * ### Creating a Floating IP
133 * **Example:** Unassigned IPv4 in nbg1
134 * ```typescript
135 * const ip = yield* Hetzner.FloatingIp("public-ip", {
136 * type: "ipv4",
137 * homeLocation: "nbg1",
138 * });
139 * ```
140 *
141 * **Example:** IPv6 with description and labels
142 * ```typescript
143 * const ip = yield* Hetzner.FloatingIp("v6", {
144 * type: "ipv6",
145 * homeLocation: "nbg1",
146 * description: "edge anycast",
147 * labels: { role: "edge" },
148 * });
149 * ```
150 *
151 * ### Updating a Floating IP
152 * **Example:** Rename and relabel
153 * ```typescript
154 * const ip = yield* Hetzner.FloatingIp("public-ip", {
155 * type: "ipv4",
156 * homeLocation: "nbg1",
157 * name: "public-ip-prod",
158 * description: "production frontend",
159 * labels: { env: "prod" },
160 * });
161 * ```
162 *
163 * @resource
164 * @product IP Address
165 */
166export const FloatingIp = Resource<FloatingIp>("Hetzner.FloatingIp");
167
168type CloudFloatingIp = GetFloatingIpResponseFloatingIp;
169
170const asType = (type: string): FloatingIpType =>
171 type === "ipv6" ? "ipv6" : "ipv4";
172
173const userLabels = (
174 labels: Record<string, string | undefined> | null | undefined,
175): Record<string, string> => stripInternalLabels(tagRecord(labels));
176
177const toAttrs = (ip: CloudFloatingIp): FloatingIp["Attributes"] => ({
178 id: ip.id,
179 name: ip.name,
180 type: asType(ip.type),
181 ip: ip.ip,
182 homeLocation: ip.home_location.name,
183 homeLocationId: ip.home_location.id,
184 description: ip.description,
185 blocked: ip.blocked,
186 deleteProtection: ip.protection.delete,
187 labels: userLabels(ip.labels),
188 created: ip.created,
189 serverId: ip.server,
190});
191
192const createFloatingIpName = (
193 id: string,
194 name: string | undefined,
195 existing?: string,
196) =>
197 Effect.gen(function* () {
198 return (
199 name ?? existing ?? (yield* createPhysicalName({ id, maxLength: 63 }))
200 );
201 });
202
203const getById = (id: number) =>
204 Hetzner.floatingIps.getFloatingIp({ id }).pipe(
205 Effect.map(({ floating_ip }) => floating_ip),
206 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
207 );
208
209const getByName = (name: string) =>
210 Hetzner.floatingIps
211 .listFloatingIps({ name, per_page: 50 })
212 .pipe(Effect.map(({ floating_ips }) => floating_ips[0]));
213
214const getByLabels = (labels: Record<string, string>) =>
215 Hetzner.floatingIps
216 .listFloatingIps({
217 label_selector: labelSelector(labels),
218 per_page: 50,
219 })
220 .pipe(Effect.map(({ floating_ips }) => floating_ips[0]));
221
222const observe = Effect.fn(function* ({
223 id,
224 name,
225 outputId,
226}: {
227 id: string;
228 name?: string;
229 outputId?: number;
230}) {
231 if (outputId !== undefined) {
232 const byId = yield* getById(outputId);
233 if (byId !== undefined) return byId;
234 }
235 if (name !== undefined) {
236 const byName = yield* getByName(name);
237 if (byName !== undefined) return byName;
238 }
239 const internal = yield* createInternalLabels(id);
240 return yield* getByLabels(internal);
241});
242
243const refresh = (id: number) =>
244 Hetzner.floatingIps.getFloatingIp({ id }).pipe(
245 Effect.map(({ floating_ip }) => floating_ip),
246 Effect.retry({
247 while: (e) => e._tag === "NotFound",
248 times: 5,
249 schedule: Schedule.min([
250 Schedule.exponential(Duration.millis(200), 1.5),
251 Schedule.spaced(Duration.seconds(2)),
252 ]),
253 }),
254 );
255
256const sameHomeLocation = (
257 desired: string | number,
258 attrs: Pick<FloatingIp["Attributes"], "homeLocation" | "homeLocationId">,
259): boolean =>
260 desired === attrs.homeLocation ||
261 desired === attrs.homeLocationId ||
262 String(desired) === String(attrs.homeLocationId);
263
264const matchesDesired = (
265 ip: CloudFloatingIp,
266 type: FloatingIpType,
267 location: { name: string; id: number },
268): boolean =>
269 asType(ip.type) === type &&
270 sameHomeLocation(location.name, {
271 homeLocation: ip.home_location.name,
272 homeLocationId: ip.home_location.id,
273 });
274
275const disableProtection = (id: number) =>
276 Hetzner.floatingIpActions
277 .changeFloatingIpProtection({ id, delete: false })
278 .pipe(Effect.flatMap(({ action }) => waitForAction(action)));
279
280export class FloatingIpNotCreated extends Data.TaggedError(
281 "Hetzner.FloatingIpNotCreated",
282)<{
283 name: string;
284}> {}
285
286export const FloatingIpProvider = () =>
287 Provider.succeed(FloatingIp, {
288 stables: ["id", "ip", "type", "homeLocation", "homeLocationId", "created"],
289 nuke: { dependsOn: ["Hetzner.Server"] },
290 list: Effect.fn(function* () {
291 const items = yield* Hetzner.floatingIps.listFloatingIps
292 .items({ label_selector: alchemyStackSelector, per_page: 50 })
293 .pipe(
294 Stream.runCollect,
295 Effect.map((chunk) => Array.from(chunk)),
296 );
297 return items.map(toAttrs);
298 }),
299 diff: Effect.fn(function* ({ news, output }) {
300 if (!isResolved(news)) return undefined;
301 if (output !== undefined) {
302 if (news.type !== output.type) {
303 return { action: "replace" } as const;
304 }
305 if (!sameHomeLocation(news.homeLocation, output)) {
306 return { action: "replace" } as const;
307 }
308 }
309 return undefined;
310 }),
311 read: Effect.fn(function* ({ id, olds, output }) {
312 const found = yield* observe({
313 id,
314 name: olds?.name ?? output?.name,
315 outputId: output?.id,
316 });
317 if (found === undefined) return undefined;
318 const attrs = toAttrs(found);
319 const owned = yield* hasAlchemyLabels(id, tagRecord(found.labels));
320 return owned ? attrs : Unowned(attrs);
321 }),
322 reconcile: Effect.fn(function* ({ id, news, output }) {
323 const location = yield* findLocation(news.homeLocation);
324 const name = yield* createFloatingIpName(id, news.name, output?.name);
325 const internalLabels = yield* createInternalLabels(id);
326 const desiredLabels = {
327 ...toLabels(news.labels),
328 ...internalLabels,
329 };
330 const desiredDescription = news.description ?? null;
331 const desiredProtection = news.deleteProtection ?? false;
332
333 // Observe — cloud state is authoritative. `output.id` is a cache
334 // for the stable identifier; if the IP is gone, we recreate.
335 let current: CloudFloatingIp | undefined = yield* observe({
336 id,
337 name,
338 outputId: output?.id,
339 });
340 // A previous generation found via ownership labels (same logical id,
341 // different type/location) is the resource being replaced — do not
342 // take it over; create a new Floating IP instead.
343 if (
344 current !== undefined &&
345 current.id !== output?.id &&
346 !matchesDesired(current, news.type, location)
347 ) {
348 current = undefined;
349 }
350
351 // Ensure — create only when missing. A Conflict is a race with a
352 // peer reconciler or a name that just became visible; re-observe.
353 if (current === undefined) {
354 const created = yield* Hetzner.floatingIps
355 .createFloatingIp({
356 type: news.type,
357 home_location: location.name,
358 name,
359 description: desiredDescription,
360 labels: desiredLabels,
361 })
362 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
363 if (created !== undefined) {
364 if (created.action) {
365 yield* waitForAction(created.action);
366 }
367 current = created.floating_ip;
368 } else {
369 const hit = yield* observe({ id, name });
370 if (hit !== undefined && matchesDesired(hit, news.type, location)) {
371 current = hit;
372 }
373 }
374 }
375
376 if (current === undefined) {
377 return yield* new FloatingIpNotCreated({ name });
378 }
379
380 // Sync — name / description / labels via PUT (labels overwrite the
381 // full set). Protection is a separate Action.
382 const observedLabels = tagRecord(current.labels);
383 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
384 const needsUpdate =
385 current.name !== name ||
386 (current.description ?? null) !== desiredDescription ||
387 upsert.length > 0 ||
388 removed.length > 0;
389 if (needsUpdate) {
390 const updated = yield* Hetzner.floatingIps.updateFloatingIp({
391 id: current.id,
392 name,
393 description: desiredDescription,
394 labels: desiredLabels,
395 });
396 current = updated.floating_ip;
397 }
398
399 if (current.protection.delete !== desiredProtection) {
400 const { action } =
401 yield* Hetzner.floatingIpActions.changeFloatingIpProtection({
402 id: current.id,
403 delete: desiredProtection,
404 });
405 yield* waitForAction(action);
406 }
407
408 return toAttrs(yield* refresh(current.id));
409 }),
410 delete: Effect.fn(function* ({ output }) {
411 const current = yield* getById(output.id);
412 if (current === undefined) return;
413 if (current.protection.delete) {
414 yield* disableProtection(current.id);
415 }
416 if (current.server !== null) {
417 const { action } = yield* Hetzner.floatingIpActions.unassignFloatingIp({
418 id: current.id,
419 });
420 yield* waitForAction(action);
421 }
422 yield* Hetzner.floatingIps
423 .deleteFloatingIp({ id: current.id })
424 .pipe(Effect.catchTag("NotFound", () => Effect.void));
425 }),
426 });