1import * as Hetzner from "@distilled.cloud/hetzner";
2import type { GetVolumeResponseVolume } from "@distilled.cloud/hetzner/volumes";
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
15/**
16 * A resource-valued prop: the resource itself, or an Effect that produces
17 * it (so `yield* Volume(...)` and `Volume(...)` both type-check).
18 */
19type Ref<T> = T | Effect.Effect<T, never, Providers>;
20
21/**
22 * Volume identity. A `Hetzner.Volume` resource satisfies this via `id`.
23 */
24export type VolumeAttachmentVolume = {
25 readonly id: number;
26};
27
28/**
29 * Server identity. A `Hetzner.Server` resource satisfies this via
30 * `serverId`.
31 */
32export type VolumeAttachmentServer = {
33 readonly serverId: number;
34};
35
36export interface VolumeAttachmentProps {
37 /**
38 * Volume to attach. Accepts a `Hetzner.Volume` or `{ id }`. Changing
39 * the Volume replaces the attachment.
40 */
41 volume: Ref<VolumeAttachmentVolume>;
42 /**
43 * Server to attach the Volume to. Accepts a `Hetzner.Server` or
44 * `{ serverId }`. Changing the Server replaces the attachment.
45 */
46 server: Ref<VolumeAttachmentServer>;
47 /**
48 * Auto-mount the Volume after attach. Updating this detaches and
49 * re-attaches the Volume so the new value is applied.
50 *
51 * @default false
52 */
53 automount?: boolean;
54}
55
56export type VolumeAttachment = Resource<
57 "Hetzner.VolumeAttachment",
58 VolumeAttachmentProps,
59 {
60 /** Numeric Hetzner Volume ID. */
61 volumeId: number;
62 /** Numeric Hetzner Server ID the Volume is attached to. */
63 serverId: number;
64 /**
65 * Last-applied automount flag. Hetzner does not expose the live
66 * automount state, so this is the desired value from the last
67 * successful reconcile.
68 */
69 automount: boolean;
70 /** Device path on the file system (e.g. `/dev/disk/by-id/scsi-…`). */
71 linuxDevice: string;
72 },
73 never,
74 Providers
75>;
76
77/**
78 * Attaches a Hetzner Cloud Volume to a Server in the same Location.
79 * The Volume and Server must already exist. Deleting the attachment
80 * detaches the Volume; it does not delete the Volume or the Server.
81 *
82 * This is an existence-style resource — its identity is the
83 * `volume`/`server` pair. Changing either replaces the attachment.
84 * `automount` updates in place (detach + re-attach).
85 *
86 * @see https://docs.hetzner.cloud/reference/cloud#volume-actions-attach-volume-to-a-server
87 *
88 * ### Attaching a Volume
89 * **Example:** Attach a Volume to a Server
90 * ```typescript
91 * const server = yield* Hetzner.Server("web", {
92 * serverType: "cx23",
93 * image: "ubuntu-24.04",
94 * location: "nbg1",
95 * });
96 * const volume = yield* Hetzner.Volume("data", {
97 * size: 10,
98 * format: "ext4",
99 * location: "nbg1",
100 * });
101 * const attachment = yield* Hetzner.VolumeAttachment("data-attach", {
102 * volume,
103 * server,
104 * });
105 * ```
106 *
107 * **Example:** Attach with automount
108 * ```typescript
109 * const attachment = yield* Hetzner.VolumeAttachment("data-attach", {
110 * volume,
111 * server,
112 * automount: true,
113 * });
114 * ```
115 *
116 * @resource
117 * @product Volume
118 */
119export const VolumeAttachment = Resource<VolumeAttachment>(
120 "Hetzner.VolumeAttachment",
121);
122
123class VolumeAttachmentError extends Data.TaggedError(
124 "Hetzner.VolumeAttachmentError",
125)<{
126 message: string;
127}> {}
128
129class VolumeNotFound extends Data.TaggedError("Hetzner.VolumeNotFound")<{
130 volumeId: number;
131}> {}
132
133class AttachmentPending extends Data.TaggedError("AttachmentPending")<{
134 volumeId: number;
135 serverId: number | null;
136}> {}
137
138class AttachmentTimeout extends Data.TaggedError("AttachmentTimeout")<{
139 volumeId: number;
140 serverId: number | null;
141}> {}
142
143const retryable = (e: { readonly _tag: string }): boolean =>
144 e._tag === "AttachmentPending" ||
145 e._tag === "TooManyRequests" ||
146 e._tag === "ServiceUnavailable" ||
147 e._tag === "InternalServerError" ||
148 e._tag === "BadGateway" ||
149 e._tag === "GatewayTimeout" ||
150 e._tag === "Locked";
151
152const backoff = Schedule.min([
153 Schedule.exponential(Duration.millis(500), 1.5),
154 Schedule.spaced(Duration.seconds(5)),
155]);
156
157const volumeIdOf = (value: unknown): number | undefined => {
158 if (value === null || typeof value !== "object") return undefined;
159 const rec = value as { id?: unknown; volumeId?: unknown };
160 if (typeof rec.id === "number") return rec.id;
161 if (typeof rec.volumeId === "number") return rec.volumeId;
162 return undefined;
163};
164
165const serverIdOf = (value: unknown): number | undefined => {
166 if (value === null || typeof value !== "object") return undefined;
167 const rec = value as { serverId?: unknown; id?: unknown };
168 if (typeof rec.serverId === "number") return rec.serverId;
169 if (typeof rec.id === "number") return rec.id;
170 return undefined;
171};
172
173const getById = (id: number) =>
174 Hetzner.volumes.getVolume({ id }).pipe(
175 Effect.map(({ volume }) => volume),
176 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
177 );
178
179const toAttrs = (
180 volume: GetVolumeResponseVolume,
181 automount: boolean,
182): VolumeAttachment["Attributes"] | undefined => {
183 if (volume.server === null) return undefined;
184 return {
185 volumeId: volume.id,
186 serverId: volume.server,
187 automount,
188 linuxDevice: volume.linux_device,
189 };
190};
191
192const waitUntilServer = (volumeId: number, serverId: number | null) =>
193 getById(volumeId).pipe(
194 Effect.flatMap((volume) => {
195 if (volume === undefined) {
196 return serverId === null
197 ? Effect.succeed(undefined)
198 : Effect.fail(
199 new AttachmentPending({
200 volumeId,
201 serverId: null,
202 }),
203 );
204 }
205 const attached = volume.server;
206 if (attached === serverId) return Effect.succeed(volume);
207 return Effect.fail(
208 new AttachmentPending({
209 volumeId,
210 serverId: attached,
211 }),
212 );
213 }),
214 Effect.retry({
215 while: retryable,
216 times: 10,
217 schedule: backoff,
218 }),
219 Effect.catchTag(
220 "AttachmentPending",
221 (e) =>
222 new AttachmentTimeout({
223 volumeId: e.volumeId,
224 serverId: e.serverId,
225 }),
226 ),
227 );
228
229const detach = (volumeId: number) =>
230 Hetzner.volumeActions.detachVolume({ id: volumeId }).pipe(
231 Effect.tap(({ action }) =>
232 waitForAction(action).pipe(
233 // Volume detach can outlive the action poll under load; observe
234 // the Volume's `server` field instead of failing the reconcile.
235 Effect.catchTag("ActionTimeout", () => Effect.void),
236 ),
237 ),
238 Effect.catchTag(["NotFound", "UnprocessableEntity"], () => Effect.void),
239 );
240
241const attach = (volumeId: number, serverId: number, automount: boolean) =>
242 Hetzner.volumeActions
243 .attachVolume({
244 id: volumeId,
245 server: serverId,
246 automount,
247 })
248 .pipe(
249 Effect.tap(({ action }) =>
250 waitForAction(action).pipe(
251 Effect.catchTag("ActionTimeout", () => Effect.void),
252 ),
253 ),
254 Effect.catchTag("UnprocessableEntity", () => Effect.void),
255 Effect.retry({
256 while: retryable,
257 times: 10,
258 schedule: backoff,
259 }),
260 );
261
262export const VolumeAttachmentProvider = () =>
263 Provider.succeed(VolumeAttachment, {
264 stables: ["volumeId", "serverId", "linuxDevice"],
265 nuke: { dependsOn: ["Hetzner.Volume", "Hetzner.Server"] },
266 list: Effect.fn(function* () {
267 const items = yield* Hetzner.volumes.listVolumes
268 .items({ label_selector: alchemyStackSelector, per_page: 50 })
269 .pipe(
270 Stream.runCollect,
271 Effect.map((chunk) => Array.from(chunk)),
272 );
273 return items.flatMap((volume) => {
274 if (volume.server === null) return [];
275 return [
276 {
277 volumeId: volume.id,
278 serverId: volume.server,
279 automount: false,
280 linuxDevice: volume.linux_device,
281 },
282 ];
283 });
284 }),
285 diff: Effect.fn(function* ({ news, output }) {
286 if (!isResolved(news)) return undefined;
287 const nextVolume = volumeIdOf(news.volume);
288 const nextServer = serverIdOf(news.server);
289 if (
290 output !== undefined &&
291 nextVolume !== undefined &&
292 nextServer !== undefined &&
293 (output.volumeId !== nextVolume || output.serverId !== nextServer)
294 ) {
295 return { action: "replace" } as const;
296 }
297 return undefined;
298 }),
299 read: Effect.fn(function* ({ olds, output }) {
300 const volumeId =
301 output?.volumeId ??
302 (olds !== undefined ? volumeIdOf(olds.volume) : undefined);
303 const serverId =
304 output?.serverId ??
305 (olds !== undefined ? serverIdOf(olds.server) : undefined);
306 if (volumeId === undefined || serverId === undefined) {
307 return undefined;
308 }
309 const found = yield* getById(volumeId);
310 if (found === undefined || found.server !== serverId) {
311 return undefined;
312 }
313 return toAttrs(found, output?.automount ?? olds?.automount ?? false);
314 }),
315 reconcile: Effect.fn(function* ({ news, output }) {
316 const volumeId = volumeIdOf(news.volume);
317 const serverId = serverIdOf(news.server);
318 if (volumeId === undefined || serverId === undefined) {
319 return yield* new VolumeAttachmentError({
320 message: "VolumeAttachment requires a resolved volume and server",
321 });
322 }
323 const desiredAutomount = news.automount ?? false;
324
325 // Observe — cached output.volumeId is a hint; the Volume is the
326 // source of truth for whether this attachment exists.
327 let current = yield* getById(volumeId);
328 if (current === undefined) {
329 return yield* new VolumeNotFound({ volumeId });
330 }
331
332 // Ensure — attach when missing or attached to a different Server.
333 // Already-attached (UnprocessableEntity) is a race; re-observe.
334 if (current.server !== serverId) {
335 if (current.server !== null) {
336 yield* detach(current.id);
337 current = (yield* waitUntilServer(current.id, null)) ?? current;
338 }
339 yield* attach(current.id, serverId, desiredAutomount);
340 current =
341 (yield* waitUntilServer(current.id, serverId)) ??
342 (yield* getById(volumeId));
343 if (current === undefined) {
344 return yield* new VolumeNotFound({ volumeId });
345 }
346 }
347
348 // Automount is only accepted on attach. Hetzner has no update API,
349 // and detach+reattach to flip it unmounts the disk — skip when the
350 // Volume is already on the desired Server.
351 const attrs = toAttrs(current, desiredAutomount);
352 if (attrs === undefined) {
353 return yield* new AttachmentTimeout({
354 volumeId,
355 serverId: current.server,
356 });
357 }
358 return attrs;
359 }),
360 delete: Effect.fn(function* ({ output }) {
361 const current = yield* getById(output.volumeId);
362 if (current === undefined) return;
363 if (current.server !== output.serverId) return;
364
365 yield* detach(current.id);
366 yield* waitUntilServer(current.id, null);
367 }),
368 });