1import * as Hetzner from "@/Hetzner";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import * as servers from "@distilled.cloud/hetzner/servers";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: Hetzner.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18const hasHetznerCreds = !!process.env.HCLOUD_TOKEN;
19
20const waitUntilGone = (id: number) =>
21 servers.getServer({ id }).pipe(
22 Effect.as("found" as const),
23 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
24 Effect.repeat({
25 schedule: Schedule.spaced("1 second"),
26 until: (status) => status === "gone",
27 times: 10,
28 }),
29 );
30
31test(
32 "composes user data with the alchemy bootstrap",
33 Effect.gen(function* () {
34 // No user data: the bootstrap script is sent as-is.
35 const bootstrap = Hetzner.composeUserData(undefined);
36 expect(bootstrap.startsWith("#!/bin/bash")).toBe(true);
37 expect(bootstrap).toContain("setup_26.x");
38
39 // A cloud-config document becomes the second part of a multipart
40 // cloud-init document, behind the bootstrap script.
41 const composed = Hetzner.composeUserData(
42 ["#cloud-config", "packages:", " - nginx"].join("\n"),
43 );
44 expect(
45 composed.startsWith('Content-Type: multipart/mixed; boundary="'),
46 ).toBe(true);
47 expect(composed).toContain("Content-Type: text/x-shellscript");
48 expect(composed).toContain("Content-Type: text/cloud-config");
49 expect(composed).toContain(" - nginx");
50 expect(composed.indexOf("setup_26.x")).toBeLessThan(
51 composed.indexOf("#cloud-config"),
52 );
53
54 // A shell script keeps its shebang; a bare snippet gets one.
55 expect(Hetzner.composeUserData("#!/bin/sh\nid")).toContain("#!/bin/sh\nid");
56 expect(Hetzner.composeUserData("apt-get install -y nginx")).toContain(
57 "#!/bin/bash\napt-get install -y nginx",
58 );
59
60 // A caller-supplied MIME document owns the whole payload.
61 const raw = 'Content-Type: multipart/mixed; boundary="x"\n\n--x--\n';
62 expect(Hetzner.composeUserData(raw)).toEqual(raw);
63 }),
64 { tags: ["provider:hetzner", "provider:hetzner:server", "live"] },
65);
66
67test.provider.skipIf(!hasHetznerCreds)(
68 "create, update, and delete a server",
69 (stack) =>
70 Effect.gen(function* () {
71 yield* stack.destroy();
72
73 const created = yield* stack.deploy(
74 Effect.gen(function* () {
75 return yield* Hetzner.Server("Web", {
76 serverType: "cpx12",
77 image: "ubuntu-24.04",
78 location: "nbg1",
79 labels: { env: "test" },
80 });
81 }),
82 );
83
84 expect(created.id).toEqual(expect.any(Number));
85 expect(created.serverId).toEqual(created.id);
86 expect(created.name).toEqual(expect.any(String));
87 expect(created.serverType).toEqual("cpx12");
88 expect(created.image).toEqual("ubuntu-24.04");
89 expect(created.location).toEqual("nbg1");
90 expect(created.locationId).toEqual(expect.any(Number));
91 expect(created.ipv4).toEqual(expect.any(String));
92 expect(created.ipv6).toEqual(expect.any(String));
93 expect(created.deleteProtection).toEqual(false);
94 expect(created.labels).toMatchObject({ env: "test" });
95
96 const fetched = yield* servers.getServer({
97 id: created.id,
98 });
99 expect(fetched.server?.id).toEqual(created.id);
100 expect(fetched.server?.server_type.name).toEqual("cpx12");
101 expect(fetched.server?.image?.name).toEqual("ubuntu-24.04");
102 expect(fetched.server?.location.name).toEqual("nbg1");
103 expect(fetched.server?.public_net.ipv4?.ip).toEqual(created.ipv4);
104 expect(fetched.server?.public_net.ipv6?.ip).toEqual(created.ipv6);
105 expect(fetched.server?.labels.env).toEqual("test");
106
107 const updated = yield* stack.deploy(
108 Effect.gen(function* () {
109 return yield* Hetzner.Server("Web", {
110 serverType: "cpx12",
111 image: "ubuntu-24.04",
112 location: "nbg1",
113 labels: { env: "prod", role: "web" },
114 });
115 }),
116 );
117
118 expect(updated.id).toEqual(created.id);
119 expect(updated.serverId).toEqual(created.serverId);
120 expect(updated.ipv4).toEqual(created.ipv4);
121 expect(updated.ipv6).toEqual(created.ipv6);
122 expect(updated.labels).toMatchObject({ env: "prod", role: "web" });
123
124 const refetched = yield* servers.getServer({
125 id: updated.id,
126 });
127 expect(refetched.server?.id).toEqual(created.id);
128 expect(refetched.server?.labels.env).toEqual("prod");
129 expect(refetched.server?.labels.role).toEqual("web");
130 expect(refetched.server?.public_net.ipv4?.ip).toEqual(created.ipv4);
131
132 yield* stack.destroy();
133
134 const gone = yield* waitUntilGone(created.id);
135 expect(gone).toEqual("gone");
136 }).pipe(logLevel),
137 {
138 tags: [
139 "provider:hetzner",
140 "provider:hetzner:server",
141 "provider:hetzner:service",
142 "live",
143 ],
144 timeout: 180_000,
145 exclusive: true,
146 },
147);
148
149test.provider.skipIf(!hasHetznerCreds)(
150 "replace when image changes",
151 (stack) =>
152 Effect.gen(function* () {
153 yield* stack.destroy();
154
155 const created = yield* stack.deploy(
156 Effect.gen(function* () {
157 return yield* Hetzner.Server("ReplaceWeb", {
158 serverType: "cpx12",
159 image: "ubuntu-24.04",
160 location: "nbg1",
161 });
162 }),
163 );
164
165 expect(created.image).toEqual("ubuntu-24.04");
166
167 const replaced = yield* stack.deploy(
168 Effect.gen(function* () {
169 return yield* Hetzner.Server("ReplaceWeb", {
170 serverType: "cpx12",
171 image: "debian-12",
172 location: "nbg1",
173 });
174 }),
175 );
176
177 expect(replaced.id).not.toEqual(created.id);
178 expect(replaced.image).toEqual("debian-12");
179 expect(replaced.location).toEqual("nbg1");
180 expect(replaced.serverType).toEqual("cpx12");
181
182 const fetched = yield* servers.getServer({
183 id: replaced.id,
184 });
185 expect(fetched.server?.image?.name).toEqual("debian-12");
186
187 const oldGone = yield* waitUntilGone(created.id);
188 expect(oldGone).toEqual("gone");
189
190 yield* stack.destroy();
191
192 const gone = yield* waitUntilGone(replaced.id);
193 expect(gone).toEqual("gone");
194 }).pipe(logLevel),
195 {
196 tags: [
197 "provider:hetzner",
198 "provider:hetzner:server",
199 "provider:hetzner:service",
200 "live",
201 ],
202 timeout: 180_000,
203 exclusive: true,
204 },
205);
206
207test.provider.skipIf(!hasHetznerCreds)(
208 "list enumerates the deployed server",
209 (stack) =>
210 Effect.gen(function* () {
211 yield* stack.destroy();
212
213 const deployed = yield* stack.deploy(
214 Effect.gen(function* () {
215 return yield* Hetzner.Server("ListWeb", {
216 serverType: "cpx12",
217 image: "ubuntu-24.04",
218 location: "nbg1",
219 });
220 }),
221 );
222
223 const provider = yield* Provider.findProvider(Hetzner.Server);
224 const all = yield* provider.list();
225 const found = all.find((server) => server.id === deployed.id);
226 expect(found).toBeDefined();
227 expect(found?.serverType).toEqual("cpx12");
228 expect(found?.image).toEqual("ubuntu-24.04");
229 expect(found?.location).toEqual("nbg1");
230 expect(found?.ipv4).toEqual(deployed.ipv4);
231 expect(found?.ipv6).toEqual(deployed.ipv6);
232
233 yield* stack.destroy();
234
235 const gone = yield* waitUntilGone(deployed.id);
236 expect(gone).toEqual("gone");
237 }).pipe(logLevel),
238 {
239 tags: [
240 "provider:hetzner",
241 "provider:hetzner:server",
242 "provider:hetzner:service",
243 "live",
244 ],
245 timeout: 180_000,
246 exclusive: true,
247 },
248);
249
250test.provider.skipIf(!hasHetznerCreds)(
251 "runs a custom init script alongside the alchemy bootstrap",
252 (stack) =>
253 Effect.gen(function* () {
254 yield* stack.destroy();
255
256 const server = yield* stack.deploy(
257 Effect.gen(function* () {
258 return yield* Hetzner.Server("InitWeb", {
259 serverType: "cpx12",
260 image: "ubuntu-24.04",
261 location: "nbg1",
262 userData: [
263 "#!/bin/bash",
264 "echo alchemy-init-ok > /etc/alchemy-init-marker",
265 ].join("\n"),
266 });
267 }),
268 );
269
270 expect(server.ipv4).toEqual(expect.any(String));
271 expect(server.privateKey).toBeDefined();
272 const host = server.ipv4 ?? "";
273 const privateKey =
274 server.privateKey === undefined
275 ? ""
276 : Redacted.value(server.privateKey);
277
278 // Both cloud-init parts must have run: the user script (marker file)
279 // and Alchemy's bootstrap (Node 26). Probed as one command — the box is
280 // still booting when the create action completes.
281 const probe = yield* Effect.gen(function* () {
282 const ssh = yield* Hetzner.openSshClient({ host, privateKey });
283 const { stdout } = yield* ssh.exec(
284 "cat /etc/alchemy-init-marker && command -v node && echo node-ok",
285 );
286 return stdout;
287 }).pipe(
288 Effect.scoped,
289 Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 30 }),
290 );
291
292 expect(probe).toContain("alchemy-init-ok");
293 expect(probe).toContain("node-ok");
294
295 // Cloud-init only runs on first boot, so a changed script replaces.
296 const replaced = yield* stack.deploy(
297 Effect.gen(function* () {
298 return yield* Hetzner.Server("InitWeb", {
299 serverType: "cpx12",
300 image: "ubuntu-24.04",
301 location: "nbg1",
302 userData: [
303 "#!/bin/bash",
304 "echo alchemy-init-v2 > /etc/alchemy-init-marker",
305 ].join("\n"),
306 });
307 }),
308 );
309
310 expect(replaced.id).not.toEqual(server.id);
311
312 const oldGone = yield* waitUntilGone(server.id);
313 expect(oldGone).toEqual("gone");
314
315 yield* stack.destroy();
316
317 const gone = yield* waitUntilGone(replaced.id);
318 expect(gone).toEqual("gone");
319 }).pipe(logLevel),
320 {
321 tags: [
322 "provider:hetzner",
323 "provider:hetzner:server",
324 "provider:hetzner:service",
325 "live",
326 ],
327 timeout: 300_000,
328 exclusive: true,
329 },
330);