1import * as sshKeys from "@distilled.cloud/hetzner/ssh_keys";
2import { generateKeyPairSync } from "node:crypto";
3import * as Hetzner from "@/Hetzner";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
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 generatePublicKey = (comment: string) =>
21 Effect.sync(() => {
22 const { publicKey } = generateKeyPairSync("ed25519");
23 const der = publicKey.export({ type: "spki", format: "der" });
24 const raw = der.subarray(der.length - 32);
25 const type = Buffer.from("ssh-ed25519");
26 const u32 = (n: number) => {
27 const buf = Buffer.alloc(4);
28 buf.writeUInt32BE(n);
29 return buf;
30 };
31 const payload = Buffer.concat([
32 u32(type.length),
33 type,
34 u32(raw.length),
35 raw,
36 ]);
37 return `ssh-ed25519 ${payload.toString("base64")} ${comment}`;
38 });
39
40const waitUntilGone = (id: number) =>
41 sshKeys.getSshKey({ id }).pipe(
42 Effect.as("found" as const),
43 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
44 Effect.repeat({
45 schedule: Schedule.spaced("1 second"),
46 until: (status) => status === "gone",
47 times: 10,
48 }),
49 );
50
51test.provider.skipIf(!hasHetznerCreds)(
52 "create, update, and delete an ssh key",
53 (stack) =>
54 Effect.gen(function* () {
55 yield* stack.destroy();
56
57 const publicKey = yield* generatePublicKey("alchemy-sshkey-crud");
58
59 const created = yield* stack.deploy(
60 Effect.gen(function* () {
61 return yield* Hetzner.SshKey("DeployKey", {
62 publicKey,
63 labels: { env: "test" },
64 });
65 }),
66 );
67
68 expect(created.id).toEqual(expect.any(Number));
69 expect(created.name).toEqual(expect.any(String));
70 expect(created.fingerprint).toEqual(expect.any(String));
71 expect(created.publicKey).toContain("ssh-ed25519");
72 expect(created.created).toEqual(expect.any(String));
73 expect(created.labels).toMatchObject({ env: "test" });
74
75 const fetched = yield* sshKeys.getSshKey({ id: created.id });
76 expect(fetched.ssh_key.id).toEqual(created.id);
77 expect(fetched.ssh_key.name).toEqual(created.name);
78 expect(fetched.ssh_key.fingerprint).toEqual(created.fingerprint);
79 expect(fetched.ssh_key.labels.env).toEqual("test");
80
81 const updated = yield* stack.deploy(
82 Effect.gen(function* () {
83 return yield* Hetzner.SshKey("DeployKey", {
84 name: `${created.name.slice(0, 55)}-renamed`,
85 publicKey,
86 labels: { env: "prod", role: "deploy" },
87 });
88 }),
89 );
90
91 expect(updated.id).toEqual(created.id);
92 expect(updated.fingerprint).toEqual(created.fingerprint);
93 expect(updated.name).toEqual(`${created.name.slice(0, 55)}-renamed`);
94 expect(updated.labels).toMatchObject({ env: "prod", role: "deploy" });
95
96 const refetched = yield* sshKeys.getSshKey({ id: updated.id });
97 expect(refetched.ssh_key.name).toEqual(updated.name);
98 expect(refetched.ssh_key.labels.env).toEqual("prod");
99 expect(refetched.ssh_key.labels.role).toEqual("deploy");
100
101 yield* stack.destroy();
102
103 const gone = yield* waitUntilGone(created.id);
104 expect(gone).toEqual("gone");
105 }).pipe(logLevel),
106 {
107 tags: [
108 "provider:hetzner",
109 "provider:hetzner:service",
110 "provider:hetzner:sshkey",
111 "live",
112 ],
113 timeout: 120_000,
114 },
115);
116
117test.provider.skipIf(!hasHetznerCreds)(
118 "replace when public key changes",
119 (stack) =>
120 Effect.gen(function* () {
121 yield* stack.destroy();
122
123 const firstKey = yield* generatePublicKey("alchemy-sshkey-replace-a");
124 const secondKey = yield* generatePublicKey("alchemy-sshkey-replace-b");
125
126 const created = yield* stack.deploy(
127 Effect.gen(function* () {
128 return yield* Hetzner.SshKey("ReplaceKey", {
129 publicKey: firstKey,
130 });
131 }),
132 );
133
134 expect(created.publicKey).toContain("ssh-ed25519");
135
136 const replaced = yield* stack.deploy(
137 Effect.gen(function* () {
138 return yield* Hetzner.SshKey("ReplaceKey", {
139 name: created.name,
140 publicKey: secondKey,
141 });
142 }),
143 );
144
145 expect(replaced.id).not.toEqual(created.id);
146 expect(replaced.fingerprint).not.toEqual(created.fingerprint);
147 expect(replaced.name).toEqual(created.name);
148
149 const fetched = yield* sshKeys.getSshKey({ id: replaced.id });
150 expect(fetched.ssh_key.id).toEqual(replaced.id);
151 expect(fetched.ssh_key.fingerprint).toEqual(replaced.fingerprint);
152
153 const oldGone = yield* waitUntilGone(created.id);
154 expect(oldGone).toEqual("gone");
155
156 yield* stack.destroy();
157
158 const gone = yield* waitUntilGone(replaced.id);
159 expect(gone).toEqual("gone");
160 }).pipe(logLevel),
161 {
162 tags: [
163 "provider:hetzner",
164 "provider:hetzner:service",
165 "provider:hetzner:sshkey",
166 "live",
167 ],
168 timeout: 180_000,
169 },
170);
171
172test.provider.skipIf(!hasHetznerCreds)(
173 "list enumerates the deployed ssh key",
174 (stack) =>
175 Effect.gen(function* () {
176 yield* stack.destroy();
177
178 const publicKey = yield* generatePublicKey("alchemy-sshkey-list");
179
180 const deployed = yield* stack.deploy(
181 Effect.gen(function* () {
182 return yield* Hetzner.SshKey("ListKey", { publicKey });
183 }),
184 );
185
186 const provider = yield* Provider.findProvider(Hetzner.SshKey);
187 const all = yield* provider.list();
188 const found = all.find((key) => key.id === deployed.id);
189 expect(found).toBeDefined();
190 expect(found?.name).toEqual(deployed.name);
191 expect(found?.fingerprint).toEqual(deployed.fingerprint);
192
193 yield* stack.destroy();
194
195 const gone = yield* waitUntilGone(deployed.id);
196 expect(gone).toEqual("gone");
197 }).pipe(logLevel),
198 {
199 tags: [
200 "provider:hetzner",
201 "provider:hetzner:service",
202 "provider:hetzner:sshkey",
203 "live",
204 ],
205 timeout: 120_000,
206 },
207);