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