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 { isResolved } from "../Diff.ts";
9import * as Provider from "../Provider.ts";
10import { Resource } from "../Resource.ts";
11import { waitForAction } from "./actions.ts";
12import { alchemyStackSelector } from "./Labels.ts";
13import type { Providers } from "./Providers.ts";
14
15export class FloatingIpAssignmentError extends Data.TaggedError(
16 "FloatingIpAssignmentError",
17)<{
18 message: string;
19}> {}
20
21/**
22 * A resource-valued prop: the resource itself, or an Effect that produces
23 * it (so `yield* FloatingIp(...)` and `FloatingIp(...)` both type-check).
24 */
25type Ref<T> = T | Effect.Effect<T, never, Providers>;
26
27/**
28 * Floating IP identity this assignment binds. Accepts a
29 * `Hetzner.FloatingIp` resource or an `{ id }` stub.
30 */
31export type FloatingIpAssignmentIp = {
32 readonly id: number;
33};
34
35/**
36 * Server identity a Floating IP can be assigned to. Accepts a
37 * `Hetzner.Server` resource or a `{ serverId }` stub.
38 */
39export type FloatingIpAssignmentServer = {
40 readonly serverId: number;
41};
42
43export interface FloatingIpAssignmentProps {
44 /**
45 * Floating IP to assign. Accepts a `Hetzner.FloatingIp` or `{ id }`.
46 * Changing it updates the assignment in place (previous IP is
47 * unassigned).
48 */
49 floatingIp: Ref<FloatingIpAssignmentIp>;
50 /**
51 * Server to assign the Floating IP to. Accepts a `Hetzner.Server` or
52 * `{ serverId }`. Changing it updates the assignment in place.
53 */
54 server: Ref<FloatingIpAssignmentServer>;
55}
56
57export type FloatingIpAssignment = Resource<
58 "Hetzner.FloatingIpAssignment",
59 FloatingIpAssignmentProps,
60 {
61 /**
62 * Numeric Hetzner ID of the assigned Floating IP.
63 */
64 floatingIpId: number;
65 /**
66 * Numeric Hetzner ID of the Server the Floating IP is assigned to.
67 */
68 serverId: number;
69 },
70 never,
71 Providers
72>;
73
74/**
75 * Assigns a Hetzner Cloud {@link FloatingIp} to a Server. The assignment
76 * is existence-only: observe the Floating IP and ensure it is bound to
77 * the Server. Changing either reference updates in place (the previous
78 * IP is unassigned, then the new pair is assigned).
79 *
80 * A Floating IP can be assigned to at most one Server at a time. A Server
81 * may hold many Floating IPs. Destroying the assignment unassigns the IP
82 * but leaves both the Floating IP and the Server in place.
83 *
84 * @see https://docs.hetzner.cloud/reference/cloud#floating-ip-actions-assign-a-floating-ip-to-a-server
85 *
86 * ### Assigning a Floating IP
87 * **Example:** Assign an IPv4 to a Server
88 * ```typescript
89 * const server = yield* Hetzner.Server("web", {
90 * image: "ubuntu-24.04",
91 * serverType: "cx23",
92 * location: "nbg1",
93 * });
94 * const ip = yield* Hetzner.FloatingIp("public-ip", {
95 * type: "ipv4",
96 * homeLocation: "nbg1",
97 * });
98 * const assignment = yield* Hetzner.FloatingIpAssignment("public-ip-web", {
99 * floatingIp: ip,
100 * server,
101 * });
102 * ```
103 *
104 * **Example:** Assign with stub identities
105 * ```typescript
106 * const assignment = yield* Hetzner.FloatingIpAssignment("public-ip-web", {
107 * floatingIp: { id: 123 },
108 * server: { serverId: 42 },
109 * });
110 * ```
111 *
112 * @resource
113 * @product IP Address
114 */
115export const FloatingIpAssignment = Resource<FloatingIpAssignment>(
116 "Hetzner.FloatingIpAssignment",
117);
118
119type CloudFloatingIp = GetFloatingIpResponseFloatingIp;
120
121const floatingIpIdOf = (value: unknown): number | undefined => {
122 if (value === null || typeof value !== "object") return undefined;
123 const rec = value as { id?: unknown; floatingIpId?: unknown };
124 if (typeof rec.id === "number") return rec.id;
125 if (typeof rec.floatingIpId === "number") return rec.floatingIpId;
126 return undefined;
127};
128
129const serverIdOf = (value: unknown): number | undefined => {
130 if (value === null || typeof value !== "object") return undefined;
131 const rec = value as { serverId?: unknown; id?: unknown };
132 if (typeof rec.serverId === "number") return rec.serverId;
133 if (typeof rec.id === "number") return rec.id;
134 return undefined;
135};
136
137const toAttrs = (
138 ip: CloudFloatingIp,
139): FloatingIpAssignment["Attributes"] | undefined => {
140 if (ip.server === null) return undefined;
141 return {
142 floatingIpId: ip.id,
143 serverId: ip.server,
144 };
145};
146
147const getById = (id: number) =>
148 Hetzner.floatingIps.getFloatingIp({ id }).pipe(
149 Effect.map(({ floating_ip }) => floating_ip),
150 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
151 );
152
153const refresh = (id: number) =>
154 Hetzner.floatingIps.getFloatingIp({ id }).pipe(
155 Effect.map(({ floating_ip }) => floating_ip),
156 Effect.retry({
157 while: (e) => e._tag === "NotFound",
158 times: 5,
159 schedule: Schedule.min([
160 Schedule.exponential(Duration.millis(200), 1.5),
161 Schedule.spaced(Duration.seconds(2)),
162 ]),
163 }),
164 );
165
166const observeAssignment = (floatingIpId: number, serverId: number) =>
167 Effect.gen(function* () {
168 const ip = yield* getById(floatingIpId);
169 if (ip === undefined) return undefined;
170 if (ip.server !== serverId) return undefined;
171 return ip;
172 });
173
174const unassignIfNeeded = (ip: CloudFloatingIp) =>
175 Effect.gen(function* () {
176 if (ip.server === null) return ip;
177 const { action } = yield* Hetzner.floatingIpActions.unassignFloatingIp({
178 id: ip.id,
179 });
180 yield* waitForAction(action);
181 return yield* refresh(ip.id);
182 });
183
184const assignTo = (ip: CloudFloatingIp, serverId: number) =>
185 Effect.gen(function* () {
186 if (ip.server === serverId) return ip;
187 const { action } = yield* Hetzner.floatingIpActions.assignFloatingIp({
188 id: ip.id,
189 server: serverId,
190 });
191 yield* waitForAction(action);
192 return yield* refresh(ip.id);
193 });
194
195export const FloatingIpAssignmentProvider = () =>
196 Provider.succeed(FloatingIpAssignment, {
197 stables: ["floatingIpId", "serverId"],
198 nuke: { dependsOn: ["Hetzner.FloatingIp", "Hetzner.Server"] },
199 list: Effect.fn(function* () {
200 const items = yield* Hetzner.floatingIps.listFloatingIps
201 .items({ label_selector: alchemyStackSelector, per_page: 50 })
202 .pipe(
203 Stream.runCollect,
204 Effect.map((chunk) => Array.from(chunk)),
205 );
206 return items.flatMap((ip) => {
207 const attrs = toAttrs(ip);
208 return attrs === undefined ? [] : [attrs];
209 });
210 }),
211 diff: Effect.fn(function* ({ news }) {
212 if (!isResolved(news)) return undefined;
213 return undefined;
214 }),
215 read: Effect.fn(function* ({ output }) {
216 if (output === undefined) return undefined;
217 const found = yield* observeAssignment(
218 output.floatingIpId,
219 output.serverId,
220 );
221 return found === undefined ? undefined : toAttrs(found);
222 }),
223 reconcile: Effect.fn(function* ({ news, output }) {
224 const floatingIpId = floatingIpIdOf(news.floatingIp);
225 const serverId = serverIdOf(news.server);
226 if (floatingIpId === undefined || serverId === undefined) {
227 return yield* new FloatingIpAssignmentError({
228 message:
229 "FloatingIpAssignment requires a resolved floatingIp and server",
230 });
231 }
232
233 // Observe — cloud assignment on the Floating IP is authoritative.
234 let current = yield* getById(floatingIpId);
235 if (current === undefined) {
236 current = yield* refresh(floatingIpId);
237 }
238
239 // Ensure — the pair exists. Release a previously owned IP first so
240 // an in-place retarget does not leave the old address assigned.
241 if (output !== undefined && output.floatingIpId !== floatingIpId) {
242 const previous = yield* getById(output.floatingIpId);
243 if (previous !== undefined && previous.server === output.serverId) {
244 yield* unassignIfNeeded(previous);
245 }
246 }
247 if (current.server !== serverId) {
248 current = yield* unassignIfNeeded(current);
249 current = yield* assignTo(current, serverId);
250 }
251
252 const attrs = toAttrs(current);
253 if (attrs === undefined) {
254 return yield* new FloatingIpAssignmentError({
255 message:
256 "FloatingIpAssignment reconcile finished without an assignment",
257 });
258 }
259 return attrs;
260 }),
261 delete: Effect.fn(function* ({ output }) {
262 const current = yield* getById(output.floatingIpId);
263 if (current === undefined) return;
264 if (current.server !== output.serverId) return;
265 yield* unassignIfNeeded(current);
266 }),
267 });