1import { generateKeyPairSync } from "node:crypto";
2import * as Hetzner from "@distilled.cloud/hetzner";
3import type {
4 GetServerResponseServer,
5 ListServersResponseServersItem,
6} from "@distilled.cloud/hetzner/servers";
7import * as Data from "effect/Data";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Redacted from "effect/Redacted";
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, waitForActions } from "./actions.ts";
20import {
21 alchemyStackSelector,
22 createInternalLabels,
23 diffLabels,
24 hasAlchemyLabels,
25 stripInternalLabels,
26 toLabels,
27} from "./Labels.ts";
28import type { Providers } from "./Providers.ts";
29
30const DEFAULT_LOCATION = "nbg1";
31const MAX_NAME_LENGTH = 63;
32
33/**
34 * A resource-valued prop: the resource itself, or an Effect that produces
35 * it (so `yield* SshKey(...)` and `SshKey(...)` both type-check).
36 */
37type Ref<T> = T | Effect.Effect<T, never, Providers>;
38
39/**
40 * SSH key identity injected at create time. Accepts a `Hetzner.SshKey`
41 * resource or a `{ id }` stub.
42 */
43export type ServerSshKey = {
44 readonly id: number;
45};
46
47/**
48 * Private Network identity. Accepts a `Hetzner.Network` resource or a
49 * `{ networkId }` stub.
50 */
51export type ServerNetwork = {
52 readonly networkId: number;
53};
54
55/**
56 * Firewall identity applied to the public NIC. Accepts a
57 * `Hetzner.Firewall` resource or a `{ id }` stub.
58 */
59export type ServerFirewall = {
60 readonly id: number;
61};
62
63/**
64 * Volume identity attached at create/sync time. Accepts a
65 * `Hetzner.Volume` resource or a `{ id }` stub.
66 */
67export type ServerVolume = {
68 readonly id: number;
69};
70
71/**
72 * Placement Group identity. Accepts a `Hetzner.PlacementGroup` resource
73 * or a `{ id }` stub.
74 */
75export type ServerPlacementGroup = {
76 readonly id: number;
77};
78
79export type ServerStatus =
80 | "running"
81 | "initializing"
82 | "starting"
83 | "stopping"
84 | "off"
85 | "deleting"
86 | "migrating"
87 | "rebuilding"
88 | "unknown";
89
90export interface ServerProps {
91 /**
92 * Server name. Must be unique per project and a valid RFC 1123
93 * hostname (letters, digits, periods, dashes). If omitted, a unique
94 * name is generated from the stack, stage and logical ID.
95 */
96 name?: string;
97 /**
98 * Server type name (`cpx12`, `cx23`, …) or numeric id. Cannot be
99 * changed after creation — changing it replaces the Server.
100 */
101 serverType: string;
102 /**
103 * Image name (`ubuntu-24.04`, …) or numeric id the Server is created
104 * from. Cannot be changed after creation — changing it replaces the
105 * Server.
106 */
107 image: string;
108 /**
109 * Location name (`nbg1`, `fsn1`, `hel1`, …) or numeric id. Cannot be
110 * changed after creation — changing it replaces the Server.
111 *
112 * @default "nbg1"
113 */
114 location?: string;
115 /**
116 * SSH keys injected at create time. Accepts `Hetzner.SshKey` resources
117 * or `{ id }` stubs. Hetzner cannot change injected keys after
118 * create — this list is applied only when the Server is provisioned.
119 */
120 sshKeys?: Array<Ref<ServerSshKey>>;
121 /**
122 * Private Networks to attach. Accepts `Hetzner.Network` resources or
123 * `{ networkId }` stubs. Synced on update when set.
124 */
125 networks?: Array<Ref<ServerNetwork>>;
126 /**
127 * Firewalls applied to the public NIC. Accepts `Hetzner.Firewall`
128 * resources or `{ id }` stubs. Synced on update when set.
129 */
130 firewalls?: Array<Ref<ServerFirewall>>;
131 /**
132 * Volumes to attach. Accepts `Hetzner.Volume` resources or `{ id }`
133 * stubs. Synced on update when set. Omit to leave Volume-side attach
134 * alone.
135 */
136 volumes?: Array<Ref<ServerVolume>>;
137 /**
138 * Placement Group to assign. Accepts a `Hetzner.PlacementGroup` or
139 * `{ id }`. Synced on update when set.
140 */
141 placementGroup?: Ref<ServerPlacementGroup>;
142 /**
143 * Cloud-init user data run on the Server's **first boot** — the place to
144 * install packages, write config files, or add users.
145 *
146 * Accepts a shell script (`#!/bin/bash …`), a `#cloud-config` document,
147 * or a bare shell snippet (a `#!/bin/bash` shebang is added for you).
148 * Alchemy combines it with its own bootstrap script (which preinstalls
149 * Node 26 for `Hetzner.Service`) into a multipart cloud-init document, so
150 * both run — the bootstrap first. A document that already starts with a
151 * `Content-Type:` / `MIME-Version:` header is passed through untouched,
152 * taking over the whole payload including the bootstrap.
153 *
154 * Capped at 32 KiB (Hetzner's limit) after composition.
155 *
156 * Cloud-init only runs once, on first boot, so changing this replaces
157 * the Server.
158 */
159 userData?: string;
160 /**
161 * Attach a public IPv4. Applied only at create time.
162 *
163 * @default true
164 */
165 enableIpv4?: boolean;
166 /**
167 * Attach a public IPv6. Applied only at create time.
168 *
169 * @default true
170 */
171 enableIpv6?: boolean;
172 /**
173 * Power the Server on after create.
174 *
175 * @default true
176 */
177 startAfterCreate?: boolean;
178 /**
179 * Prevent the Server from being deleted or rebuilt via the API until
180 * this is cleared. The provider disables protection before delete.
181 *
182 * @default false
183 */
184 deleteProtection?: boolean;
185 /**
186 * User-defined labels. Alchemy ownership labels (`alchemy.stack` /
187 * `alchemy.stage` / `alchemy.id`) are always merged in.
188 */
189 labels?: Record<string, string>;
190}
191
192export type Server = Resource<
193 "Hetzner.Server",
194 ServerProps,
195 {
196 /** Numeric Hetzner Server ID. */
197 id: number;
198 /**
199 * Same as {@link id}. Present so Volume / Firewall `server` /
200 * `applyTo` props accept this resource directly.
201 */
202 serverId: number;
203 /** Server name (unique per project). */
204 name: string;
205 /** Server status. */
206 status: ServerStatus;
207 /** Server type name (`cx22`, …). */
208 serverType: string;
209 /** Numeric Server type id. */
210 serverTypeId: number;
211 /** Image name, or `undefined` for snapshot/backup images without a name. */
212 image: string | undefined;
213 /** Numeric Image id, or `undefined` if no image is attached. */
214 imageId: number | undefined;
215 /** Location name (`nbg1`, `fsn1`, …). */
216 location: string;
217 /** Numeric location ID. */
218 locationId: number;
219 /** Public IPv4 address, or `undefined` if IPv4 is disabled. */
220 ipv4: string | undefined;
221 /** Public IPv6 network (`…/64`), or `undefined` if IPv6 is disabled. */
222 ipv6: string | undefined;
223 /** Attached Volume IDs. */
224 volumeIds: number[];
225 /** Attached private Network IDs. */
226 networkIds: number[];
227 /** Firewall IDs applied to the public NIC. */
228 firewallIds: number[];
229 /** Placement Group ID, or `undefined` if unassigned. */
230 placementGroupId: number | undefined;
231 /** Whether delete/rebuild protection is enabled. */
232 deleteProtection: boolean;
233 /** RFC3339 creation timestamp. */
234 created: string;
235 /** User-defined labels (Alchemy ownership labels stripped). */
236 labels: Record<string, string>;
237 /**
238 * Alchemy-managed deploy SSH private key (PKCS8 PEM). Injected at
239 * create via a companion SSH key so `Hetzner.Ssh` / `Hetzner.Service`
240 * can reach the box. Persisted in state; not present on adopted
241 * foreign servers.
242 */
243 privateKey?: Redacted.Redacted<string>;
244 /** Numeric id of the Alchemy-managed deploy SSH key. */
245 deploySshKeyId?: number;
246 },
247 {
248 /** Environment variables collected from bindings. */
249 env?: Record<string, any>;
250 /**
251 * Volumes to attach and mount. Collected from `Hetzner.MountVolume`
252 * when the Server is the bind host.
253 */
254 volumes?: Array<{
255 volumeId: number;
256 path: string;
257 }>;
258 },
259 Providers
260>;
261
262/**
263 * A Hetzner Cloud Server — a virtual machine in a Location, created from
264 * a Server type and an Image. `serverType`, `image`, and `location` are
265 * immutable (changing any of them replaces the Server). Name, labels,
266 * delete protection, and (when set) Networks / Firewalls / Volumes /
267 * Placement Group update in place. SSH keys are injected only at create.
268 *
269 * @see https://docs.hetzner.cloud/reference/cloud#servers
270 *
271 * ### Creating a Server
272 * **Example:** Basic Server
273 * ```typescript
274 * const server = yield* Hetzner.Server("web", {
275 * serverType: "cpx12",
276 * image: "ubuntu-24.04",
277 * location: "nbg1",
278 * });
279 * ```
280 *
281 * **Example:** Named Server with labels
282 * ```typescript
283 * const server = yield* Hetzner.Server("web", {
284 * name: "app-web",
285 * serverType: "cpx12",
286 * image: "ubuntu-24.04",
287 * location: "nbg1",
288 * labels: { role: "web" },
289 * });
290 * ```
291 *
292 * ### Attachments
293 * **Example:** SSH key and Placement Group
294 * ```typescript
295 * const key = yield* Hetzner.SshKey("deploy", {
296 * publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… user@host",
297 * });
298 * const group = yield* Hetzner.PlacementGroup("web");
299 * const server = yield* Hetzner.Server("web", {
300 * serverType: "cpx12",
301 * image: "ubuntu-24.04",
302 * location: "nbg1",
303 * sshKeys: [key],
304 * placementGroup: group,
305 * });
306 * ```
307 *
308 * ### Custom init script
309 * **Example:** Install packages on first boot
310 * ```typescript
311 * const server = yield* Hetzner.Server("web", {
312 * serverType: "cpx12",
313 * image: "ubuntu-24.04",
314 * userData: `#!/bin/bash
315 * apt-get update
316 * DEBIAN_FRONTEND=noninteractive apt-get install -y nginx
317 * systemctl enable --now nginx`,
318 * });
319 * ```
320 *
321 * **Example:** cloud-config document
322 * ```typescript
323 * const server = yield* Hetzner.Server("web", {
324 * serverType: "cpx12",
325 * image: "ubuntu-24.04",
326 * userData: `#cloud-config
327 * packages:
328 * - nginx
329 * - postgresql-client`,
330 * });
331 * ```
332 *
333 * `userData` is merged with Alchemy's own bootstrap script into a
334 * multipart cloud-init document, so both run. It is applied on first boot
335 * only — changing it replaces the Server.
336 *
337 * @resource
338 * @product Server
339 */
340export const Server = Resource<Server>("Hetzner.Server");
341
342export class ServerNotResolved extends Data.TaggedError(
343 "Hetzner.ServerNotResolved",
344)<{
345 name: string;
346}> {}
347
348/**
349 * The composed cloud-init document (Alchemy's bootstrap plus the Server's
350 * `userData`) exceeds Hetzner's 32 KiB user-data limit.
351 */
352export class ServerUserDataTooLarge extends Data.TaggedError(
353 "Hetzner.ServerUserDataTooLarge",
354)<{
355 name: string;
356 bytes: number;
357 limit: number;
358}> {}
359
360type CloudServer = GetServerResponseServer | ListServersResponseServersItem;
361
362class ServerPending extends Data.TaggedError("ServerPending")<{
363 serverId: number;
364 status: string;
365}> {}
366
367class ServerTimeout extends Data.TaggedError("ServerTimeout")<{
368 serverId: number;
369 status: string;
370}> {}
371
372const asStatus = (status: string): ServerStatus => {
373 switch (status) {
374 case "running":
375 case "initializing":
376 case "starting":
377 case "stopping":
378 case "off":
379 case "deleting":
380 case "migrating":
381 case "rebuilding":
382 case "unknown":
383 return status;
384 default:
385 return "unknown";
386 }
387};
388
389const userLabels = (
390 labels: Record<string, string | undefined> | null | undefined,
391): Record<string, string> => stripInternalLabels(tagRecord(labels));
392
393const toAttrs = (server: CloudServer): Server["Attributes"] => ({
394 id: server.id,
395 serverId: server.id,
396 name: server.name,
397 status: asStatus(server.status),
398 serverType: server.server_type.name,
399 serverTypeId: server.server_type.id,
400 image: server.image?.name ?? undefined,
401 imageId: server.image?.id,
402 location: server.location.name,
403 locationId: server.location.id,
404 ipv4: server.public_net.ipv4?.ip,
405 ipv6: server.public_net.ipv6?.ip,
406 volumeIds: [...(server.volumes ?? [])],
407 networkIds: server.private_net
408 .map((item) => item.network)
409 .filter((id): id is number => typeof id === "number"),
410 firewallIds: (server.public_net.firewalls ?? [])
411 .map((item) => item.id)
412 .filter((id): id is number => typeof id === "number"),
413 placementGroupId: server.placement_group?.id,
414 deleteProtection: server.protection.delete,
415 created: server.created,
416 labels: userLabels(server.labels),
417});
418
419const retryable = (e: { readonly _tag: string }): boolean =>
420 e._tag === "ServerPending" ||
421 e._tag === "TooManyRequests" ||
422 e._tag === "ServiceUnavailable" ||
423 e._tag === "InternalServerError" ||
424 e._tag === "BadGateway" ||
425 e._tag === "GatewayTimeout" ||
426 e._tag === "Locked";
427
428const backoff = Schedule.min([
429 Schedule.exponential(Duration.millis(500), 1.5),
430 Schedule.spaced(Duration.seconds(5)),
431]);
432
433/** Companion deploy keys are not stack resources — delete must be idempotent. */
434const deleteDeployKey = (id: number | undefined) =>
435 id === undefined
436 ? Effect.void
437 : Hetzner.sshKeys.deleteSshKey({ id }).pipe(
438 Effect.retry({
439 while: (e) =>
440 retryable(e) ||
441 e._tag === "UnprocessableEntity" ||
442 e._tag === "Conflict",
443 times: 8,
444 schedule: backoff,
445 }),
446 Effect.catchTag("NotFound", () => Effect.void),
447 );
448
449const createServerName = (
450 id: string,
451 name: string | undefined,
452 existing?: string,
453) =>
454 Effect.gen(function* () {
455 return (
456 name ??
457 existing ??
458 (yield* createPhysicalName({
459 id,
460 maxLength: MAX_NAME_LENGTH,
461 lowercase: true,
462 }))
463 );
464 });
465
466/**
467 * Preinstall Node 26 so `Hetzner.Service`'s first deploy does not have to.
468 * Mirrors the SSH-side install in `./hosted.ts`. Never fails the boot:
469 * `hosted.ts` installs Node over SSH if this did not manage to.
470 */
471const ALCHEMY_BOOTSTRAP = `#!/bin/bash
472set -uo pipefail
473export HOME=/root
474if ! command -v curl >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then
475 apt-get update || true
476 DEBIAN_FRONTEND=noninteractive apt-get install -y curl unzip ca-certificates || true
477fi
478if ! command -v node >/dev/null 2>&1; then
479 for attempt in 1 2 3; do
480 curl -fsSL https://deb.nodesource.com/setup_26.x | bash - && DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs && break
481 sleep 5
482 done
483fi
484exit 0
485`;
486
487/** Hetzner rejects user data larger than 32 KiB. */
488const MAX_USER_DATA_BYTES = 32 * 1024;
489
490const MIME_BOUNDARY = "==ALCHEMY==";
491
492/**
493 * Cloud-init dispatches on a document's first line. Anything unrecognized
494 * is treated as a shell script.
495 */
496const CLOUD_INIT_FORMATS: ReadonlyArray<readonly [string, string]> = [
497 ["#cloud-config", "text/cloud-config"],
498 ["#cloud-boothook", "text/cloud-boothook"],
499 ["#include", "text/x-include-url"],
500 ["#upstart-job", "text/upstart-job"],
501 ["#part-handler", "text/part-handler"],
502 ["#!", "text/x-shellscript"],
503];
504
505type UserDataPart = {
506 readonly contentType: string;
507 readonly body: string;
508};
509
510const classifyUserData = (doc: string): UserDataPart => {
511 const first = doc.split("\n", 1)[0]?.trim() ?? "";
512 for (const [prefix, contentType] of CLOUD_INIT_FORMATS) {
513 if (first.startsWith(prefix)) return { contentType, body: doc };
514 }
515 // A bare snippet (`apt-get install -y nginx`) is the common case —
516 // cloud-init needs a shebang before it will execute one.
517 return { contentType: "text/x-shellscript", body: `#!/bin/bash\n${doc}` };
518};
519
520/**
521 * Wrap the parts in a multipart cloud-init document. Parts are named
522 * `part-001`, `part-002`, … the way cloud-init names anonymous parts
523 * itself — scripts run in that (lexical) order, so the bootstrap goes
524 * first.
525 */
526const mimeMultipart = (parts: ReadonlyArray<UserDataPart>) =>
527 [
528 `Content-Type: multipart/mixed; boundary="${MIME_BOUNDARY}"`,
529 "MIME-Version: 1.0",
530 "",
531 ...parts.flatMap((part, index) => [
532 `--${MIME_BOUNDARY}`,
533 `Content-Type: ${part.contentType}; charset="utf-8"`,
534 "MIME-Version: 1.0",
535 `Content-Disposition: attachment; filename="part-${String(index + 1).padStart(3, "0")}"`,
536 "",
537 part.body.replace(/\n+$/, ""),
538 ]),
539 `--${MIME_BOUNDARY}--`,
540 "",
541 ].join("\n");
542
543/**
544 * Compose the cloud-init document sent at create: Alchemy's bootstrap
545 * script plus the caller's `userData`, if any.
546 */
547export const composeUserData = (userData: string | undefined): string => {
548 if (userData === undefined) return ALCHEMY_BOOTSTRAP;
549 const doc = userData.replace(/^\uFEFF/, "").replace(/^[\s\n]+/, "");
550 if (doc === "") return ALCHEMY_BOOTSTRAP;
551 // An explicit MIME document is passed through verbatim — the caller owns
552 // the whole payload, bootstrap included.
553 if (/^(content-type|mime-version):/i.test(doc)) return userData;
554 return mimeMultipart([
555 { contentType: "text/x-shellscript", body: ALCHEMY_BOOTSTRAP },
556 classifyUserData(doc),
557 ]);
558};
559
560const buildUserData = Effect.fn(function* (input: {
561 name: string;
562 userData: string | undefined;
563}) {
564 const doc = yield* Effect.sync(() => composeUserData(input.userData));
565 const bytes = yield* Effect.sync(() => Buffer.byteLength(doc, "utf8"));
566 if (bytes > MAX_USER_DATA_BYTES) {
567 return yield* new ServerUserDataTooLarge({
568 name: input.name,
569 bytes,
570 limit: MAX_USER_DATA_BYTES,
571 });
572 }
573 return doc;
574});
575
576const u32be = (n: number) => {
577 const buf = Buffer.alloc(4);
578 buf.writeUInt32BE(n);
579 return buf;
580};
581
582const sshString = (data: Buffer | string) => {
583 const buf = typeof data === "string" ? Buffer.from(data) : data;
584 return Buffer.concat([u32be(buf.length), buf]);
585};
586
587/**
588 * Encode an ed25519 keypair as OpenSSH public + private key files.
589 * `ssh -i` on macOS rejects PKCS8 ed25519 PEMs with exit 255.
590 */
591const encodeOpenSshEd25519 = (
592 publicRaw: Buffer,
593 seed: Buffer,
594 comment: string,
595) => {
596 const algo = Buffer.from("ssh-ed25519");
597 const pubBlob = Buffer.concat([sshString(algo), sshString(publicRaw)]);
598 const publicKey = `ssh-ed25519 ${pubBlob.toString("base64")} ${comment}`;
599
600 const check = Buffer.alloc(4);
601 check.writeUInt32BE(0xa1b2c3d4);
602 const secret = Buffer.concat([seed, publicRaw]);
603 let inner = Buffer.concat([
604 check,
605 check,
606 sshString(algo),
607 sshString(publicRaw),
608 sshString(secret),
609 sshString(comment),
610 ]);
611 const padLen = (8 - (inner.length % 8)) % 8;
612 const pad = Buffer.alloc(padLen);
613 for (let i = 0; i < padLen; i++) pad[i] = i + 1;
614 inner = Buffer.concat([inner, pad]);
615
616 const body = Buffer.concat([
617 Buffer.from("openssh-key-v1\0"),
618 sshString("none"),
619 sshString("none"),
620 sshString(""),
621 u32be(1),
622 sshString(pubBlob),
623 sshString(inner),
624 ]);
625 const b64 = body.toString("base64");
626 const wrapped = b64.match(/.{1,70}/g)?.join("\n") ?? b64;
627 const privateKey = `-----BEGIN OPENSSH PRIVATE KEY-----\n${wrapped}\n-----END OPENSSH PRIVATE KEY-----\n`;
628 return { publicKey, privateKey };
629};
630
631const generateDeployKey = Effect.sync(() => {
632 const { publicKey, privateKey } = generateKeyPairSync("ed25519");
633 const spki = publicKey.export({ type: "spki", format: "der" });
634 const pkcs8 = privateKey.export({ type: "pkcs8", format: "der" });
635 const publicRaw = spki.subarray(spki.length - 32);
636 const seed = pkcs8.subarray(pkcs8.length - 32);
637 return encodeOpenSshEd25519(publicRaw, seed, "alchemy-deploy");
638});
639
640const unwrapPrivateKey = (
641 value: Redacted.Redacted<string> | string | undefined,
642): string | undefined => {
643 if (value === undefined) return undefined;
644 return typeof value === "string" ? value : Redacted.value(value);
645};
646
647const deployKeyName = (name: string) => `${name.slice(0, 55)}-d`;
648
649const findDeployKeyId = Effect.fn(function* (id: string, name: string) {
650 const keyName = deployKeyName(name);
651 const { ssh_keys } = yield* Hetzner.sshKeys.listSshKeys({
652 name: keyName,
653 per_page: 50,
654 });
655 const key = ssh_keys.find((item) => item.name === keyName);
656 return key !== undefined &&
657 (yield* hasAlchemyLabels(id, tagRecord(key.labels)))
658 ? key.id
659 : undefined;
660});
661
662const ensureDeployKey = Effect.fn(function* (input: {
663 name: string;
664 labels: Record<string, string>;
665 output?: Pick<Server["Attributes"], "privateKey" | "deploySshKeyId">;
666 creating: boolean;
667}) {
668 const existingKey = unwrapPrivateKey(input.output?.privateKey);
669 const existingId = input.output?.deploySshKeyId;
670 if (existingKey !== undefined && existingId !== undefined) {
671 return {
672 privateKey: Redacted.make(existingKey),
673 deploySshKeyId: existingId,
674 };
675 }
676 if (!input.creating) {
677 return {
678 privateKey: input.output?.privateKey,
679 deploySshKeyId: existingId,
680 };
681 }
682 const generated = yield* generateDeployKey;
683 const keyName = deployKeyName(input.name);
684 const created = yield* Hetzner.sshKeys
685 .createSshKey({
686 name: keyName,
687 public_key: generated.publicKey,
688 labels: input.labels,
689 })
690 .pipe(
691 Effect.catchTag("Conflict", () =>
692 Hetzner.sshKeys.listSshKeys({ name: keyName, per_page: 50 }).pipe(
693 Effect.map(({ ssh_keys }) =>
694 ssh_keys.find((item) => item.name === keyName),
695 ),
696 Effect.flatMap((hit) =>
697 hit !== undefined
698 ? Effect.succeed({ ssh_key: hit })
699 : Hetzner.sshKeys.createSshKey({
700 name: keyName,
701 public_key: generated.publicKey,
702 labels: input.labels,
703 }),
704 ),
705 ),
706 ),
707 );
708 return {
709 privateKey: Redacted.make(generated.privateKey),
710 deploySshKeyId: created.ssh_key.id,
711 };
712});
713
714const getById = (id: number) =>
715 Hetzner.servers.getServer({ id }).pipe(
716 Effect.map(({ server }) => server),
717 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
718 );
719
720const getByName = (name: string) =>
721 Hetzner.servers
722 .listServers({ name, per_page: 50 })
723 .pipe(
724 Effect.map(({ servers }) => servers.find((item) => item.name === name)),
725 );
726
727const observe = Effect.fn(function* ({
728 name,
729 outputId,
730}: {
731 id: string;
732 name?: string;
733 outputId?: number;
734}) {
735 if (outputId !== undefined) {
736 const byId = yield* getById(outputId);
737 if (byId !== undefined) return byId;
738 }
739 if (name !== undefined) {
740 return yield* getByName(name);
741 }
742 return undefined;
743});
744
745const READY = new Set<string>(["running", "off"]);
746
747const waitUntilReady = (serverId: number) =>
748 Hetzner.servers.getServer({ id: serverId }).pipe(
749 Effect.flatMap(({ server }) =>
750 server !== undefined && READY.has(server.status)
751 ? Effect.succeed(server)
752 : Effect.fail(
753 new ServerPending({
754 serverId,
755 status: server?.status ?? "missing",
756 }),
757 ),
758 ),
759 Effect.retry({
760 while: retryable,
761 times: 10,
762 schedule: backoff,
763 }),
764 Effect.catchTag(
765 "ServerPending",
766 (e) =>
767 new ServerTimeout({
768 serverId: e.serverId,
769 status: e.status,
770 }),
771 ),
772 );
773
774const waitUntilGone = (serverId: number) =>
775 Hetzner.servers.getServer({ id: serverId }).pipe(
776 Effect.map(() => false),
777 Effect.catchTag("NotFound", () => Effect.succeed(true)),
778 Effect.repeat({
779 schedule: Schedule.spaced(Duration.seconds(1)),
780 until: (gone) => gone,
781 times: 10,
782 }),
783 Effect.flatMap((gone) =>
784 gone
785 ? Effect.void
786 : Effect.fail(new ServerTimeout({ serverId, status: "deleting" })),
787 ),
788 );
789
790const numericId = (
791 value: unknown,
792 keys: readonly string[],
793): number | undefined => {
794 if (value === null || typeof value !== "object") return undefined;
795 const rec = value as Record<string, unknown>;
796 for (const key of keys) {
797 if (typeof rec[key] === "number") return rec[key];
798 }
799 return undefined;
800};
801
802const idsOf = (
803 items: ReadonlyArray<unknown> | undefined,
804 keys: readonly string[],
805): number[] => {
806 const ids = new Set<number>();
807 for (const item of items ?? []) {
808 const id = numericId(item, keys);
809 if (id !== undefined) ids.add(id);
810 }
811 return [...ids];
812};
813
814const sameRef = (desired: string, name: string, id: number): boolean =>
815 desired === name || desired === String(id);
816
817const setEquals = (a: ReadonlyArray<number>, b: ReadonlyArray<number>) => {
818 if (a.length !== b.length) return false;
819 const left = new Set(a);
820 return b.every((id) => left.has(id));
821};
822
823const refresh = (id: number) =>
824 getById(id).pipe(
825 Effect.flatMap((server) =>
826 server !== undefined ? Effect.succeed(server) : waitUntilReady(id),
827 ),
828 );
829
830const syncNetworks = Effect.fn(function* (input: {
831 serverId: number;
832 observed: ReadonlyArray<number>;
833 desired: ReadonlyArray<number>;
834}) {
835 const observed = new Set(input.observed);
836 const desired = new Set(input.desired);
837 for (const networkId of input.observed) {
838 if (desired.has(networkId)) continue;
839 const { action } = yield* Hetzner.serverActions.detachServerFromNetwork({
840 id: input.serverId,
841 network: networkId,
842 });
843 yield* waitForAction(action);
844 }
845 for (const networkId of input.desired) {
846 if (observed.has(networkId)) continue;
847 const { action } = yield* Hetzner.serverActions.attachServerToNetwork({
848 id: input.serverId,
849 network: networkId,
850 });
851 yield* waitForAction(action);
852 }
853});
854
855const syncVolumes = Effect.fn(function* (input: {
856 serverId: number;
857 observed: ReadonlyArray<number>;
858 desired: ReadonlyArray<number>;
859}) {
860 const observed = new Set(input.observed);
861 const desired = new Set(input.desired);
862 for (const volumeId of input.observed) {
863 if (desired.has(volumeId)) continue;
864 const { action } = yield* Hetzner.volumeActions.detachVolume({
865 id: volumeId,
866 });
867 yield* waitForAction(action);
868 }
869 for (const volumeId of input.desired) {
870 if (observed.has(volumeId)) continue;
871 const { action } = yield* Hetzner.volumeActions.attachVolume({
872 id: volumeId,
873 server: input.serverId,
874 });
875 yield* waitForAction(action);
876 }
877});
878
879const firewallApplyItems = (serverId: number) => [
880 { type: "server" as const, server: { id: serverId } },
881];
882
883const syncFirewalls = Effect.fn(function* (input: {
884 serverId: number;
885 observed: ReadonlyArray<number>;
886 desired: ReadonlyArray<number>;
887}) {
888 const observed = new Set(input.observed);
889 const desired = new Set(input.desired);
890 for (const firewallId of input.observed) {
891 if (desired.has(firewallId)) continue;
892 const { actions } =
893 yield* Hetzner.firewallActions.removeFirewallFromResources({
894 id: firewallId,
895 remove_from: firewallApplyItems(input.serverId),
896 });
897 yield* waitForActions(actions);
898 }
899 for (const firewallId of input.desired) {
900 if (observed.has(firewallId)) continue;
901 const { actions } = yield* Hetzner.firewallActions.applyFirewallToResources(
902 {
903 id: firewallId,
904 apply_to: firewallApplyItems(input.serverId),
905 },
906 );
907 yield* waitForActions(actions);
908 }
909});
910
911const syncPlacementGroup = Effect.fn(function* (input: {
912 serverId: number;
913 observed: number | undefined;
914 desired: number | undefined;
915}) {
916 if (input.desired === input.observed) return;
917 if (input.observed !== undefined) {
918 const { action } =
919 yield* Hetzner.serverActions.removeServerFromPlacementGroup({
920 id: input.serverId,
921 });
922 yield* waitForAction(action);
923 }
924 if (input.desired !== undefined) {
925 const { action } = yield* Hetzner.serverActions.addServerToPlacementGroup({
926 id: input.serverId,
927 placement_group: input.desired,
928 });
929 yield* waitForAction(action);
930 }
931});
932
933export const ServerProvider = () =>
934 Provider.succeed(Server, {
935 stables: [
936 "id",
937 "serverId",
938 "created",
939 "location",
940 "locationId",
941 "ipv4",
942 "ipv6",
943 "privateKey",
944 "deploySshKeyId",
945 ],
946 list: Effect.fn(function* () {
947 const items = yield* Hetzner.servers.listServers
948 .items({ label_selector: alchemyStackSelector, per_page: 50 })
949 .pipe(
950 Stream.runCollect,
951 Effect.map((chunk) => Array.from(chunk)),
952 );
953 return items.map(toAttrs);
954 }),
955 diff: Effect.fn(function* ({ news, olds, output }) {
956 if (!isResolved(news)) return undefined;
957 if (output !== undefined) {
958 // Cloud-init runs once, on first boot — a changed init script can
959 // only take effect on a freshly provisioned Server.
960 if (news.userData !== olds?.userData) {
961 return { action: "replace" } as const;
962 }
963 const location = news.location ?? DEFAULT_LOCATION;
964 if (!sameRef(location, output.location, output.locationId)) {
965 return { action: "replace" } as const;
966 }
967 if (!sameRef(news.serverType, output.serverType, output.serverTypeId)) {
968 return { action: "replace" } as const;
969 }
970 const imageId = output.imageId;
971 const imageMatches =
972 output.image !== undefined && news.image === output.image
973 ? true
974 : imageId !== undefined && news.image === String(imageId);
975 if (!imageMatches) {
976 return { action: "replace" } as const;
977 }
978 }
979 return undefined;
980 }),
981 read: Effect.fn(function* ({ id, olds, output }) {
982 const name = yield* createServerName(id, olds?.name, output?.name);
983 const found = yield* observe({
984 id,
985 name,
986 outputId: output?.id ?? output?.serverId,
987 });
988 if (found === undefined) return undefined;
989 const attrs = {
990 ...toAttrs(found),
991 privateKey: output?.privateKey,
992 deploySshKeyId: output?.deploySshKeyId,
993 };
994 if (!(yield* hasAlchemyLabels(id, tagRecord(found.labels)))) {
995 return Unowned(attrs);
996 }
997 return {
998 ...attrs,
999 deploySshKeyId:
1000 attrs.deploySshKeyId ?? (yield* findDeployKeyId(id, found.name)),
1001 };
1002 }),
1003 reconcile: Effect.fn(function* ({ id, news, output }) {
1004 const name = yield* createServerName(id, news.name, output?.name);
1005 const internalLabels = yield* createInternalLabels(id);
1006 const desiredLabels = {
1007 ...toLabels(news.labels),
1008 ...internalLabels,
1009 };
1010 const location = news.location ?? DEFAULT_LOCATION;
1011 const userSshKeyIds = idsOf(news.sshKeys, ["id"]);
1012 const networkIds = idsOf(news.networks, ["networkId", "id"]);
1013 const firewallIds = idsOf(news.firewalls, ["id"]);
1014 const volumeIds = idsOf(news.volumes, ["id"]);
1015 const placementGroupId = numericId(news.placementGroup, ["id"]);
1016 const startAfterCreate = news.startAfterCreate ?? true;
1017 const desiredProtection = news.deleteProtection ?? false;
1018 const userData = yield* buildUserData({ name, userData: news.userData });
1019
1020 // Observe by id then desired name only. Do not fall back to
1021 // ownership labels — a create-first replacement still has the old
1022 // generation live under the same logical id.
1023 let current =
1024 output?.id !== undefined ? yield* getById(output.id) : undefined;
1025 if (current === undefined) {
1026 current = yield* getByName(name);
1027 }
1028
1029 const deployKey = yield* ensureDeployKey({
1030 name,
1031 labels: desiredLabels,
1032 output,
1033 creating: current === undefined,
1034 });
1035 const sshKeyIds = [
1036 ...userSshKeyIds,
1037 ...(deployKey.deploySshKeyId !== undefined
1038 ? [deployKey.deploySshKeyId]
1039 : []),
1040 ];
1041
1042 // Ensure — create only when missing. A Conflict is a race with a
1043 // peer reconciler or a name that just became visible; re-observe.
1044 if (current === undefined) {
1045 const created = yield* Hetzner.servers
1046 .createServer({
1047 name,
1048 server_type: news.serverType,
1049 image: news.image,
1050 location,
1051 labels: desiredLabels,
1052 start_after_create: startAfterCreate,
1053 ssh_keys: sshKeyIds.length > 0 ? sshKeyIds : undefined,
1054 networks: networkIds.length > 0 ? networkIds : undefined,
1055 firewalls:
1056 firewallIds.length > 0
1057 ? firewallIds.map((firewall) => ({ firewall }))
1058 : undefined,
1059 volumes: volumeIds.length > 0 ? volumeIds : undefined,
1060 placement_group: placementGroupId,
1061 user_data: userData,
1062 public_net:
1063 news.enableIpv4 !== undefined || news.enableIpv6 !== undefined
1064 ? {
1065 enable_ipv4: news.enableIpv4 ?? true,
1066 enable_ipv6: news.enableIpv6 ?? true,
1067 }
1068 : undefined,
1069 })
1070 .pipe(
1071 Effect.retry({
1072 while: (e) =>
1073 e._tag === "ServerLimitExceeded" ||
1074 e._tag === "ServerPlacementError",
1075 schedule: Schedule.spaced("5 seconds"),
1076 times: 8,
1077 }),
1078 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
1079 // The deploy key is a side-effect, not a stack resource. If
1080 // createServer fails after minting it (quota skip, timeout),
1081 // nothing is persisted for Server.delete to clean up.
1082 Effect.tapError(() =>
1083 deleteDeployKey(deployKey.deploySshKeyId).pipe(Effect.ignore),
1084 ),
1085 );
1086 if (created !== undefined) {
1087 if (created.action) {
1088 yield* waitForActions([created.action, ...created.next_actions]);
1089 }
1090 current = yield* waitUntilReady(created.server.id);
1091 } else {
1092 const hit = yield* getByName(name);
1093 if (hit === undefined) {
1094 return yield* new ServerNotResolved({ name });
1095 }
1096 current = yield* waitUntilReady(hit.id);
1097 }
1098 }
1099
1100 // Sync name + labels against observed cloud labels, not olds.
1101 // updateServer overwrites the full label set.
1102 const observedLabels = tagRecord(current.labels);
1103 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
1104 const needsMeta =
1105 current.name !== name || upsert.length > 0 || removed.length > 0;
1106 if (needsMeta) {
1107 yield* Hetzner.servers.updateServer({
1108 id: current.id,
1109 name,
1110 labels: desiredLabels,
1111 });
1112 current = yield* refresh(current.id);
1113 }
1114
1115 if (current.protection.delete !== desiredProtection) {
1116 const { action } = yield* Hetzner.serverActions.changeServerProtection({
1117 id: current.id,
1118 delete: desiredProtection,
1119 rebuild: desiredProtection,
1120 });
1121 yield* waitForAction(action);
1122 current = yield* refresh(current.id);
1123 }
1124
1125 // Attachment props are opt-in: omit them to leave Volume / Firewall
1126 // / Network / PlacementGroup as the owner of the relationship.
1127 if (news.networks !== undefined) {
1128 const observed = current.private_net
1129 .map((item) => item.network)
1130 .filter((netId): netId is number => typeof netId === "number");
1131 if (!setEquals(observed, networkIds)) {
1132 yield* syncNetworks({
1133 serverId: current.id,
1134 observed,
1135 desired: networkIds,
1136 });
1137 current = yield* refresh(current.id);
1138 }
1139 }
1140
1141 if (news.volumes !== undefined) {
1142 const observed = current.volumes ?? [];
1143 if (!setEquals(observed, volumeIds)) {
1144 yield* syncVolumes({
1145 serverId: current.id,
1146 observed,
1147 desired: volumeIds,
1148 });
1149 current = yield* refresh(current.id);
1150 }
1151 }
1152
1153 if (news.firewalls !== undefined) {
1154 const observed = (current.public_net.firewalls ?? [])
1155 .map((item) => item.id)
1156 .filter((fwId): fwId is number => typeof fwId === "number");
1157 if (!setEquals(observed, firewallIds)) {
1158 yield* syncFirewalls({
1159 serverId: current.id,
1160 observed,
1161 desired: firewallIds,
1162 });
1163 current = yield* refresh(current.id);
1164 }
1165 }
1166
1167 if (news.placementGroup !== undefined) {
1168 const observed = current.placement_group?.id;
1169 if (observed !== placementGroupId) {
1170 yield* syncPlacementGroup({
1171 serverId: current.id,
1172 observed,
1173 desired: placementGroupId,
1174 });
1175 current = yield* refresh(current.id);
1176 }
1177 }
1178
1179 return {
1180 ...toAttrs(current),
1181 privateKey: deployKey.privateKey,
1182 deploySshKeyId: deployKey.deploySshKeyId,
1183 };
1184 }),
1185 delete: Effect.fn(function* ({ output }) {
1186 // Keep the server discoverable if key deletion fails. Removing the project
1187 // key does not revoke the public key already injected into the server.
1188 yield* deleteDeployKey(output.deploySshKeyId);
1189 const current = yield* getById(output.id);
1190 if (current !== undefined) {
1191 if (current.protection.delete) {
1192 const { action } =
1193 yield* Hetzner.serverActions.changeServerProtection({
1194 id: current.id,
1195 delete: false,
1196 rebuild: false,
1197 });
1198 yield* waitForAction(action);
1199 }
1200
1201 const deleted = yield* Hetzner.servers
1202 .deleteServer({ id: current.id })
1203 .pipe(
1204 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
1205 Effect.retry({
1206 while: retryable,
1207 times: 8,
1208 schedule: backoff,
1209 }),
1210 );
1211 if (deleted?.action) {
1212 yield* waitForAction(deleted.action);
1213 }
1214 yield* waitUntilGone(current.id);
1215 }
1216 }),
1217 });