1import * as privateca from "@distilled.cloud/gcp/privateca_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 { waitForOperation as waitForGcpOperation } from "../Operation.ts";
14import {
15 createInternalLabels,
16 diffLabels,
17 hasAlchemyLabels,
18 stripInternalLabels,
19 toLabels,
20} from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22
23const DEFAULT_TIER = "DEVOPS";
24const MAX_NAME_LENGTH = 63;
25
26export type CaPoolTier = privateca.CaPoolTierEnum | (string & {});
27
28export type PublishingOptionsEncodingFormat =
29 | privateca.PublishingOptionsEncodingFormatEnum
30 | (string & {});
31
32export type PublishingOptions = {
33 /**
34 * When true, publishes each CA certificate and includes its URL in the
35 * Authority Information Access extension of issued certificates.
36 */
37 publishCaCert?: boolean;
38 /**
39 * When true, publishes each CA CRL and includes its URL in the CRL
40 * Distribution Points extension of issued certificates.
41 */
42 publishCrl?: boolean;
43 /**
44 * Encoding of published CA certificates and CRLs. `PEM` or `DER`.
45 * Defaults to `PEM` when omitted.
46 */
47 encodingFormat?: PublishingOptionsEncodingFormat;
48};
49
50export type EncryptionSpec = {
51 /**
52 * Cloud KMS CryptoKey used to encrypt Subject, SubjectAltNames, and
53 * PEM certificate fields at rest
54 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
55 * Immutable — changing it replaces the pool.
56 */
57 cloudKmsKey?: string;
58};
59
60/**
61 * Issuance controls applied to every certificate minted from this pool.
62 * See Certificate Authority Service `IssuancePolicy`.
63 */
64export type IssuancePolicy = privateca.IssuancePolicy;
65
66export type CaPoolProps = {
67 /**
68 * CaPool id (the `{caPool}` segment of
69 * `projects/{project}/locations/{location}/caPools/{caPool}`).
70 * If omitted, a unique name is generated from the stack, stage, and
71 * logical id. Must match `[a-zA-Z0-9_-]{1,63}`. Immutable — changing it
72 * replaces the pool.
73 */
74 caPoolId?: string;
75 /**
76 * Certificate Authority Service location (`us-central1`, `us-east1`,
77 * …). Immutable — changing it replaces the pool. `US-CENTRAL1` is
78 * accepted and normalized to `us-central1`.
79 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
80 */
81 location?: string;
82 /**
83 * Pool tier. `DEVOPS` is cheaper and intended for tests and
84 * non-production; `ENTERPRISE` enables CA-certificate and CRL
85 * publication. Immutable — changing it replaces the pool.
86 * @default "DEVOPS"
87 */
88 tier?: CaPoolTier;
89 /**
90 * User labels. Alchemy ownership labels are merged in automatically.
91 */
92 labels?: Record<string, string>;
93 /**
94 * How CA certificates and CRLs are published for authorities in this
95 * pool.
96 */
97 publishingOptions?: PublishingOptions;
98 /**
99 * Controls over certificate issuance from this pool (lifetime, allowed
100 * key types, identity constraints, baseline X.509 values).
101 */
102 issuancePolicy?: IssuancePolicy;
103 /**
104 * Customer-managed encryption for certificate fields at rest.
105 * Immutable — changing it replaces the pool.
106 */
107 encryptionSpec?: EncryptionSpec;
108};
109
110export type CaPool = Resource<
111 "GCP.PrivateCA.CaPool",
112 CaPoolProps,
113 {
114 /** Full resource name `projects/{project}/locations/{location}/caPools/{caPool}`. */
115 name: string;
116 /** CaPool id (last path segment). */
117 caPoolId: string;
118 /** Project id. */
119 project: string;
120 /** Location id (`us-central1`, …). */
121 location: string;
122 /** Pool tier (`DEVOPS` or `ENTERPRISE`). */
123 tier: string;
124 /** User labels (Alchemy ownership labels stripped). */
125 labels: Record<string, string>;
126 /** Publishing options currently configured on the pool. */
127 publishingOptions: PublishingOptions | undefined;
128 /** Issuance policy currently configured on the pool. */
129 issuancePolicy: IssuancePolicy | undefined;
130 /** CMEK spec, if any. */
131 encryptionSpec: EncryptionSpec | undefined;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * A Certificate Authority Service CaPool — a group of Certificate
139 * Authorities that share issuance policy and form a trust anchor.
140 *
141 * Changing `caPoolId`, `location`, `tier`, or `encryptionSpec` replaces
142 * the resource. Labels, `publishingOptions`, and `issuancePolicy` update
143 * in place.
144 *
145 * ### Creating a CaPool
146 * **Example:** Generated name
147 * ```typescript
148 * const pool = yield* GCP.PrivateCA.CaPool("AppCa", {});
149 * ```
150 *
151 * **Example:** Explicit id, labels, and publishing
152 * ```typescript
153 * const pool = yield* GCP.PrivateCA.CaPool("AppCa", {
154 * caPoolId: "app-ca",
155 * location: "us-central1",
156 * tier: "DEVOPS",
157 * labels: { env: "prod" },
158 * publishingOptions: {
159 * publishCaCert: false,
160 * publishCrl: false,
161 * },
162 * });
163 * ```
164 *
165 * ### Issuance Policy
166 * **Example:** Cap lifetime and allow config-based issuance
167 * ```typescript
168 * const pool = yield* GCP.PrivateCA.CaPool("LeafCa", {
169 * tier: "DEVOPS",
170 * issuancePolicy: {
171 * maximumLifetime: "2592000s",
172 * allowedIssuanceModes: {
173 * allowConfigBasedIssuance: true,
174 * allowCsrBasedIssuance: true,
175 * },
176 * identityConstraints: {
177 * allowSubjectPassthrough: true,
178 * allowSubjectAltNamesPassthrough: true,
179 * },
180 * },
181 * });
182 * ```
183 *
184 * @resource
185 * @category PrivateCA
186 */
187export const CaPool = Resource<CaPool>("GCP.PrivateCA.CaPool");
188
189export class CaPoolNotResolved extends Data.TaggedError(
190 "GCP.PrivateCA.CaPoolNotResolved",
191)<{
192 name: string;
193}> {}
194
195export class CaPoolStillExists extends Data.TaggedError(
196 "GCP.PrivateCA.CaPoolStillExists",
197)<{
198 name: string;
199}> {}
200
201const lastSegment = (value: string) => {
202 const trimmed = value.replace(/\/+$/, "");
203 const parts = trimmed.split("/");
204 return parts[parts.length - 1] || trimmed;
205};
206
207const rfc1035 = (name: string): string => {
208 let next = name
209 .toLowerCase()
210 .replace(/[^a-z0-9-]/g, "-")
211 .replace(/-+/g, "-")
212 .replace(/^-+/, "")
213 .replace(/-+$/, "");
214 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
215 return next.length > 0 ? next : "ca-pool";
216};
217
218const normalizeLocation = (location: string) =>
219 lastSegment(location).toLowerCase();
220
221const normalizeTier = (tier: string | undefined) => {
222 const value = (tier ?? DEFAULT_TIER).toUpperCase();
223 return value === "TIER_UNSPECIFIED" ? DEFAULT_TIER : value;
224};
225
226const resourceName = (project: string, location: string, caPoolId: string) =>
227 `projects/${project}/locations/${location}/caPools/${caPoolId}`;
228
229const parseName = (name: string) => {
230 const parts = name.split("/").filter((part) => part.length > 0);
231 const poolsAt = parts.lastIndexOf("caPools");
232 const locationsAt = parts.lastIndexOf("locations");
233 const projectsAt = parts.lastIndexOf("projects");
234 return {
235 project:
236 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
237 location:
238 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
239 caPoolId:
240 poolsAt >= 0 && parts[poolsAt + 1]
241 ? parts[poolsAt + 1]!
242 : lastSegment(name),
243 };
244};
245
246const userLabels = (
247 labels: Record<string, string | undefined> | null | undefined,
248): Record<string, string> => stripInternalLabels(tagRecord(labels));
249
250const toId = (id: string, caPoolId: string | undefined, existing?: string) =>
251 Effect.gen(function* () {
252 if (caPoolId !== undefined) return caPoolId;
253 if (existing !== undefined) return existing;
254 return rfc1035(
255 yield* createPhysicalName({
256 id,
257 maxLength: MAX_NAME_LENGTH,
258 lowercase: true,
259 }),
260 );
261 });
262
263const canonical = (value: unknown): unknown => {
264 if (value === undefined || value === null) return undefined;
265 if (typeof value === "boolean" || typeof value === "number") return value;
266 if (typeof value === "string") {
267 return value.length === 0 ? undefined : value;
268 }
269 if (Array.isArray(value)) {
270 const items = value.map(canonical).filter((item) => item !== undefined);
271 return items.length === 0 ? undefined : items;
272 }
273 if (typeof value === "object") {
274 const entries = Object.entries(value as Record<string, unknown>)
275 .map(([key, item]) => [key, canonical(item)] as const)
276 .filter(([, item]) => item !== undefined)
277 .sort(([left], [right]) => left.localeCompare(right));
278 if (entries.length === 0) return undefined;
279 return Object.fromEntries(entries);
280 }
281 return undefined;
282};
283
284const fingerprint = (value: unknown): string =>
285 JSON.stringify(canonical(value) ?? null);
286
287const encodingOf = (value: string | undefined) => {
288 if (
289 value === undefined ||
290 value === "" ||
291 value === "ENCODING_FORMAT_UNSPECIFIED"
292 ) {
293 return undefined;
294 }
295 return value;
296};
297
298const toPublishingOptions = (
299 options: PublishingOptions | privateca.PublishingOptions | undefined,
300): PublishingOptions => ({
301 publishCaCert: options?.publishCaCert === true,
302 publishCrl: options?.publishCrl === true,
303 encodingFormat: encodingOf(options?.encodingFormat),
304});
305
306const publishingFingerprint = (
307 options: PublishingOptions | privateca.PublishingOptions | undefined,
308) =>
309 fingerprint({
310 publishCaCert: options?.publishCaCert === true,
311 publishCrl: options?.publishCrl === true,
312 encodingFormat: encodingOf(options?.encodingFormat) ?? "PEM",
313 });
314
315const toEncryptionSpec = (
316 spec: EncryptionSpec | privateca.EncryptionSpec | undefined,
317): EncryptionSpec | undefined => {
318 const cloudKmsKey = spec?.cloudKmsKey;
319 if (cloudKmsKey === undefined || cloudKmsKey.length === 0) return undefined;
320 return { cloudKmsKey };
321};
322
323const toIssuancePolicy = (
324 policy: IssuancePolicy | undefined,
325): IssuancePolicy | undefined => {
326 if (policy === undefined) return undefined;
327 const next = canonical(policy);
328 return next === undefined ? undefined : (next as IssuancePolicy);
329};
330
331const toAttrs = (pool: privateca.CaPool, project: string) => {
332 const name = pool.name ?? "";
333 const parsed = parseName(name);
334 return {
335 name,
336 caPoolId: parsed.caPoolId,
337 project: parsed.project || project,
338 location: parsed.location,
339 tier: normalizeTier(pool.tier),
340 labels: userLabels(pool.labels),
341 publishingOptions: toPublishingOptions(pool.publishingOptions),
342 issuancePolicy: toIssuancePolicy(pool.issuancePolicy),
343 encryptionSpec: toEncryptionSpec(pool.encryptionSpec),
344 };
345};
346
347const getByName = (name: string) =>
348 privateca
349 .getProjectsLocationsCaPools({ name })
350 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
351
352/**
353 * Wait for a Certificate Authority Service operation; CA creation and
354 * activation take a few minutes. ALREADY_EXISTS (code 6) counts as success
355 * (create race); with `notFoundOk`, so does NOT_FOUND (code 5, delete race)
356 * and an operation that is already gone.
357 */
358const waitForOperation = (
359 operation: privateca.Operation,
360 options?: { notFoundOk?: boolean },
361) =>
362 waitForGcpOperation(
363 operation,
364 (name) => privateca.getProjectsLocationsOperations({ name }),
365 { budget: "15 minutes" },
366 ).pipe(
367 Effect.catchIf(
368 (error) =>
369 (error._tag === "GCP.OperationFailed" &&
370 (error.code === 6 ||
371 (options?.notFoundOk === true && error.code === 5))) ||
372 (options?.notFoundOk === true && error._tag === "NotFound"),
373 () => Effect.succeed(operation),
374 ),
375 );
376
377const waitUntilExists = (name: string) =>
378 getByName(name).pipe(
379 Effect.flatMap((pool) =>
380 pool
381 ? Effect.succeed(pool)
382 : Effect.fail(new CaPoolNotResolved({ name })),
383 ),
384 Effect.retry({
385 while: (error) => error._tag === "GCP.PrivateCA.CaPoolNotResolved",
386 times: 8,
387 schedule: Schedule.spaced("1 second"),
388 }),
389 );
390
391const waitUntilGone = (name: string) =>
392 getByName(name).pipe(
393 Effect.flatMap((pool) =>
394 pool === undefined
395 ? Effect.void
396 : Effect.fail(new CaPoolStillExists({ name })),
397 ),
398 Effect.retry({
399 while: (error) => error._tag === "GCP.PrivateCA.CaPoolStillExists",
400 times: 10,
401 schedule: Schedule.spaced("1 second"),
402 }),
403 );
404
405const listOwnedCaPools = (project: string) =>
406 privateca.listProjectsLocationsCaPools
407 .pages({
408 parent: `projects/${project}/locations/-`,
409 pageSize: 1000,
410 })
411 .pipe(
412 Stream.flatMap((page) => Stream.fromIterable(page.caPools ?? [])),
413 Stream.filter((pool) =>
414 Object.keys(pool.labels ?? {}).some((key) =>
415 key.startsWith("alchemy-"),
416 ),
417 ),
418 Stream.map((pool) => toAttrs(pool, project)),
419 Stream.runCollect,
420 Effect.map((chunk) => Array.from(chunk)),
421 Effect.catchTag("NotFound", () => Effect.succeed([])),
422 );
423
424const toCreateBody = (
425 news: CaPoolProps,
426 desiredLabels: Record<string, string>,
427): privateca.CaPool => {
428 const body: privateca.CaPool = {
429 tier: normalizeTier(news.tier),
430 labels: desiredLabels,
431 };
432 if (news.publishingOptions !== undefined) {
433 body.publishingOptions = toPublishingOptions(news.publishingOptions);
434 }
435 const issuance = toIssuancePolicy(news.issuancePolicy);
436 if (issuance !== undefined) {
437 body.issuancePolicy = issuance;
438 }
439 const encryption = toEncryptionSpec(news.encryptionSpec);
440 if (encryption !== undefined) {
441 body.encryptionSpec = encryption;
442 }
443 return body;
444};
445
446export const CaPoolProvider = () =>
447 Provider.succeed(CaPool, {
448 stables: ["name", "caPoolId", "project", "location", "tier"],
449
450 diff: Effect.fn(function* ({ news, olds, output }) {
451 if (!isResolved(news)) return undefined;
452 const env = yield* GcpEnvironment.current;
453
454 const previousId = olds?.caPoolId ?? output?.caPoolId;
455 const nextId = news.caPoolId ?? previousId;
456 const idChanged =
457 previousId !== undefined &&
458 nextId !== undefined &&
459 nextId !== previousId;
460
461 const previousLocation = normalizeLocation(
462 olds?.location ?? output?.location ?? env.region,
463 );
464 const nextLocation = normalizeLocation(
465 news.location ?? olds?.location ?? output?.location ?? env.region,
466 );
467 const locationChanged = previousLocation !== nextLocation;
468
469 const previousTier = normalizeTier(olds?.tier ?? output?.tier);
470 const nextTier = normalizeTier(news.tier ?? olds?.tier ?? output?.tier);
471 const tierChanged = previousTier !== nextTier;
472
473 const previousKey =
474 olds?.encryptionSpec?.cloudKmsKey ??
475 output?.encryptionSpec?.cloudKmsKey;
476 const nextKey = news.encryptionSpec?.cloudKmsKey ?? previousKey;
477 const encryptionChanged =
478 news.encryptionSpec !== undefined && previousKey !== nextKey;
479
480 if (
481 !idChanged &&
482 !locationChanged &&
483 !tierChanged &&
484 !encryptionChanged
485 ) {
486 return undefined;
487 }
488
489 return {
490 action: "replace" as const,
491 deleteFirst:
492 !idChanged &&
493 !locationChanged &&
494 previousId !== undefined &&
495 nextId === previousId,
496 };
497 }),
498
499 read: Effect.fn(function* ({ id, olds, output }) {
500 const env = yield* GcpEnvironment.current;
501 const caPoolId = yield* toId(id, olds?.caPoolId, output?.caPoolId);
502 const location = normalizeLocation(
503 olds?.location ?? output?.location ?? env.region,
504 );
505 const name =
506 output?.name ?? resourceName(env.project, location, caPoolId);
507 const existing = yield* getByName(name);
508 if (existing === undefined) return undefined;
509 const attrs = toAttrs(existing, env.project);
510 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
511 ? attrs
512 : Unowned(attrs);
513 }),
514
515 list: () =>
516 Effect.gen(function* () {
517 const env = yield* GcpEnvironment.current;
518 return yield* listOwnedCaPools(env.project);
519 }),
520
521 reconcile: Effect.fn(function* ({ id, news, output }) {
522 const env = yield* GcpEnvironment.current;
523 const caPoolId = yield* toId(id, news.caPoolId, output?.caPoolId);
524 const location = normalizeLocation(
525 news.location ?? output?.location ?? env.region,
526 );
527 const name = resourceName(env.project, location, caPoolId);
528 const desiredLabels = {
529 ...toLabels(news.labels),
530 ...(yield* createInternalLabels(id)),
531 };
532
533 let current = yield* getByName(name);
534
535 if (current === undefined) {
536 const created = yield* privateca
537 .createProjectsLocationsCaPools({
538 parent: `projects/${env.project}/locations/${location}`,
539 caPoolId,
540 body: toCreateBody(news, desiredLabels),
541 })
542 .pipe(
543 Effect.retry({
544 while: (error) => error._tag === "Conflict",
545 times: 5,
546 schedule: Schedule.spaced("2 seconds"),
547 }),
548 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
549 );
550 if (created !== undefined) {
551 yield* waitForOperation(created);
552 }
553 current = yield* waitUntilExists(name);
554 }
555
556 if (current === undefined) {
557 return yield* new CaPoolNotResolved({ name });
558 }
559
560 const observedLabels = tagRecord(current.labels);
561 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
562 const labelsChanged = upsert.length > 0 || removed.length > 0;
563
564 const desiredPublishing = toPublishingOptions(news.publishingOptions);
565 const publishingChanged =
566 news.publishingOptions !== undefined &&
567 publishingFingerprint(news.publishingOptions) !==
568 publishingFingerprint(current.publishingOptions);
569
570 const desiredIssuance = toIssuancePolicy(news.issuancePolicy);
571 const issuanceChanged =
572 news.issuancePolicy !== undefined &&
573 fingerprint(desiredIssuance) !==
574 fingerprint(toIssuancePolicy(current.issuancePolicy));
575
576 if (labelsChanged || publishingChanged || issuanceChanged) {
577 const updateMask = [
578 labelsChanged ? "labels" : undefined,
579 publishingChanged ? "publishingOptions" : undefined,
580 issuanceChanged ? "issuancePolicy" : undefined,
581 ].filter((field): field is string => field !== undefined);
582
583 const operation = yield* privateca.patchProjectsLocationsCaPools({
584 name,
585 updateMask: updateMask.join(","),
586 body: {
587 name,
588 labels: desiredLabels,
589 publishingOptions: desiredPublishing,
590 issuancePolicy: desiredIssuance,
591 },
592 });
593 yield* waitForOperation(operation);
594 current = yield* waitUntilExists(name);
595 }
596
597 if (current === undefined) {
598 return yield* new CaPoolNotResolved({ name });
599 }
600
601 return toAttrs(current, env.project);
602 }),
603
604 delete: Effect.fn(function* ({ output }) {
605 const operation = yield* privateca
606 .deleteProjectsLocationsCaPools({ name: output.name })
607 .pipe(
608 Effect.retry({
609 while: (error) => error._tag === "Conflict",
610 times: 8,
611 schedule: Schedule.spaced("2 seconds"),
612 }),
613 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
614 );
615 if (operation !== undefined) {
616 yield* waitForOperation(operation, { notFoundOk: true });
617 }
618 yield* waitUntilGone(output.name);
619 }),
620 });