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 MAX_NAME_LENGTH = 63;
24
25/** CEL expression used to validate a certificate Subject / SAN. */
26export type CelExpression = {
27 /** CEL text. */
28 expression?: string;
29 /** Short title shown in UIs. */
30 title?: string;
31 /** Longer description of the expression. */
32 description?: string;
33 /** Source location used in error reporting. */
34 location?: string;
35};
36
37/**
38 * Constraints on identities that may appear in certificates issued with
39 * this template. When set, both passthrough flags are required by the API.
40 */
41export type CertificateIdentityConstraints = {
42 /**
43 * Copy the requested Subject into the issued certificate.
44 */
45 allowSubjectPassthrough?: boolean;
46 /**
47 * Copy the requested Subject Alternative Names into the issued
48 * certificate.
49 */
50 allowSubjectAltNamesPassthrough?: boolean;
51 /** Optional CEL expression evaluated against the resolved identity. */
52 celExpression?: CelExpression;
53};
54
55export type KnownCertificateExtension =
56 | "KNOWN_CERTIFICATE_EXTENSION_UNSPECIFIED"
57 | "BASE_KEY_USAGE"
58 | "EXTENDED_KEY_USAGE"
59 | "CA_OPTIONS"
60 | "POLICY_IDS"
61 | "AIA_OCSP_SERVERS"
62 | "NAME_CONSTRAINTS";
63
64export type ObjectId = {
65 /** OID path, most significant component first (e.g. `[1, 3, 6, 1]`). */
66 objectIdPath?: number[];
67};
68
69/** X.509 extensions that may be copied from a certificate request. */
70export type CertificateExtensionConstraints = {
71 /** Named X.509 extensions that may appear on issued certificates. */
72 knownExtensions?: Array<KnownCertificateExtension | (string & {})>;
73 /** Custom extension OIDs that may appear on issued certificates. */
74 additionalExtensions?: ObjectId[];
75};
76
77export type KeyUsageOptions = {
78 digitalSignature?: boolean;
79 contentCommitment?: boolean;
80 keyEncipherment?: boolean;
81 dataEncipherment?: boolean;
82 keyAgreement?: boolean;
83 certSign?: boolean;
84 crlSign?: boolean;
85 encipherOnly?: boolean;
86 decipherOnly?: boolean;
87};
88
89export type ExtendedKeyUsageOptions = {
90 serverAuth?: boolean;
91 clientAuth?: boolean;
92 codeSigning?: boolean;
93 emailProtection?: boolean;
94 timeStamping?: boolean;
95 ocspSigning?: boolean;
96};
97
98export type KeyUsage = {
99 baseKeyUsage?: KeyUsageOptions;
100 extendedKeyUsage?: ExtendedKeyUsageOptions;
101 unknownExtendedKeyUsages?: ObjectId[];
102};
103
104export type CaOptions = {
105 /** When missing, the basic-constraints extension is omitted. */
106 isCa?: boolean;
107 /** Depth of allowed subordinate CAs. Omitted when unset. */
108 maxIssuerPathLength?: number;
109};
110
111export type NameConstraints = {
112 critical?: boolean;
113 permittedDnsNames?: string[];
114 excludedDnsNames?: string[];
115 permittedIpRanges?: string[];
116 excludedIpRanges?: string[];
117 permittedEmailAddresses?: string[];
118 excludedEmailAddresses?: string[];
119 permittedUris?: string[];
120 excludedUris?: string[];
121};
122
123export type X509Extension = {
124 objectId?: ObjectId;
125 critical?: boolean;
126 /** Base64-encoded extension value. */
127 value?: string;
128};
129
130/** X.509 values applied to every certificate issued with this template. */
131export type X509Parameters = {
132 keyUsage?: KeyUsage;
133 caOptions?: CaOptions;
134 policyIds?: ObjectId[];
135 aiaOcspServers?: string[];
136 nameConstraints?: NameConstraints;
137 additionalExtensions?: X509Extension[];
138};
139
140export type CertificateTemplateProps = {
141 /**
142 * Template id (the `{certificateTemplate}` segment of
143 * `projects/{project}/locations/{location}/certificateTemplates/{certificateTemplate}`).
144 * If omitted, a unique name is generated from the stack, stage, and
145 * logical id. Must match `[a-zA-Z0-9_-]{1,63}`. Immutable — changing it
146 * replaces the template.
147 */
148 certificateTemplateId?: string;
149 /**
150 * Location of the template (`us-central1`, …). Immutable — changing it
151 * replaces the template. `US-CENTRAL1` is accepted and normalized to
152 * `us-central1`.
153 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
154 */
155 location?: string;
156 /**
157 * Human-readable description of scenarios this template is intended for.
158 */
159 description?: string;
160 /**
161 * User labels. Alchemy ownership labels are merged in automatically.
162 */
163 labels?: Record<string, string>;
164 /**
165 * Maximum lifetime allowed for issued certificates (e.g. `"86400s"`).
166 * The issuing CaPool may shorten this further.
167 */
168 maximumLifetime?: string;
169 /**
170 * Constraints on identities that may appear in issued certificates.
171 * When set, `allowSubjectPassthrough` and
172 * `allowSubjectAltNamesPassthrough` are required.
173 */
174 identityConstraints?: CertificateIdentityConstraints;
175 /**
176 * X.509 extensions that may appear on certificates issued with this
177 * template. Extensions outside this set are dropped (except those in
178 * `predefinedValues`).
179 */
180 passthroughExtensions?: CertificateExtensionConstraints;
181 /**
182 * X.509 values applied to every certificate issued with this template.
183 * Conflicting values in the request are overwritten.
184 */
185 predefinedValues?: X509Parameters;
186};
187
188export type CertificateTemplate = Resource<
189 "GCP.PrivateCA.CertificateTemplate",
190 CertificateTemplateProps,
191 {
192 /** Full resource name `projects/{project}/locations/{location}/certificateTemplates/{certificateTemplate}`. */
193 name: string;
194 /** Template id (last path segment). */
195 certificateTemplateId: string;
196 /** Project id. */
197 project: string;
198 /** Location id (`us-central1`). */
199 location: string;
200 /** User-provided description. */
201 description: string | undefined;
202 /** User labels (Alchemy ownership labels stripped). */
203 labels: Record<string, string>;
204 /** Maximum lifetime of issued certificates. */
205 maximumLifetime: string | undefined;
206 /** Identity constraints applied at issuance. */
207 identityConstraints: CertificateIdentityConstraints | undefined;
208 /** Allowed X.509 extensions. */
209 passthroughExtensions: CertificateExtensionConstraints | undefined;
210 /** Predefined X.509 values. */
211 predefinedValues: X509Parameters | undefined;
212 /** RFC3339 creation timestamp. */
213 createTime: string | undefined;
214 /** RFC3339 last-update timestamp. */
215 updateTime: string | undefined;
216 },
217 never,
218 Providers
219>;
220
221/**
222 * A Certificate Authority Service certificate template — reusable issuance
223 * policy (identity constraints, X.509 defaults, and allowed extensions)
224 * applied when issuing certificates from a CaPool.
225 *
226 * Changing `certificateTemplateId` or `location` replaces the template.
227 * Description, labels, lifetime, identity constraints, passthrough
228 * extensions, and predefined X.509 values update in place.
229 *
230 * ### Creating a Certificate Template
231 * **Example:** Generated name
232 * ```typescript
233 * const template = yield* GCP.PrivateCA.CertificateTemplate("LeafTls", {
234 * description: "leaf TLS certificates",
235 * identityConstraints: {
236 * allowSubjectPassthrough: true,
237 * allowSubjectAltNamesPassthrough: true,
238 * },
239 * });
240 * ```
241 *
242 * **Example:** Explicit id, lifetime, and labels
243 * ```typescript
244 * const template = yield* GCP.PrivateCA.CertificateTemplate("LeafTls", {
245 * certificateTemplateId: "leaf-tls",
246 * location: "us-central1",
247 * description: "leaf TLS certificates",
248 * maximumLifetime: "86400s",
249 * labels: { env: "prod" },
250 * identityConstraints: {
251 * allowSubjectPassthrough: true,
252 * allowSubjectAltNamesPassthrough: true,
253 * },
254 * passthroughExtensions: {
255 * knownExtensions: ["EXTENDED_KEY_USAGE"],
256 * },
257 * predefinedValues: {
258 * caOptions: { isCa: false },
259 * keyUsage: {
260 * baseKeyUsage: {
261 * digitalSignature: true,
262 * keyEncipherment: true,
263 * },
264 * extendedKeyUsage: { serverAuth: true },
265 * },
266 * },
267 * });
268 * ```
269 *
270 * ### Updating a Certificate Template
271 * **Example:** Description, labels, and lifetime
272 * ```typescript
273 * const template = yield* GCP.PrivateCA.CertificateTemplate("LeafTls", {
274 * certificateTemplateId: "leaf-tls",
275 * description: "leaf TLS certificates v2",
276 * maximumLifetime: "172800s",
277 * labels: { env: "prod", role: "tls" },
278 * identityConstraints: {
279 * allowSubjectPassthrough: true,
280 * allowSubjectAltNamesPassthrough: false,
281 * },
282 * });
283 * ```
284 *
285 * @resource
286 * @category PrivateCA
287 */
288export const CertificateTemplate = Resource<CertificateTemplate>(
289 "GCP.PrivateCA.CertificateTemplate",
290);
291
292export class CertificateTemplateNotResolved extends Data.TaggedError(
293 "GCP.PrivateCA.CertificateTemplateNotResolved",
294)<{
295 name: string;
296}> {}
297
298export class CertificateTemplateStillExists extends Data.TaggedError(
299 "GCP.PrivateCA.CertificateTemplateStillExists",
300)<{
301 name: string;
302}> {}
303
304const lastSegment = (value: string) => {
305 const trimmed = value.replace(/\/+$/, "");
306 const parts = trimmed.split("/");
307 return parts[parts.length - 1] || trimmed;
308};
309
310const normalizeLocation = (location: string) =>
311 lastSegment(location).toLowerCase();
312
313const resourceName = (
314 project: string,
315 location: string,
316 certificateTemplateId: string,
317) =>
318 `projects/${project}/locations/${location}/certificateTemplates/${certificateTemplateId}`;
319
320const locationParent = (project: string, location: string) =>
321 `projects/${project}/locations/${location}`;
322
323const parseName = (name: string) => {
324 const parts = name.split("/").filter((part) => part.length > 0);
325 const templatesAt = parts.lastIndexOf("certificateTemplates");
326 const locationsAt = parts.lastIndexOf("locations");
327 const projectsAt = parts.lastIndexOf("projects");
328 return {
329 project:
330 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
331 location:
332 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
333 certificateTemplateId:
334 templatesAt >= 0 && parts[templatesAt + 1]
335 ? parts[templatesAt + 1]!
336 : lastSegment(name),
337 };
338};
339
340const userLabels = (
341 labels: Record<string, string | undefined> | null | undefined,
342): Record<string, string> => stripInternalLabels(tagRecord(labels));
343
344const toId = (
345 id: string,
346 certificateTemplateId: string | undefined,
347 existing?: string,
348) =>
349 Effect.gen(function* () {
350 return (
351 certificateTemplateId ??
352 existing ??
353 (yield* createPhysicalName({
354 id,
355 maxLength: MAX_NAME_LENGTH,
356 lowercase: true,
357 }))
358 );
359 });
360
361const toObjectId = (value: privateca.ObjectId | ObjectId | undefined) =>
362 value === undefined
363 ? undefined
364 : { objectIdPath: value.objectIdPath ?? undefined };
365
366const toObjectIds = (
367 values: ReadonlyArray<privateca.ObjectId | ObjectId> | undefined,
368): ObjectId[] | undefined => {
369 if (values === undefined) return undefined;
370 return values.map((value) => toObjectId(value) ?? {});
371};
372
373const toCelExpression = (
374 value: privateca.Expr | CelExpression | undefined,
375): CelExpression | undefined => {
376 if (value === undefined) return undefined;
377 if (
378 value.expression === undefined &&
379 value.title === undefined &&
380 value.description === undefined &&
381 value.location === undefined
382 ) {
383 return undefined;
384 }
385 return {
386 expression: value.expression,
387 title: value.title,
388 description: value.description,
389 location: value.location,
390 };
391};
392
393const toIdentityConstraints = (
394 value:
395 | privateca.CertificateIdentityConstraints
396 | CertificateIdentityConstraints
397 | undefined,
398): CertificateIdentityConstraints | undefined => {
399 if (value === undefined) return undefined;
400 return {
401 allowSubjectPassthrough: value.allowSubjectPassthrough,
402 allowSubjectAltNamesPassthrough: value.allowSubjectAltNamesPassthrough,
403 celExpression: toCelExpression(value.celExpression),
404 };
405};
406
407const toPassthroughExtensions = (
408 value:
409 | privateca.CertificateExtensionConstraints
410 | CertificateExtensionConstraints
411 | undefined,
412): CertificateExtensionConstraints | undefined => {
413 if (value === undefined) return undefined;
414 return {
415 knownExtensions: value.knownExtensions,
416 additionalExtensions: toObjectIds(value.additionalExtensions),
417 };
418};
419
420const toKeyUsage = (
421 value: privateca.KeyUsage | KeyUsage | undefined,
422): KeyUsage | undefined => {
423 if (value === undefined) return undefined;
424 return {
425 baseKeyUsage: value.baseKeyUsage,
426 extendedKeyUsage: value.extendedKeyUsage,
427 unknownExtendedKeyUsages: toObjectIds(value.unknownExtendedKeyUsages),
428 };
429};
430
431const toNameConstraints = (
432 value: privateca.NameConstraints | NameConstraints | undefined,
433): NameConstraints | undefined => {
434 if (value === undefined) return undefined;
435 return {
436 critical: value.critical,
437 permittedDnsNames: value.permittedDnsNames,
438 excludedDnsNames: value.excludedDnsNames,
439 permittedIpRanges: value.permittedIpRanges,
440 excludedIpRanges: value.excludedIpRanges,
441 permittedEmailAddresses: value.permittedEmailAddresses,
442 excludedEmailAddresses: value.excludedEmailAddresses,
443 permittedUris: value.permittedUris,
444 excludedUris: value.excludedUris,
445 };
446};
447
448const toX509Extension = (
449 value: privateca.X509Extension | X509Extension,
450): X509Extension => ({
451 objectId: toObjectId(value.objectId),
452 critical: value.critical,
453 value: value.value,
454});
455
456const toPredefinedValues = (
457 value: privateca.X509Parameters | X509Parameters | undefined,
458): X509Parameters | undefined => {
459 if (value === undefined) return undefined;
460 return {
461 keyUsage: toKeyUsage(value.keyUsage),
462 caOptions: value.caOptions
463 ? {
464 isCa: value.caOptions.isCa,
465 maxIssuerPathLength: value.caOptions.maxIssuerPathLength,
466 }
467 : undefined,
468 policyIds: toObjectIds(value.policyIds),
469 aiaOcspServers: value.aiaOcspServers,
470 nameConstraints: toNameConstraints(value.nameConstraints),
471 additionalExtensions: value.additionalExtensions?.map(toX509Extension),
472 };
473};
474
475const stable = (value: unknown): unknown => {
476 if (value === null || value === undefined) return null;
477 if (Array.isArray(value)) return value.map(stable);
478 if (typeof value === "object") {
479 return Object.fromEntries(
480 Object.entries(value as Record<string, unknown>)
481 .filter(([, nested]) => nested !== undefined)
482 .sort(([left], [right]) => left.localeCompare(right))
483 .map(([key, nested]) => [key, stable(nested)]),
484 );
485 }
486 return value;
487};
488
489const fingerprint = (value: unknown) => JSON.stringify(stable(value));
490
491const toAttrs = (template: privateca.CertificateTemplate, project: string) => {
492 const name = template.name ?? "";
493 const parsed = parseName(name);
494 return {
495 name,
496 certificateTemplateId: parsed.certificateTemplateId,
497 project: parsed.project || project,
498 location: parsed.location,
499 description: template.description,
500 labels: userLabels(template.labels),
501 maximumLifetime: template.maximumLifetime,
502 identityConstraints: toIdentityConstraints(template.identityConstraints),
503 passthroughExtensions: toPassthroughExtensions(
504 template.passthroughExtensions,
505 ),
506 predefinedValues: toPredefinedValues(template.predefinedValues),
507 createTime: template.createTime,
508 updateTime: template.updateTime,
509 };
510};
511
512const desiredBody = (
513 news: CertificateTemplateProps,
514 desiredLabels: Record<string, string>,
515): privateca.CertificateTemplate => ({
516 description: news.description,
517 labels: desiredLabels,
518 maximumLifetime: news.maximumLifetime,
519 identityConstraints: news.identityConstraints,
520 passthroughExtensions: news.passthroughExtensions,
521 predefinedValues: news.predefinedValues,
522});
523
524const getByName = (name: string) =>
525 privateca
526 .getProjectsLocationsCertificateTemplates({ name })
527 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
528
529/**
530 * Wait for a Certificate Authority Service operation; CA creation and
531 * activation take a few minutes. ALREADY_EXISTS (code 6) counts as success
532 * (create race); with `notFoundOk`, so does NOT_FOUND (code 5, delete race)
533 * and an operation that is already gone.
534 */
535const waitForOperation = (
536 operation: privateca.Operation,
537 options?: { notFoundOk?: boolean },
538) =>
539 waitForGcpOperation(
540 operation,
541 (name) => privateca.getProjectsLocationsOperations({ name }),
542 { budget: "15 minutes" },
543 ).pipe(
544 Effect.catchIf(
545 (error) =>
546 (error._tag === "GCP.OperationFailed" &&
547 (error.code === 6 ||
548 (options?.notFoundOk === true && error.code === 5))) ||
549 (options?.notFoundOk === true && error._tag === "NotFound"),
550 () => Effect.succeed(operation),
551 ),
552 );
553
554const waitUntilExists = (name: string) =>
555 getByName(name).pipe(
556 Effect.flatMap((template) =>
557 template
558 ? Effect.succeed(template)
559 : Effect.fail(new CertificateTemplateNotResolved({ name })),
560 ),
561 Effect.retry({
562 while: (error) =>
563 error._tag === "GCP.PrivateCA.CertificateTemplateNotResolved",
564 times: 8,
565 schedule: Schedule.spaced("1 second"),
566 }),
567 );
568
569const waitUntilGone = (name: string) =>
570 getByName(name).pipe(
571 Effect.flatMap((template) =>
572 template === undefined
573 ? Effect.void
574 : Effect.fail(new CertificateTemplateStillExists({ name })),
575 ),
576 Effect.retry({
577 while: (error) =>
578 error._tag === "GCP.PrivateCA.CertificateTemplateStillExists",
579 times: 10,
580 schedule: Schedule.spaced("1 second"),
581 }),
582 );
583
584const listOwnedTemplates = (project: string) =>
585 privateca.listProjectsLocationsCertificateTemplates
586 .pages({
587 parent: `projects/${project}/locations/-`,
588 pageSize: 1000,
589 })
590 .pipe(
591 Stream.flatMap((page) =>
592 Stream.fromIterable(page.certificateTemplates ?? []),
593 ),
594 Stream.filter((template) =>
595 Object.keys(template.labels ?? {}).some((key) =>
596 key.startsWith("alchemy-"),
597 ),
598 ),
599 Stream.map((template) => toAttrs(template, project)),
600 Stream.runCollect,
601 Effect.map((chunk) => Array.from(chunk)),
602 Effect.catchTag("NotFound", () => Effect.succeed([])),
603 );
604
605export const CertificateTemplateProvider = () =>
606 Provider.succeed(CertificateTemplate, {
607 stables: [
608 "name",
609 "certificateTemplateId",
610 "project",
611 "location",
612 "createTime",
613 ],
614
615 diff: Effect.fn(function* ({ news, olds, output }) {
616 if (!isResolved(news)) return undefined;
617 const env = yield* GcpEnvironment.current;
618
619 const previousId =
620 olds?.certificateTemplateId ?? output?.certificateTemplateId;
621 const nextId = news.certificateTemplateId ?? previousId;
622 const previousLocation = normalizeLocation(
623 olds?.location ?? output?.location ?? env.region,
624 );
625 const nextLocation = normalizeLocation(
626 news.location ?? olds?.location ?? output?.location ?? env.region,
627 );
628
629 const replace =
630 (previousId !== undefined &&
631 nextId !== undefined &&
632 nextId !== previousId) ||
633 previousLocation !== nextLocation;
634
635 if (!replace) return undefined;
636 return { action: "replace" as const };
637 }),
638
639 read: Effect.fn(function* ({ id, olds, output }) {
640 const env = yield* GcpEnvironment.current;
641 const certificateTemplateId = yield* toId(
642 id,
643 olds?.certificateTemplateId,
644 output?.certificateTemplateId,
645 );
646 const location = normalizeLocation(
647 olds?.location ?? output?.location ?? env.region,
648 );
649 const name =
650 output?.name ??
651 resourceName(env.project, location, certificateTemplateId);
652 const existing = yield* getByName(name);
653 if (existing === undefined) return undefined;
654 const attrs = toAttrs(existing, env.project);
655 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
656 ? attrs
657 : Unowned(attrs);
658 }),
659
660 list: () =>
661 Effect.gen(function* () {
662 const env = yield* GcpEnvironment.current;
663 return yield* listOwnedTemplates(env.project);
664 }),
665
666 reconcile: Effect.fn(function* ({ id, news, output }) {
667 const env = yield* GcpEnvironment.current;
668 const certificateTemplateId = yield* toId(
669 id,
670 news.certificateTemplateId,
671 output?.certificateTemplateId,
672 );
673 const location = normalizeLocation(
674 news.location ?? output?.location ?? env.region,
675 );
676 const name = resourceName(env.project, location, certificateTemplateId);
677 const desiredLabels = {
678 ...toLabels(news.labels),
679 ...(yield* createInternalLabels(id)),
680 };
681 const body = desiredBody(news, desiredLabels);
682
683 let current = yield* getByName(name);
684
685 if (current === undefined) {
686 const created = yield* privateca
687 .createProjectsLocationsCertificateTemplates({
688 parent: locationParent(env.project, location),
689 certificateTemplateId,
690 body,
691 })
692 .pipe(
693 Effect.retry({
694 while: (error) => error._tag === "Conflict",
695 times: 5,
696 schedule: Schedule.spaced("2 seconds"),
697 }),
698 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
699 );
700 if (created !== undefined) {
701 yield* waitForOperation(created);
702 }
703 current = yield* waitUntilExists(name);
704 }
705
706 if (current === undefined) {
707 return yield* new CertificateTemplateNotResolved({ name });
708 }
709
710 const observed = toAttrs(current, env.project);
711 const observedLabels = tagRecord(current.labels);
712 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
713 const labelsChanged = upsert.length > 0 || removed.length > 0;
714 const descriptionChanged =
715 (observed.description ?? "") !== (news.description ?? "");
716 const lifetimeChanged =
717 (observed.maximumLifetime ?? "") !== (news.maximumLifetime ?? "");
718 const identityChanged =
719 fingerprint(observed.identityConstraints) !==
720 fingerprint(toIdentityConstraints(news.identityConstraints));
721 const passthroughChanged =
722 fingerprint(observed.passthroughExtensions) !==
723 fingerprint(toPassthroughExtensions(news.passthroughExtensions));
724 const predefinedChanged =
725 fingerprint(observed.predefinedValues) !==
726 fingerprint(toPredefinedValues(news.predefinedValues));
727
728 if (
729 labelsChanged ||
730 descriptionChanged ||
731 lifetimeChanged ||
732 identityChanged ||
733 passthroughChanged ||
734 predefinedChanged
735 ) {
736 const updateMask = [
737 labelsChanged ? "labels" : undefined,
738 descriptionChanged ? "description" : undefined,
739 lifetimeChanged ? "maximumLifetime" : undefined,
740 identityChanged ? "identityConstraints" : undefined,
741 passthroughChanged ? "passthroughExtensions" : undefined,
742 predefinedChanged ? "predefinedValues" : undefined,
743 ].filter((field): field is string => field !== undefined);
744
745 const operation =
746 yield* privateca.patchProjectsLocationsCertificateTemplates({
747 name,
748 updateMask: updateMask.join(","),
749 body: {
750 name,
751 ...body,
752 },
753 });
754 yield* waitForOperation(operation);
755 current = yield* waitUntilExists(name);
756 }
757
758 return toAttrs(current, env.project);
759 }),
760
761 delete: Effect.fn(function* ({ output }) {
762 const operation = yield* privateca
763 .deleteProjectsLocationsCertificateTemplates({ name: output.name })
764 .pipe(
765 Effect.retry({
766 while: (error) => error._tag === "Conflict",
767 times: 8,
768 schedule: Schedule.spaced("2 seconds"),
769 }),
770 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
771 );
772 if (operation !== undefined) {
773 yield* waitForOperation(operation, { notFoundOk: true });
774 }
775 yield* waitUntilGone(output.name);
776 }),
777 });