1import * as certificatemanager from "@distilled.cloud/gcp/certificatemanager_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import { waitForOperation } from "./operations.ts";
22
23const DEFAULT_LOCATION = "global";
24const DEFAULT_SCOPE = "DEFAULT";
25const MAX_NAME_LENGTH = 63;
26
27export type CertificateType = "SELF_MANAGED" | "MANAGED" | "MANAGED_IDENTITY";
28
29export type CertificateScope =
30 | "DEFAULT"
31 | "EDGE_CACHE"
32 | "ALL_REGIONS"
33 | "CLIENT_AUTH";
34
35export type CertificateSelfManaged = {
36 /** PEM-encoded certificate chain. Leaf first, then intermediates. Input-only. */
37 pemCertificate?: string;
38 /** PEM-encoded private key of the leaf certificate. Input-only. */
39 pemPrivateKey?: string;
40};
41
42export type CertificateManaged = {
43 /**
44 * Domains covered by a Google-managed certificate. Wildcard domains
45 * require DNS authorization. Immutable — changing them replaces the
46 * certificate.
47 */
48 domains?: string[];
49 /**
50 * DnsAuthorization resource names used to prove domain ownership.
51 * Immutable — changing them replaces the certificate.
52 */
53 dnsAuthorizations?: string[];
54 /**
55 * CertificateIssuanceConfig resource name for private PKI certificates.
56 * Immutable — changing it replaces the certificate.
57 */
58 issuanceConfig?: string;
59};
60
61export type CertificateManagedIdentity = {
62 /**
63 * SPIFFE ID of the Managed Identity used for this certificate.
64 * Immutable — changing it replaces the certificate.
65 */
66 identity?: string;
67};
68
69export type CertificateProps = {
70 /**
71 * Certificate id (the `{certificate}` segment of
72 * `projects/{project}/locations/{location}/certificates/{certificate}`).
73 * If omitted, a unique name is generated from the stack, stage, and
74 * logical id. Must be 1-63 characters, start with a letter, and match
75 * `[a-zA-Z][a-zA-Z0-9_-]*`. Immutable — changing it replaces the
76 * certificate.
77 */
78 certificateId?: string;
79 /**
80 * Certificate Manager location (`global`, `us-central1`, …). Immutable —
81 * changing it replaces the certificate. `US-CENTRAL1` is accepted and
82 * normalized to `us-central1`. `DEFAULT` / `EDGE_CACHE` / `CLIENT_AUTH`
83 * / `ALL_REGIONS` certificates live in `global`; omit or set a region
84 * for a regional certificate.
85 * @default "global"
86 */
87 location?: string;
88 /**
89 * Human-readable description.
90 */
91 description?: string;
92 /**
93 * User labels. Alchemy ownership labels are merged in automatically.
94 */
95 labels?: Record<string, string>;
96 /**
97 * Certificate serving scope. Immutable — changing it replaces the
98 * certificate. Inferred from `managed` / `selfManaged` / `managedIdentity`
99 * when omitted.
100 * @default "DEFAULT"
101 */
102 scope?: CertificateScope | (string & {});
103 /**
104 * `SELF_MANAGED` (upload PEM), `MANAGED` (Google provisions), or
105 * `MANAGED_IDENTITY`. Inferred from nested blocks when omitted.
106 * Immutable — changing it replaces the certificate.
107 * @default "SELF_MANAGED"
108 */
109 type?: CertificateType;
110 /**
111 * PEM certificate chain for a self-managed certificate. Input-only.
112 * Changing it replaces the certificate.
113 */
114 pemCertificate?: string;
115 /**
116 * PEM private key for a self-managed certificate. Input-only. Changing
117 * it replaces the certificate.
118 */
119 pemPrivateKey?: string;
120 /**
121 * Nested self-managed payload. Top-level `pemCertificate` /
122 * `pemPrivateKey` take precedence when both are set.
123 */
124 selfManaged?: CertificateSelfManaged;
125 /**
126 * Google-managed certificate configuration. Immutable — changing
127 * domains, DNS authorizations, or issuance config replaces the
128 * certificate.
129 */
130 managed?: CertificateManaged;
131 /**
132 * Managed-identity certificate configuration. Immutable — changing
133 * the identity replaces the certificate.
134 */
135 managedIdentity?: CertificateManagedIdentity;
136};
137
138export type Certificate = Resource<
139 "GCP.CertificateManager.Certificate",
140 CertificateProps,
141 {
142 /** Full resource name `projects/{project}/locations/{location}/certificates/{certificate}`. */
143 name: string;
144 /** Certificate id (last path segment). */
145 certificateId: string;
146 /** Project id. */
147 project: string;
148 /** Location id (`global`, `us-central1`, …). */
149 location: string;
150 /** `SELF_MANAGED`, `MANAGED`, or `MANAGED_IDENTITY`. */
151 type: CertificateType;
152 /** Serving scope (`DEFAULT`, `EDGE_CACHE`, `ALL_REGIONS`, `CLIENT_AUTH`). */
153 scope: string;
154 /** User-provided description. */
155 description: string | undefined;
156 /** User labels (Alchemy ownership labels stripped). */
157 labels: Record<string, string>;
158 /** PEM certificate chain returned by the API. */
159 pemCertificate: string | undefined;
160 /** Subject alternative DNS names on the certificate. */
161 sanDnsnames: string[];
162 /** RFC3339 expiry, if known. */
163 expireTime: string | undefined;
164 /** Google-managed domains, if this is a managed certificate. */
165 managedDomains: string[];
166 /** Provisioning state of a managed certificate. */
167 managedState: string | undefined;
168 /** DnsAuthorization names used by a managed certificate. */
169 managedDnsAuthorizations: string[];
170 /** CertificateIssuanceConfig name, if set. */
171 issuanceConfig: string | undefined;
172 /** SPIFFE identity of a managed-identity certificate. */
173 managedIdentity: string | undefined;
174 /** Provisioning-issue reason, if the managed certificate failed. */
175 provisioningIssue: string | undefined;
176 /** Resource names that currently reference this certificate. */
177 usedBy: string[];
178 /** RFC3339 creation timestamp. */
179 createTime: string | undefined;
180 /** RFC3339 last-update timestamp. */
181 updateTime: string | undefined;
182 },
183 never,
184 Providers
185>;
186
187/**
188 * A Certificate Manager TLS certificate — self-managed (uploaded PEM),
189 * Google-managed, or managed-identity.
190 *
191 * Changing `certificateId`, `location`, `scope`, certificate type,
192 * managed-certificate configuration, or self-managed PEM replaces the
193 * resource (Certificate Manager rejects in-place SAN list updates).
194 * Description and labels update in place.
195 *
196 * ### Creating a Self-Managed Certificate
197 * **Example:** Generated name
198 * ```typescript
199 * const cert = yield* GCP.CertificateManager.Certificate("FrontendTls", {
200 * pemCertificate,
201 * pemPrivateKey,
202 * });
203 * ```
204 *
205 * **Example:** Named certificate with labels
206 * ```typescript
207 * const cert = yield* GCP.CertificateManager.Certificate("FrontendTls", {
208 * certificateId: "app-frontend-tls",
209 * description: "prod frontend",
210 * labels: { env: "prod" },
211 * selfManaged: {
212 * pemCertificate,
213 * pemPrivateKey,
214 * },
215 * });
216 * ```
217 *
218 * ### Google-Managed Certificates
219 * **Example:** Provision a managed certificate for a domain
220 * ```typescript
221 * const cert = yield* GCP.CertificateManager.Certificate("FrontendTls", {
222 * type: "MANAGED",
223 * managed: {
224 * domains: ["www.example.com"],
225 * dnsAuthorizations: [authorization.name],
226 * },
227 * });
228 * ```
229 *
230 * ### Regional Certificates
231 * **Example:** Self-managed certificate in us-central1
232 * ```typescript
233 * const cert = yield* GCP.CertificateManager.Certificate("RegionalTls", {
234 * location: "us-central1",
235 * pemCertificate,
236 * pemPrivateKey,
237 * });
238 * ```
239 *
240 * @resource
241 * @category CertificateManager
242 */
243export const Certificate = Resource<Certificate>(
244 "GCP.CertificateManager.Certificate",
245);
246
247export class CertificateNotResolved extends Data.TaggedError(
248 "GCP.CertificateManager.CertificateNotResolved",
249)<{
250 name: string;
251}> {}
252
253export class CertificateStillExists extends Data.TaggedError(
254 "GCP.CertificateManager.CertificateStillExists",
255)<{
256 name: string;
257}> {}
258
259const lastSegment = (value: string) => {
260 const trimmed = value.replace(/\/+$/, "");
261 const parts = trimmed.split("/");
262 return parts[parts.length - 1] || trimmed;
263};
264
265const rfc1035 = (name: string): string => {
266 let next = name
267 .toLowerCase()
268 .replace(/[^a-z0-9-]/g, "-")
269 .replace(/-+/g, "-")
270 .replace(/^-+/, "")
271 .replace(/-+$/, "");
272 if (!/^[a-z]/.test(next)) {
273 next = `c${next}`;
274 }
275 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
276 return next.length > 0 ? next : "certificate";
277};
278
279const normalizeLocation = (location: string | undefined) =>
280 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
281
282const normalizeScope = (scope: string | undefined) => {
283 const value = (scope ?? DEFAULT_SCOPE).toUpperCase();
284 return value === "SCOPE_UNSPECIFIED" || value === "" ? DEFAULT_SCOPE : value;
285};
286
287const resourceName = (
288 project: string,
289 location: string,
290 certificateId: string,
291) => `projects/${project}/locations/${location}/certificates/${certificateId}`;
292
293const parseName = (name: string) => {
294 const parts = name.split("/").filter((part) => part.length > 0);
295 const certificatesAt = parts.lastIndexOf("certificates");
296 const locationsAt = parts.lastIndexOf("locations");
297 const projectsAt = parts.lastIndexOf("projects");
298 return {
299 project:
300 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
301 location:
302 locationsAt >= 0 && parts[locationsAt + 1]
303 ? parts[locationsAt + 1]!
304 : DEFAULT_LOCATION,
305 certificateId:
306 certificatesAt >= 0 && parts[certificatesAt + 1]
307 ? parts[certificatesAt + 1]!
308 : lastSegment(name),
309 };
310};
311
312const userLabels = (
313 labels: Record<string, string | undefined> | null | undefined,
314): Record<string, string> => stripInternalLabels(tagRecord(labels));
315
316const toId = (
317 id: string,
318 certificateId: string | undefined,
319 existing?: string,
320) =>
321 Effect.gen(function* () {
322 if (certificateId !== undefined) return certificateId;
323 if (existing !== undefined) return existing;
324 return rfc1035(
325 yield* createPhysicalName({
326 id,
327 maxLength: MAX_NAME_LENGTH,
328 lowercase: true,
329 }),
330 );
331 });
332
333const selfManagedOf = (props: {
334 pemCertificate?: string;
335 pemPrivateKey?: string;
336 selfManaged?: CertificateSelfManaged;
337}): CertificateSelfManaged => ({
338 pemCertificate: props.pemCertificate ?? props.selfManaged?.pemCertificate,
339 pemPrivateKey: props.pemPrivateKey ?? props.selfManaged?.pemPrivateKey,
340});
341
342const typeOf = (
343 props: {
344 type?: string;
345 managed?: CertificateManaged;
346 managedIdentity?: CertificateManagedIdentity;
347 pemCertificate?: string;
348 pemPrivateKey?: string;
349 selfManaged?: CertificateSelfManaged;
350 },
351 fallback: CertificateType = "SELF_MANAGED",
352): CertificateType => {
353 if (
354 props.type === "SELF_MANAGED" ||
355 props.type === "MANAGED" ||
356 props.type === "MANAGED_IDENTITY"
357 ) {
358 return props.type;
359 }
360 if ((props.managedIdentity?.identity ?? "") !== "") {
361 return "MANAGED_IDENTITY";
362 }
363 if (
364 (props.managed?.domains?.length ?? 0) > 0 ||
365 (props.managed?.dnsAuthorizations?.length ?? 0) > 0 ||
366 (props.managed?.issuanceConfig ?? "") !== ""
367 ) {
368 return "MANAGED";
369 }
370 if ((selfManagedOf(props).pemCertificate ?? "") !== "") {
371 return "SELF_MANAGED";
372 }
373 return fallback;
374};
375
376const typeFromCert = (
377 cert: certificatemanager.Certificate,
378): CertificateType => {
379 if (cert.managedIdentity !== undefined) return "MANAGED_IDENTITY";
380 if (cert.managed !== undefined) return "MANAGED";
381 return "SELF_MANAGED";
382};
383
384const normalizePem = (pem: string | undefined): string =>
385 (pem ?? "").replace(/\s+/g, "");
386
387const sameList = (left?: readonly string[], right?: readonly string[]) =>
388 [...(left ?? [])].sort().join("\0") === [...(right ?? [])].sort().join("\0");
389
390const pemDiffers = (
391 next: CertificateSelfManaged,
392 previous: CertificateSelfManaged,
393) => {
394 if (
395 next.pemCertificate !== undefined &&
396 normalizePem(next.pemCertificate) !== normalizePem(previous.pemCertificate)
397 ) {
398 return true;
399 }
400 if (
401 next.pemPrivateKey !== undefined &&
402 previous.pemPrivateKey !== undefined &&
403 normalizePem(next.pemPrivateKey) !== normalizePem(previous.pemPrivateKey)
404 ) {
405 return true;
406 }
407 return false;
408};
409
410const toAttrs = (cert: certificatemanager.Certificate, project: string) => {
411 const name = cert.name ?? "";
412 const parsed = parseName(name);
413 return {
414 name,
415 certificateId: parsed.certificateId,
416 project: parsed.project || project,
417 location: parsed.location,
418 type: typeFromCert(cert),
419 scope: normalizeScope(cert.scope),
420 description: cert.description,
421 labels: userLabels(cert.labels),
422 pemCertificate: cert.pemCertificate,
423 sanDnsnames: cert.sanDnsnames ?? [],
424 expireTime: cert.expireTime,
425 managedDomains: cert.managed?.domains ?? [],
426 managedState: cert.managed?.state,
427 managedDnsAuthorizations: cert.managed?.dnsAuthorizations ?? [],
428 issuanceConfig: cert.managed?.issuanceConfig,
429 managedIdentity: cert.managedIdentity?.identity,
430 provisioningIssue: cert.managed?.provisioningIssue?.reason,
431 usedBy: (cert.usedBy ?? [])
432 .map((entry) => entry.name)
433 .filter((value): value is string => typeof value === "string"),
434 createTime: cert.createTime,
435 updateTime: cert.updateTime,
436 };
437};
438
439const getByName = (name: string) =>
440 certificatemanager
441 .getProjectsLocationsCertificates({ name })
442 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
443
444const waitUntilExists = (name: string) =>
445 getByName(name).pipe(
446 Effect.flatMap((cert) =>
447 cert
448 ? Effect.succeed(cert)
449 : Effect.fail(new CertificateNotResolved({ name })),
450 ),
451 Effect.retry({
452 while: (error) =>
453 error._tag === "GCP.CertificateManager.CertificateNotResolved",
454 times: 8,
455 schedule: Schedule.spaced("1 second"),
456 }),
457 );
458
459const waitUntilGone = (name: string) =>
460 getByName(name).pipe(
461 Effect.flatMap((cert) =>
462 cert === undefined
463 ? Effect.void
464 : Effect.fail(new CertificateStillExists({ name })),
465 ),
466 Effect.retry({
467 while: (error) =>
468 error._tag === "GCP.CertificateManager.CertificateStillExists",
469 times: 10,
470 schedule: Schedule.spaced("1 second"),
471 }),
472 );
473
474const listOwnedCertificates = (project: string) =>
475 certificatemanager.listProjectsLocationsCertificates
476 .pages({
477 parent: `projects/${project}/locations/-`,
478 pageSize: 1000,
479 })
480 .pipe(
481 Stream.flatMap((page) => Stream.fromIterable(page.certificates ?? [])),
482 Stream.filter((cert) =>
483 Object.keys(cert.labels ?? {}).some((key) =>
484 key.startsWith("alchemy-"),
485 ),
486 ),
487 Stream.map((cert) => toAttrs(cert, project)),
488 Stream.runCollect,
489 Effect.map((chunk) => Array.from(chunk)),
490 Effect.catchTag("NotFound", () => Effect.succeed([])),
491 );
492
493const toCreateBody = (
494 news: CertificateProps,
495 type: CertificateType,
496 scope: string,
497 desiredLabels: Record<string, string>,
498): certificatemanager.Certificate => {
499 const body: certificatemanager.Certificate = {
500 description: news.description,
501 labels: desiredLabels,
502 scope,
503 };
504 if (type === "MANAGED") {
505 body.managed = {
506 domains: news.managed?.domains,
507 dnsAuthorizations: news.managed?.dnsAuthorizations,
508 issuanceConfig: news.managed?.issuanceConfig,
509 };
510 return body;
511 }
512 if (type === "MANAGED_IDENTITY") {
513 body.managedIdentity = {
514 identity: news.managedIdentity?.identity,
515 };
516 return body;
517 }
518 const material = selfManagedOf(news);
519 body.selfManaged = {
520 pemCertificate: material.pemCertificate,
521 pemPrivateKey: material.pemPrivateKey,
522 };
523 return body;
524};
525
526const immutableChanged = (
527 news: CertificateProps,
528 olds: Partial<CertificateProps> | undefined,
529 output: Certificate["Attributes"] | undefined,
530) => {
531 const previousType = typeOf(
532 {
533 type: olds?.type ?? output?.type,
534 managed:
535 olds?.managed ??
536 (output
537 ? {
538 domains: output.managedDomains,
539 dnsAuthorizations: output.managedDnsAuthorizations,
540 issuanceConfig: output.issuanceConfig,
541 }
542 : undefined),
543 managedIdentity:
544 olds?.managedIdentity ??
545 (output?.managedIdentity
546 ? { identity: output.managedIdentity }
547 : undefined),
548 pemCertificate: olds?.pemCertificate ?? output?.pemCertificate,
549 selfManaged: olds?.selfManaged,
550 },
551 output?.type,
552 );
553 const nextType = typeOf(news, previousType);
554 if (nextType !== previousType) return true;
555
556 const previousScope = normalizeScope(olds?.scope ?? output?.scope);
557 const nextScope = normalizeScope(news.scope ?? olds?.scope ?? output?.scope);
558 if (previousScope !== nextScope) return true;
559
560 if (nextType === "MANAGED") {
561 const previousDomains =
562 olds?.managed?.domains ?? output?.managedDomains ?? [];
563 if (
564 news.managed?.domains !== undefined &&
565 !sameList(news.managed.domains, previousDomains)
566 ) {
567 return true;
568 }
569 const previousAuths =
570 olds?.managed?.dnsAuthorizations ??
571 output?.managedDnsAuthorizations ??
572 [];
573 if (
574 news.managed?.dnsAuthorizations !== undefined &&
575 !sameList(news.managed.dnsAuthorizations, previousAuths)
576 ) {
577 return true;
578 }
579 const previousIssuance =
580 olds?.managed?.issuanceConfig ?? output?.issuanceConfig ?? "";
581 if (
582 news.managed?.issuanceConfig !== undefined &&
583 news.managed.issuanceConfig !== previousIssuance
584 ) {
585 return true;
586 }
587 return false;
588 }
589
590 if (nextType === "MANAGED_IDENTITY") {
591 const previousIdentity =
592 olds?.managedIdentity?.identity ?? output?.managedIdentity ?? "";
593 if (
594 news.managedIdentity?.identity !== undefined &&
595 news.managedIdentity.identity !== previousIdentity
596 ) {
597 return true;
598 }
599 return false;
600 }
601
602 // Self-managed PEM is replace: the API rejects SAN list updates
603 // (`can't update list of Subject Alternative Names`).
604 const nextMaterial = selfManagedOf(news);
605 const previousMaterial = selfManagedOf({
606 pemCertificate: olds?.pemCertificate ?? output?.pemCertificate,
607 pemPrivateKey: olds?.pemPrivateKey,
608 selfManaged: olds?.selfManaged,
609 });
610 if (
611 nextMaterial.pemCertificate !== undefined &&
612 previousMaterial.pemCertificate !== undefined &&
613 pemDiffers(nextMaterial, previousMaterial)
614 ) {
615 return true;
616 }
617
618 return false;
619};
620
621export const CertificateProvider = () =>
622 Provider.succeed(Certificate, {
623 stables: [
624 "name",
625 "certificateId",
626 "project",
627 "location",
628 "type",
629 "scope",
630 "createTime",
631 ],
632
633 diff: Effect.fn(function* ({ news, olds, output }) {
634 if (!isResolved(news)) return undefined;
635
636 const previousId = olds?.certificateId ?? output?.certificateId;
637 const nextId = news.certificateId ?? previousId;
638 const previousLocation = normalizeLocation(
639 olds?.location ?? output?.location,
640 );
641 const nextLocation = normalizeLocation(
642 news.location ?? olds?.location ?? output?.location,
643 );
644
645 const replace =
646 (previousId !== undefined &&
647 nextId !== undefined &&
648 nextId !== previousId) ||
649 previousLocation !== nextLocation ||
650 immutableChanged(news, olds, output);
651
652 if (!replace) return undefined;
653 return {
654 action: "replace" as const,
655 deleteFirst:
656 previousLocation === nextLocation &&
657 previousId !== undefined &&
658 nextId === previousId,
659 };
660 }),
661
662 read: Effect.fn(function* ({ id, olds, output }) {
663 const env = yield* GcpEnvironment.current;
664 const certificateId = yield* toId(
665 id,
666 olds?.certificateId,
667 output?.certificateId,
668 );
669 const location = normalizeLocation(olds?.location ?? output?.location);
670 const name =
671 output?.name ?? resourceName(env.project, location, certificateId);
672 const existing = yield* getByName(name);
673 if (existing === undefined) return undefined;
674 const attrs = toAttrs(existing, env.project);
675 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
676 ? attrs
677 : Unowned(attrs);
678 }),
679
680 list: () =>
681 Effect.gen(function* () {
682 const env = yield* GcpEnvironment.current;
683 return yield* listOwnedCertificates(env.project);
684 }),
685
686 reconcile: Effect.fn(function* ({ id, news, output }) {
687 const env = yield* GcpEnvironment.current;
688 const certificateId = yield* toId(
689 id,
690 news.certificateId,
691 output?.certificateId,
692 );
693 const location = normalizeLocation(news.location ?? output?.location);
694 const type = typeOf(news, output?.type);
695 const scope = normalizeScope(news.scope ?? output?.scope);
696 const name = resourceName(env.project, location, certificateId);
697 const desiredLabels = {
698 ...toLabels(news.labels),
699 ...(yield* createInternalLabels(id)),
700 };
701
702 let current = yield* getByName(name);
703
704 if (current === undefined) {
705 const created = yield* certificatemanager
706 .createProjectsLocationsCertificates({
707 parent: `projects/${env.project}/locations/${location}`,
708 certificateId,
709 body: toCreateBody(news, type, scope, desiredLabels),
710 })
711 .pipe(
712 Effect.retry({
713 while: (error) => error._tag === "Conflict",
714 times: 5,
715 schedule: Schedule.spaced("2 seconds"),
716 }),
717 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
718 );
719 if (created !== undefined) {
720 yield* waitForOperation(created);
721 }
722 current = yield* waitUntilExists(name);
723 }
724
725 if (current === undefined) {
726 return yield* new CertificateNotResolved({ name });
727 }
728
729 const observedLabels = tagRecord(current.labels);
730 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
731 const labelsChanged = upsert.length > 0 || removed.length > 0;
732 const descriptionChanged =
733 (current.description ?? "") !== (news.description ?? "");
734
735 if (labelsChanged || descriptionChanged) {
736 const updateMask = [
737 labelsChanged ? "labels" : undefined,
738 descriptionChanged ? "description" : undefined,
739 ].filter((field): field is string => field !== undefined);
740
741 const operation =
742 yield* certificatemanager.patchProjectsLocationsCertificates({
743 name,
744 updateMask: updateMask.join(","),
745 body: {
746 name,
747 labels: desiredLabels,
748 description: news.description,
749 },
750 });
751 yield* waitForOperation(operation);
752 current = yield* waitUntilExists(name);
753 }
754
755 return toAttrs(current, env.project);
756 }),
757
758 delete: Effect.fn(function* ({ output }) {
759 const operation = yield* certificatemanager
760 .deleteProjectsLocationsCertificates({ name: output.name })
761 .pipe(
762 Effect.retry({
763 while: (error) => error._tag === "Conflict",
764 times: 8,
765 schedule: Schedule.spaced("2 seconds"),
766 }),
767 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
768 );
769 if (operation !== undefined) {
770 yield* waitForOperation(operation, { notFoundOk: true });
771 }
772 yield* waitUntilGone(output.name);
773 }),
774 });