1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
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 { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20export type SslCertificateType = "SELF_MANAGED" | "MANAGED";
21
22export type SslCertificateSelfManaged = {
23 /** PEM-encoded certificate chain. Leaf first, then intermediates. */
24 certificate?: string;
25 /** PEM-encoded private key. Write-only on insert. */
26 privateKey?: string;
27};
28
29export type SslCertificateManaged = {
30 /**
31 * Domains for a Google-managed certificate. Each certificate supports
32 * up to the project quota (typically 100).
33 */
34 domains?: string[];
35};
36
37export type SslCertificateProps = {
38 /**
39 * Certificate name (RFC1035, 1-63 chars). If omitted, a unique name is
40 * generated from the stack, stage, and logical id. Changing it replaces
41 * the resource.
42 */
43 sslCertificateName?: string;
44 /**
45 * Optional description. Compute SSL certificates have no labels field
46 * and no update API, so Alchemy ownership is stored in a `[alchemy …]`
47 * prefix and any description change replaces the certificate.
48 */
49 description?: string;
50 /**
51 * `SELF_MANAGED` (upload PEM) or `MANAGED` (Google provisions). Inferred
52 * from `managed.domains` vs `certificate` / `selfManaged` when omitted.
53 * Immutable — changing it replaces the certificate.
54 * @default "SELF_MANAGED"
55 */
56 type?: SslCertificateType;
57 /**
58 * PEM certificate chain for a self-managed certificate. Immutable —
59 * changing it replaces the certificate.
60 */
61 certificate?: string;
62 /**
63 * PEM private key for a self-managed certificate. Write-only; not
64 * returned by the API. Immutable — changing it replaces the certificate.
65 */
66 privateKey?: string;
67 /**
68 * Nested self-managed payload. `certificate` / `privateKey` on the
69 * resource take precedence when both are set.
70 */
71 selfManaged?: SslCertificateSelfManaged;
72 /**
73 * Google-managed certificate configuration. Immutable — changing domains
74 * replaces the certificate.
75 */
76 managed?: SslCertificateManaged;
77};
78
79export type SslCertificate = Resource<
80 "GCP.Compute.SslCertificate",
81 SslCertificateProps,
82 {
83 /** Certificate name. */
84 sslCertificateName: string;
85 /** Project id. */
86 project: string;
87 /** `SELF_MANAGED` or `MANAGED`. */
88 type: SslCertificateType;
89 /** User description with the Alchemy ownership prefix stripped. */
90 description: string | undefined;
91 /** PEM certificate returned by the API (self-managed; empty until a managed cert is ACTIVE). */
92 certificate: string | undefined;
93 /** RFC3339 expiry, if known. */
94 expireTime: string | undefined;
95 /** Subject alternative names on the certificate. */
96 subjectAlternativeNames: string[];
97 /** Google-managed domains, if this is a managed certificate. */
98 managedDomains: string[];
99 /** Provisioning status of a managed certificate. */
100 managedStatus: string | undefined;
101 /** Per-domain status of a managed certificate. */
102 managedDomainStatus: Record<string, string>;
103 /** Server-defined URL. */
104 selfLink: string | undefined;
105 /** Server-assigned numeric id. */
106 sslCertificateId: string | undefined;
107 /** RFC3339 creation timestamp. */
108 creationTimestamp: string | undefined;
109 /** Resource kind. */
110 kind: string | undefined;
111 },
112 never,
113 Providers
114>;
115
116/**
117 * A global Compute Engine SSL certificate for HTTPS / SSL load balancing.
118 *
119 * Maps to the global `sslCertificates` collection (`regionSslCertificates`
120 * is a separate resource). Certificates cannot be updated in place — every
121 * user-facing field is immutable and changing it replaces the resource.
122 * Compute SSL certificates have no labels field, so Alchemy stamps
123 * ownership into the description for `list` / nuke.
124 *
125 * ### Creating a Self-Managed Certificate
126 * **Example:** Generated name
127 * ```typescript
128 * const cert = yield* GCP.Compute.SslCertificate("Frontend", {
129 * certificate: pemCertificate,
130 * privateKey: pemPrivateKey,
131 * });
132 * ```
133 *
134 * **Example:** Named certificate with a description
135 * ```typescript
136 * const cert = yield* GCP.Compute.SslCertificate("Frontend", {
137 * sslCertificateName: "app-frontend-tls",
138 * description: "prod frontend",
139 * certificate: pemCertificate,
140 * privateKey: pemPrivateKey,
141 * });
142 * ```
143 *
144 * ### Google-Managed Certificates
145 * **Example:** Provision a managed certificate for a domain
146 * ```typescript
147 * const cert = yield* GCP.Compute.SslCertificate("Frontend", {
148 * type: "MANAGED",
149 * managed: { domains: ["www.example.com"] },
150 * });
151 * ```
152 *
153 * @resource
154 * @category Compute
155 */
156export const SslCertificate = Resource<SslCertificate>(
157 "GCP.Compute.SslCertificate",
158);
159
160export class SslCertificateNotResolved extends Data.TaggedError(
161 "GCP.Compute.SslCertificateNotResolved",
162)<{
163 sslCertificateName: string;
164}> {}
165
166const rfc1035 = (name: string): string => {
167 let next = name
168 .toLowerCase()
169 .replace(/[^a-z0-9-]/g, "-")
170 .replace(/-+/g, "-")
171 .replace(/^-+/, "")
172 .replace(/-+$/, "");
173 if (!/^[a-z]/.test(next)) {
174 next = `s${next}`;
175 }
176 next = next.slice(0, 63).replace(/-+$/, "");
177 return next.length > 0 ? next : "sslcert";
178};
179
180const toName = (id: string, name: string | undefined, existing?: string) =>
181 Effect.gen(function* () {
182 if (name !== undefined) return name;
183 if (existing !== undefined) return existing;
184 return rfc1035(
185 yield* createPhysicalName({
186 id,
187 maxLength: 63,
188 lowercase: true,
189 }),
190 );
191 });
192
193const encodeDescription = (
194 labels: Record<string, string>,
195 description: string | undefined,
196): string => {
197 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
198 return description ? `${marker}\n${description}` : marker;
199};
200
201const parseDescription = (
202 description: string | undefined,
203): {
204 labels: Record<string, string>;
205 description: string | undefined;
206} => {
207 if (!description?.startsWith("[alchemy ")) {
208 return { labels: {}, description };
209 }
210 const end = description.indexOf("]");
211 if (end < 0) return { labels: {}, description };
212 const labels: Record<string, string> = {};
213 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
214 const eq = part.indexOf("=");
215 if (eq > 0) {
216 labels[part.slice(0, eq)] = part.slice(eq + 1);
217 }
218 }
219 const rest = description.slice(end + 1).replace(/^\n/, "");
220 return { labels, description: rest.length > 0 ? rest : undefined };
221};
222
223const hasOwnershipMarker = (description: string | undefined): boolean =>
224 (description ?? "").startsWith("[alchemy ");
225
226const typeOf = (props: {
227 type?: string;
228 managed?: SslCertificateManaged;
229 certificate?: string;
230 selfManaged?: SslCertificateSelfManaged;
231}): SslCertificateType => {
232 if (props.type === "MANAGED" || props.type === "SELF_MANAGED") {
233 return props.type;
234 }
235 if ((props.managed?.domains?.length ?? 0) > 0) return "MANAGED";
236 return "SELF_MANAGED";
237};
238
239const asType = (type: string | undefined): SslCertificateType =>
240 type === "MANAGED" ? "MANAGED" : "SELF_MANAGED";
241
242const selfManagedOf = (props: {
243 certificate?: string;
244 privateKey?: string;
245 selfManaged?: SslCertificateSelfManaged;
246}) => ({
247 certificate: props.certificate ?? props.selfManaged?.certificate,
248 privateKey: props.privateKey ?? props.selfManaged?.privateKey,
249});
250
251const normalizePem = (pem: string | undefined): string =>
252 (pem ?? "").replace(/\s+/g, "");
253
254const sameDomains = (left?: readonly string[], right?: readonly string[]) =>
255 [...(left ?? [])].sort().join("\0") === [...(right ?? [])].sort().join("\0");
256
257const toBody = (
258 sslCertificateName: string,
259 props: SslCertificateProps,
260 ownership: Record<string, string>,
261): compute.SslCertificate => {
262 const type = typeOf(props);
263 const description = encodeDescription(ownership, props.description);
264 if (type === "MANAGED") {
265 return {
266 name: sslCertificateName,
267 description,
268 type: "MANAGED",
269 managed: { domains: props.managed?.domains },
270 };
271 }
272 const material = selfManagedOf(props);
273 return {
274 name: sslCertificateName,
275 description,
276 type: "SELF_MANAGED",
277 certificate: material.certificate,
278 privateKey: material.privateKey,
279 selfManaged: {
280 certificate: material.certificate,
281 privateKey: material.privateKey,
282 },
283 };
284};
285
286const toAttrs = (cert: compute.SslCertificate, project: string) => {
287 const parsed = parseDescription(cert.description);
288 return {
289 sslCertificateName: cert.name ?? cert.id ?? "",
290 project,
291 type: asType(cert.type),
292 description: parsed.description,
293 certificate: cert.certificate,
294 expireTime: cert.expireTime,
295 subjectAlternativeNames: cert.subjectAlternativeNames ?? [],
296 managedDomains: cert.managed?.domains ?? [],
297 managedStatus: cert.managed?.status,
298 managedDomainStatus: Object.fromEntries(
299 Object.entries(cert.managed?.domainStatus ?? {}).filter(
300 (entry): entry is [string, string] => typeof entry[1] === "string",
301 ),
302 ),
303 selfLink: cert.selfLink,
304 sslCertificateId: cert.id,
305 creationTimestamp: cert.creationTimestamp,
306 kind: cert.kind,
307 };
308};
309
310const getByName = (project: string, sslCertificate: string) =>
311 compute
312 .getSslCertificates({ project, sslCertificate })
313 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
314
315const awaitResource = (project: string, sslCertificateName: string) =>
316 getByName(project, sslCertificateName).pipe(
317 Effect.repeat({
318 schedule: Schedule.spaced("1 second"),
319 until: (cert) => cert !== undefined,
320 times: 8,
321 }),
322 );
323
324const immutableChanged = (
325 news: SslCertificateProps,
326 olds: Partial<SslCertificateProps> | undefined,
327 output: SslCertificate["Attributes"] | undefined,
328) => {
329 const previousType = typeOf({
330 type: olds?.type ?? output?.type,
331 managed: olds?.managed ?? { domains: output?.managedDomains },
332 certificate: olds?.certificate ?? output?.certificate,
333 selfManaged: olds?.selfManaged,
334 });
335 if (typeOf(news) !== previousType) return true;
336
337 const previousDescription = olds?.description ?? output?.description ?? "";
338 if ((news.description ?? "") !== previousDescription) return true;
339
340 if (typeOf(news) === "MANAGED") {
341 const previousDomains =
342 olds?.managed?.domains ?? output?.managedDomains ?? [];
343 if (
344 news.managed?.domains !== undefined &&
345 !sameDomains(news.managed.domains, previousDomains)
346 ) {
347 return true;
348 }
349 return false;
350 }
351
352 const nextMaterial = selfManagedOf(news);
353 const previousMaterial = selfManagedOf({
354 certificate: olds?.certificate ?? output?.certificate,
355 privateKey: olds?.privateKey,
356 selfManaged: olds?.selfManaged,
357 });
358 if (
359 nextMaterial.certificate !== undefined &&
360 normalizePem(nextMaterial.certificate) !==
361 normalizePem(previousMaterial.certificate)
362 ) {
363 return true;
364 }
365 if (
366 nextMaterial.privateKey !== undefined &&
367 previousMaterial.privateKey !== undefined &&
368 normalizePem(nextMaterial.privateKey) !==
369 normalizePem(previousMaterial.privateKey)
370 ) {
371 return true;
372 }
373 return false;
374};
375
376export const SslCertificateProvider = () =>
377 Provider.succeed(SslCertificate, {
378 stables: [
379 "sslCertificateName",
380 "project",
381 "sslCertificateId",
382 "selfLink",
383 "creationTimestamp",
384 "type",
385 ],
386
387 diff: Effect.fn(function* ({ news, olds, output }) {
388 if (!isResolved(news)) return undefined;
389 const previousName =
390 olds?.sslCertificateName ?? output?.sslCertificateName;
391 const nextName = news.sslCertificateName ?? previousName;
392 if (previousName === undefined && output === undefined) {
393 return undefined;
394 }
395 const nameChanged =
396 news.sslCertificateName !== undefined &&
397 previousName !== undefined &&
398 news.sslCertificateName !== previousName;
399 if (nameChanged || immutableChanged(news, olds, output)) {
400 return {
401 action: "replace" as const,
402 deleteFirst: nextName !== undefined && nextName === previousName,
403 };
404 }
405 return undefined;
406 }),
407
408 read: Effect.fn(function* ({ id, olds, output }) {
409 const env = yield* GcpEnvironment.current;
410 const sslCertificateName = yield* toName(
411 id,
412 olds?.sslCertificateName,
413 output?.sslCertificateName,
414 );
415 const existing = yield* getByName(env.project, sslCertificateName);
416 if (existing === undefined) return undefined;
417 const attrs = toAttrs(existing, env.project);
418 const { labels } = parseDescription(existing.description);
419 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
420 }),
421
422 list: () =>
423 Effect.gen(function* () {
424 const env = yield* GcpEnvironment.current;
425 return yield* compute.listSslCertificates
426 .items({ project: env.project, maxResults: 500 })
427 .pipe(
428 Stream.filter((cert) => hasOwnershipMarker(cert.description)),
429 Stream.map((cert) => toAttrs(cert, env.project)),
430 Stream.runCollect,
431 Effect.map((chunk) => Array.from(chunk)),
432 );
433 }),
434
435 reconcile: Effect.fn(function* ({ id, news, output }) {
436 const env = yield* GcpEnvironment.current;
437 const sslCertificateName = yield* toName(
438 id,
439 news.sslCertificateName,
440 output?.sslCertificateName,
441 );
442 const ownership = yield* createInternalLabels(id);
443 const desired = toBody(sslCertificateName, news, ownership);
444
445 let current = yield* getByName(env.project, sslCertificateName);
446
447 if (current === undefined) {
448 const created = yield* compute
449 .insertSslCertificates({
450 project: env.project,
451 body: desired,
452 })
453 .pipe(
454 Effect.flatMap((operation) =>
455 waitGlobalOperation(env.project, operation),
456 ),
457 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
458 );
459 current =
460 created !== undefined
461 ? yield* awaitResource(env.project, sslCertificateName)
462 : yield* getByName(env.project, sslCertificateName);
463 }
464
465 if (current === undefined) {
466 return yield* new SslCertificateNotResolved({ sslCertificateName });
467 }
468
469 return toAttrs(current, env.project);
470 }),
471
472 delete: Effect.fn(function* ({ output }) {
473 const env = yield* GcpEnvironment.current;
474 const operation = yield* compute
475 .deleteSslCertificates({
476 project: env.project,
477 sslCertificate: output.sslCertificateName,
478 })
479 .pipe(
480 Effect.retry({
481 while: (e) => e._tag === "Conflict",
482 times: 8,
483 schedule: Schedule.spaced("2 seconds"),
484 }),
485 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
486 );
487 if (operation !== undefined) {
488 yield* waitGlobalOperation(env.project, operation).pipe(
489 Effect.catchTag("NotFound", () => Effect.void),
490 );
491 }
492 }),
493 });