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 MAX_NAME_LENGTH = 63;
25
26export type TrustConfigTrustAnchor = {
27 /** PEM-encoded root certificate used as a trust anchor. Up to 5kB. */
28 pemCertificate?: string;
29};
30
31export type TrustConfigIntermediateCA = {
32 /**
33 * PEM-encoded intermediate CA used while building a validation path.
34 * Up to 5kB. Not supported for the workload-certificate / SPIFFE
35 * feature.
36 */
37 pemCertificate?: string;
38};
39
40export type TrustConfigTrustStore = {
41 /** Trust anchors used when validating client certificates. */
42 trustAnchors?: TrustConfigTrustAnchor[];
43 /**
44 * Intermediate CAs used during path building. Currently unsupported
45 * when the TrustConfig is used for SPIFFE / workload certificates.
46 */
47 intermediateCas?: TrustConfigIntermediateCA[];
48};
49
50export type TrustConfigAllowlistedCertificate = {
51 /**
52 * PEM-encoded certificate that is always considered valid when it
53 * parses, proof of private-key possession is established, and SAN
54 * constraints are met. Up to 5kB.
55 */
56 pemCertificate?: string;
57};
58
59export type TrustConfigProps = {
60 /**
61 * TrustConfig id (the `{trustConfig}` segment of
62 * `projects/{project}/locations/{location}/trustConfigs/{trustConfig}`).
63 * If omitted, a unique name is generated from the stack, stage, and
64 * logical id. Must be 1-63 characters and match `[a-z0-9-]{1,63}`.
65 * Immutable — changing it replaces the TrustConfig.
66 */
67 trustConfigId?: string;
68 /**
69 * Certificate Manager location (`global`, `us-central1`, …). Immutable —
70 * changing it replaces the TrustConfig. `US-CENTRAL1` is accepted and
71 * normalized to `us-central1`.
72 * @default "global"
73 */
74 location?: string;
75 /**
76 * Human-readable description.
77 */
78 description?: string;
79 /**
80 * User labels. Alchemy ownership labels are merged in automatically.
81 */
82 labels?: Record<string, string>;
83 /**
84 * Trust stores used with load-balancer mTLS. Only one store is
85 * currently allowed. Not supported for SPIFFE validation — use
86 * `spiffeTrustStores` instead.
87 */
88 trustStores?: TrustConfigTrustStore[];
89 /**
90 * Certificates that are always considered valid (allowlist).
91 */
92 allowlistedCertificates?: TrustConfigAllowlistedCertificate[];
93 /**
94 * Mapping from SPIFFE trust domain to a TrustStore. Used for SPIFFE
95 * certificate validation.
96 */
97 spiffeTrustStores?: Record<string, TrustConfigTrustStore>;
98};
99
100export type TrustConfig = Resource<
101 "GCP.CertificateManager.TrustConfig",
102 TrustConfigProps,
103 {
104 /** Full resource name `projects/{project}/locations/{location}/trustConfigs/{trustConfig}`. */
105 name: string;
106 /** TrustConfig id (last path segment). */
107 trustConfigId: string;
108 /** Project id. */
109 project: string;
110 /** Location id (`global`, `us-central1`, …). */
111 location: string;
112 /** User-provided description. */
113 description: string | undefined;
114 /** User labels (Alchemy ownership labels stripped). */
115 labels: Record<string, string>;
116 /** Trust stores configured for load-balancer mTLS. */
117 trustStores: TrustConfigTrustStore[];
118 /** Allowlisted certificates. */
119 allowlistedCertificates: TrustConfigAllowlistedCertificate[];
120 /** SPIFFE trust-domain to TrustStore mapping. */
121 spiffeTrustStores: Record<string, TrustConfigTrustStore>;
122 /** Server-computed checksum, used as an optimistic-concurrency token. */
123 etag: string | undefined;
124 /** RFC3339 creation timestamp. */
125 createTime: string | undefined;
126 /** RFC3339 last-update timestamp. */
127 updateTime: string | undefined;
128 },
129 never,
130 Providers
131>;
132
133/**
134 * A Certificate Manager TrustConfig — the PKI used to validate client
135 * certificates in mutual TLS (load balancer or SPIFFE).
136 *
137 * Changing `trustConfigId` or `location` replaces the resource.
138 * Description, labels, trust stores, allowlisted certificates, and SPIFFE
139 * trust stores update in place.
140 *
141 * ### Creating a Trust Config
142 * **Example:** Generated name
143 * ```typescript
144 * const trust = yield* GCP.CertificateManager.TrustConfig("ClientMtls", {
145 * trustStores: [
146 * {
147 * trustAnchors: [{ pemCertificate }],
148 * },
149 * ],
150 * });
151 * ```
152 *
153 * **Example:** Named config with labels
154 * ```typescript
155 * const trust = yield* GCP.CertificateManager.TrustConfig("ClientMtls", {
156 * trustConfigId: "app-client-mtls",
157 * description: "prod mTLS roots",
158 * labels: { env: "prod" },
159 * trustStores: [
160 * {
161 * trustAnchors: [{ pemCertificate: rootPem }],
162 * intermediateCas: [{ pemCertificate: intermediatePem }],
163 * },
164 * ],
165 * });
166 * ```
167 *
168 * ### Allowlisted Certificates
169 * **Example:** Allowlist client certificates
170 * ```typescript
171 * const trust = yield* GCP.CertificateManager.TrustConfig("Allowlist", {
172 * allowlistedCertificates: [
173 * { pemCertificate: clientPemA },
174 * { pemCertificate: clientPemB },
175 * ],
176 * });
177 * ```
178 *
179 * ### Regional Trust Config
180 * **Example:** Trust config in us-central1
181 * ```typescript
182 * const trust = yield* GCP.CertificateManager.TrustConfig("RegionalMtls", {
183 * location: "us-central1",
184 * trustStores: [
185 * {
186 * trustAnchors: [{ pemCertificate }],
187 * },
188 * ],
189 * });
190 * ```
191 *
192 * @resource
193 * @category CertificateManager
194 */
195export const TrustConfig = Resource<TrustConfig>(
196 "GCP.CertificateManager.TrustConfig",
197);
198
199export class TrustConfigNotResolved extends Data.TaggedError(
200 "GCP.CertificateManager.TrustConfigNotResolved",
201)<{
202 name: string;
203}> {}
204
205export class TrustConfigStillExists extends Data.TaggedError(
206 "GCP.CertificateManager.TrustConfigStillExists",
207)<{
208 name: string;
209}> {}
210
211const lastSegment = (value: string) => {
212 const trimmed = value.replace(/\/+$/, "");
213 const parts = trimmed.split("/");
214 return parts[parts.length - 1] || trimmed;
215};
216
217const rfc1035 = (name: string): string => {
218 let next = name
219 .toLowerCase()
220 .replace(/[^a-z0-9-]/g, "-")
221 .replace(/-+/g, "-")
222 .replace(/^-+/, "")
223 .replace(/-+$/, "");
224 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
225 return next.length > 0 ? next : "trust-config";
226};
227
228const normalizeLocation = (location: string | undefined) =>
229 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
230
231const resourceName = (
232 project: string,
233 location: string,
234 trustConfigId: string,
235) => `projects/${project}/locations/${location}/trustConfigs/${trustConfigId}`;
236
237const parseName = (name: string) => {
238 const parts = name.split("/").filter((part) => part.length > 0);
239 const trustConfigsAt = parts.lastIndexOf("trustConfigs");
240 const locationsAt = parts.lastIndexOf("locations");
241 const projectsAt = parts.lastIndexOf("projects");
242 return {
243 project:
244 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
245 location:
246 locationsAt >= 0 && parts[locationsAt + 1]
247 ? parts[locationsAt + 1]!
248 : DEFAULT_LOCATION,
249 trustConfigId:
250 trustConfigsAt >= 0 && parts[trustConfigsAt + 1]
251 ? parts[trustConfigsAt + 1]!
252 : lastSegment(name),
253 };
254};
255
256const userLabels = (
257 labels: Record<string, string | undefined> | null | undefined,
258): Record<string, string> => stripInternalLabels(tagRecord(labels));
259
260const toId = (
261 id: string,
262 trustConfigId: string | undefined,
263 existing?: string,
264) =>
265 Effect.gen(function* () {
266 if (trustConfigId !== undefined) return trustConfigId;
267 if (existing !== undefined) return existing;
268 return rfc1035(
269 yield* createPhysicalName({
270 id,
271 maxLength: MAX_NAME_LENGTH,
272 lowercase: true,
273 }),
274 );
275 });
276
277const normalizePem = (pem: string | undefined): string =>
278 (pem ?? "").replace(/\s+/g, "");
279
280const pemEntries = (
281 entries: { pemCertificate?: string }[] | undefined,
282): { pemCertificate?: string }[] =>
283 (entries ?? [])
284 .map((entry) => ({ pemCertificate: entry.pemCertificate }))
285 .filter((entry) => (entry.pemCertificate ?? "").length > 0);
286
287const toStore = (
288 store: certificatemanager.TrustStore | TrustConfigTrustStore | undefined,
289): TrustConfigTrustStore => ({
290 trustAnchors: pemEntries(store?.trustAnchors),
291 intermediateCas: pemEntries(store?.intermediateCas),
292});
293
294const toStores = (
295 stores:
296 | ReadonlyArray<certificatemanager.TrustStore | TrustConfigTrustStore>
297 | null
298 | undefined,
299): TrustConfigTrustStore[] => (stores ?? []).map((store) => toStore(store));
300
301const toSpiffeStores = (
302 stores:
303 | Record<
304 string,
305 certificatemanager.TrustStore | TrustConfigTrustStore | undefined
306 >
307 | null
308 | undefined,
309): Record<string, TrustConfigTrustStore> =>
310 Object.fromEntries(
311 Object.entries(stores ?? {})
312 .filter(
313 (
314 entry,
315 ): entry is [
316 string,
317 certificatemanager.TrustStore | TrustConfigTrustStore,
318 ] => entry[1] !== undefined,
319 )
320 .map(([domain, store]) => [domain, toStore(store)]),
321 );
322
323const pemFingerprint = (entries?: { pemCertificate?: string }[]) =>
324 pemEntries(entries)
325 .map((entry) => normalizePem(entry.pemCertificate))
326 .sort()
327 .join("\0");
328
329const storeFingerprint = (store: TrustConfigTrustStore) =>
330 `${pemFingerprint(store.trustAnchors)}\n${pemFingerprint(store.intermediateCas)}`;
331
332const storesFingerprint = (stores?: TrustConfigTrustStore[]) =>
333 toStores(stores).map(storeFingerprint).sort().join("||");
334
335const spiffeFingerprint = (
336 stores?: Record<string, TrustConfigTrustStore | undefined>,
337) => {
338 const normalized = toSpiffeStores(stores);
339 return Object.keys(normalized)
340 .sort()
341 .map((domain) => `${domain}=${storeFingerprint(normalized[domain]!)}`)
342 .join("||");
343};
344
345const toAttrs = (config: certificatemanager.TrustConfig, project: string) => {
346 const name = config.name ?? "";
347 const parsed = parseName(name);
348 return {
349 name,
350 trustConfigId: parsed.trustConfigId,
351 project: parsed.project || project,
352 location: parsed.location,
353 description: config.description,
354 labels: userLabels(config.labels),
355 trustStores: toStores(config.trustStores),
356 allowlistedCertificates: pemEntries(config.allowlistedCertificates),
357 spiffeTrustStores: toSpiffeStores(config.spiffeTrustStores),
358 etag: config.etag,
359 createTime: config.createTime,
360 updateTime: config.updateTime,
361 };
362};
363
364const getByName = (name: string) =>
365 certificatemanager
366 .getProjectsLocationsTrustConfigs({ name })
367 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
368
369const waitUntilExists = (name: string) =>
370 getByName(name).pipe(
371 Effect.flatMap((config) =>
372 config
373 ? Effect.succeed(config)
374 : Effect.fail(new TrustConfigNotResolved({ name })),
375 ),
376 Effect.retry({
377 while: (error) =>
378 error._tag === "GCP.CertificateManager.TrustConfigNotResolved",
379 times: 8,
380 schedule: Schedule.spaced("1 second"),
381 }),
382 );
383
384const waitUntilGone = (name: string) =>
385 getByName(name).pipe(
386 Effect.flatMap((config) =>
387 config === undefined
388 ? Effect.void
389 : Effect.fail(new TrustConfigStillExists({ name })),
390 ),
391 Effect.retry({
392 while: (error) =>
393 error._tag === "GCP.CertificateManager.TrustConfigStillExists",
394 times: 10,
395 schedule: Schedule.spaced("1 second"),
396 }),
397 );
398
399const listOwnedTrustConfigs = (project: string) =>
400 certificatemanager.listProjectsLocationsTrustConfigs
401 .pages({
402 parent: `projects/${project}/locations/-`,
403 pageSize: 1000,
404 })
405 .pipe(
406 Stream.flatMap((page) => Stream.fromIterable(page.trustConfigs ?? [])),
407 Stream.filter((config) =>
408 Object.keys(config.labels ?? {}).some((key) =>
409 key.startsWith("alchemy-"),
410 ),
411 ),
412 Stream.map((config) => toAttrs(config, project)),
413 Stream.runCollect,
414 Effect.map((chunk) => Array.from(chunk)),
415 Effect.catchTag("NotFound", () => Effect.succeed([])),
416 );
417
418const toCreateBody = (
419 news: TrustConfigProps,
420 desiredLabels: Record<string, string>,
421): certificatemanager.TrustConfig => {
422 const body: certificatemanager.TrustConfig = {
423 description: news.description,
424 labels: desiredLabels,
425 };
426 const trustStores = toStores(news.trustStores);
427 if (trustStores.length > 0) {
428 body.trustStores = trustStores;
429 }
430 const allowlisted = pemEntries(news.allowlistedCertificates);
431 if (allowlisted.length > 0) {
432 body.allowlistedCertificates = allowlisted;
433 }
434 const spiffe = toSpiffeStores(news.spiffeTrustStores);
435 if (Object.keys(spiffe).length > 0) {
436 body.spiffeTrustStores = spiffe;
437 }
438 return body;
439};
440
441export const TrustConfigProvider = () =>
442 Provider.succeed(TrustConfig, {
443 stables: ["name", "trustConfigId", "project", "location", "createTime"],
444
445 diff: Effect.fn(function* ({ news, olds, output }) {
446 if (!isResolved(news)) return undefined;
447
448 const previousId = olds?.trustConfigId ?? output?.trustConfigId;
449 const nextId = news.trustConfigId ?? previousId;
450 const previousLocation = normalizeLocation(
451 olds?.location ?? output?.location,
452 );
453 const nextLocation = normalizeLocation(
454 news.location ?? olds?.location ?? output?.location,
455 );
456
457 const replace =
458 (previousId !== undefined &&
459 nextId !== undefined &&
460 nextId !== previousId) ||
461 previousLocation !== nextLocation;
462
463 if (!replace) return undefined;
464 return { action: "replace" as const };
465 }),
466
467 read: Effect.fn(function* ({ id, olds, output }) {
468 const env = yield* GcpEnvironment.current;
469 const trustConfigId = yield* toId(
470 id,
471 olds?.trustConfigId,
472 output?.trustConfigId,
473 );
474 const location = normalizeLocation(olds?.location ?? output?.location);
475 const name =
476 output?.name ?? resourceName(env.project, location, trustConfigId);
477 const existing = yield* getByName(name);
478 if (existing === undefined) return undefined;
479 const attrs = toAttrs(existing, env.project);
480 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
481 ? attrs
482 : Unowned(attrs);
483 }),
484
485 list: () =>
486 Effect.gen(function* () {
487 const env = yield* GcpEnvironment.current;
488 return yield* listOwnedTrustConfigs(env.project);
489 }),
490
491 reconcile: Effect.fn(function* ({ id, news, output }) {
492 const env = yield* GcpEnvironment.current;
493 const trustConfigId = yield* toId(
494 id,
495 news.trustConfigId,
496 output?.trustConfigId,
497 );
498 const location = normalizeLocation(news.location ?? output?.location);
499 const name = resourceName(env.project, location, trustConfigId);
500 const desiredLabels = {
501 ...toLabels(news.labels),
502 ...(yield* createInternalLabels(id)),
503 };
504
505 let current = yield* getByName(name);
506
507 if (current === undefined) {
508 const created = yield* certificatemanager
509 .createProjectsLocationsTrustConfigs({
510 parent: `projects/${env.project}/locations/${location}`,
511 trustConfigId,
512 body: toCreateBody(news, desiredLabels),
513 })
514 .pipe(
515 Effect.retry({
516 while: (error) => error._tag === "Conflict",
517 times: 5,
518 schedule: Schedule.spaced("2 seconds"),
519 }),
520 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
521 );
522 if (created !== undefined) {
523 yield* waitForOperation(created);
524 }
525 current = yield* waitUntilExists(name);
526 }
527
528 if (current === undefined) {
529 return yield* new TrustConfigNotResolved({ name });
530 }
531
532 const observedLabels = tagRecord(current.labels);
533 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
534 const labelsChanged = upsert.length > 0 || removed.length > 0;
535 const descriptionChanged =
536 (current.description ?? "") !== (news.description ?? "");
537 const desiredStores = toStores(news.trustStores);
538 const storesChanged =
539 storesFingerprint(desiredStores) !==
540 storesFingerprint(current.trustStores);
541 const desiredAllowlisted = pemEntries(news.allowlistedCertificates);
542 const allowlistedChanged =
543 pemFingerprint(desiredAllowlisted) !==
544 pemFingerprint(current.allowlistedCertificates);
545 const desiredSpiffe = toSpiffeStores(news.spiffeTrustStores);
546 const spiffeChanged =
547 spiffeFingerprint(desiredSpiffe) !==
548 spiffeFingerprint(current.spiffeTrustStores);
549
550 if (
551 labelsChanged ||
552 descriptionChanged ||
553 storesChanged ||
554 allowlistedChanged ||
555 spiffeChanged
556 ) {
557 const updateMask = [
558 labelsChanged ? "labels" : undefined,
559 descriptionChanged ? "description" : undefined,
560 storesChanged ? "trustStores" : undefined,
561 allowlistedChanged ? "allowlistedCertificates" : undefined,
562 spiffeChanged ? "spiffeTrustStores" : undefined,
563 ].filter((field): field is string => field !== undefined);
564
565 const operation =
566 yield* certificatemanager.patchProjectsLocationsTrustConfigs({
567 name,
568 updateMask: updateMask.join(","),
569 body: {
570 name,
571 labels: desiredLabels,
572 description: news.description,
573 trustStores: desiredStores,
574 allowlistedCertificates: desiredAllowlisted,
575 spiffeTrustStores: desiredSpiffe,
576 },
577 });
578 yield* waitForOperation(operation);
579 current = yield* waitUntilExists(name);
580 }
581
582 return toAttrs(current, env.project);
583 }),
584
585 delete: Effect.fn(function* ({ output }) {
586 const operation = yield* certificatemanager
587 .deleteProjectsLocationsTrustConfigs({ name: output.name })
588 .pipe(
589 Effect.retry({
590 while: (error) => error._tag === "Conflict",
591 times: 8,
592 schedule: Schedule.spaced("2 seconds"),
593 }),
594 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
595 );
596 if (operation !== undefined) {
597 yield* waitForOperation(operation, { notFoundOk: true });
598 }
599 yield* waitUntilGone(output.name);
600 }),
601 });