1import * as Hetzner from "@distilled.cloud/hetzner";
2import type {
3 GetImageResponseImage,
4 ListImagesResponseImagesItem,
5 UpdateImageResponseImage,
6} from "@distilled.cloud/hetzner/images";
7import type { CreateServerImageResponseImage } from "@distilled.cloud/hetzner/server_actions";
8import * as Data from "effect/Data";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
13import { Unowned } from "../AdoptPolicy.ts";
14import { isResolved } from "../Diff.ts";
15import { createPhysicalName } from "../PhysicalName.ts";
16import * as Provider from "../Provider.ts";
17import { Resource } from "../Resource.ts";
18import { tagRecord } from "../Tags.ts";
19import { waitForAction } from "./actions.ts";
20import {
21 alchemyLabelKeys,
22 alchemyStackSelector,
23 createInternalLabels,
24 diffLabels,
25 hasAlchemyLabels,
26 labelSelector,
27 stripInternalLabels,
28 toLabels,
29} from "./Labels.ts";
30import type { Providers } from "./Providers.ts";
31
32const MAX_DESCRIPTION_LENGTH = 64;
33
34export type ImageType = "snapshot" | "backup";
35
36export type ImageStatus = "available" | "creating" | "unavailable";
37
38export type ImageArchitecture = "x86" | "arm";
39
40export type ImageOsFlavor =
41 | "ubuntu"
42 | "centos"
43 | "debian"
44 | "fedora"
45 | "rocky"
46 | "alma"
47 | "opensuse"
48 | "unknown";
49
50/**
51 * A resource-valued prop: the resource itself, or an Effect that produces
52 * it (so `yield* Server(...)` and `Server(...)` both type-check).
53 */
54type Ref<T> = T | Effect.Effect<T, never, Providers>;
55
56/**
57 * Server identity an Image is snapshotted from. Accepts a `Hetzner.Server`
58 * resource or a `{ serverId }` stub. Stock (system/app) Images are not
59 * managed here — look those up with `Hetzner.findImage`.
60 */
61export type ImageServer = {
62 readonly serverId: number;
63};
64
65export interface ImageProps {
66 /**
67 * Server to snapshot. Required on create. Changing the source Server
68 * replaces the Image (Hetzner cannot re-snapshot onto an existing Image).
69 * Accepts a `Hetzner.Server` or `{ serverId }`.
70 */
71 server: Ref<ImageServer>;
72 /**
73 * Human-readable description. Snapshots have no unique `name` (that
74 * field is only set on system Images). If omitted, a unique description
75 * is generated from the stack, stage and logical ID.
76 */
77 description?: string;
78 /**
79 * Image type. Snapshots are independent of the source Server and billed
80 * per GB. Backups are bound to the Server (and deleted with it) and
81 * require backups to be enabled. Convert a backup to a snapshot in
82 * place; snapshot → backup replaces.
83 *
84 * @default "snapshot"
85 */
86 type?: ImageType;
87 /**
88 * User-defined labels. Alchemy ownership labels (`alchemy.stack` /
89 * `alchemy.stage` / `alchemy.id`) are always merged in.
90 */
91 labels?: Record<string, string>;
92 /**
93 * Prevent the Image from being deleted via the API. Only valid on
94 * snapshots.
95 *
96 * @default false
97 */
98 deleteProtection?: boolean;
99}
100
101export type Image = Resource<
102 "Hetzner.Image",
103 ImageProps,
104 {
105 /** Numeric Hetzner Image ID. */
106 id: number;
107 /** Image type (`snapshot` or `backup`). */
108 type: ImageType;
109 /** Image status. */
110 status: ImageStatus;
111 /**
112 * Unique identifier. Only set for system Images — snapshots and
113 * backups are `null`.
114 */
115 name: string | null;
116 /** Human-readable description. */
117 description: string;
118 /**
119 * Size of the Image file in Hetzner storage in GB. Relevant for
120 * snapshot billing. `null` while the Image is still creating.
121 */
122 imageSize: number | null;
123 /** Size of the disk contained in the Image in GB. */
124 diskSize: number;
125 /** RFC3339 creation timestamp. */
126 created: string;
127 /** ID of the Server this Image was created from, or `null`. */
128 createdFromId: number | null;
129 /** Server name at snapshot time, or `null`. */
130 createdFromName: string | null;
131 /**
132 * Server ID this Image is bound to. Only set for `backup` Images.
133 */
134 boundTo: number | null;
135 /** Flavor of operating system contained in the Image. */
136 osFlavor: ImageOsFlavor;
137 /** Operating system version, or `null`. */
138 osVersion: string | null;
139 /** Whether rapid deploy of the Image is available. */
140 rapidDeploy: boolean | undefined;
141 /** Whether delete protection is enabled. */
142 deleteProtection: boolean;
143 /** RFC3339 deprecation timestamp, or `null`. */
144 deprecated: string | null;
145 /** CPU architecture compatible with the Image. */
146 architecture: ImageArchitecture;
147 /** User-defined labels (Alchemy ownership labels stripped). */
148 labels: Record<string, string>;
149 },
150 never,
151 Providers
152>;
153
154/**
155 * A Hetzner Cloud custom Image — a snapshot (or backup) of a Server's
156 * disk. Stock system/app Images (`ubuntu-24.04`, …) are Catalog lookups
157 * via `Hetzner.findImage`, not this resource.
158 *
159 * Snapshots are created with `POST /servers/{id}/actions/create_image`
160 * and billed per GB. Description, labels, delete protection, and
161 * backup→snapshot conversion update in place. Changing the source Server
162 * replaces the Image.
163 *
164 * @see https://docs.hetzner.cloud/reference/cloud#images
165 *
166 * ### Creating a Snapshot
167 * **Example:** Snapshot from a Server
168 * ```typescript
169 * const server = yield* Hetzner.Server("web", {
170 * serverType: "cx22",
171 * image: "ubuntu-24.04",
172 * location: "nbg1",
173 * });
174 * const image = yield* Hetzner.Image("golden", {
175 * server,
176 * description: "golden-web",
177 * labels: { role: "golden" },
178 * });
179 * ```
180 *
181 * **Example:** Snapshot with generated description
182 * ```typescript
183 * const image = yield* Hetzner.Image("backup", {
184 * server: { serverId: 42 },
185 * });
186 * ```
187 *
188 * ### Updating a Snapshot
189 * **Example:** Description, labels, and protection
190 * ```typescript
191 * const image = yield* Hetzner.Image("golden", {
192 * server,
193 * description: "golden-web-v2",
194 * labels: { role: "golden", env: "prod" },
195 * deleteProtection: true,
196 * });
197 * ```
198 *
199 * @resource
200 * @product Server
201 */
202export const Image = Resource<Image>("Hetzner.Image");
203
204type CloudImage =
205 | GetImageResponseImage
206 | ListImagesResponseImagesItem
207 | CreateServerImageResponseImage
208 | UpdateImageResponseImage;
209
210class ImagePending extends Data.TaggedError("ImagePending")<{
211 imageId: number;
212 status: string;
213}> {}
214
215class ImageTimeout extends Data.TaggedError("ImageTimeout")<{
216 imageId: number;
217 status: string;
218}> {}
219
220class ImageNotResolved extends Data.TaggedError("Hetzner.ImageNotResolved")<{
221 serverId: number;
222 description: string;
223}> {}
224
225class ImageServerRequired extends Data.TaggedError(
226 "Hetzner.ImageServerRequired",
227)<{
228 description: string;
229}> {}
230
231const DEFAULT_TYPE: ImageType = "snapshot";
232
233const asType = (type: string): ImageType =>
234 type === "backup" ? "backup" : "snapshot";
235
236const asStatus = (status: string): ImageStatus =>
237 status === "creating" || status === "unavailable" ? status : "available";
238
239const asArchitecture = (architecture: string): ImageArchitecture =>
240 architecture === "arm" ? "arm" : "x86";
241
242const asOsFlavor = (flavor: string): ImageOsFlavor => {
243 switch (flavor) {
244 case "ubuntu":
245 case "centos":
246 case "debian":
247 case "fedora":
248 case "rocky":
249 case "alma":
250 case "opensuse":
251 case "unknown":
252 return flavor;
253 default:
254 return "unknown";
255 }
256};
257
258const userLabels = (
259 labels: Record<string, string | undefined> | null | undefined,
260): Record<string, string> => stripInternalLabels(tagRecord(labels));
261
262const toAttrs = (image: CloudImage): Image["Attributes"] => ({
263 id: image.id,
264 type: asType(image.type),
265 status: asStatus(image.status),
266 name: image.name,
267 description: image.description,
268 imageSize: image.image_size,
269 diskSize: image.disk_size,
270 created: image.created,
271 createdFromId: image.created_from?.id ?? null,
272 createdFromName: image.created_from?.name ?? null,
273 boundTo: image.bound_to,
274 osFlavor: asOsFlavor(image.os_flavor),
275 osVersion: image.os_version,
276 rapidDeploy: image.rapid_deploy,
277 deleteProtection: image.protection.delete,
278 deprecated: image.deprecated,
279 architecture: asArchitecture(image.architecture),
280 labels: userLabels(image.labels),
281});
282
283const retryable = (e: { readonly _tag: string }): boolean =>
284 e._tag === "ImagePending" ||
285 e._tag === "TooManyRequests" ||
286 e._tag === "ServiceUnavailable" ||
287 e._tag === "InternalServerError" ||
288 e._tag === "BadGateway" ||
289 e._tag === "GatewayTimeout" ||
290 e._tag === "Locked";
291
292const backoff = Schedule.min([
293 Schedule.exponential(Duration.millis(500), 1.5),
294 Schedule.spaced(Duration.seconds(5)),
295]);
296
297const toDescription = (
298 id: string,
299 description: string | undefined,
300 existing?: string,
301) =>
302 Effect.gen(function* () {
303 return (
304 description ??
305 existing ??
306 (yield* createPhysicalName({ id, maxLength: MAX_DESCRIPTION_LENGTH }))
307 );
308 });
309
310const getById = (id: number) =>
311 Hetzner.images.getImage({ id }).pipe(
312 Effect.map(({ image }) => image),
313 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
314 );
315
316const getByLabels = (labels: Record<string, string>) =>
317 Hetzner.images
318 .listImages({
319 type: ["snapshot", "backup"],
320 label_selector: labelSelector(labels),
321 per_page: 50,
322 })
323 .pipe(Effect.map(({ images }) => images[0]));
324
325const observe = Effect.fn(function* ({
326 id,
327 outputId,
328}: {
329 id: string;
330 outputId?: number;
331}) {
332 if (outputId !== undefined) {
333 const byId = yield* getById(outputId);
334 if (byId !== undefined) return byId;
335 }
336 const internal = yield* createInternalLabels(id);
337 return yield* getByLabels(internal);
338});
339
340const waitUntilAvailable = (imageId: number) =>
341 getById(imageId).pipe(
342 Effect.flatMap((image) =>
343 image !== undefined && image.status === "available"
344 ? Effect.succeed(image)
345 : Effect.fail(
346 new ImagePending({
347 imageId,
348 status: image?.status ?? "missing",
349 }),
350 ),
351 ),
352 Effect.retry({
353 while: retryable,
354 times: 10,
355 schedule: Schedule.spaced(Duration.seconds(5)),
356 }),
357 Effect.catchTag(
358 "ImagePending",
359 (e) =>
360 new ImageTimeout({
361 imageId: e.imageId,
362 status: e.status,
363 }),
364 ),
365 );
366
367const waitUntilGone = (imageId: number) =>
368 Hetzner.images.getImage({ id: imageId }).pipe(
369 Effect.map(() => false),
370 Effect.catchTag("NotFound", () => Effect.succeed(true)),
371 Effect.repeat({
372 schedule: Schedule.spaced(Duration.seconds(1)),
373 until: (gone) => gone,
374 times: 10,
375 }),
376 );
377
378const serverIdOf = (value: unknown): number | undefined => {
379 if (value == null || typeof value !== "object") return undefined;
380 const id = (value as { serverId?: unknown }).serverId;
381 return typeof id === "number" ? id : undefined;
382};
383
384const disableProtection = (id: number) =>
385 Hetzner.imageActions
386 .changeImageProtection({ id, delete: false })
387 .pipe(Effect.flatMap(({ action }) => waitForAction(action)));
388
389export const ImageProvider = () =>
390 Provider.succeed(Image, {
391 stables: [
392 "id",
393 "created",
394 "createdFromId",
395 "architecture",
396 "diskSize",
397 "osFlavor",
398 ],
399 list: Effect.fn(function* () {
400 const items = yield* Hetzner.images.listImages
401 .items({
402 type: ["snapshot", "backup"],
403 label_selector: alchemyStackSelector,
404 per_page: 50,
405 })
406 .pipe(
407 Stream.runCollect,
408 Effect.map((chunk) => Array.from(chunk)),
409 );
410 return items.map(toAttrs);
411 }),
412 diff: Effect.fn(function* ({ news, output }) {
413 if (!isResolved(news)) return undefined;
414 if (output !== undefined) {
415 const nextServerId = serverIdOf(news.server);
416 if (
417 nextServerId !== undefined &&
418 output.createdFromId !== null &&
419 nextServerId !== output.createdFromId
420 ) {
421 return { action: "replace" } as const;
422 }
423 const nextType = news.type ?? DEFAULT_TYPE;
424 if (nextType === "backup" && output.type === "snapshot") {
425 return { action: "replace" } as const;
426 }
427 }
428 return undefined;
429 }),
430 read: Effect.fn(function* ({ id, output }) {
431 const found = yield* observe({
432 id,
433 outputId: output?.id,
434 });
435 if (found === undefined) return undefined;
436 const attrs = toAttrs(found);
437 const owned = yield* hasAlchemyLabels(id, tagRecord(found.labels));
438 return owned ? attrs : Unowned(attrs);
439 }),
440 reconcile: Effect.fn(function* ({ id, news, output }) {
441 const description = yield* toDescription(
442 id,
443 news.description,
444 output?.description,
445 );
446 const internalLabels = yield* createInternalLabels(id);
447 const desiredLabels = {
448 ...toLabels(news.labels),
449 ...internalLabels,
450 };
451 const desiredType = news.type ?? DEFAULT_TYPE;
452 const desiredProtection = news.deleteProtection ?? false;
453 const desiredServerId = serverIdOf(news.server);
454
455 // Observe by id only. Do not fall back to ownership labels — a
456 // create-first replacement still has the old generation live under
457 // the same logical id, and snapshots are not uniquely named.
458 let current =
459 output?.id !== undefined ? yield* getById(output.id) : undefined;
460
461 if (current === undefined) {
462 if (desiredServerId === undefined) {
463 return yield* new ImageServerRequired({ description });
464 }
465 const created = yield* Hetzner.serverActions.createServerImage({
466 id: desiredServerId,
467 description,
468 type: desiredType,
469 labels: desiredLabels,
470 });
471 const imageId =
472 created.image?.id ??
473 created.action?.resources.find(
474 (resource) => resource.type === "image",
475 )?.id;
476 if (imageId === undefined) {
477 return yield* new ImageNotResolved({
478 serverId: desiredServerId,
479 description,
480 });
481 }
482 if (created.action) {
483 yield* waitForAction(created.action).pipe(
484 Effect.catchTag("ActionTimeout", () => Effect.void),
485 );
486 }
487 current = yield* waitUntilAvailable(imageId);
488 }
489
490 const observedLabels = tagRecord(current.labels);
491 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
492 const labelsChanged = upsert.length > 0 || removed.length > 0;
493 const convertToSnapshot =
494 current.type === "backup" && desiredType === "snapshot";
495 const descriptionChanged = current.description !== description;
496 if (descriptionChanged || labelsChanged || convertToSnapshot) {
497 const updated = yield* Hetzner.images.updateImage({
498 id: current.id,
499 description: descriptionChanged ? description : undefined,
500 type: convertToSnapshot ? "snapshot" : undefined,
501 labels: labelsChanged ? desiredLabels : undefined,
502 });
503 current = updated.image ?? (yield* waitUntilAvailable(current.id));
504 }
505
506 if (current.protection.delete !== desiredProtection) {
507 const { action } = yield* Hetzner.imageActions.changeImageProtection({
508 id: current.id,
509 delete: desiredProtection,
510 });
511 yield* waitForAction(action);
512 current = (yield* getById(current.id)) ?? current;
513 }
514
515 return toAttrs(current);
516 }),
517 delete: Effect.fn(function* ({ output }) {
518 const current = yield* getById(output.id);
519 if (current === undefined) return;
520
521 if (current.protection.delete) {
522 yield* disableProtection(current.id);
523 }
524
525 yield* Hetzner.images.deleteImage({ id: current.id }).pipe(
526 Effect.catchTag("NotFound", () => Effect.void),
527 Effect.retry({
528 while: retryable,
529 times: 8,
530 schedule: backoff,
531 }),
532 );
533 yield* waitUntilGone(current.id);
534 }),
535 });