1import * as kms from "@distilled.cloud/gcp/cloudkms_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 type OperationFailed,
12 type OperationTimedOut,
13 waitForOperation as waitForGcpOperation,
14} from "../Operation.ts";
15
16const DEFAULT_STATE: kms.CryptoKeyVersionStateEnum = "ENABLED";
17const DELETABLE_STATES = new Set([
18 "DESTROYED",
19 "IMPORT_FAILED",
20 "GENERATION_FAILED",
21]);
22const DESTROYABLE_STATES = new Set(["ENABLED", "DISABLED"]);
23const PATCHABLE_STATES = new Set(["ENABLED", "DISABLED"]);
24
25export type CryptoKeyVersionExternalProtectionLevelOptions = {
26 /**
27 * URI of the external key this version represents. EXTERNAL
28 * protection level only.
29 */
30 externalKeyUri?: string;
31 /**
32 * Path to the external key material on the EKM when using an
33 * EkmConnection (e.g. `"v0/my/key"`). EXTERNAL_VPC protection level
34 * only.
35 */
36 ekmConnectionKeyPath?: string;
37};
38
39export type CryptoKeyVersionProps = {
40 /**
41 * Parent CryptoKey. Full name
42 * `projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`
43 * or the crypto key id (combined with `keyRing` / `location`).
44 * Immutable — changing it replaces the version.
45 */
46 cryptoKey: string;
47 /**
48 * Parent KeyRing. Full name
49 * `projects/{project}/locations/{location}/keyRings/{keyRing}` or the
50 * key ring id. Used when `cryptoKey` is a bare id. Immutable —
51 * changing it replaces the version.
52 */
53 keyRing?: string;
54 /**
55 * Cloud KMS location (`us-central1`, `global`, `us`, …). Used when
56 * `cryptoKey` is a bare id. Immutable — changing it replaces the
57 * version. `US-CENTRAL1` is accepted and normalized to `us-central1`.
58 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
59 */
60 location?: string;
61 /**
62 * CryptoKeyVersion id (the last path segment, typically a decimal
63 * sequence assigned by Cloud KMS). Create always assigns the next
64 * sequential id; this field targets an existing version on adopt or
65 * update. Changing it replaces the version.
66 */
67 cryptoKeyVersionId?: string;
68 /**
69 * Desired version state. `ENABLED` and `DISABLED` patch in place.
70 * Other transitions use Destroy / Restore. Cloud KMS sets
71 * `PENDING_GENERATION` on create until the key material is ready.
72 * @default "ENABLED"
73 */
74 state?: kms.CryptoKeyVersionStateEnum;
75 /**
76 * External / EXTERNAL_VPC key material pointer. Ignored for SOFTWARE
77 * and HSM keys.
78 */
79 externalProtectionLevelOptions?: CryptoKeyVersionExternalProtectionLevelOptions;
80};
81
82export type CryptoKeyVersionAttrs = {
83 /** Full resource name `projects/.../cryptoKeyVersions/{version}`. */
84 name: string;
85 /** CryptoKeyVersion id (last path segment). */
86 cryptoKeyVersionId: string;
87 /** Parent CryptoKey resource name. */
88 cryptoKey: string;
89 /** Parent KeyRing resource name. */
90 keyRing: string;
91 /** Location id (`us-central1`, `global`, …). */
92 location: string;
93 /** Project id. */
94 project: string;
95 /** Current version state. */
96 state: string;
97 /** Algorithm from the parent CryptoKey version template. */
98 algorithm: string | undefined;
99 /** Protection level of this version. */
100 protectionLevel: string | undefined;
101 /** External key pointer, if any. */
102 externalProtectionLevelOptions:
103 | CryptoKeyVersionExternalProtectionLevelOptions
104 | undefined;
105 /** Import job that produced this version, if imported. */
106 importJob: string | undefined;
107 /** Whether the version can be reimported. */
108 reimportEligible: boolean;
109 /** RFC3339 creation timestamp. */
110 createTime: string | undefined;
111 /** RFC3339 generation timestamp. */
112 generateTime: string | undefined;
113 /** RFC3339 scheduled-destruction timestamp. */
114 destroyTime: string | undefined;
115 /** RFC3339 time key material was destroyed. */
116 destroyEventTime: string | undefined;
117 /** Generation failure reason, if `state` is `GENERATION_FAILED`. */
118 generationFailureReason: string | undefined;
119};
120
121export type CryptoKeyVersion = Resource<
122 "GCP.KMS.CryptoKeyVersion",
123 CryptoKeyVersionProps,
124 CryptoKeyVersionAttrs,
125 never,
126 Providers
127>;
128
129/**
130 * A Cloud KMS CryptoKeyVersion — one generation of key material under a
131 * CryptoKey.
132 *
133 * Parent CryptoKey, KeyRing, location, and version id are identity
134 * (changing them replaces the version). `state` toggles between
135 * `ENABLED` and `DISABLED` in place. Cloud KMS assigns sequential
136 * version ids on create; versions have no labels, so `list` returns
137 * versions whose parent CryptoKey carries Alchemy ownership labels.
138 *
139 * Destroy schedules destruction (`DESTROY_SCHEDULED`, minimum 24h).
140 * Permanent `delete` is only possible for `DESTROYED`, `IMPORT_FAILED`,
141 * or `GENERATION_FAILED` versions that were never successfully imported.
142 *
143 * ### Creating a CryptoKeyVersion
144 * **Example:** Next version on an existing CryptoKey
145 * ```typescript
146 * const ring = yield* GCP.KMS.KeyRing("Keys", {});
147 * const key = yield* GCP.KMS.CryptoKey("Data", {
148 * keyRing: ring.name,
149 * skipInitialVersionCreation: true,
150 * });
151 * const version = yield* GCP.KMS.CryptoKeyVersion("V1", {
152 * cryptoKey: key.name,
153 * });
154 * ```
155 *
156 * **Example:** Disabled version
157 * ```typescript
158 * const version = yield* GCP.KMS.CryptoKeyVersion("V1", {
159 * cryptoKey: key.name,
160 * state: "DISABLED",
161 * });
162 * ```
163 *
164 * @resource
165 * @category KMS
166 */
167export const CryptoKeyVersion = Resource<CryptoKeyVersion>(
168 "GCP.KMS.CryptoKeyVersion",
169);
170
171export class CryptoKeyVersionNotResolved extends Data.TaggedError(
172 "GCP.KMS.CryptoKeyVersionNotResolved",
173)<{
174 name: string;
175}> {}
176
177export class CryptoKeyVersionOperationFailed extends Data.TaggedError(
178 "GCP.KMS.CryptoKeyVersionOperationFailed",
179)<{
180 operation: string;
181 message: string;
182}> {}
183
184export class CryptoKeyVersionNotReady extends Data.TaggedError(
185 "GCP.KMS.CryptoKeyVersionNotReady",
186)<{
187 name: string;
188 state: string;
189}> {}
190
191const lastSegment = (value: string) => {
192 const trimmed = value.replace(/\/+$/, "");
193 const parts = trimmed.split("/");
194 return parts[parts.length - 1] || trimmed;
195};
196
197const normalizeLocation = (location: string | undefined, fallback: string) =>
198 lastSegment(location ?? fallback).toLowerCase();
199
200const parseName = (name: string, fallbackLocation: string) => {
201 const parts = name.split("/").filter((part) => part.length > 0);
202 const versionsAt = parts.lastIndexOf("cryptoKeyVersions");
203 const cryptoKeysAt = parts.lastIndexOf("cryptoKeys");
204 const keyRingsAt = parts.lastIndexOf("keyRings");
205 const locationsAt = parts.lastIndexOf("locations");
206 const projectsAt = parts.lastIndexOf("projects");
207 const cryptoKey =
208 cryptoKeysAt >= 0 ? parts.slice(0, cryptoKeysAt + 2).join("/") : "";
209 const keyRing =
210 keyRingsAt >= 0 ? parts.slice(0, keyRingsAt + 2).join("/") : "";
211 return {
212 project:
213 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
214 location:
215 locationsAt >= 0 && parts[locationsAt + 1]
216 ? parts[locationsAt + 1]!
217 : fallbackLocation,
218 keyRing,
219 cryptoKey,
220 cryptoKeyVersionId:
221 versionsAt >= 0 && parts[versionsAt + 1]
222 ? parts[versionsAt + 1]!
223 : lastSegment(name),
224 };
225};
226
227const resolveParent = (
228 project: string,
229 cryptoKey: string,
230 keyRing: string | undefined,
231 location: string | undefined,
232 region: string,
233) => {
234 if (cryptoKey.includes("/cryptoKeyVersions/")) {
235 const parsed = parseName(cryptoKey, region);
236 return {
237 parent: parsed.cryptoKey,
238 location: parsed.location,
239 project: parsed.project || project,
240 };
241 }
242 if (cryptoKey.includes("/cryptoKeys/")) {
243 const parsed = parseName(`${cryptoKey}/cryptoKeyVersions/_`, region);
244 return {
245 parent: parsed.cryptoKey,
246 location: parsed.location,
247 project: parsed.project || project,
248 };
249 }
250 const loc = normalizeLocation(location, region);
251 const ring =
252 keyRing !== undefined && keyRing.includes("/")
253 ? keyRing
254 : `projects/${project}/locations/${loc}/keyRings/${keyRing ?? "default"}`;
255 return {
256 parent: `${ring}/cryptoKeys/${cryptoKey}`,
257 location: loc,
258 project,
259 };
260};
261
262const resourceName = (parent: string, cryptoKeyVersionId: string) =>
263 `${parent}/cryptoKeyVersions/${cryptoKeyVersionId}`;
264
265const desiredState = (
266 value: string | undefined,
267): kms.CryptoKeyVersionStateEnum =>
268 !value || value === "CRYPTO_KEY_VERSION_STATE_UNSPECIFIED"
269 ? DEFAULT_STATE
270 : (value as kms.CryptoKeyVersionStateEnum);
271
272const toAttrs = (
273 version: kms.CryptoKeyVersion,
274 project: string,
275 region: string,
276): CryptoKeyVersionAttrs => {
277 const name = version.name ?? "";
278 const parsed = parseName(name, region);
279 const options = version.externalProtectionLevelOptions;
280 return {
281 name,
282 cryptoKeyVersionId: parsed.cryptoKeyVersionId,
283 cryptoKey: parsed.cryptoKey,
284 keyRing: parsed.keyRing,
285 location: parsed.location,
286 project: parsed.project || project,
287 state: version.state ?? DEFAULT_STATE,
288 algorithm: version.algorithm,
289 protectionLevel: version.protectionLevel,
290 externalProtectionLevelOptions:
291 options === undefined
292 ? undefined
293 : {
294 externalKeyUri: options.externalKeyUri,
295 ekmConnectionKeyPath: options.ekmConnectionKeyPath,
296 },
297 importJob: version.importJob,
298 reimportEligible: version.reimportEligible === true,
299 createTime: version.createTime,
300 generateTime: version.generateTime,
301 destroyTime: version.destroyTime,
302 destroyEventTime: version.destroyEventTime,
303 generationFailureReason: version.generationFailureReason,
304 };
305};
306
307const getByName = (name: string) =>
308 kms
309 .getProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({ name })
310 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
311
312const paginate = <A, E, R>(
313 fetch: (
314 pageToken: string | undefined,
315 ) => Effect.Effect<{ items: A[]; nextPageToken?: string }, E, R>,
316) =>
317 Effect.gen(function* () {
318 const found: A[] = [];
319 let pageToken: string | undefined;
320 for (let page = 0; page < 10; page++) {
321 const response = yield* fetch(pageToken);
322 found.push(...response.items);
323 pageToken = response.nextPageToken;
324 if (pageToken === undefined || pageToken === "") break;
325 }
326 return found;
327 });
328
329const listKeyRingsAt = (parent: string) =>
330 paginate((pageToken) =>
331 kms
332 .listProjectsLocationsKeyRings({
333 parent,
334 pageSize: 1000,
335 pageToken,
336 })
337 .pipe(
338 Effect.map((response) => ({
339 items: response.keyRings ?? [],
340 nextPageToken: response.nextPageToken,
341 })),
342 Effect.catchTag("NotFound", () =>
343 Effect.succeed({
344 items: [] as kms.KeyRing[],
345 nextPageToken: undefined,
346 }),
347 ),
348 ),
349 );
350
351const listAlchemyKeysInRing = (parent: string) =>
352 paginate((pageToken) =>
353 kms
354 .listProjectsLocationsKeyRingsCryptoKeys({
355 parent,
356 pageSize: 1000,
357 pageToken,
358 })
359 .pipe(
360 Effect.map((response) => ({
361 items: (response.cryptoKeys ?? []).filter((key) =>
362 Object.keys(key.labels ?? {}).some((label) =>
363 label.startsWith("alchemy-"),
364 ),
365 ),
366 nextPageToken: response.nextPageToken,
367 })),
368 Effect.catchTag("NotFound", () =>
369 Effect.succeed({
370 items: [] as kms.CryptoKey[],
371 nextPageToken: undefined,
372 }),
373 ),
374 ),
375 );
376
377const listVersionsInKey = (parent: string) =>
378 paginate((pageToken) =>
379 kms
380 .listProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
381 parent,
382 pageSize: 1000,
383 pageToken,
384 })
385 .pipe(
386 Effect.map((response) => ({
387 items: response.cryptoKeyVersions ?? [],
388 nextPageToken: response.nextPageToken,
389 })),
390 Effect.catchTag("NotFound", () =>
391 Effect.succeed({
392 items: [] as kms.CryptoKeyVersion[],
393 nextPageToken: undefined,
394 }),
395 ),
396 ),
397 );
398
399const listVersionsAt = (locationParent: string) =>
400 Effect.gen(function* () {
401 const rings = yield* listKeyRingsAt(locationParent);
402 const keyPages = yield* Effect.forEach(
403 rings,
404 (ring) =>
405 ring.name
406 ? listAlchemyKeysInRing(ring.name)
407 : Effect.succeed([] as kms.CryptoKey[]),
408 { concurrency: 4 },
409 );
410 const keys = keyPages.flat();
411 const versionPages = yield* Effect.forEach(
412 keys,
413 (key) =>
414 key.name
415 ? listVersionsInKey(key.name)
416 : Effect.succeed([] as kms.CryptoKeyVersion[]),
417 { concurrency: 4 },
418 );
419 return versionPages.flat();
420 });
421
422const waitOperation = (operation: kms.Operation) =>
423 waitForGcpOperation(
424 operation,
425 (name) => kms.getProjectsLocationsOperations({ name }),
426 { budget: "10 minutes" },
427 ).pipe(
428 // Re-read the finished operation for its typed response.
429 Effect.flatMap(() =>
430 operation.name === undefined
431 ? Effect.succeed(operation)
432 : kms.getProjectsLocationsOperations({ name: operation.name }),
433 ),
434 );
435
436const waitReady = (
437 name: string,
438): Effect.Effect<
439 kms.CryptoKeyVersion,
440 | CryptoKeyVersionNotResolved
441 | CryptoKeyVersionOperationFailed
442 | CryptoKeyVersionNotReady
443 | kms.GetProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersionsError,
444 kms.GcpOpContext
445> => {
446 const probe: Effect.Effect<
447 kms.CryptoKeyVersion,
448 | CryptoKeyVersionNotResolved
449 | CryptoKeyVersionOperationFailed
450 | CryptoKeyVersionNotReady
451 | kms.GetProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersionsError,
452 kms.GcpOpContext
453 > = getByName(name).pipe(
454 Effect.flatMap(
455 (
456 version,
457 ): Effect.Effect<
458 kms.CryptoKeyVersion,
459 | CryptoKeyVersionNotResolved
460 | CryptoKeyVersionOperationFailed
461 | CryptoKeyVersionNotReady
462 > => {
463 if (version === undefined) {
464 return Effect.fail(new CryptoKeyVersionNotResolved({ name }));
465 }
466 const state = version.state;
467 if (
468 state === undefined ||
469 state === "ENABLED" ||
470 state === "DISABLED" ||
471 state === "DESTROY_SCHEDULED" ||
472 state === "DESTROYED"
473 ) {
474 return Effect.succeed(version);
475 }
476 if (state === "GENERATION_FAILED") {
477 return Effect.fail(
478 new CryptoKeyVersionOperationFailed({
479 operation: version.name ?? name,
480 message: version.generationFailureReason ?? state,
481 }),
482 );
483 }
484 if (state === "IMPORT_FAILED") {
485 return Effect.fail(
486 new CryptoKeyVersionOperationFailed({
487 operation: version.name ?? name,
488 message: version.importFailureReason ?? state,
489 }),
490 );
491 }
492 return Effect.fail(
493 new CryptoKeyVersionNotReady({
494 name,
495 state: state ?? "PENDING_GENERATION",
496 }),
497 );
498 },
499 ),
500 );
501 return probe.pipe(
502 Effect.retry({
503 while: (error) => error._tag === "GCP.KMS.CryptoKeyVersionNotReady",
504 times: 8,
505 schedule: Schedule.spaced("500 millis"),
506 }),
507 );
508};
509
510const optionsEqual = (
511 observed: kms.ExternalProtectionLevelOptions | undefined,
512 desired: CryptoKeyVersionExternalProtectionLevelOptions | undefined,
513) =>
514 (observed?.externalKeyUri ?? "") === (desired?.externalKeyUri ?? "") &&
515 (observed?.ekmConnectionKeyPath ?? "") ===
516 (desired?.ekmConnectionKeyPath ?? "");
517
518const destroyVersion = (name: string) =>
519 kms
520 .destroyProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
521 name,
522 body: {},
523 })
524 .pipe(
525 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
526 Effect.catchTag("BadRequest", () => Effect.succeed(undefined)),
527 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
528 );
529
530const restoreVersion = (name: string) =>
531 kms
532 .restoreProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
533 name,
534 body: {},
535 })
536 .pipe(
537 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
538 Effect.catchTag("BadRequest", () => Effect.succeed(undefined)),
539 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
540 );
541
542const deleteVersion = (name: string) =>
543 kms.deleteProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({ name }).pipe(
544 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
545 Effect.catchTag("BadRequest", () => Effect.succeed(undefined)),
546 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
547 Effect.flatMap(
548 (
549 operation,
550 ): Effect.Effect<
551 void,
552 | CryptoKeyVersionOperationFailed
553 | OperationFailed
554 | OperationTimedOut
555 | kms.GetProjectsLocationsOperationsError,
556 kms.GcpOpContext
557 > =>
558 operation === undefined
559 ? Effect.void
560 : waitOperation(operation).pipe(Effect.asVoid),
561 ),
562 );
563
564const resolveName = (args: {
565 outputName: string | undefined;
566 outputVersionId: string | undefined;
567 propsVersionId: string | undefined;
568 parent: string;
569}) => {
570 if (args.outputName !== undefined) return args.outputName;
571 const versionId = args.propsVersionId ?? args.outputVersionId;
572 return versionId !== undefined
573 ? resourceName(args.parent, versionId)
574 : undefined;
575};
576
577export const CryptoKeyVersionProvider = () =>
578 Provider.succeed(CryptoKeyVersion, {
579 stables: [
580 "name",
581 "cryptoKeyVersionId",
582 "cryptoKey",
583 "keyRing",
584 "location",
585 "project",
586 "algorithm",
587 "protectionLevel",
588 "createTime",
589 ],
590
591 diff: Effect.fn(function* ({ news, olds, output }) {
592 if (!isResolved(news)) return undefined;
593 const env = yield* GcpEnvironment.current;
594
595 const previousId = olds?.cryptoKeyVersionId ?? output?.cryptoKeyVersionId;
596 const nextId = news.cryptoKeyVersionId ?? previousId;
597 const idChanged =
598 previousId !== undefined &&
599 nextId !== undefined &&
600 nextId !== previousId;
601
602 const previousParent =
603 output?.cryptoKey ??
604 (olds?.cryptoKey
605 ? resolveParent(
606 "",
607 olds.cryptoKey,
608 olds.keyRing,
609 olds.location,
610 env.region,
611 ).parent
612 : undefined);
613 const nextParent = resolveParent(
614 output?.project ?? "",
615 news.cryptoKey,
616 news.keyRing ?? output?.keyRing,
617 news.location ?? output?.location,
618 env.region,
619 ).parent;
620 const parentChanged =
621 previousParent !== undefined && previousParent !== nextParent;
622
623 if (!idChanged && !parentChanged) return undefined;
624 return { action: "replace" as const, deleteFirst: false };
625 }),
626
627 read: Effect.fn(function* ({ olds, output }) {
628 const env = yield* GcpEnvironment.current;
629 const parent =
630 olds?.cryptoKey || output?.cryptoKey
631 ? resolveParent(
632 env.project,
633 olds?.cryptoKey ?? output?.cryptoKey ?? "",
634 olds?.keyRing ?? output?.keyRing,
635 olds?.location ?? output?.location,
636 env.region,
637 ).parent
638 : undefined;
639 const name = resolveName({
640 outputName: output?.name,
641 outputVersionId: output?.cryptoKeyVersionId,
642 propsVersionId: olds?.cryptoKeyVersionId,
643 parent: parent ?? "",
644 });
645 if (name === undefined) return undefined;
646 const existing = yield* getByName(name);
647 if (existing === undefined) return undefined;
648 if (DELETABLE_STATES.has(existing.state ?? "")) return undefined;
649 // Versions have no labels. Existence at the stored name is
650 // ownership; adopting a version of an alchemy-labeled key is
651 // harmless.
652 return toAttrs(existing, env.project, env.region);
653 }),
654
655 list: () =>
656 Effect.gen(function* () {
657 const env = yield* GcpEnvironment.current;
658 const found: CryptoKeyVersionAttrs[] = [];
659 let pageToken: string | undefined;
660 for (let page = 0; page < 10; page++) {
661 const response = yield* kms.listProjectsLocations({
662 name: `projects/${env.project}`,
663 pageSize: 100,
664 pageToken,
665 });
666 const parents = (response.locations ?? [])
667 .map((location) => location.name)
668 .filter((name): name is string => !!name);
669 const batches = yield* Effect.forEach(
670 parents,
671 (parent) => listVersionsAt(parent),
672 { concurrency: 4 },
673 );
674 for (const versions of batches) {
675 for (const version of versions) {
676 if (version.state === "DESTROY_SCHEDULED") continue;
677 found.push(toAttrs(version, env.project, env.region));
678 }
679 }
680 pageToken = response.nextPageToken;
681 if (pageToken === undefined || pageToken === "") break;
682 }
683 return found;
684 }),
685
686 reconcile: Effect.fn(function* ({ news, output }) {
687 const env = yield* GcpEnvironment.current;
688 const parent = resolveParent(
689 env.project,
690 news.cryptoKey,
691 news.keyRing ?? output?.keyRing,
692 news.location ?? output?.location,
693 env.region,
694 );
695 const name = resolveName({
696 outputName: output?.name,
697 outputVersionId: output?.cryptoKeyVersionId,
698 propsVersionId: news.cryptoKeyVersionId,
699 parent: parent.parent,
700 });
701 const targetState = desiredState(news.state);
702
703 let current = name === undefined ? undefined : yield* getByName(name);
704
705 if (current !== undefined && DELETABLE_STATES.has(current.state ?? "")) {
706 if (current.name !== undefined) {
707 yield* deleteVersion(current.name);
708 }
709 current = undefined;
710 }
711
712 if (current === undefined) {
713 const created = yield* kms
714 .createProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
715 parent: parent.parent,
716 body: {
717 state: targetState === "DISABLED" ? "DISABLED" : undefined,
718 externalProtectionLevelOptions:
719 news.externalProtectionLevelOptions,
720 },
721 })
722 .pipe(
723 Effect.catchTag("Conflict", () =>
724 name !== undefined ? getByName(name) : Effect.succeed(undefined),
725 ),
726 );
727 current = created ?? undefined;
728 }
729
730 if (current === undefined || current.name === undefined) {
731 return yield* new CryptoKeyVersionNotResolved({
732 name: name ?? resourceName(parent.parent, "unknown"),
733 });
734 }
735
736 const currentName = current.name;
737 if (
738 current.state === "PENDING_GENERATION" ||
739 current.state === "PENDING_IMPORT"
740 ) {
741 current = yield* waitReady(currentName);
742 }
743
744 if (
745 current.state === "DESTROY_SCHEDULED" &&
746 PATCHABLE_STATES.has(targetState)
747 ) {
748 const restored = yield* restoreVersion(currentName);
749 current = restored ?? (yield* waitReady(currentName));
750 }
751
752 const observedState = current.state ?? DEFAULT_STATE;
753 const stateChanged =
754 PATCHABLE_STATES.has(targetState) &&
755 PATCHABLE_STATES.has(observedState) &&
756 observedState !== targetState;
757 const optionsChanged =
758 news.externalProtectionLevelOptions !== undefined &&
759 !optionsEqual(
760 current.externalProtectionLevelOptions,
761 news.externalProtectionLevelOptions,
762 );
763
764 if (stateChanged || optionsChanged) {
765 const updateMask = [
766 stateChanged ? "state" : undefined,
767 optionsChanged ? "externalProtectionLevelOptions" : undefined,
768 ]
769 .filter((field): field is string => field !== undefined)
770 .join(",");
771 current =
772 yield* kms.patchProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
773 name: currentName,
774 updateMask,
775 body: {
776 state: stateChanged ? targetState : undefined,
777 externalProtectionLevelOptions: optionsChanged
778 ? news.externalProtectionLevelOptions
779 : undefined,
780 },
781 });
782 }
783
784 return toAttrs(current, env.project, env.region);
785 }),
786
787 delete: Effect.fn(function* ({ output }) {
788 const name = output.name;
789 const current = yield* getByName(name);
790 if (current === undefined) return;
791
792 const state = current.state ?? "";
793 if (DESTROYABLE_STATES.has(state)) {
794 yield* destroyVersion(name);
795 return;
796 }
797 if (DELETABLE_STATES.has(state)) {
798 yield* deleteVersion(name);
799 }
800 }),
801 });