1import * as Hetzner from "@distilled.cloud/hetzner";
2import type { GetCertificateResponseCertificate } from "@distilled.cloud/hetzner/certificates";
3import * as Data from "effect/Data";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { Unowned } from "../AdoptPolicy.ts";
9import { isResolved } from "../Diff.ts";
10import { createPhysicalName } from "../PhysicalName.ts";
11import * as Provider from "../Provider.ts";
12import { Resource } from "../Resource.ts";
13import { tagRecord } from "../Tags.ts";
14import { arrayEqualsUnordered } from "../Util/equal.ts";
15import { waitForAction } from "./actions.ts";
16import {
17 alchemyLabelKeys,
18 alchemyStackSelector,
19 createInternalLabels,
20 diffLabels,
21 hasAlchemyLabels,
22 labelSelector,
23 stripInternalLabels,
24 toLabels,
25} from "./Labels.ts";
26import type { Providers } from "./Providers.ts";
27
28export type CertificateType = "uploaded" | "managed";
29
30export type CertificateStatus = {
31 /** Let's Encrypt issuance state. `undefined` for uploaded Certificates. */
32 issuance?: "pending" | "completed" | "failed" | (string & {});
33 /** Let's Encrypt renewal state. `undefined` for uploaded Certificates. */
34 renewal?: "scheduled" | "pending" | "failed" | "unavailable" | (string & {});
35 /** Present when issuance or renewal is `failed`. */
36 error?: { code?: string; message?: string };
37};
38
39export type UploadedCertificateProps = {
40 /**
41 * Upload an existing PEM certificate and private key. You monitor
42 * expiry and handle renewal yourself.
43 * @default "uploaded"
44 */
45 type?: "uploaded";
46 /**
47 * Name of the Certificate. Must be unique per Hetzner project. If
48 * omitted, a unique name is generated from the stack, stage, and
49 * logical id.
50 */
51 name?: string;
52 /**
53 * Certificate and chain in PEM format, in order so that each record
54 * directly certifies the one preceding. Changing it replaces the
55 * Certificate.
56 */
57 certificate: string;
58 /**
59 * Certificate private key in PEM format. Never returned by the API
60 * and not stored in resource attributes. Changing it replaces the
61 * Certificate.
62 */
63 privateKey: string;
64 /**
65 * User-defined labels (`key/value` pairs). Alchemy ownership labels
66 * are merged in automatically.
67 */
68 labels?: Record<string, string>;
69};
70
71export type ManagedCertificateProps = {
72 /**
73 * Request a Let's Encrypt Certificate for `domainNames`. Only domains
74 * managed by Hetzner DNS are supported. Hetzner handles renewal.
75 */
76 type: "managed";
77 /**
78 * Name of the Certificate. Must be unique per Hetzner project. If
79 * omitted, a unique name is generated from the stack, stage, and
80 * logical id.
81 */
82 name?: string;
83 /**
84 * Domains and subdomains that should be contained in the Certificate.
85 * Changing them replaces the Certificate.
86 */
87 domainNames: string[];
88 /**
89 * User-defined labels (`key/value` pairs). Alchemy ownership labels
90 * are merged in automatically.
91 */
92 labels?: Record<string, string>;
93};
94
95export type CertificateProps =
96 | UploadedCertificateProps
97 | ManagedCertificateProps;
98
99export type Certificate = Resource<
100 "Hetzner.Certificate",
101 CertificateProps,
102 {
103 /** Numeric Hetzner Certificate id. */
104 id: number;
105 /** Name of the Certificate (unique per project). */
106 name: string;
107 /** `uploaded` or `managed`. */
108 type: CertificateType;
109 /**
110 * Certificate and chain in PEM format. `undefined` while a managed
111 * Certificate is still being issued.
112 */
113 certificate: string | undefined;
114 /** SHA256 fingerprint of the Certificate. */
115 fingerprint: string | undefined;
116 /** Domains and subdomains covered by the Certificate. */
117 domainNames: string[];
118 /** RFC3339 instant when the Certificate becomes valid. */
119 notValidBefore: string | undefined;
120 /** RFC3339 instant when the Certificate stops being valid. */
121 notValidAfter: string | undefined;
122 /** RFC3339 creation timestamp. */
123 created: string;
124 /** User-defined labels (Alchemy ownership labels stripped). */
125 labels: Record<string, string>;
126 /** Managed issuance/renewal status. `undefined` for uploaded Certificates. */
127 status: CertificateStatus | undefined;
128 /** Resources currently using the Certificate. */
129 usedBy: { id: number; type: string }[];
130 },
131 never,
132 Providers
133>;
134
135/**
136 * A Hetzner Cloud TLS Certificate. Upload a PEM (`type: "uploaded"`, the
137 * default) or request a Let's Encrypt Certificate for domains in Hetzner
138 * DNS (`type: "managed"`).
139 *
140 * `name` and `labels` update in place. Changing the type, the uploaded
141 * PEM/key, or the managed domain list replaces the Certificate.
142 *
143 * @see https://docs.hetzner.cloud/reference/cloud#certificates
144 *
145 * ### Uploading a Certificate
146 * **Example:** Generated name
147 * ```typescript
148 * const cert = yield* Hetzner.Certificate("web", {
149 * certificate: pem,
150 * privateKey: key,
151 * });
152 * ```
153 *
154 * **Example:** Explicit name and labels
155 * ```typescript
156 * const cert = yield* Hetzner.Certificate("web", {
157 * name: "my-website-cert",
158 * certificate: pem,
159 * privateKey: key,
160 * labels: { env: "prod" },
161 * });
162 * ```
163 *
164 * ### Managed Let's Encrypt
165 * **Example:** Issue for Hetzner DNS domains
166 * ```typescript
167 * const cert = yield* Hetzner.Certificate("le", {
168 * type: "managed",
169 * domainNames: ["example.com", "www.example.com"],
170 * });
171 * ```
172 *
173 * @resource
174 * @product Certificate
175 */
176export const Certificate = Resource<Certificate>("Hetzner.Certificate");
177
178export class CertificateNotResolved extends Data.TaggedError(
179 "Hetzner.CertificateNotResolved",
180)<{
181 name: string;
182}> {}
183
184export class CertificateIssuancePending extends Data.TaggedError(
185 "Hetzner.CertificateIssuancePending",
186)<{
187 certificateId: number;
188 issuance: string;
189}> {}
190
191export class CertificateIssuanceFailed extends Data.TaggedError(
192 "Hetzner.CertificateIssuanceFailed",
193)<{
194 certificateId: number;
195 code?: string;
196 message: string;
197}> {}
198
199type CloudCertificate = GetCertificateResponseCertificate;
200
201const asType = (type: string | undefined): CertificateType =>
202 type === "managed" ? "managed" : "uploaded";
203
204const userLabels = (
205 labels: Record<string, string | undefined> | null | undefined,
206): Record<string, string> => stripInternalLabels(tagRecord(labels));
207
208const toStatus = (
209 status: CloudCertificate["status"],
210): CertificateStatus | undefined => {
211 if (status == null) return undefined;
212 return {
213 issuance: status.issuance,
214 renewal: status.renewal,
215 error:
216 status.error == null
217 ? undefined
218 : { code: status.error.code, message: status.error.message },
219 };
220};
221
222const toAttrs = (cert: CloudCertificate): Certificate["Attributes"] => ({
223 id: cert.id,
224 name: cert.name,
225 type: asType(cert.type),
226 certificate: cert.certificate ?? undefined,
227 fingerprint: cert.fingerprint ?? undefined,
228 domainNames: cert.domain_names,
229 notValidBefore: cert.not_valid_before ?? undefined,
230 notValidAfter: cert.not_valid_after ?? undefined,
231 created: cert.created,
232 labels: userLabels(cert.labels),
233 status: toStatus(cert.status),
234 usedBy: cert.used_by.map((item) => ({ id: item.id, type: item.type })),
235});
236
237const toName = (id: string, name: string | undefined, existing?: string) =>
238 Effect.succeed(name ?? existing ?? undefined).pipe(
239 Effect.flatMap((resolved) =>
240 resolved !== undefined
241 ? Effect.succeed(resolved)
242 : createPhysicalName({ id, maxLength: 64 }),
243 ),
244 );
245
246const normalizePem = (pem: string | undefined): string =>
247 (pem ?? "").replace(/\r\n/g, "\n").trim();
248
249const getById = (id: number) =>
250 Hetzner.certificates.getCertificate({ id }).pipe(
251 Effect.map(({ certificate }) => certificate),
252 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
253 );
254
255const findByName = (name: string) =>
256 Hetzner.certificates
257 .listCertificates({ name, per_page: 50 })
258 .pipe(
259 Effect.map(({ certificates }) =>
260 certificates.find((cert) => cert.name === name),
261 ),
262 );
263
264const findByLabels = (labels: Record<string, string>) =>
265 Hetzner.certificates
266 .listCertificates({
267 label_selector: labelSelector(labels),
268 per_page: 50,
269 })
270 .pipe(Effect.map(({ certificates }) => certificates[0]));
271
272const observe = Effect.fn(function* (input: {
273 id?: number;
274 name?: string;
275 logicalId: string;
276}) {
277 if (input.id !== undefined) {
278 const byId = yield* getById(input.id);
279 if (byId !== undefined) return byId;
280 }
281 if (input.name !== undefined) {
282 const byName = yield* findByName(input.name);
283 if (byName !== undefined) return byName;
284 }
285 const internal = yield* createInternalLabels(input.logicalId);
286 return yield* findByLabels(internal);
287});
288
289const deleteById = (id: number) =>
290 Hetzner.certificates
291 .deleteCertificate({ id })
292 .pipe(Effect.catchTag("NotFound", () => Effect.void));
293
294const waitForManagedIssuance = (id: number) =>
295 Hetzner.certificates.getCertificate({ id }).pipe(
296 Effect.flatMap(({ certificate }) =>
297 Effect.gen(function* () {
298 const issuance = certificate.status?.issuance;
299 if (issuance === "failed") {
300 return yield* new CertificateIssuanceFailed({
301 certificateId: id,
302 code: certificate.status?.error?.code,
303 message:
304 certificate.status?.error?.message ??
305 "Certificate issuance failed",
306 });
307 }
308 if (issuance !== undefined && issuance !== "completed") {
309 return yield* new CertificateIssuancePending({
310 certificateId: id,
311 issuance,
312 });
313 }
314 return certificate;
315 }),
316 ),
317 Effect.retry({
318 while: (e) =>
319 e._tag === "Hetzner.CertificateIssuancePending" ||
320 e._tag === "NotFound" ||
321 e._tag === "TooManyRequests" ||
322 e._tag === "ServiceUnavailable" ||
323 e._tag === "InternalServerError" ||
324 e._tag === "BadGateway" ||
325 e._tag === "GatewayTimeout",
326 times: 10,
327 schedule: Schedule.min([
328 Schedule.exponential(Duration.millis(500), 1.5),
329 Schedule.spaced(Duration.seconds(5)),
330 ]),
331 }),
332 );
333
334const ensureCreated = Effect.fn(function* ({
335 name,
336 news,
337 desiredLabels,
338}: {
339 name: string;
340 news: CertificateProps;
341 desiredLabels: Record<string, string>;
342}) {
343 const created =
344 news.type === "managed"
345 ? yield* Hetzner.certificates
346 .createCertificate({
347 name,
348 type: "managed",
349 domain_names: news.domainNames,
350 labels: desiredLabels,
351 })
352 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)))
353 : yield* Hetzner.certificates
354 .createCertificate({
355 name,
356 type: "uploaded",
357 certificate: news.certificate,
358 private_key: news.privateKey,
359 labels: desiredLabels,
360 })
361 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
362
363 if (created === undefined) {
364 return undefined;
365 }
366
367 const cleanup = deleteById(created.certificate.id);
368 if (created.action) {
369 yield* waitForAction(created.action).pipe(Effect.tapError(() => cleanup));
370 }
371 if (asType(created.certificate.type) === "managed") {
372 return yield* waitForManagedIssuance(created.certificate.id).pipe(
373 Effect.tapError(() => cleanup),
374 );
375 }
376 return created.certificate;
377});
378
379export const CertificateProvider = () =>
380 Provider.succeed(Certificate, {
381 stables: [
382 "id",
383 "type",
384 "fingerprint",
385 "certificate",
386 "domainNames",
387 "notValidBefore",
388 "notValidAfter",
389 "created",
390 ],
391
392 diff: Effect.fn(function* ({ news, olds, output }) {
393 if (!isResolved(news)) return undefined;
394 if (output === undefined) return undefined;
395
396 const nextType = news.type ?? "uploaded";
397 const previousName = olds?.name ?? output.name;
398 const nextName = news.name ?? previousName;
399 const deleteFirst = nextName === previousName;
400
401 if (nextType !== output.type) {
402 return { action: "replace" as const, deleteFirst };
403 }
404
405 if (news.type === "managed") {
406 if (!arrayEqualsUnordered(news.domainNames, output.domainNames)) {
407 return { action: "replace" as const, deleteFirst };
408 }
409 return undefined;
410 }
411
412 const prevCert =
413 olds !== undefined && olds.type !== "managed"
414 ? olds.certificate
415 : undefined;
416 const prevKey =
417 olds !== undefined && olds.type !== "managed"
418 ? olds.privateKey
419 : undefined;
420 if (
421 (prevCert !== undefined &&
422 normalizePem(news.certificate) !== normalizePem(prevCert)) ||
423 (prevKey !== undefined &&
424 normalizePem(news.privateKey) !== normalizePem(prevKey))
425 ) {
426 return { action: "replace" as const, deleteFirst };
427 }
428 return undefined;
429 }),
430
431 read: Effect.fn(function* ({ id, olds, output }) {
432 const name = yield* toName(id, olds?.name, output?.name);
433 const existing = yield* observe({
434 id: output?.id,
435 name,
436 logicalId: id,
437 });
438 if (existing === undefined) return undefined;
439 const attrs = toAttrs(existing);
440 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
441 ? attrs
442 : Unowned(attrs);
443 }),
444
445 list: () =>
446 Hetzner.certificates.listCertificates
447 .items({ label_selector: alchemyStackSelector, per_page: 50 })
448 .pipe(
449 Stream.runCollect,
450 Effect.map((chunk) => Array.from(chunk, toAttrs)),
451 ),
452
453 reconcile: Effect.fn(function* ({ id, news, output }) {
454 const name = yield* toName(id, news.name, output?.name);
455 const desiredLabels = {
456 ...toLabels(news.labels),
457 ...(yield* createInternalLabels(id)),
458 };
459
460 let current = yield* observe({
461 id: output?.id,
462 name,
463 logicalId: id,
464 });
465
466 if (current === undefined) {
467 const created = yield* ensureCreated({
468 name,
469 news,
470 desiredLabels,
471 });
472 current =
473 created ??
474 (yield* observe({
475 id: output?.id,
476 name,
477 logicalId: id,
478 }));
479 }
480
481 if (current === undefined) {
482 return yield* new CertificateNotResolved({ name });
483 }
484
485 if (asType(current.type) === "managed") {
486 const issuance = current.status?.issuance;
487 if (issuance === "pending") {
488 current = yield* waitForManagedIssuance(current.id);
489 } else if (issuance === "failed") {
490 return yield* new CertificateIssuanceFailed({
491 certificateId: current.id,
492 code: current.status?.error?.code,
493 message:
494 current.status?.error?.message ?? "Certificate issuance failed",
495 });
496 }
497 }
498
499 const observedLabels = tagRecord(current.labels);
500 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
501 const nameChanged = current.name !== name;
502 const labelsChanged = upsert.length > 0 || removed.length > 0;
503 if (nameChanged || labelsChanged) {
504 const updated = yield* Hetzner.certificates.updateCertificate({
505 id: current.id,
506 name: nameChanged ? name : undefined,
507 labels: labelsChanged ? desiredLabels : undefined,
508 });
509 current = updated.certificate;
510 }
511
512 return toAttrs(current);
513 }),
514
515 delete: Effect.fn(function* ({ output }) {
516 yield* deleteById(output.id);
517 }),
518 });