1import { createHash } from "node:crypto";
2import * as Hetzner from "@distilled.cloud/hetzner";
3import type { GetSshKeyResponseSshKey } from "@distilled.cloud/hetzner/ssh_keys";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../AdoptPolicy.ts";
8import { isResolved } from "../Diff.ts";
9import { createPhysicalName } from "../PhysicalName.ts";
10import * as Provider from "../Provider.ts";
11import { Resource } from "../Resource.ts";
12import { tagRecord } from "../Tags.ts";
13import {
14 alchemyLabelKeys,
15 alchemyStackSelector,
16 createInternalLabels,
17 diffLabels,
18 hasAlchemyLabels,
19 stripInternalLabels,
20 toLabels,
21} from "./Labels.ts";
22import type { Providers } from "./Providers.ts";
23
24export type SshKeyProps = {
25 /**
26 * Name of the SSH key. Must be unique per Hetzner project. If omitted,
27 * a unique name is generated from the stack, stage, and logical id.
28 */
29 name?: string;
30 /**
31 * OpenSSH-format public key. Changing it replaces the SSH key.
32 */
33 publicKey: string;
34 /**
35 * User-defined labels (`key/value` pairs). Alchemy ownership labels are
36 * merged in automatically.
37 */
38 labels?: Record<string, string>;
39};
40
41export type SshKey = Resource<
42 "Hetzner.SshKey",
43 SshKeyProps,
44 {
45 /** Numeric Hetzner SSH key id. */
46 id: number;
47 /** Name of the SSH key (unique per project). */
48 name: string;
49 /** MD5 fingerprint of the public key (`aa:bb:…`). */
50 fingerprint: string;
51 /** Public key as stored by Hetzner. */
52 publicKey: string;
53 /** User-defined labels (Alchemy ownership labels stripped). */
54 labels: Record<string, string>;
55 /** RFC3339 creation timestamp. */
56 created: string;
57 },
58 never,
59 Providers
60>;
61
62/**
63 * A Hetzner Cloud SSH key. Public keys are injected into Servers at create
64 * time. The public key is immutable — changing it replaces the key.
65 *
66 * @see https://docs.hetzner.cloud/reference/cloud#ssh-keys
67 *
68 * ### Creating an SSH Key
69 * **Example:** Generated name
70 * ```typescript
71 * const key = yield* Hetzner.SshKey("deploy", {
72 * publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… user@host",
73 * });
74 * ```
75 *
76 * **Example:** Explicit name and labels
77 * ```typescript
78 * const key = yield* Hetzner.SshKey("deploy", {
79 * name: "deploy-key",
80 * publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… user@host",
81 * labels: { role: "deploy" },
82 * });
83 * ```
84 *
85 * @resource
86 * @product Server
87 */
88export const SshKey = Resource<SshKey>("Hetzner.SshKey");
89
90export class SshKeyNotResolved extends Data.TaggedError(
91 "Hetzner.SshKeyNotResolved",
92)<{
93 name: string;
94}> {}
95
96const userLabels = (
97 labels: Record<string, string | undefined> | null | undefined,
98): Record<string, string> => stripInternalLabels(tagRecord(labels));
99
100const toName = (id: string, name: string | undefined, existing?: string) =>
101 Effect.gen(function* () {
102 return (
103 name ?? existing ?? (yield* createPhysicalName({ id, maxLength: 64 }))
104 );
105 });
106
107const toAttrs = (key: GetSshKeyResponseSshKey) => ({
108 id: key.id,
109 name: key.name,
110 fingerprint: key.fingerprint,
111 publicKey: key.public_key,
112 labels: userLabels(key.labels),
113 created: key.created,
114});
115
116const fingerprintOf = (publicKey: string) =>
117 Effect.sync(() => {
118 const b64 = publicKey.trim().split(/\s+/)[1];
119 if (b64 === undefined) return undefined;
120 const hex = createHash("md5")
121 .update(Buffer.from(b64, "base64"))
122 .digest("hex");
123 return hex.match(/.{2}/g)?.join(":");
124 });
125
126const getById = (id: number) =>
127 Hetzner.sshKeys.getSshKey({ id }).pipe(
128 Effect.map(({ ssh_key }) => ssh_key),
129 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
130 );
131
132const findByName = (name: string) =>
133 Hetzner.sshKeys
134 .listSshKeys({ name, per_page: 50 })
135 .pipe(
136 Effect.map(({ ssh_keys }) => ssh_keys.find((key) => key.name === name)),
137 );
138
139const findByFingerprint = (fingerprint: string) =>
140 Hetzner.sshKeys
141 .listSshKeys({ fingerprint, per_page: 50 })
142 .pipe(Effect.map(({ ssh_keys }) => ssh_keys[0]));
143
144const observe = Effect.fn(function* (input: {
145 id?: number;
146 name: string;
147 publicKey: string;
148}) {
149 if (input.id !== undefined) {
150 const byId = yield* getById(input.id);
151 if (byId !== undefined) return byId;
152 }
153 const byName = yield* findByName(input.name);
154 if (byName !== undefined) return byName;
155 const fingerprint = yield* fingerprintOf(input.publicKey);
156 if (fingerprint === undefined) return undefined;
157 return yield* findByFingerprint(fingerprint);
158});
159
160export const SshKeyProvider = () =>
161 Provider.succeed(SshKey, {
162 stables: ["id", "fingerprint", "publicKey", "created"],
163
164 diff: Effect.fn(function* ({ news, olds, output }) {
165 if (!isResolved(news)) return undefined;
166 const previous = olds?.publicKey ?? output?.publicKey;
167 if (previous !== undefined && news.publicKey.trim() !== previous.trim()) {
168 const previousName = olds?.name ?? output?.name;
169 const nextName = news.name ?? previousName;
170 return {
171 action: "replace" as const,
172 deleteFirst: nextName !== undefined && nextName === previousName,
173 };
174 }
175 return undefined;
176 }),
177
178 read: Effect.fn(function* ({ id, olds, output }) {
179 const name = yield* toName(id, olds?.name, output?.name);
180 const publicKey = olds?.publicKey ?? output?.publicKey ?? "";
181 const existing = yield* observe({
182 id: output?.id,
183 name,
184 publicKey,
185 });
186 if (existing === undefined) return undefined;
187 const attrs = toAttrs(existing);
188 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
189 ? attrs
190 : Unowned(attrs);
191 }),
192
193 list: () =>
194 Hetzner.sshKeys.listSshKeys
195 .items({ label_selector: alchemyStackSelector, per_page: 50 })
196 .pipe(
197 Stream.runCollect,
198 Effect.map((chunk) => Array.from(chunk, toAttrs)),
199 ),
200
201 reconcile: Effect.fn(function* ({ id, news, output }) {
202 const name = yield* toName(id, news.name, output?.name);
203 const desiredLabels = {
204 ...toLabels(news.labels),
205 ...(yield* createInternalLabels(id)),
206 };
207
208 let current = yield* observe({
209 id: output?.id,
210 name,
211 publicKey: news.publicKey,
212 });
213
214 if (current === undefined) {
215 const created = yield* Hetzner.sshKeys
216 .createSshKey({
217 name,
218 public_key: news.publicKey,
219 labels: desiredLabels,
220 })
221 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
222 current =
223 created?.ssh_key ??
224 (yield* observe({
225 id: output?.id,
226 name,
227 publicKey: news.publicKey,
228 }));
229 }
230
231 if (current === undefined) {
232 return yield* new SshKeyNotResolved({ name });
233 }
234
235 const observedLabels = tagRecord(current.labels);
236 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
237 const nameChanged = current.name !== name;
238 const labelsChanged = upsert.length > 0 || removed.length > 0;
239 if (nameChanged || labelsChanged) {
240 const updated = yield* Hetzner.sshKeys.updateSshKey({
241 id: current.id,
242 name: nameChanged ? name : undefined,
243 labels: labelsChanged ? desiredLabels : undefined,
244 });
245 current = updated.ssh_key;
246 }
247
248 return toAttrs(current);
249 }),
250
251 delete: Effect.fn(function* ({ output }) {
252 yield* Hetzner.sshKeys
253 .deleteSshKey({ id: output.id })
254 .pipe(Effect.catchTag("NotFound", () => Effect.void));
255 }),
256 });