1import * as certificatemanager from "@distilled.cloud/gcp/certificatemanager_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import { waitForOperation } from "./operations.ts";
22
23const DEFAULT_LOCATION = "global";
24const DEFAULT_LIFETIME = "2592000s";
25const DEFAULT_ROTATION = 66;
26const DEFAULT_KEY_ALGORITHM: CertificateIssuanceConfigKeyAlgorithm = "RSA_2048";
27const MAX_NAME_LENGTH = 63;
28
29export type CertificateIssuanceConfigKeyAlgorithm =
30 | "RSA_2048"
31 | "ECDSA_P256"
32 | (string & {});
33
34export type CertificateAuthorityServiceConfig = {
35 /**
36 * CA pool that issues workload certificates
37 * (`projects/{project}/locations/{location}/caPools/{caPool}`).
38 * A bare pool id is expanded with this issuance config's project and
39 * location. Immutable — changing it replaces the issuance config.
40 */
41 caPool: string;
42};
43
44export type CertificateAuthorityConfig = {
45 /** Certificate Authority Service pool used to mint certificates. */
46 certificateAuthorityServiceConfig: CertificateAuthorityServiceConfig;
47};
48
49export type CertificateIssuanceConfigProps = {
50 /**
51 * CertificateIssuanceConfig id (the `{certificateIssuanceConfig}`
52 * segment of `projects/{project}/locations/{location}/certificateIssuanceConfigs/{certificateIssuanceConfig}`).
53 * If omitted, a unique name is generated from the stack, stage, and
54 * logical id. Must be 1-63 characters, start with a letter, and match
55 * `[a-z]([-a-z0-9]*[a-z0-9])?`. Immutable — changing it replaces the
56 * issuance config. Ids are unique across all locations.
57 */
58 certificateIssuanceConfigId?: string;
59 /**
60 * Certificate Manager location (`global`, `us-central1`, …). Immutable —
61 * changing it replaces the issuance config. `US-CENTRAL1` is accepted
62 * and normalized to `us-central1`. A regional CA pool may be referenced
63 * from a `global` issuance config.
64 * @default "global"
65 */
66 location?: string;
67 /**
68 * Human-readable description.
69 */
70 description?: string;
71 /**
72 * User labels. Alchemy ownership labels are merged in automatically.
73 */
74 labels?: Record<string, string>;
75 /**
76 * Requested lifetime of issued certificates as a protobuf Duration
77 * (`2592000s` for 30 days). Valid range is 21-30 days. Immutable —
78 * changing it replaces the issuance config.
79 * @default "2592000s"
80 */
81 lifetime?: string;
82 /**
83 * Percentage of elapsed lifetime at which renewal begins. Must be
84 * 1-99 and leave at least 7 days after issuance and before expiry.
85 * Immutable — changing it replaces the issuance config.
86 * @default 66
87 */
88 rotationWindowPercentage?: number;
89 /**
90 * Key algorithm used when generating the private key. `RSA_2048` or
91 * `ECDSA_P256`. Immutable — changing it replaces the issuance config.
92 * @default "RSA_2048"
93 */
94 keyAlgorithm?: CertificateIssuanceConfigKeyAlgorithm;
95 /**
96 * CA that issues the workload certificate. Immutable — changing the
97 * CA pool replaces the issuance config.
98 */
99 certificateAuthorityConfig: CertificateAuthorityConfig;
100};
101
102export type CertificateIssuanceConfig = Resource<
103 "GCP.CertificateManager.CertificateIssuanceConfig",
104 CertificateIssuanceConfigProps,
105 {
106 /** Full resource name `projects/{project}/locations/{location}/certificateIssuanceConfigs/{certificateIssuanceConfig}`. */
107 name: string;
108 /** CertificateIssuanceConfig id (last path segment). */
109 certificateIssuanceConfigId: string;
110 /** Project id. */
111 project: string;
112 /** Location id (`global`, `us-central1`, …). */
113 location: string;
114 /** User-provided description. */
115 description: string | undefined;
116 /** User labels (Alchemy ownership labels stripped). */
117 labels: Record<string, string>;
118 /** Requested certificate lifetime (protobuf Duration). */
119 lifetime: string;
120 /** Percentage of elapsed lifetime at which renewal begins. */
121 rotationWindowPercentage: number;
122 /** Key algorithm (`RSA_2048` or `ECDSA_P256`). */
123 keyAlgorithm: string;
124 /** CA pool resource name used to issue certificates. */
125 caPool: string;
126 /** RFC3339 creation timestamp. */
127 createTime: string | undefined;
128 /** RFC3339 last-update timestamp. */
129 updateTime: string | undefined;
130 },
131 never,
132 Providers
133>;
134
135/**
136 * A Certificate Manager issuance config — how Google-managed certificates
137 * are minted from Certificate Authority Service (lifetime, rotation,
138 * key algorithm, and CA pool).
139 *
140 * Changing `certificateIssuanceConfigId`, `location`, `lifetime`,
141 * `rotationWindowPercentage`, `keyAlgorithm`, or the CA pool replaces
142 * the resource. Description and labels update in place.
143 *
144 * ### Creating an Issuance Config
145 * **Example:** Generated name
146 * ```typescript
147 * const pool = yield* GCP.PrivateCA.CaPool("AppCa", {
148 * location: "us-central1",
149 * });
150 * const issuance = yield* GCP.CertificateManager.CertificateIssuanceConfig(
151 * "WorkloadTls",
152 * {
153 * certificateAuthorityConfig: {
154 * certificateAuthorityServiceConfig: { caPool: pool.name },
155 * },
156 * },
157 * );
158 * ```
159 *
160 * **Example:** Named config with labels
161 * ```typescript
162 * const issuance = yield* GCP.CertificateManager.CertificateIssuanceConfig(
163 * "WorkloadTls",
164 * {
165 * certificateIssuanceConfigId: "app-workload-tls",
166 * description: "prod workload certs",
167 * labels: { env: "prod" },
168 * lifetime: "2592000s",
169 * rotationWindowPercentage: 66,
170 * keyAlgorithm: "ECDSA_P256",
171 * certificateAuthorityConfig: {
172 * certificateAuthorityServiceConfig: { caPool: pool.name },
173 * },
174 * },
175 * );
176 * ```
177 *
178 * ### Regional Issuance Config
179 * **Example:** us-central1 issuance config
180 * ```typescript
181 * const issuance = yield* GCP.CertificateManager.CertificateIssuanceConfig(
182 * "RegionalTls",
183 * {
184 * location: "us-central1",
185 * certificateAuthorityConfig: {
186 * certificateAuthorityServiceConfig: { caPool: pool.name },
187 * },
188 * },
189 * );
190 * ```
191 *
192 * ### Google-Managed Private Certificates
193 * **Example:** Issue a managed certificate from an issuance config
194 * ```typescript
195 * const cert = yield* GCP.CertificateManager.Certificate("FrontendTls", {
196 * type: "MANAGED",
197 * managed: {
198 * domains: ["www.example.com"],
199 * issuanceConfig: issuance.name,
200 * },
201 * });
202 * ```
203 *
204 * @resource
205 * @category CertificateManager
206 */
207export const CertificateIssuanceConfig = Resource<CertificateIssuanceConfig>(
208 "GCP.CertificateManager.CertificateIssuanceConfig",
209);
210
211export class CertificateIssuanceConfigNotResolved extends Data.TaggedError(
212 "GCP.CertificateManager.CertificateIssuanceConfigNotResolved",
213)<{
214 name: string;
215}> {}
216
217export class CertificateIssuanceConfigStillExists extends Data.TaggedError(
218 "GCP.CertificateManager.CertificateIssuanceConfigStillExists",
219)<{
220 name: string;
221}> {}
222
223const lastSegment = (value: string) => {
224 const trimmed = value.replace(/\/+$/, "");
225 const parts = trimmed.split("/");
226 return parts[parts.length - 1] || trimmed;
227};
228
229const rfc1035 = (name: string): string => {
230 let next = name
231 .toLowerCase()
232 .replace(/[^a-z0-9-]/g, "-")
233 .replace(/-+/g, "-")
234 .replace(/^-+/, "")
235 .replace(/-+$/, "");
236 if (!/^[a-z]/.test(next)) {
237 next = `c${next}`;
238 }
239 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
240 return next.length > 0 ? next : "issuance-config";
241};
242
243const normalizeLocation = (location: string | undefined) =>
244 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
245
246const resourceName = (
247 project: string,
248 location: string,
249 certificateIssuanceConfigId: string,
250) =>
251 `projects/${project}/locations/${location}/certificateIssuanceConfigs/${certificateIssuanceConfigId}`;
252
253const parseName = (name: string) => {
254 const parts = name.split("/").filter((part) => part.length > 0);
255 const configsAt = parts.lastIndexOf("certificateIssuanceConfigs");
256 const locationsAt = parts.lastIndexOf("locations");
257 const projectsAt = parts.lastIndexOf("projects");
258 return {
259 project:
260 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
261 location:
262 locationsAt >= 0 && parts[locationsAt + 1]
263 ? parts[locationsAt + 1]!
264 : DEFAULT_LOCATION,
265 certificateIssuanceConfigId:
266 configsAt >= 0 && parts[configsAt + 1]
267 ? parts[configsAt + 1]!
268 : lastSegment(name),
269 };
270};
271
272const userLabels = (
273 labels: Record<string, string | undefined> | null | undefined,
274): Record<string, string> => stripInternalLabels(tagRecord(labels));
275
276const toId = (
277 id: string,
278 certificateIssuanceConfigId: string | undefined,
279 existing?: string,
280) =>
281 Effect.gen(function* () {
282 if (certificateIssuanceConfigId !== undefined) {
283 return certificateIssuanceConfigId;
284 }
285 if (existing !== undefined) return existing;
286 return rfc1035(
287 yield* createPhysicalName({
288 id,
289 maxLength: MAX_NAME_LENGTH,
290 lowercase: true,
291 }),
292 );
293 });
294
295const parseDurationSeconds = (
296 value: string | undefined,
297): number | undefined => {
298 if (value === undefined || value.trim() === "") return undefined;
299 const trimmed = value.trim();
300 const seconds = /^(-?\d+(?:\.\d+)?)s$/i.exec(trimmed);
301 if (seconds) return Number(seconds[1]);
302 const days = /^(-?\d+(?:\.\d+)?)d$/i.exec(trimmed);
303 if (days) return Number(days[1]) * 86400;
304 return undefined;
305};
306
307const normalizeLifetime = (value: string | undefined): string => {
308 const raw =
309 value === undefined || value.trim() === "" ? DEFAULT_LIFETIME : value;
310 const seconds = parseDurationSeconds(raw);
311 return seconds !== undefined && Number.isFinite(seconds)
312 ? `${Math.trunc(seconds)}s`
313 : raw;
314};
315
316const lifetimeEquals = (left: string | undefined, right: string | undefined) =>
317 normalizeLifetime(left) === normalizeLifetime(right);
318
319const normalizeKeyAlgorithm = (value: string | undefined): string => {
320 const compact = (value ?? "").trim().toUpperCase().replace(/-/g, "_");
321 if (
322 compact === "" ||
323 compact === "KEY_ALGORITHM_UNSPECIFIED" ||
324 compact === "UNSPECIFIED"
325 ) {
326 return DEFAULT_KEY_ALGORITHM;
327 }
328 if (
329 compact === "ECDSA_P256" ||
330 compact === "ECDSA" ||
331 compact === "EC_P256" ||
332 compact === "ECDSA_P_256"
333 ) {
334 return "ECDSA_P256";
335 }
336 if (compact === "RSA_2048" || compact === "RSA") {
337 return "RSA_2048";
338 }
339 return compact;
340};
341
342const normalizeRotation = (value: number | undefined): number =>
343 value ?? DEFAULT_ROTATION;
344
345const normalizeCaPool = (
346 value: string | undefined,
347 project: string,
348 location: string,
349): string => {
350 if (value === undefined) return "";
351 const trimmed = value.replace(/\/+$/, "").trim();
352 if (trimmed.length === 0) return "";
353 if (trimmed.includes("/")) return trimmed;
354 return `projects/${project}/locations/${location}/caPools/${trimmed}`;
355};
356
357const caPoolOf = (
358 config:
359 | CertificateAuthorityConfig
360 | certificatemanager.CertificateAuthorityConfig
361 | undefined,
362) => config?.certificateAuthorityServiceConfig?.caPool;
363
364const toAttrs = (
365 config: certificatemanager.CertificateIssuanceConfig,
366 project: string,
367) => {
368 const name = config.name ?? "";
369 const parsed = parseName(name);
370 const location = parsed.location;
371 const resolvedProject = parsed.project || project;
372 return {
373 name,
374 certificateIssuanceConfigId: parsed.certificateIssuanceConfigId,
375 project: resolvedProject,
376 location,
377 description: config.description,
378 labels: userLabels(config.labels),
379 lifetime: normalizeLifetime(config.lifetime),
380 rotationWindowPercentage: normalizeRotation(
381 config.rotationWindowPercentage,
382 ),
383 keyAlgorithm: normalizeKeyAlgorithm(config.keyAlgorithm),
384 caPool: normalizeCaPool(
385 caPoolOf(config.certificateAuthorityConfig),
386 resolvedProject,
387 location,
388 ),
389 createTime: config.createTime,
390 updateTime: config.updateTime,
391 };
392};
393
394const getByName = (name: string) =>
395 certificatemanager
396 .getProjectsLocationsCertificateIssuanceConfigs({ name })
397 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
398
399const waitUntilExists = (name: string) =>
400 getByName(name).pipe(
401 Effect.flatMap((config) =>
402 config
403 ? Effect.succeed(config)
404 : Effect.fail(new CertificateIssuanceConfigNotResolved({ name })),
405 ),
406 Effect.retry({
407 while: (error) =>
408 error._tag ===
409 "GCP.CertificateManager.CertificateIssuanceConfigNotResolved",
410 times: 8,
411 schedule: Schedule.spaced("1 second"),
412 }),
413 );
414
415const waitUntilGone = (name: string) =>
416 getByName(name).pipe(
417 Effect.flatMap((config) =>
418 config === undefined
419 ? Effect.void
420 : Effect.fail(new CertificateIssuanceConfigStillExists({ name })),
421 ),
422 Effect.retry({
423 while: (error) =>
424 error._tag ===
425 "GCP.CertificateManager.CertificateIssuanceConfigStillExists",
426 times: 10,
427 schedule: Schedule.spaced("1 second"),
428 }),
429 );
430
431const listOwnedIssuanceConfigs = (project: string) =>
432 certificatemanager.listProjectsLocationsCertificateIssuanceConfigs
433 .pages({
434 parent: `projects/${project}/locations/-`,
435 pageSize: 1000,
436 })
437 .pipe(
438 Stream.flatMap((page) =>
439 Stream.fromIterable(page.certificateIssuanceConfigs ?? []),
440 ),
441 Stream.filter((config) =>
442 Object.keys(config.labels ?? {}).some((key) =>
443 key.startsWith("alchemy-"),
444 ),
445 ),
446 Stream.map((config) => toAttrs(config, project)),
447 Stream.runCollect,
448 Effect.map((chunk) => Array.from(chunk)),
449 Effect.catchTag("NotFound", () => Effect.succeed([])),
450 );
451
452const desiredCaPool = (
453 news: CertificateIssuanceConfigProps,
454 project: string,
455 location: string,
456) =>
457 normalizeCaPool(caPoolOf(news.certificateAuthorityConfig), project, location);
458
459const toCreateBody = (
460 news: CertificateIssuanceConfigProps,
461 desiredLabels: Record<string, string>,
462 caPool: string,
463): certificatemanager.CertificateIssuanceConfig => ({
464 description: news.description,
465 labels: desiredLabels,
466 lifetime: normalizeLifetime(news.lifetime),
467 rotationWindowPercentage: normalizeRotation(news.rotationWindowPercentage),
468 keyAlgorithm: normalizeKeyAlgorithm(news.keyAlgorithm),
469 certificateAuthorityConfig: {
470 certificateAuthorityServiceConfig: { caPool },
471 },
472});
473
474export const CertificateIssuanceConfigProvider = () =>
475 Provider.succeed(CertificateIssuanceConfig, {
476 stables: [
477 "name",
478 "certificateIssuanceConfigId",
479 "project",
480 "location",
481 "lifetime",
482 "rotationWindowPercentage",
483 "keyAlgorithm",
484 "caPool",
485 "createTime",
486 ],
487
488 diff: Effect.fn(function* ({ news, olds, output }) {
489 if (!isResolved(news)) return undefined;
490
491 const previousId =
492 olds?.certificateIssuanceConfigId ??
493 output?.certificateIssuanceConfigId;
494 const nextId = news.certificateIssuanceConfigId ?? previousId;
495 const previousLocation = normalizeLocation(
496 olds?.location ?? output?.location,
497 );
498 const nextLocation = normalizeLocation(
499 news.location ?? olds?.location ?? output?.location,
500 );
501 const previousLifetime = olds?.lifetime ?? output?.lifetime;
502 const previousRotation = normalizeRotation(
503 olds?.rotationWindowPercentage ?? output?.rotationWindowPercentage,
504 );
505 const nextRotation = normalizeRotation(news.rotationWindowPercentage);
506 const previousAlgorithm = normalizeKeyAlgorithm(
507 olds?.keyAlgorithm ?? output?.keyAlgorithm,
508 );
509 const nextAlgorithm = normalizeKeyAlgorithm(news.keyAlgorithm);
510 const previousCaPool = normalizeCaPool(
511 olds?.certificateAuthorityConfig
512 ? caPoolOf(olds.certificateAuthorityConfig)
513 : output?.caPool,
514 output?.project ?? "",
515 previousLocation,
516 );
517 const nextCaPool = normalizeCaPool(
518 caPoolOf(news.certificateAuthorityConfig) ?? output?.caPool,
519 output?.project ?? "",
520 nextLocation,
521 );
522
523 const replace =
524 (previousId !== undefined &&
525 nextId !== undefined &&
526 nextId !== previousId) ||
527 previousLocation !== nextLocation ||
528 (previousLifetime !== undefined &&
529 !lifetimeEquals(
530 news.lifetime ?? previousLifetime,
531 previousLifetime,
532 )) ||
533 previousRotation !== nextRotation ||
534 previousAlgorithm !== nextAlgorithm ||
535 (previousCaPool !== "" &&
536 nextCaPool !== "" &&
537 previousCaPool !== nextCaPool);
538
539 if (!replace) return undefined;
540 return {
541 action: "replace" as const,
542 deleteFirst:
543 previousLocation === nextLocation &&
544 previousId !== undefined &&
545 nextId === previousId,
546 };
547 }),
548
549 read: Effect.fn(function* ({ id, olds, output }) {
550 const env = yield* GcpEnvironment.current;
551 const certificateIssuanceConfigId = yield* toId(
552 id,
553 olds?.certificateIssuanceConfigId,
554 output?.certificateIssuanceConfigId,
555 );
556 const location = normalizeLocation(olds?.location ?? output?.location);
557 const name =
558 output?.name ??
559 resourceName(env.project, location, certificateIssuanceConfigId);
560 const existing = yield* getByName(name);
561 if (existing === undefined) return undefined;
562 const attrs = toAttrs(existing, env.project);
563 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
564 ? attrs
565 : Unowned(attrs);
566 }),
567
568 list: () =>
569 Effect.gen(function* () {
570 const env = yield* GcpEnvironment.current;
571 return yield* listOwnedIssuanceConfigs(env.project);
572 }),
573
574 reconcile: Effect.fn(function* ({ id, news, output }) {
575 const env = yield* GcpEnvironment.current;
576 const certificateIssuanceConfigId = yield* toId(
577 id,
578 news.certificateIssuanceConfigId,
579 output?.certificateIssuanceConfigId,
580 );
581 const location = normalizeLocation(news.location ?? output?.location);
582 const name = resourceName(
583 env.project,
584 location,
585 certificateIssuanceConfigId,
586 );
587 const desiredLabels = {
588 ...toLabels(news.labels),
589 ...(yield* createInternalLabels(id)),
590 };
591 const caPool = desiredCaPool(news, env.project, location);
592
593 let current = yield* getByName(name);
594
595 if (current === undefined) {
596 yield* Effect.gen(function* () {
597 const created = yield* certificatemanager
598 .createProjectsLocationsCertificateIssuanceConfigs({
599 parent: `projects/${env.project}/locations/${location}`,
600 certificateIssuanceConfigId,
601 body: toCreateBody(news, desiredLabels, caPool),
602 })
603 .pipe(
604 Effect.retry({
605 while: (error) => error._tag === "Conflict",
606 times: 5,
607 schedule: Schedule.spaced("2 seconds"),
608 }),
609 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
610 );
611 if (created !== undefined) {
612 yield* waitForOperation(created);
613 }
614 }).pipe(
615 Effect.retry({
616 // "config validation failed" (INVALID_ARGUMENT) while the CA
617 // pool grant for the Certificate Manager agent propagates.
618 while: (error) =>
619 error._tag === "GCP.OperationFailed" && error.code === 3,
620 times: 8,
621 schedule: Schedule.spaced("3 seconds"),
622 }),
623 );
624 current = yield* waitUntilExists(name);
625 }
626
627 if (current === undefined) {
628 return yield* new CertificateIssuanceConfigNotResolved({ name });
629 }
630
631 const observedLabels = tagRecord(current.labels);
632 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
633 const labelsChanged = upsert.length > 0 || removed.length > 0;
634 const descriptionChanged =
635 (current.description ?? "") !== (news.description ?? "");
636
637 if (labelsChanged || descriptionChanged) {
638 const updateMask = [
639 labelsChanged ? "labels" : undefined,
640 descriptionChanged ? "description" : undefined,
641 ].filter((field): field is string => field !== undefined);
642
643 const operation =
644 yield* certificatemanager.patchProjectsLocationsCertificateIssuanceConfigs(
645 {
646 name,
647 updateMask: updateMask.join(","),
648 body: {
649 name,
650 labels: desiredLabels,
651 description: news.description,
652 },
653 },
654 );
655 yield* waitForOperation(operation);
656 current = yield* waitUntilExists(name);
657 }
658
659 return toAttrs(current, env.project);
660 }),
661
662 delete: Effect.fn(function* ({ output }) {
663 const operation = yield* certificatemanager
664 .deleteProjectsLocationsCertificateIssuanceConfigs({
665 name: output.name,
666 })
667 .pipe(
668 Effect.retry({
669 while: (error) => error._tag === "Conflict",
670 times: 8,
671 schedule: Schedule.spaced("2 seconds"),
672 }),
673 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
674 );
675 if (operation !== undefined) {
676 yield* waitForOperation(operation, { notFoundOk: true });
677 }
678 yield* waitUntilGone(output.name);
679 }),
680 });