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_TYPE: privateca.CertificateAuthorityTypeEnum = "SELF_SIGNED";
24const DEFAULT_LIFETIME = "315360000s";
25const DEFAULT_ALGORITHM: privateca.KeyVersionSpecAlgorithmEnum =
26 "EC_P256_SHA256";
27const DEFAULT_DESIRED_STATE: DesiredState = "ENABLED";
28const MAX_NAME_LENGTH = 63;
29const STEADY_STATES = new Set([
30 "ENABLED",
31 "DISABLED",
32 "STAGED",
33 "AWAITING_USER_ACTIVATION",
34]);
35
36export type DesiredState = "ENABLED" | "DISABLED" | "STAGED";
37
38export type KeyVersionSpec = {
39 /**
40 * Algorithm for a Google-managed Cloud KMS key. Managed keys use
41 * `HSM` protection. Immutable — changing it replaces the CA.
42 */
43 algorithm?: privateca.KeyVersionSpecAlgorithmEnum | (string & {});
44 /**
45 * Existing Cloud KMS CryptoKeyVersion
46 * `projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}/cryptoKeyVersions/{version}`.
47 * Immutable — changing it replaces the CA.
48 */
49 cloudKmsKeyVersion?: string;
50};
51
52export type Subject = {
53 /** Common name of the subject. */
54 commonName?: string;
55 /** Organization of the subject. */
56 organization?: string;
57 /** Organizational unit of the subject. */
58 organizationalUnit?: string;
59 /** Locality or city of the subject. */
60 locality?: string;
61 /** Province, territory, or regional state of the subject. */
62 province?: string;
63 /** Country code of the subject. */
64 countryCode?: string;
65 /** Street address of the subject. */
66 streetAddress?: string;
67 /** Postal code of the subject. */
68 postalCode?: string;
69};
70
71export type SubjectAltNames = {
72 /** Fully-qualified DNS names. */
73 dnsNames?: string[];
74 /** RFC 3986 URIs. */
75 uris?: string[];
76 /** RFC 2822 email addresses. */
77 emailAddresses?: string[];
78 /** IPv4 or IPv6 addresses. */
79 ipAddresses?: string[];
80};
81
82export type SubjectConfig = {
83 /** Distinguished name fields. */
84 subject?: Subject;
85 /** Subject alternative names. */
86 subjectAltName?: SubjectAltNames;
87};
88
89export type KeyUsageOptions = {
90 digitalSignature?: boolean;
91 contentCommitment?: boolean;
92 keyEncipherment?: boolean;
93 dataEncipherment?: boolean;
94 keyAgreement?: boolean;
95 certSign?: boolean;
96 crlSign?: boolean;
97 encipherOnly?: boolean;
98 decipherOnly?: boolean;
99};
100
101export type ExtendedKeyUsageOptions = {
102 serverAuth?: boolean;
103 clientAuth?: boolean;
104 codeSigning?: boolean;
105 emailProtection?: boolean;
106 timeStamping?: boolean;
107 ocspSigning?: boolean;
108};
109
110export type KeyUsage = {
111 /** High-level key uses (digital signature, cert sign, …). */
112 baseKeyUsage?: KeyUsageOptions;
113 /** Extended key uses (server auth, client auth, …). */
114 extendedKeyUsage?: ExtendedKeyUsageOptions;
115};
116
117export type CaOptions = {
118 /**
119 * Whether this certificate is a CA. Must be `true` for a
120 * CertificateAuthority.
121 */
122 isCa?: boolean;
123 /**
124 * Maximum depth of subordinate CA certificates allowed. Omit to leave
125 * the path-length constraint off the certificate.
126 */
127 maxIssuerPathLength?: number;
128};
129
130export type X509Config = {
131 /** CA basic-constraints options. */
132 caOptions?: CaOptions;
133 /** Key usage extension. */
134 keyUsage?: KeyUsage;
135 /** OCSP endpoints in the Authority Information Access extension. */
136 aiaOcspServers?: string[];
137};
138
139export type CertificateConfig = {
140 /** Subject and SAN fields written into the CA certificate. Immutable. */
141 subjectConfig?: SubjectConfig;
142 /** X.509 extensions written into the CA certificate. Immutable. */
143 x509Config?: X509Config;
144};
145
146export type UserDefinedAccessUrls = {
147 /** URLs where CRL information is published. */
148 crlAccessUrls?: string[];
149 /** URLs where the issuer CA certificate may be downloaded. */
150 aiaIssuingCertificateUrls?: string[];
151};
152
153export type SubordinateConfig = {
154 /**
155 * Issuing CertificateAuthority resource name
156 * `projects/{project}/locations/{location}/caPools/{caPool}/certificateAuthorities/{certificateAuthority}`.
157 */
158 certificateAuthority?: string;
159 /** PEM certificate chain of the issuers (leaf-to-root), excluding this CA. */
160 pemIssuerChain?: {
161 pemCertificates?: string[];
162 };
163};
164
165export type CertificateAuthorityProps = {
166 /**
167 * Parent CaPool. Full name
168 * `projects/{project}/locations/{location}/caPools/{caPool}` or the
169 * pool id (combined with `location`). Immutable — changing it replaces
170 * the CA.
171 */
172 caPool: string;
173 /**
174 * Location (`us-central1`, …). Used when `caPool` is a bare id.
175 * Immutable — changing it replaces the CA. `US-CENTRAL1` is accepted
176 * and normalized to `us-central1`.
177 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
178 */
179 location?: string;
180 /**
181 * CertificateAuthority id (the last path segment). If omitted, a unique
182 * name is generated from the stack, stage, and logical id. Must match
183 * `[a-zA-Z0-9_-]{1,63}`. Immutable — changing it replaces the CA.
184 */
185 certificateAuthorityId?: string;
186 /**
187 * `SELF_SIGNED` root or `SUBORDINATE`. Immutable — changing it
188 * replaces the CA.
189 * @default "SELF_SIGNED"
190 */
191 type?: privateca.CertificateAuthorityTypeEnum | (string & {});
192 /**
193 * Desired lifetime of the CA certificate (e.g. `"315360000s"`).
194 * Immutable — changing it replaces the CA.
195 * @default "315360000s"
196 */
197 lifetime?: string;
198 /**
199 * Signing key. Either `algorithm` (Google-managed HSM key) or
200 * `cloudKmsKeyVersion`. Immutable — changing it replaces the CA.
201 * @default { algorithm: "EC_P256_SHA256" }
202 */
203 keySpec?: KeyVersionSpec;
204 /**
205 * X.509 certificate config (subject + CA extensions). Immutable —
206 * changing it replaces the CA. When omitted, Alchemy fills a self-signed
207 * CA subject (`organization: "Alchemy"`, `commonName` = id) with
208 * `isCa: true` and cert/CRL sign usage.
209 */
210 config?: CertificateConfig;
211 /**
212 * User labels. Alchemy ownership labels are merged in automatically.
213 */
214 labels?: Record<string, string>;
215 /**
216 * Cloud Storage bucket that publishes the CA certificate and CRLs
217 * (bucket name only, no `gs://` prefix). Immutable.
218 */
219 gcsBucket?: string;
220 /**
221 * User-defined AIA/CRL URLs. The service does not publish to these
222 * URLs; they are mirrored by the operator. Updatable.
223 */
224 userDefinedAccessUrls?: UserDefinedAccessUrls;
225 /**
226 * Issuer information for a subordinate CA. Updatable, but the CA must
227 * continue to validate against the chain.
228 */
229 subordinateConfig?: SubordinateConfig;
230 /**
231 * PEM CA certificate used to activate a `SUBORDINATE` CA that is in
232 * `AWAITING_USER_ACTIVATION`. Pair with `subordinateConfig`.
233 */
234 pemCaCertificate?: string;
235 /**
236 * Target operational state. After create the API leaves the CA in
237 * `STAGED`. `ENABLED` calls enable; `DISABLED` calls disable; `STAGED`
238 * leaves the create-time state (it is not possible to return to
239 * `STAGED` from `ENABLED`/`DISABLED`).
240 * @default "ENABLED"
241 */
242 desiredState?: DesiredState | (string & {});
243};
244
245export type CertificateAuthorityAttrs = {
246 /** Full resource name `projects/{project}/locations/{location}/caPools/{caPool}/certificateAuthorities/{certificateAuthority}`. */
247 name: string;
248 /** CertificateAuthority id (last path segment). */
249 certificateAuthorityId: string;
250 /** Parent CaPool resource name. */
251 caPool: string;
252 /** Project id. */
253 project: string;
254 /** Location id (`us-central1`, …). */
255 location: string;
256 /** `SELF_SIGNED` or `SUBORDINATE`. */
257 type: string;
258 /** Server-reported state (`ENABLED`, `STAGED`, `DISABLED`, …). */
259 state: string | undefined;
260 /** CaPool tier (`ENTERPRISE`, `DEVOPS`). */
261 tier: string | undefined;
262 /** User labels (Alchemy ownership labels stripped). */
263 labels: Record<string, string>;
264 /** CA certificate lifetime. */
265 lifetime: string | undefined;
266 /** Signing-key spec currently applied. */
267 keySpec: KeyVersionSpec | undefined;
268 /** Cloud Storage bucket used for publishing, if any. */
269 gcsBucket: string | undefined;
270 /** PEM CA certificate chain (self-to-root). */
271 pemCaCertificates: string[];
272 /** User-defined AIA/CRL URLs. */
273 userDefinedAccessUrls: UserDefinedAccessUrls | undefined;
274 /** Subordinate issuer config, if any. */
275 subordinateConfig: SubordinateConfig | undefined;
276 /** URLs for the CA certificate and CRLs. */
277 caCertificateUrl: string | undefined;
278 /** CRL access URL. */
279 crlAccessUrl: string | undefined;
280 /** RFC3339 creation timestamp. */
281 createTime: string | undefined;
282 /** RFC3339 last-update timestamp. */
283 updateTime: string | undefined;
284 /** RFC3339 soft-delete timestamp, if the CA is `DELETED`. */
285 deleteTime: string | undefined;
286 /** RFC3339 purge timestamp, if the CA is `DELETED`. */
287 expireTime: string | undefined;
288};
289
290export type CertificateAuthority = Resource<
291 "GCP.PrivateCA.CertificateAuthority",
292 CertificateAuthorityProps,
293 CertificateAuthorityAttrs,
294 never,
295 Providers
296>;
297
298/**
299 * A Certificate Authority Service CertificateAuthority — a named CA in a
300 * CaPool that issues certificates.
301 *
302 * Changing `certificateAuthorityId`, `caPool`, `location`, `type`,
303 * `lifetime`, `keySpec`, `config`, or `gcsBucket` replaces the CA. Labels
304 * and `userDefinedAccessUrls` update in place. `desiredState` enable/disable
305 * the CA after create (`STAGED` is the API's create-time state).
306 *
307 * Destroy disables an `ENABLED` CA, then deletes it with `skipGracePeriod`
308 * so the 30-day undelete window is skipped (required for `pnpm nuke:gcp`).
309 * `STAGED` CAs are deleted directly. Provisioning a Google-managed HSM key
310 * typically takes under a minute on a `DEVOPS` pool.
311 *
312 * ### Creating a Certificate Authority
313 * **Example:** Generated name in an existing pool
314 * ```typescript
315 * const pool = yield* GCP.PrivateCA.CaPool("Pool", { tier: "DEVOPS" });
316 * const ca = yield* GCP.PrivateCA.CertificateAuthority("Root", {
317 * caPool: pool.name,
318 * });
319 * ```
320 *
321 * **Example:** Explicit id, labels, and subject
322 * ```typescript
323 * const ca = yield* GCP.PrivateCA.CertificateAuthority("Root", {
324 * caPool: pool.name,
325 * certificateAuthorityId: "app-root",
326 * type: "SELF_SIGNED",
327 * lifetime: "315360000s",
328 * keySpec: { algorithm: "EC_P256_SHA256" },
329 * desiredState: "ENABLED",
330 * labels: { env: "prod" },
331 * config: {
332 * subjectConfig: {
333 * subject: {
334 * organization: "Example",
335 * commonName: "Example Root CA",
336 * },
337 * },
338 * x509Config: {
339 * caOptions: { isCa: true },
340 * keyUsage: {
341 * baseKeyUsage: {
342 * certSign: true,
343 * crlSign: true,
344 * },
345 * },
346 * },
347 * },
348 * });
349 * ```
350 *
351 * ### Staging a CA
352 * **Example:** Leave the CA in STAGED after create
353 * ```typescript
354 * const ca = yield* GCP.PrivateCA.CertificateAuthority("Root", {
355 * caPool: pool.name,
356 * desiredState: "STAGED",
357 * });
358 * ```
359 *
360 * @resource
361 * @category PrivateCA
362 */
363export const CertificateAuthority = Resource<CertificateAuthority>(
364 "GCP.PrivateCA.CertificateAuthority",
365);
366
367export class CertificateAuthorityNotResolved extends Data.TaggedError(
368 "GCP.PrivateCA.CertificateAuthorityNotResolved",
369)<{
370 name: string;
371}> {}
372
373export class CertificateAuthorityNotReady extends Data.TaggedError(
374 "GCP.PrivateCA.CertificateAuthorityNotReady",
375)<{
376 name: string;
377 state: string;
378}> {}
379
380export class CertificateAuthorityStillExists extends Data.TaggedError(
381 "GCP.PrivateCA.CertificateAuthorityStillExists",
382)<{
383 name: string;
384}> {}
385
386const lastSegment = (value: string) => {
387 const trimmed = value.replace(/\/+$/, "");
388 const parts = trimmed.split("/");
389 return parts[parts.length - 1] || trimmed;
390};
391
392const normalizeLocation = (location: string) =>
393 lastSegment(location).toLowerCase();
394
395const normalizeType = (type: string | undefined) => {
396 const value = (type ?? DEFAULT_TYPE).toUpperCase();
397 return value === "TYPE_UNSPECIFIED" ? DEFAULT_TYPE : value;
398};
399
400const normalizeDesired = (state: string | undefined): DesiredState => {
401 const value = (state ?? DEFAULT_DESIRED_STATE).toUpperCase();
402 if (value === "DISABLED") return "DISABLED";
403 if (value === "STAGED") return "STAGED";
404 return "ENABLED";
405};
406
407const resourceName = (caPool: string, certificateAuthorityId: string) =>
408 `${caPool}/certificateAuthorities/${certificateAuthorityId}`;
409
410const parseName = (name: string) => {
411 const parts = name.split("/").filter((part) => part.length > 0);
412 const casAt = parts.lastIndexOf("certificateAuthorities");
413 const poolsAt = parts.lastIndexOf("caPools");
414 const locationsAt = parts.lastIndexOf("locations");
415 const projectsAt = parts.lastIndexOf("projects");
416 const caPool = poolsAt >= 0 ? parts.slice(0, poolsAt + 2).join("/") : "";
417 return {
418 project:
419 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
420 location:
421 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
422 caPool,
423 certificateAuthorityId:
424 casAt >= 0 && parts[casAt + 1] ? parts[casAt + 1]! : lastSegment(name),
425 };
426};
427
428const resolveParent = (project: string, caPool: string, location: string) => {
429 if (caPool.includes("/")) {
430 const parsed = parseName(
431 caPool.includes("/certificateAuthorities/")
432 ? caPool
433 : `${caPool}/certificateAuthorities/_`,
434 );
435 return {
436 parent: parsed.caPool,
437 location: parsed.location,
438 project: parsed.project || project,
439 };
440 }
441 const loc = normalizeLocation(location);
442 return {
443 parent: `projects/${project}/locations/${loc}/caPools/${caPool}`,
444 location: loc,
445 project,
446 };
447};
448
449const userLabels = (
450 labels: Record<string, string | undefined> | null | undefined,
451): Record<string, string> => stripInternalLabels(tagRecord(labels));
452
453const toId = (
454 id: string,
455 certificateAuthorityId: string | undefined,
456 existing?: string,
457) =>
458 Effect.gen(function* () {
459 return (
460 certificateAuthorityId ??
461 existing ??
462 (yield* createPhysicalName({
463 id,
464 maxLength: MAX_NAME_LENGTH,
465 lowercase: true,
466 }))
467 );
468 });
469
470const keySpecOf = (
471 spec: privateca.KeyVersionSpec | KeyVersionSpec | undefined,
472): KeyVersionSpec | undefined => {
473 if (spec === undefined) return undefined;
474 return {
475 algorithm: spec.algorithm,
476 cloudKmsKeyVersion: spec.cloudKmsKeyVersion,
477 };
478};
479
480const urlsOf = (
481 urls: privateca.UserDefinedAccessUrls | UserDefinedAccessUrls | undefined,
482): UserDefinedAccessUrls | undefined => {
483 if (urls === undefined) return undefined;
484 return {
485 crlAccessUrls: urls.crlAccessUrls,
486 aiaIssuingCertificateUrls: urls.aiaIssuingCertificateUrls,
487 };
488};
489
490const subordinateOf = (
491 config: privateca.SubordinateConfig | SubordinateConfig | undefined,
492): SubordinateConfig | undefined => {
493 if (config === undefined) return undefined;
494 return {
495 certificateAuthority: config.certificateAuthority,
496 pemIssuerChain:
497 config.pemIssuerChain === undefined
498 ? undefined
499 : { pemCertificates: config.pemIssuerChain.pemCertificates },
500 };
501};
502
503const toAttrs = (
504 ca: privateca.CertificateAuthority,
505 project: string,
506): CertificateAuthorityAttrs => {
507 const name = ca.name ?? "";
508 const parsed = parseName(name);
509 return {
510 name,
511 certificateAuthorityId: parsed.certificateAuthorityId,
512 caPool: parsed.caPool,
513 project: parsed.project || project,
514 location: parsed.location,
515 type: normalizeType(ca.type),
516 state: ca.state,
517 tier: ca.tier,
518 labels: userLabels(ca.labels),
519 lifetime: ca.lifetime,
520 keySpec: keySpecOf(ca.keySpec),
521 gcsBucket: ca.gcsBucket,
522 pemCaCertificates: ca.pemCaCertificates ?? [],
523 userDefinedAccessUrls: urlsOf(ca.userDefinedAccessUrls),
524 subordinateConfig: subordinateOf(ca.subordinateConfig),
525 caCertificateUrl: ca.accessUrls?.caCertificateAccessUrl,
526 crlAccessUrl: ca.accessUrls?.crlAccessUrls?.[0],
527 createTime: ca.createTime,
528 updateTime: ca.updateTime,
529 deleteTime: ca.deleteTime,
530 expireTime: ca.expireTime,
531 };
532};
533
534const defaultConfig = (certificateAuthorityId: string): CertificateConfig => ({
535 subjectConfig: {
536 subject: {
537 organization: "Alchemy",
538 commonName: certificateAuthorityId,
539 },
540 },
541 x509Config: {
542 caOptions: { isCa: true },
543 keyUsage: {
544 baseKeyUsage: {
545 certSign: true,
546 crlSign: true,
547 },
548 },
549 },
550});
551
552const toCreateBody = (
553 news: CertificateAuthorityProps,
554 certificateAuthorityId: string,
555 desiredLabels: Record<string, string>,
556): privateca.CertificateAuthority => {
557 const config = news.config ?? defaultConfig(certificateAuthorityId);
558 return {
559 type: normalizeType(news.type),
560 lifetime: news.lifetime ?? DEFAULT_LIFETIME,
561 keySpec: {
562 algorithm: news.keySpec?.algorithm ?? DEFAULT_ALGORITHM,
563 cloudKmsKeyVersion: news.keySpec?.cloudKmsKeyVersion,
564 },
565 config: {
566 subjectConfig: config.subjectConfig,
567 x509Config: config.x509Config,
568 },
569 labels: desiredLabels,
570 gcsBucket: news.gcsBucket,
571 userDefinedAccessUrls: news.userDefinedAccessUrls,
572 subordinateConfig: news.subordinateConfig,
573 };
574};
575
576const jsonKey = (value: unknown) => JSON.stringify(value ?? null);
577
578const keySpecKey = (spec: KeyVersionSpec | undefined) =>
579 jsonKey({
580 algorithm: (spec?.algorithm ?? "").toUpperCase(),
581 cloudKmsKeyVersion: spec?.cloudKmsKeyVersion ?? "",
582 });
583
584const urlsKey = (urls: UserDefinedAccessUrls | undefined) =>
585 jsonKey({
586 crl: [...(urls?.crlAccessUrls ?? [])].sort(),
587 aia: [...(urls?.aiaIssuingCertificateUrls ?? [])].sort(),
588 });
589
590const subordinateKey = (config: SubordinateConfig | undefined) =>
591 jsonKey({
592 certificateAuthority: config?.certificateAuthority ?? "",
593 pemCertificates: [...(config?.pemIssuerChain?.pemCertificates ?? [])],
594 });
595
596const configKey = (config: CertificateConfig | undefined) => jsonKey(config);
597
598const getByName = (name: string) =>
599 privateca
600 .getProjectsLocationsCaPoolsCertificateAuthorities({ name })
601 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
602
603/**
604 * Wait for a Certificate Authority Service operation; CA creation and
605 * activation take a few minutes. ALREADY_EXISTS (code 6) counts as success
606 * (create race); with `notFoundOk`, so does NOT_FOUND (code 5, delete race)
607 * and an operation that is already gone.
608 */
609const waitForOperation = (
610 operation: privateca.Operation,
611 options?: { notFoundOk?: boolean },
612) =>
613 waitForGcpOperation(
614 operation,
615 (name) => privateca.getProjectsLocationsOperations({ name }),
616 { budget: "15 minutes" },
617 ).pipe(
618 Effect.catchIf(
619 (error) =>
620 (error._tag === "GCP.OperationFailed" &&
621 (error.code === 6 ||
622 (options?.notFoundOk === true && error.code === 5))) ||
623 (options?.notFoundOk === true && error._tag === "NotFound"),
624 () => Effect.succeed(operation),
625 ),
626 );
627
628const waitUntilExists = (name: string) =>
629 getByName(name).pipe(
630 Effect.flatMap((ca) =>
631 ca
632 ? Effect.succeed(ca)
633 : Effect.fail(new CertificateAuthorityNotResolved({ name })),
634 ),
635 Effect.retry({
636 while: (error) =>
637 error._tag === "GCP.PrivateCA.CertificateAuthorityNotResolved",
638 times: 8,
639 schedule: Schedule.spaced("1 second"),
640 }),
641 );
642
643const waitUntilSteady = (name: string) =>
644 getByName(name).pipe(
645 Effect.filterOrFail(
646 (ca): ca is privateca.CertificateAuthority => ca !== undefined,
647 () => new CertificateAuthorityNotResolved({ name }),
648 ),
649 Effect.filterOrFail(
650 (ca) => STEADY_STATES.has((ca.state ?? "").toUpperCase()),
651 (ca) =>
652 new CertificateAuthorityNotReady({
653 name,
654 state: ca.state ?? "STATE_UNSPECIFIED",
655 }),
656 ),
657 Effect.retry({
658 while: (error) =>
659 error._tag === "GCP.PrivateCA.CertificateAuthorityNotReady" ||
660 error._tag === "GCP.PrivateCA.CertificateAuthorityNotResolved",
661 times: 10,
662 schedule: Schedule.spaced("5 seconds"),
663 }),
664 );
665
666const isGone = (ca: privateca.CertificateAuthority | undefined) =>
667 ca === undefined || (ca.state ?? "").toUpperCase() === "DELETED";
668
669const waitUntilGone = (name: string) =>
670 getByName(name).pipe(
671 Effect.flatMap((ca) =>
672 isGone(ca)
673 ? Effect.void
674 : Effect.fail(new CertificateAuthorityStillExists({ name })),
675 ),
676 Effect.retry({
677 while: (error) =>
678 error._tag === "GCP.PrivateCA.CertificateAuthorityStillExists",
679 times: 10,
680 schedule: Schedule.spaced("2 seconds"),
681 }),
682 );
683
684const runOperation = (
685 operation: privateca.Operation | undefined,
686 options?: { notFoundOk?: boolean },
687) =>
688 operation === undefined
689 ? Effect.void
690 : waitForOperation(operation, options).pipe(Effect.asVoid);
691
692const listOwned = (project: string) =>
693 privateca.listProjectsLocationsCaPoolsCertificateAuthorities
694 .pages({
695 parent: `projects/${project}/locations/-/caPools/-`,
696 pageSize: 1000,
697 })
698 .pipe(
699 Stream.flatMap((page) =>
700 Stream.fromIterable(page.certificateAuthorities ?? []),
701 ),
702 Stream.filter(
703 (ca) =>
704 (ca.state ?? "").toUpperCase() !== "DELETED" &&
705 Object.keys(ca.labels ?? {}).some((key) =>
706 key.startsWith("alchemy-"),
707 ),
708 ),
709 Stream.map((ca) => toAttrs(ca, project)),
710 Stream.runCollect,
711 Effect.map((chunk) => Array.from(chunk)),
712 Effect.catchTag("NotFound", () => Effect.succeed([])),
713 );
714
715const disableIfNeeded = (current: privateca.CertificateAuthority) => {
716 const name = current.name ?? "";
717 const state = (current.state ?? "").toUpperCase();
718 // STAGED / DISABLED / AWAITING_USER_ACTIVATION can be deleted directly.
719 // Disable is only valid from ENABLED.
720 if (state !== "ENABLED") {
721 return Effect.succeed(current);
722 }
723 return privateca
724 .disableProjectsLocationsCaPoolsCertificateAuthorities({
725 name,
726 body: { ignoreDependentResources: true },
727 })
728 .pipe(
729 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
730 Effect.catchTag("BadRequest", () => Effect.succeed(undefined)),
731 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
732 Effect.flatMap((operation) => runOperation(operation)),
733 Effect.flatMap(() => waitUntilSteady(name)),
734 Effect.catchTag("GCP.PrivateCA.CertificateAuthorityNotResolved", () =>
735 Effect.succeed(current),
736 ),
737 );
738};
739
740const syncDesiredState = (
741 name: string,
742 current: privateca.CertificateAuthority,
743 desired: DesiredState,
744) => {
745 const state = (current.state ?? "").toUpperCase();
746 if (desired === "ENABLED" && (state === "STAGED" || state === "DISABLED")) {
747 return privateca
748 .enableProjectsLocationsCaPoolsCertificateAuthorities({
749 name,
750 body: {},
751 })
752 .pipe(
753 Effect.flatMap((operation) => waitForOperation(operation)),
754 Effect.catchTag("BadRequest", () => Effect.void),
755 Effect.catchTag("Conflict", () => Effect.void),
756 Effect.flatMap(() => waitUntilSteady(name)),
757 );
758 }
759 if (desired === "DISABLED" && (state === "STAGED" || state === "ENABLED")) {
760 return privateca
761 .disableProjectsLocationsCaPoolsCertificateAuthorities({
762 name,
763 body: { ignoreDependentResources: true },
764 })
765 .pipe(
766 Effect.flatMap((operation) => waitForOperation(operation)),
767 Effect.catchTag("BadRequest", () => Effect.void),
768 Effect.catchTag("Conflict", () => Effect.void),
769 Effect.flatMap(() => waitUntilSteady(name)),
770 );
771 }
772 return Effect.succeed(current);
773};
774
775const activateIfNeeded = (
776 name: string,
777 current: privateca.CertificateAuthority,
778 news: CertificateAuthorityProps,
779) => {
780 const state = (current.state ?? "").toUpperCase();
781 if (
782 state !== "AWAITING_USER_ACTIVATION" ||
783 news.pemCaCertificate === undefined ||
784 news.subordinateConfig === undefined
785 ) {
786 return Effect.succeed(current);
787 }
788 return privateca
789 .activateProjectsLocationsCaPoolsCertificateAuthorities({
790 name,
791 body: {
792 pemCaCertificate: news.pemCaCertificate,
793 subordinateConfig: news.subordinateConfig,
794 },
795 })
796 .pipe(
797 Effect.flatMap((operation) => waitForOperation(operation)),
798 Effect.flatMap(() => waitUntilSteady(name)),
799 );
800};
801
802const undeleteIfNeeded = (current: privateca.CertificateAuthority) => {
803 const name = current.name ?? "";
804 if ((current.state ?? "").toUpperCase() !== "DELETED") {
805 return Effect.succeed(current);
806 }
807 return privateca
808 .undeleteProjectsLocationsCaPoolsCertificateAuthorities({
809 name,
810 body: {},
811 })
812 .pipe(
813 Effect.flatMap((operation) => waitForOperation(operation)),
814 Effect.flatMap(() => waitUntilSteady(name)),
815 );
816};
817
818export const CertificateAuthorityProvider = () =>
819 Provider.succeed(CertificateAuthority, {
820 stables: [
821 "name",
822 "certificateAuthorityId",
823 "caPool",
824 "project",
825 "location",
826 "type",
827 "createTime",
828 ],
829
830 diff: Effect.fn(function* ({ news, olds, output }) {
831 if (!isResolved(news)) return undefined;
832 const env = yield* GcpEnvironment.current;
833
834 const previousId =
835 olds?.certificateAuthorityId ?? output?.certificateAuthorityId;
836 const nextId = news.certificateAuthorityId ?? previousId;
837 const previousPool = olds?.caPool ?? output?.caPool ?? "";
838 const nextPool = news.caPool;
839 const previousLocation = normalizeLocation(
840 olds?.location ?? output?.location ?? env.region,
841 );
842 const nextLocation = normalizeLocation(
843 news.location ?? output?.location ?? env.region,
844 );
845 const previousType = normalizeType(olds?.type ?? output?.type);
846 const nextType = normalizeType(news.type ?? output?.type);
847 const previousLifetime = olds?.lifetime ?? output?.lifetime ?? "";
848 const nextLifetime = news.lifetime ?? previousLifetime;
849 const previousBucket = olds?.gcsBucket ?? output?.gcsBucket ?? "";
850 const nextBucket = news.gcsBucket ?? previousBucket;
851 const previousKey = keySpecKey(olds?.keySpec ?? output?.keySpec);
852 const nextKey =
853 news.keySpec === undefined ? previousKey : keySpecKey(news.keySpec);
854 const configChanged =
855 news.config !== undefined &&
856 olds?.config !== undefined &&
857 configKey(news.config) !== configKey(olds.config);
858
859 const replace =
860 (previousId !== undefined &&
861 nextId !== undefined &&
862 nextId !== previousId) ||
863 (previousPool !== "" && nextPool !== previousPool) ||
864 previousLocation !== nextLocation ||
865 previousType !== nextType ||
866 previousLifetime !== nextLifetime ||
867 previousBucket !== nextBucket ||
868 previousKey !== nextKey ||
869 configChanged;
870
871 if (!replace) return undefined;
872 return {
873 action: "replace" as const,
874 deleteFirst:
875 previousLocation === nextLocation &&
876 previousPool === nextPool &&
877 previousId !== undefined &&
878 nextId === previousId,
879 };
880 }),
881
882 read: Effect.fn(function* ({ id, olds, output }) {
883 const env = yield* GcpEnvironment.current;
884 const certificateAuthorityId = yield* toId(
885 id,
886 olds?.certificateAuthorityId,
887 output?.certificateAuthorityId,
888 );
889 const { parent } = resolveParent(
890 env.project,
891 olds?.caPool ?? output?.caPool ?? "",
892 olds?.location ?? output?.location ?? env.region,
893 );
894 const name = output?.name ?? resourceName(parent, certificateAuthorityId);
895 if (name.endsWith("/certificateAuthorities/") || name.endsWith("/_")) {
896 return undefined;
897 }
898 const existing = yield* getByName(name);
899 if (existing === undefined) return undefined;
900 if ((existing.state ?? "").toUpperCase() === "DELETED") {
901 return undefined;
902 }
903 const attrs = toAttrs(existing, env.project);
904 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
905 ? attrs
906 : Unowned(attrs);
907 }),
908
909 list: () =>
910 Effect.gen(function* () {
911 const env = yield* GcpEnvironment.current;
912 return yield* listOwned(env.project);
913 }),
914
915 reconcile: Effect.fn(function* ({ id, news, output }) {
916 const env = yield* GcpEnvironment.current;
917 const certificateAuthorityId = yield* toId(
918 id,
919 news.certificateAuthorityId,
920 output?.certificateAuthorityId,
921 );
922 const { parent, project } = resolveParent(
923 env.project,
924 news.caPool,
925 news.location ?? output?.location ?? env.region,
926 );
927 const name = resourceName(parent, certificateAuthorityId);
928 const desiredLabels = {
929 ...toLabels(news.labels),
930 ...(yield* createInternalLabels(id)),
931 };
932 const desiredState = normalizeDesired(news.desiredState);
933
934 let current = yield* getByName(output?.name ?? name);
935
936 if (current === undefined) {
937 const created = yield* privateca
938 .createProjectsLocationsCaPoolsCertificateAuthorities({
939 parent,
940 certificateAuthorityId,
941 body: toCreateBody(news, certificateAuthorityId, desiredLabels),
942 })
943 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
944 yield* runOperation(created);
945 current = yield* waitUntilExists(name);
946 }
947
948 if (current === undefined) {
949 return yield* new CertificateAuthorityNotResolved({ name });
950 }
951
952 current = yield* undeleteIfNeeded(current);
953
954 if (!STEADY_STATES.has((current.state ?? "").toUpperCase())) {
955 current = yield* waitUntilSteady(name);
956 }
957
958 current = yield* activateIfNeeded(name, current, news);
959
960 const observedLabels = tagRecord(current.labels);
961 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
962 const labelsChanged = upsert.length > 0 || removed.length > 0;
963 const urlsChanged =
964 news.userDefinedAccessUrls !== undefined &&
965 urlsKey(urlsOf(current.userDefinedAccessUrls)) !==
966 urlsKey(news.userDefinedAccessUrls);
967 const subordinateChanged =
968 news.subordinateConfig !== undefined &&
969 subordinateKey(subordinateOf(current.subordinateConfig)) !==
970 subordinateKey(news.subordinateConfig);
971
972 if (labelsChanged || urlsChanged || subordinateChanged) {
973 const updateMask = [
974 labelsChanged ? "labels" : undefined,
975 urlsChanged ? "userDefinedAccessUrls" : undefined,
976 subordinateChanged ? "subordinateConfig" : undefined,
977 ].filter((field): field is string => field !== undefined);
978
979 const patched = yield* privateca
980 .patchProjectsLocationsCaPoolsCertificateAuthorities({
981 name,
982 updateMask: updateMask.join(","),
983 body: {
984 name,
985 labels: desiredLabels,
986 userDefinedAccessUrls: news.userDefinedAccessUrls,
987 subordinateConfig: news.subordinateConfig,
988 },
989 })
990 .pipe(Effect.catchTag("BadRequest", () => Effect.succeed(undefined)));
991 yield* runOperation(patched);
992 current = yield* waitUntilSteady(name);
993 }
994
995 current = yield* syncDesiredState(name, current, desiredState);
996
997 return toAttrs(current, project);
998 }),
999
1000 delete: Effect.fn(function* ({ output }) {
1001 const name = output.name;
1002 const existing = yield* getByName(name);
1003 if (existing === undefined) return;
1004
1005 if ((existing.state ?? "").toUpperCase() !== "DELETED") {
1006 yield* disableIfNeeded(existing);
1007 const operation = yield* privateca
1008 .deleteProjectsLocationsCaPoolsCertificateAuthorities({
1009 name,
1010 skipGracePeriod: true,
1011 ignoreActiveCertificates: true,
1012 ignoreDependentResources: true,
1013 })
1014 .pipe(
1015 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
1016 Effect.catchTag("BadRequest", () => Effect.succeed(undefined)),
1017 Effect.retry({
1018 while: (error) => error._tag === "Conflict",
1019 times: 8,
1020 schedule: Schedule.spaced("5 seconds"),
1021 }),
1022 );
1023 yield* runOperation(operation, { notFoundOk: true });
1024 }
1025
1026 yield* waitUntilGone(name);
1027 }),
1028 });