1import * as Hetzner from "@distilled.cloud/hetzner";
2import type {
3 GetVolumeResponseVolume,
4 ListVolumesResponseVolumesItem,
5} from "@distilled.cloud/hetzner/volumes";
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 { waitForAction, waitForActions } from "./actions.ts";
18import {
19 alchemyStackSelector,
20 createInternalLabels,
21 diffLabels,
22 hasAlchemyLabels,
23 labelSelector,
24 stripInternalLabels,
25 toLabels,
26} from "./Labels.ts";
27import type { Providers } from "./Providers.ts";
28
29const DEFAULT_LOCATION = "nbg1";
30const MIN_SIZE_GB = 10;
31const MAX_NAME_LENGTH = 64;
32
33export type VolumeFormat = "ext4" | "xfs";
34
35export type VolumeStatus = "available" | "creating";
36
37/**
38 * A resource-valued prop: the resource itself, or an Effect that produces
39 * it (so `yield* Server(...)` and `Server(...)` both type-check).
40 */
41type Ref<T> = T | Effect.Effect<T, never, Providers>;
42
43/**
44 * Server identity a Volume can attach to at create time. Accepts a
45 * `Hetzner.Server` resource or a `{ serverId }` stub.
46 */
47export type VolumeServer = {
48 readonly serverId: number;
49};
50
51export interface VolumeProps {
52 /**
53 * Size of the Volume in GB. Minimum 10, maximum 10240. Increasing size
54 * updates in place (Hetzner cannot shrink a Volume — decreasing it
55 * replaces).
56 */
57 size: number;
58 /**
59 * Filesystem to format on create. One of `ext4` or `xfs`. Cannot be
60 * changed after creation — changing it replaces the Volume.
61 */
62 format?: VolumeFormat;
63 /**
64 * Location to create the Volume in (`nbg1`, `fsn1`, `hel1`, …). Required
65 * unless `server` is set (the Volume is then created in the Server's
66 * location). Cannot be changed after creation.
67 *
68 * @default "nbg1"
69 */
70 location?: string;
71 /**
72 * Volume name. Must be unique per project, 1–64 characters, alphanumeric
73 * with dashes/underscores/dots, starting and ending alphanumeric. If
74 * omitted, a unique name is generated from the stack, stage and logical
75 * ID.
76 */
77 name?: string;
78 /**
79 * User-defined labels. Alchemy ownership labels (`alchemy.stack` /
80 * `alchemy.stage` / `alchemy.id`) are always merged in.
81 */
82 labels?: Record<string, string>;
83 /**
84 * Server to attach the Volume to at create time. Accepts a
85 * `Hetzner.Server` or `{ serverId }`. Location may be omitted when this
86 * is set. Subsequent attach/detach is reconciled from the observed
87 * server.
88 */
89 server?: Ref<VolumeServer>;
90 /**
91 * Auto-mount the Volume after attach. Only used when `server` is set.
92 *
93 * @default false
94 */
95 automount?: boolean;
96}
97
98export type Volume = Resource<
99 "Hetzner.Volume",
100 VolumeProps,
101 {
102 /** Numeric Hetzner Volume ID. */
103 id: number;
104 /** Volume name (unique per project). */
105 name: string;
106 /** Size in GB. */
107 size: number;
108 /** Filesystem if formatted on creation. */
109 format: VolumeFormat | undefined;
110 /** Location name (`nbg1`, `fsn1`, …). */
111 location: string;
112 /** Numeric location ID. */
113 locationId: number;
114 /** Device path on the file system (e.g. `/dev/disk/by-id/scsi-…`). */
115 linuxDevice: string;
116 /** Volume status. */
117 status: VolumeStatus;
118 /** Attached Server ID, or `null` if unattached. */
119 serverId: number | null;
120 /** RFC3339 creation timestamp. */
121 created: string;
122 /** User-defined labels (Alchemy ownership labels stripped). */
123 labels: Record<string, string>;
124 },
125 never,
126 Providers
127>;
128
129/**
130 * A Hetzner Cloud Volume — a network block device that can be attached to
131 * a Server in the same Location. Unattached Volumes are valid; pass
132 * `server` to attach at create time.
133 *
134 * Size can grow in place (min 10 GB). Format and location are immutable
135 * (changing either replaces the Volume). Hetzner cannot shrink a Volume.
136 *
137 * @see https://docs.hetzner.cloud/reference/cloud#volumes
138 *
139 * ### Creating a Volume
140 * **Example:** Unattached Volume
141 * ```typescript
142 * const volume = yield* Hetzner.Volume("data", {
143 * size: 10,
144 * format: "ext4",
145 * location: "nbg1",
146 * });
147 * ```
148 *
149 * **Example:** Named Volume with labels
150 * ```typescript
151 * const volume = yield* Hetzner.Volume("data", {
152 * name: "app-data",
153 * size: 20,
154 * format: "xfs",
155 * location: "nbg1",
156 * labels: { role: "db" },
157 * });
158 * ```
159 *
160 * ### Attaching to a Server
161 * **Example:** Create-time attach
162 * ```typescript
163 * const server = yield* Hetzner.Server("web", {
164 * serverType: "cx22",
165 * image: "ubuntu-24.04",
166 * location: "nbg1",
167 * });
168 * const volume = yield* Hetzner.Volume("data", {
169 * size: 10,
170 * format: "ext4",
171 * server,
172 * automount: true,
173 * });
174 * ```
175 *
176 * @resource
177 * @product Volume
178 */
179export const Volume = Resource<Volume>("Hetzner.Volume");
180
181type CloudVolume = GetVolumeResponseVolume | ListVolumesResponseVolumesItem;
182
183class VolumePending extends Data.TaggedError("VolumePending")<{
184 volumeId: number;
185 status: string;
186}> {}
187
188class VolumeTimeout extends Data.TaggedError("VolumeTimeout")<{
189 volumeId: number;
190 status: string;
191}> {}
192
193class VolumeNotCreated extends Data.TaggedError("Hetzner.VolumeNotCreated")<{
194 name: string;
195}> {}
196
197const asFormat = (
198 format: string | null | undefined,
199): VolumeFormat | undefined =>
200 format === "ext4" || format === "xfs" ? format : undefined;
201
202const userLabels = (
203 labels: Record<string, string | undefined> | null | undefined,
204): Record<string, string> => stripInternalLabels(tagRecord(labels));
205
206const toAttrs = (volume: CloudVolume): Volume["Attributes"] => ({
207 id: volume.id,
208 name: volume.name,
209 size: volume.size,
210 format: asFormat(volume.format),
211 location: volume.location.name,
212 locationId: volume.location.id,
213 linuxDevice: volume.linux_device,
214 status: volume.status,
215 serverId: volume.server,
216 created: volume.created,
217 labels: userLabels(volume.labels),
218});
219
220const retryable = (e: { readonly _tag: string }): boolean =>
221 e._tag === "VolumePending" ||
222 e._tag === "TooManyRequests" ||
223 e._tag === "ServiceUnavailable" ||
224 e._tag === "InternalServerError" ||
225 e._tag === "BadGateway" ||
226 e._tag === "GatewayTimeout" ||
227 e._tag === "Locked";
228
229const backoff = Schedule.min([
230 Schedule.exponential(Duration.millis(500), 1.5),
231 Schedule.spaced(Duration.seconds(5)),
232]);
233
234const createVolumeName = (
235 id: string,
236 name: string | undefined,
237 existing?: string,
238) =>
239 Effect.gen(function* () {
240 return (
241 name ??
242 existing ??
243 (yield* createPhysicalName({ id, maxLength: MAX_NAME_LENGTH }))
244 );
245 });
246
247const getById = (id: number) =>
248 Hetzner.volumes.getVolume({ id }).pipe(
249 Effect.map(({ volume }) => volume),
250 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
251 );
252
253const getByName = (name: string) =>
254 Hetzner.volumes
255 .listVolumes({ name, per_page: 50 })
256 .pipe(
257 Effect.map(({ volumes }) => volumes.find((item) => item.name === name)),
258 );
259
260const getByLabels = (labels: Record<string, string>) =>
261 Hetzner.volumes
262 .listVolumes({
263 label_selector: labelSelector(labels),
264 per_page: 50,
265 })
266 .pipe(Effect.map(({ volumes }) => volumes[0]));
267
268const observe = Effect.fn(function* ({
269 id,
270 name,
271 outputId,
272}: {
273 id: string;
274 name?: string;
275 outputId?: number;
276}) {
277 if (outputId !== undefined) {
278 const byId = yield* getById(outputId);
279 if (byId !== undefined) return byId;
280 }
281 if (name !== undefined) {
282 const byName = yield* getByName(name);
283 if (byName !== undefined) return byName;
284 }
285 const internal = yield* createInternalLabels(id);
286 return yield* getByLabels(internal);
287});
288
289const waitUntilAvailable = (volumeId: number) =>
290 Hetzner.volumes.getVolume({ id: volumeId }).pipe(
291 Effect.flatMap(({ volume }) =>
292 volume.status === "available"
293 ? Effect.succeed(volume)
294 : Effect.fail(
295 new VolumePending({
296 volumeId: volume.id,
297 status: volume.status,
298 }),
299 ),
300 ),
301 Effect.retry({
302 while: retryable,
303 times: 10,
304 schedule: backoff,
305 }),
306 Effect.catchTag(
307 "VolumePending",
308 (e) =>
309 new VolumeTimeout({
310 volumeId: e.volumeId,
311 status: e.status,
312 }),
313 ),
314 );
315
316const settleAction = (action: Parameters<typeof waitForAction>[0]) =>
317 waitForAction(action).pipe(
318 Effect.catchTag("ActionTimeout", () => Effect.void),
319 );
320
321const settleActions = (actions: Parameters<typeof waitForActions>[0]) =>
322 waitForActions(actions).pipe(
323 Effect.catchTag("ActionTimeout", () => Effect.void),
324 );
325
326const waitUntilGone = (volumeId: number) =>
327 Hetzner.volumes.getVolume({ id: volumeId }).pipe(
328 Effect.map(() => false),
329 Effect.catchTag("NotFound", () => Effect.succeed(true)),
330 Effect.repeat({
331 schedule: Schedule.spaced(Duration.seconds(1)),
332 until: (gone) => gone,
333 times: 10,
334 }),
335 );
336
337const serverIdOf = (value: unknown): number | undefined => {
338 if (value == null || typeof value !== "object") return undefined;
339 const id = (value as { serverId?: unknown }).serverId;
340 return typeof id === "number" ? id : undefined;
341};
342
343export const VolumeProvider = () =>
344 Provider.succeed(Volume, {
345 stables: ["id", "linuxDevice", "location", "locationId", "created"],
346 list: Effect.fn(function* () {
347 const items = yield* Hetzner.volumes.listVolumes
348 .items({ label_selector: alchemyStackSelector, per_page: 50 })
349 .pipe(
350 Stream.runCollect,
351 Effect.map((chunk) => Array.from(chunk)),
352 );
353 return items.map(toAttrs);
354 }),
355 diff: Effect.fn(function* ({ news, output }) {
356 if (!isResolved(news)) return undefined;
357 if (output !== undefined) {
358 if (news.location !== undefined && news.location !== output.location) {
359 return { action: "replace" } as const;
360 }
361 if (news.format !== undefined && news.format !== output.format) {
362 return { action: "replace" } as const;
363 }
364 if (news.size < output.size) {
365 return { action: "replace" } as const;
366 }
367 }
368 return undefined;
369 }),
370 read: Effect.fn(function* ({ id, olds, output }) {
371 const found = yield* observe({
372 id,
373 name: olds?.name ?? output?.name,
374 outputId: output?.id,
375 });
376 if (found === undefined) return undefined;
377 const attrs = toAttrs(found);
378 const owned = yield* hasAlchemyLabels(id, tagRecord(found.labels));
379 return owned ? attrs : Unowned(attrs);
380 }),
381 reconcile: Effect.fn(function* ({ id, news, output }) {
382 const name = yield* createVolumeName(id, news.name, output?.name);
383 const internalLabels = yield* createInternalLabels(id);
384 const desiredLabels = {
385 ...toLabels(news.labels),
386 ...internalLabels,
387 };
388 const desiredServerId = serverIdOf(news.server);
389 const location =
390 news.location ??
391 (desiredServerId === undefined ? DEFAULT_LOCATION : undefined);
392
393 // Observe by id then desired name only. Do not fall back to
394 // ownership labels — a create-first replacement still has the old
395 // generation live under the same logical id.
396 let current =
397 output?.id !== undefined ? yield* getById(output.id) : undefined;
398 if (current === undefined) {
399 current = yield* getByName(name);
400 }
401
402 // Ensure — create only when missing. A Conflict is a race with a
403 // peer reconciler or a name that just became visible; re-observe.
404 if (current === undefined) {
405 const created = yield* Hetzner.volumes
406 .createVolume({
407 name,
408 size: Math.max(news.size, MIN_SIZE_GB),
409 format: news.format,
410 location,
411 labels: desiredLabels,
412 server: desiredServerId,
413 automount:
414 desiredServerId !== undefined ? news.automount : undefined,
415 })
416 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
417 if (created !== undefined) {
418 if (created.action) {
419 yield* settleActions([created.action, ...created.next_actions]);
420 }
421 current = yield* waitUntilAvailable(created.volume.id);
422 } else {
423 const hit = yield* getByName(name);
424 if (hit === undefined) {
425 return yield* new VolumeNotCreated({ name });
426 }
427 current = yield* waitUntilAvailable(hit.id);
428 }
429 }
430
431 // Sync name + labels against observed cloud labels, not olds.
432 // updateVolume overwrites the full label set.
433 const observedLabels = tagRecord(current.labels);
434 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
435 const needsMeta =
436 current.name !== name || upsert.length > 0 || removed.length > 0;
437 if (needsMeta) {
438 const updated = yield* Hetzner.volumes.updateVolume({
439 id: current.id,
440 name,
441 labels: desiredLabels,
442 });
443 current = updated.volume;
444 }
445
446 // Sync size — grow only. Shrink is a replacement (handled in diff).
447 if (news.size > current.size) {
448 const { action } = yield* Hetzner.volumeActions.resizeVolume({
449 id: current.id,
450 size: news.size,
451 });
452 yield* settleAction(action);
453 current = yield* waitUntilAvailable(current.id);
454 }
455
456 // Sync attach — create-time sugar also applies on later updates.
457 const observedServerId = current.server ?? undefined;
458 if (desiredServerId !== observedServerId) {
459 if (observedServerId !== undefined) {
460 const { action } = yield* Hetzner.volumeActions.detachVolume({
461 id: current.id,
462 });
463 yield* settleAction(action);
464 current = yield* waitUntilAvailable(current.id);
465 }
466 if (desiredServerId !== undefined) {
467 const { action } = yield* Hetzner.volumeActions.attachVolume({
468 id: current.id,
469 server: desiredServerId,
470 automount: news.automount,
471 });
472 yield* settleAction(action);
473 current = yield* waitUntilAvailable(current.id);
474 }
475 }
476
477 return toAttrs(current);
478 }),
479 delete: Effect.fn(function* ({ output }) {
480 const current = yield* getById(output.id);
481 if (current === undefined) return;
482
483 if (current.protection.delete) {
484 const { action } = yield* Hetzner.volumeActions.changeVolumeProtection({
485 id: current.id,
486 delete: false,
487 });
488 yield* settleAction(action);
489 }
490
491 const attached = current.server;
492 if (attached !== null) {
493 yield* Hetzner.volumeActions.detachVolume({ id: current.id }).pipe(
494 Effect.flatMap(({ action }) => settleAction(action)),
495 // Server delete detaches the Volume first; treat that race as
496 // already-detached.
497 Effect.catchTag(
498 ["NotFound", "UnprocessableEntity"],
499 () => Effect.void,
500 ),
501 );
502 }
503
504 yield* Hetzner.volumes.deleteVolume({ id: current.id }).pipe(
505 Effect.catchTag("NotFound", () => Effect.void),
506 Effect.retry({
507 while: (e) => retryable(e) || e._tag === "UnprocessableEntity",
508 times: 8,
509 schedule: backoff,
510 }),
511 );
512 yield* waitUntilGone(current.id);
513 }),
514 });