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 { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 stripInternalLabels,
16 toLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import {
20 type OperationFailed,
21 type OperationTimedOut,
22 waitForOperation as waitForGcpOperation,
23} from "../Operation.ts";
24import { deterministicKmsId } from "./internal.ts";
25
26const DEFAULT_PURPOSE: kms.CryptoKeyPurposeEnum = "ENCRYPT_DECRYPT";
27const DEFAULT_ALGORITHM: kms.CryptoKeyVersionTemplateAlgorithmEnum =
28 "GOOGLE_SYMMETRIC_ENCRYPTION";
29const DEFAULT_PROTECTION: kms.CryptoKeyVersionTemplateProtectionLevelEnum =
30 "SOFTWARE";
31const DELETABLE_VERSION_STATES = new Set([
32 "DESTROYED",
33 "IMPORT_FAILED",
34 "GENERATION_FAILED",
35]);
36const DESTROYABLE_VERSION_STATES = new Set(["ENABLED", "DISABLED"]);
37const USABLE_VERSION_STATES = new Set(["ENABLED", "PENDING_GENERATION"]);
38
39/**
40 * Cloud KMS never deletes a key while any version is merely
41 * `DESTROY_SCHEDULED` (at least 24h), never deletes a key ring, and
42 * retires a deleted key's name forever. On delete Alchemy therefore never
43 * deletes the key: it destroys every version and replaces the ownership
44 * labels with this marker. A released key holds no usable key material, so
45 * any stack may reclaim it on create: reconcile re-stamps ownership and
46 * mints a fresh primary. This keeps both fixed and default (deterministic)
47 * `cryptoKeyId`s reusable across destroy/redeploy cycles.
48 */
49export const RELEASED_LABEL = "alchemy-released";
50
51const isReleased = (key: kms.CryptoKey) =>
52 (key.labels ?? {})[RELEASED_LABEL] !== undefined;
53
54export type CryptoKeyVersionTemplate = {
55 /**
56 * Algorithm for new versions. Defaults to `GOOGLE_SYMMETRIC_ENCRYPTION`
57 * when `purpose` is `ENCRYPT_DECRYPT`.
58 */
59 algorithm?: kms.CryptoKeyVersionTemplateAlgorithmEnum | (string & {});
60 /**
61 * Protection level for new versions. Immutable — changing it replaces
62 * the key.
63 * @default "SOFTWARE"
64 */
65 protectionLevel?:
66 | kms.CryptoKeyVersionTemplateProtectionLevelEnum
67 | (string & {});
68};
69
70export type CryptoKeyProps = {
71 /**
72 * Parent KeyRing. Full name
73 * `projects/{project}/locations/{location}/keyRings/{keyRing}` or the
74 * key ring id (combined with `location`). Immutable — changing it
75 * replaces the key.
76 */
77 keyRing: string;
78 /**
79 * Cloud KMS location (`us-central1`, `global`, `us`, …). Used when
80 * `keyRing` is a bare id. Immutable — changing it replaces the key.
81 * `US-CENTRAL1` is accepted and normalized to `us-central1`.
82 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
83 */
84 location?: string;
85 /**
86 * CryptoKey id (the last path segment). If omitted, a deterministic
87 * name `{stack}-{id}-{stage}` is derived (no random suffix), plus a short
88 * hash of `purpose`, `versionTemplate.protectionLevel`, `importOnly`,
89 * `destroyScheduledDuration`, and `cryptoKeyBackend` when any differ
90 * from their defaults — so a redeploy after destroy reclaims the same
91 * key, and a replacement caused by those settings gets a distinct name.
92 * Must match `[a-zA-Z0-9_-]{1,63}`. Immutable — changing it replaces the
93 * key.
94 */
95 cryptoKeyId?: string;
96 /**
97 * Immutable purpose of this key.
98 * @default "ENCRYPT_DECRYPT"
99 */
100 purpose?: kms.CryptoKeyPurposeEnum;
101 /**
102 * Template for new CryptoKeyVersions. `protectionLevel` is immutable
103 * (replacement); `algorithm` can be patched.
104 */
105 versionTemplate?: CryptoKeyVersionTemplate;
106 /**
107 * User labels. Alchemy ownership labels are merged in automatically.
108 */
109 labels?: Record<string, string>;
110 /**
111 * Automatic rotation period (e.g. `"2592000s"`). ENCRYPT_DECRYPT keys
112 * only. Must be paired with `nextRotationTime` (Alchemy fills
113 * `now + period` when omitted).
114 */
115 rotationPeriod?: string;
116 /**
117 * RFC3339 time of the next automatic rotation. ENCRYPT_DECRYPT keys
118 * only.
119 */
120 nextRotationTime?: string;
121 /**
122 * How long versions stay in `DESTROY_SCHEDULED` before `DESTROYED`.
123 * Immutable. Minimum 24h except import-only keys.
124 */
125 destroyScheduledDuration?: string;
126 /**
127 * Create the key with no versions (and keep it version-less when a
128 * released key is reclaimed), e.g. for import-only or externally
129 * managed material.
130 * @default false
131 */
132 skipInitialVersionCreation?: boolean;
133 /**
134 * Whether this key may contain imported versions only. Immutable.
135 * @default false
136 */
137 importOnly?: boolean;
138 /**
139 * Backend for EXTERNAL_VPC / HSM_SINGLE_TENANT keys. Immutable.
140 */
141 cryptoKeyBackend?: string;
142};
143
144export type CryptoKeyAttrs = {
145 /** Full resource name `projects/.../cryptoKeys/{cryptoKey}`. */
146 name: string;
147 /** CryptoKey id (last path segment). */
148 cryptoKeyId: string;
149 /** Parent KeyRing resource name. */
150 keyRing: string;
151 /** Location id (`us-central1`, `global`, …). */
152 location: string;
153 /** Project id. */
154 project: string;
155 /** Key purpose. */
156 purpose: string;
157 /** User labels (Alchemy ownership labels stripped). */
158 labels: Record<string, string>;
159 /** Version template currently applied. */
160 versionTemplate: CryptoKeyVersionTemplate | undefined;
161 /** Automatic rotation period, if set. */
162 rotationPeriod: string | undefined;
163 /** Next automatic rotation time, if set. */
164 nextRotationTime: string | undefined;
165 /** Scheduled-destruction duration. */
166 destroyScheduledDuration: string | undefined;
167 /** Whether the key is import-only. */
168 importOnly: boolean;
169 /** External / single-tenant HSM backend, if any. */
170 cryptoKeyBackend: string | undefined;
171 /** Primary version resource name, if any. */
172 primaryVersion: string | undefined;
173 /** RFC3339 creation timestamp. */
174 createTime: string | undefined;
175};
176
177export type CryptoKey = Resource<
178 "GCP.KMS.CryptoKey",
179 CryptoKeyProps,
180 CryptoKeyAttrs,
181 never,
182 Providers
183>;
184
185/**
186 * A Cloud KMS CryptoKey — a named key that holds zero or more versions.
187 *
188 * Purpose, location, parent KeyRing, import-only, backend,
189 * `destroyScheduledDuration`, and `versionTemplate.protectionLevel` are
190 * immutable (changing them replaces the key). Labels, rotation, and
191 * `versionTemplate.algorithm` update in place.
192 *
193 * Cloud KMS only permanently deletes a CryptoKey after every version is
194 * gone, and a deleted CryptoKey's name is retired forever — it can never
195 * be created again in the project.
196 *
197 * Destroying a key therefore *releases* it instead of deleting it: every
198 * version is scheduled for destruction and the ownership labels are
199 * replaced by `alchemy-released`. A later deploy with the same
200 * `cryptoKeyId` — from this stack or any other — reclaims the released key
201 * and mints a fresh primary version. The default `cryptoKeyId` is
202 * deterministic per stack, stage, and logical id, so destroy/redeploy
203 * cycles reuse one key rather than leaving a new released key behind each
204 * time. Ciphertext encrypted under the old versions is not recoverable.
205 *
206 * ### Creating a CryptoKey
207 * **Example:** Generated name on an existing KeyRing
208 * ```typescript
209 * const ring = yield* GCP.KMS.KeyRing("Keys", {});
210 * const key = yield* GCP.KMS.CryptoKey("Data", {
211 * keyRing: ring.name,
212 * });
213 * ```
214 *
215 * **Example:** Explicit id, labels, and no initial version
216 * ```typescript
217 * const key = yield* GCP.KMS.CryptoKey("Data", {
218 * keyRing: ring.name,
219 * cryptoKeyId: "app-data",
220 * labels: { env: "prod" },
221 * skipInitialVersionCreation: true,
222 * });
223 * ```
224 *
225 * ### Encrypting and Decrypting
226 * **Example:** Encrypt then decrypt
227 * ```typescript
228 * const encrypt = yield* GCP.KMS.Encrypt(key);
229 * const decrypt = yield* GCP.KMS.Decrypt(key);
230 * const { ciphertext } = yield* encrypt({
231 * body: { plaintext: btoa("hello") },
232 * });
233 * const { plaintext } = yield* decrypt({
234 * body: { ciphertext },
235 * });
236 * ```
237 *
238 * @resource
239 * @category KMS
240 */
241export const CryptoKey = Resource<CryptoKey>("GCP.KMS.CryptoKey");
242
243export class CryptoKeyNotResolved extends Data.TaggedError(
244 "GCP.KMS.CryptoKeyNotResolved",
245)<{
246 name: string;
247}> {}
248
249export class CryptoKeyOperationFailed extends Data.TaggedError(
250 "GCP.KMS.CryptoKeyOperationFailed",
251)<{
252 operation: string;
253 message: string;
254}> {}
255
256export class CryptoKeyVersionPending extends Data.TaggedError(
257 "GCP.KMS.CryptoKeyVersionPending",
258)<{
259 name: string;
260 state: string;
261}> {}
262
263const lastSegment = (value: string) => {
264 const trimmed = value.replace(/\/+$/, "");
265 const parts = trimmed.split("/");
266 return parts[parts.length - 1] || trimmed;
267};
268
269const normalizeLocation = (location: string | undefined, fallback: string) =>
270 lastSegment(location ?? fallback).toLowerCase();
271
272const normalizeProtection = (
273 value: string | undefined,
274): kms.CryptoKeyVersionTemplateProtectionLevelEnum =>
275 !value || value === "PROTECTION_LEVEL_UNSPECIFIED"
276 ? DEFAULT_PROTECTION
277 : (value as kms.CryptoKeyVersionTemplateProtectionLevelEnum);
278
279const parseName = (name: string, fallbackLocation: string) => {
280 const parts = name.split("/").filter((part) => part.length > 0);
281 const cryptoKeysAt = parts.lastIndexOf("cryptoKeys");
282 const keyRingsAt = parts.lastIndexOf("keyRings");
283 const locationsAt = parts.lastIndexOf("locations");
284 const projectsAt = parts.lastIndexOf("projects");
285 const keyRing =
286 keyRingsAt >= 0 ? parts.slice(0, keyRingsAt + 2).join("/") : "";
287 return {
288 project:
289 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
290 location:
291 locationsAt >= 0 && parts[locationsAt + 1]
292 ? parts[locationsAt + 1]!
293 : fallbackLocation,
294 keyRing,
295 cryptoKeyId:
296 cryptoKeysAt >= 0 && parts[cryptoKeysAt + 1]
297 ? parts[cryptoKeysAt + 1]!
298 : lastSegment(name),
299 };
300};
301
302const resolveParent = (
303 project: string,
304 keyRing: string,
305 location: string | undefined,
306 region: string,
307) => {
308 if (keyRing.includes("/")) {
309 const parsed = parseName(
310 keyRing.includes("/cryptoKeys/") ? keyRing : `${keyRing}/cryptoKeys/_`,
311 region,
312 );
313 return {
314 parent: parsed.keyRing,
315 location: parsed.location,
316 project: parsed.project || project,
317 };
318 }
319 const loc = normalizeLocation(location, region);
320 return {
321 parent: `projects/${project}/locations/${loc}/keyRings/${keyRing}`,
322 location: loc,
323 project,
324 };
325};
326
327const resourceName = (parent: string, cryptoKeyId: string) =>
328 `${parent}/cryptoKeys/${cryptoKeyId}`;
329
330const userLabels = (
331 labels: Record<string, string | undefined> | null | undefined,
332): Record<string, string> => stripInternalLabels(tagRecord(labels));
333
334type ImmutableKeyProps = Pick<
335 CryptoKeyProps,
336 | "purpose"
337 | "versionTemplate"
338 | "importOnly"
339 | "destroyScheduledDuration"
340 | "cryptoKeyBackend"
341>;
342
343/**
344 * The replacement-triggering settings (besides id and parent) folded into
345 * the generated id, or `undefined` when all are defaults so the common
346 * case keeps the plain `{stack}-{id}-{stage}` name.
347 */
348const immutableVariant = (props: ImmutableKeyProps | undefined) => {
349 const purpose = props?.purpose ?? DEFAULT_PURPOSE;
350 const protectionLevel = normalizeProtection(
351 props?.versionTemplate?.protectionLevel,
352 );
353 const importOnly = props?.importOnly === true;
354 const destroyScheduledDuration = props?.destroyScheduledDuration;
355 const cryptoKeyBackend = props?.cryptoKeyBackend || undefined;
356 if (
357 purpose === DEFAULT_PURPOSE &&
358 protectionLevel === DEFAULT_PROTECTION &&
359 !importOnly &&
360 destroyScheduledDuration === undefined &&
361 cryptoKeyBackend === undefined
362 ) {
363 return undefined;
364 }
365 return {
366 purpose,
367 protectionLevel,
368 importOnly,
369 destroyScheduledDuration,
370 cryptoKeyBackend,
371 };
372};
373
374const toId = (
375 id: string,
376 props: (ImmutableKeyProps & { cryptoKeyId?: string }) | undefined,
377 existing?: string,
378) =>
379 Effect.gen(function* () {
380 return (
381 props?.cryptoKeyId ??
382 existing ??
383 (yield* deterministicKmsId(id, immutableVariant(props)))
384 );
385 });
386
387const toAttrs = (
388 key: kms.CryptoKey,
389 project: string,
390 region: string,
391): CryptoKeyAttrs => {
392 const name = key.name ?? "";
393 const parsed = parseName(name, region);
394 const template = key.versionTemplate;
395 return {
396 name,
397 cryptoKeyId: parsed.cryptoKeyId,
398 keyRing: parsed.keyRing,
399 location: parsed.location,
400 project: parsed.project || project,
401 purpose: key.purpose ?? DEFAULT_PURPOSE,
402 labels: userLabels(key.labels),
403 versionTemplate:
404 template === undefined
405 ? undefined
406 : {
407 algorithm: template.algorithm,
408 protectionLevel: template.protectionLevel,
409 },
410 rotationPeriod: key.rotationPeriod,
411 nextRotationTime: key.nextRotationTime,
412 destroyScheduledDuration: key.destroyScheduledDuration,
413 importOnly: key.importOnly === true,
414 cryptoKeyBackend: key.cryptoKeyBackend,
415 primaryVersion: key.primary?.name,
416 createTime: key.createTime,
417 };
418};
419
420const getByName = (name: string) =>
421 kms
422 .getProjectsLocationsKeyRingsCryptoKeys({ name })
423 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
424
425const paginate = <A, E, R>(
426 fetch: (
427 pageToken: string | undefined,
428 ) => Effect.Effect<{ items: A[]; nextPageToken?: string }, E, R>,
429) =>
430 Effect.gen(function* () {
431 const found: A[] = [];
432 let pageToken: string | undefined;
433 for (let page = 0; page < 10; page++) {
434 const response = yield* fetch(pageToken);
435 found.push(...response.items);
436 pageToken = response.nextPageToken;
437 if (pageToken === undefined || pageToken === "") break;
438 }
439 return found;
440 });
441
442const listKeyRingsAt = (parent: string) =>
443 paginate((pageToken) =>
444 kms
445 .listProjectsLocationsKeyRings({
446 parent,
447 pageSize: 1000,
448 pageToken,
449 })
450 .pipe(
451 Effect.map((response) => ({
452 items: response.keyRings ?? [],
453 nextPageToken: response.nextPageToken,
454 })),
455 Effect.catchTag("NotFound", () =>
456 Effect.succeed({
457 items: [] as kms.KeyRing[],
458 nextPageToken: undefined,
459 }),
460 ),
461 ),
462 );
463
464const listKeysInRing = (parent: string) =>
465 paginate((pageToken) =>
466 kms
467 .listProjectsLocationsKeyRingsCryptoKeys({
468 parent,
469 pageSize: 1000,
470 pageToken,
471 })
472 .pipe(
473 Effect.map((response) => ({
474 // Released keys hold no key material and belong to no stack.
475 items: (response.cryptoKeys ?? []).filter(
476 (key) =>
477 !isReleased(key) &&
478 Object.keys(key.labels ?? {}).some((label) =>
479 label.startsWith("alchemy-"),
480 ),
481 ),
482 nextPageToken: response.nextPageToken,
483 })),
484 Effect.catchTag("NotFound", () =>
485 Effect.succeed({
486 items: [] as kms.CryptoKey[],
487 nextPageToken: undefined,
488 }),
489 ),
490 ),
491 );
492
493const listCryptoKeysAt = (locationParent: string) =>
494 Effect.gen(function* () {
495 const rings = yield* listKeyRingsAt(locationParent);
496 const pages = yield* Effect.forEach(
497 rings,
498 (ring) =>
499 ring.name
500 ? listKeysInRing(ring.name)
501 : Effect.succeed([] as kms.CryptoKey[]),
502 { concurrency: 4 },
503 );
504 return pages.flat();
505 });
506
507const listVersions = (parent: string) =>
508 paginate((pageToken) =>
509 kms
510 .listProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
511 parent,
512 pageSize: 1000,
513 pageToken,
514 })
515 .pipe(
516 Effect.map((response) => ({
517 items: response.cryptoKeyVersions ?? [],
518 nextPageToken: response.nextPageToken,
519 })),
520 Effect.catchTag("NotFound", () =>
521 Effect.succeed({
522 items: [] as kms.CryptoKeyVersion[],
523 nextPageToken: undefined,
524 }),
525 ),
526 ),
527 );
528
529const waitOperation = (operation: kms.Operation) =>
530 waitForGcpOperation(
531 operation,
532 (name) => kms.getProjectsLocationsOperations({ name }),
533 { budget: "10 minutes" },
534 ).pipe(
535 // Re-read the finished operation for its typed response.
536 Effect.flatMap(() =>
537 operation.name === undefined
538 ? Effect.succeed(operation)
539 : kms.getProjectsLocationsOperations({ name: operation.name }),
540 ),
541 );
542
543const waitPrimaryReady = (
544 name: string,
545): Effect.Effect<
546 kms.CryptoKey,
547 | CryptoKeyNotResolved
548 | CryptoKeyOperationFailed
549 | CryptoKeyVersionPending
550 | kms.GetProjectsLocationsKeyRingsCryptoKeysError,
551 kms.GcpOpContext
552> => {
553 const probe: Effect.Effect<
554 kms.CryptoKey,
555 | CryptoKeyNotResolved
556 | CryptoKeyOperationFailed
557 | CryptoKeyVersionPending
558 | kms.GetProjectsLocationsKeyRingsCryptoKeysError,
559 kms.GcpOpContext
560 > = getByName(name).pipe(
561 Effect.flatMap(
562 (
563 key,
564 ): Effect.Effect<
565 kms.CryptoKey,
566 | CryptoKeyNotResolved
567 | CryptoKeyOperationFailed
568 | CryptoKeyVersionPending
569 > => {
570 if (key === undefined) {
571 return Effect.fail(new CryptoKeyNotResolved({ name }));
572 }
573 const state = key.primary?.state;
574 if (state === undefined || state === "ENABLED") {
575 return Effect.succeed(key);
576 }
577 if (state === "GENERATION_FAILED") {
578 return Effect.fail(
579 new CryptoKeyOperationFailed({
580 operation: key.primary?.name ?? name,
581 message: key.primary?.generationFailureReason ?? state,
582 }),
583 );
584 }
585 return Effect.fail(
586 new CryptoKeyVersionPending({
587 name,
588 state,
589 }),
590 );
591 },
592 ),
593 );
594 return probe.pipe(
595 Effect.retry({
596 while: (error) => error._tag === "GCP.KMS.CryptoKeyVersionPending",
597 times: 8,
598 schedule: Schedule.spaced("500 millis"),
599 }),
600 );
601};
602
603const desiredAlgorithm = (
604 purpose: kms.CryptoKeyPurposeEnum,
605 template: CryptoKeyVersionTemplate | undefined,
606) =>
607 template?.algorithm ??
608 (purpose === "ENCRYPT_DECRYPT" ? DEFAULT_ALGORITHM : template?.algorithm);
609
610const nextRotationFromPeriod = (period: string) =>
611 Effect.sync(() => {
612 const match = /^(\d+)s$/.exec(period);
613 const seconds = match ? Number(match[1]) : 86_400;
614 return new Date(Date.now() + seconds * 1000).toISOString();
615 });
616
617const destroyVersion = (name: string) =>
618 kms
619 .destroyProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
620 name,
621 body: {},
622 })
623 .pipe(
624 Effect.catchTag("NotFound", () => Effect.void),
625 Effect.catchTag("BadRequest", () => Effect.void),
626 Effect.catchTag("Conflict", () => Effect.void),
627 );
628
629const deleteVersion = (name: string) =>
630 kms.deleteProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({ name }).pipe(
631 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
632 Effect.catchTag("BadRequest", () => Effect.succeed(undefined)),
633 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
634 Effect.flatMap(
635 (
636 operation,
637 ): Effect.Effect<
638 void,
639 | CryptoKeyOperationFailed
640 | OperationFailed
641 | OperationTimedOut
642 | kms.GetProjectsLocationsOperationsError,
643 kms.GcpOpContext
644 > =>
645 operation === undefined
646 ? Effect.void
647 : waitOperation(operation).pipe(Effect.asVoid),
648 ),
649 );
650
651const clearRotation = (name: string, current: kms.CryptoKey) => {
652 if (!current.rotationPeriod && !current.nextRotationTime) {
653 return Effect.void;
654 }
655 return kms
656 .patchProjectsLocationsKeyRingsCryptoKeys({
657 name,
658 updateMask: "rotationPeriod,nextRotationTime",
659 body: {},
660 })
661 .pipe(
662 Effect.catchTag("NotFound", () => Effect.void),
663 Effect.asVoid,
664 );
665};
666
667/**
668 * Mark a key reclaimable: keep user labels, drop the stack/stage/id
669 * ownership labels, and add {@link RELEASED_LABEL}.
670 */
671const releaseKey = (name: string, current: kms.CryptoKey) =>
672 isReleased(current)
673 ? Effect.void
674 : kms
675 .patchProjectsLocationsKeyRingsCryptoKeys({
676 name,
677 updateMask: "labels",
678 body: {
679 labels: { ...userLabels(current.labels), [RELEASED_LABEL]: "true" },
680 },
681 })
682 .pipe(
683 Effect.catchTag("NotFound", () => Effect.void),
684 Effect.asVoid,
685 );
686
687/**
688 * Give a key usable material when it has none — a reclaimed released key,
689 * or one whose versions were destroyed out of band. Symmetric keys also
690 * need that version as their primary.
691 */
692const ensureUsableVersion = (
693 name: string,
694 current: kms.CryptoKey,
695 purpose: kms.CryptoKeyPurposeEnum | (string & {}),
696) =>
697 Effect.gen(function* () {
698 const symmetric = purpose === "ENCRYPT_DECRYPT";
699 if (symmetric && USABLE_VERSION_STATES.has(current.primary?.state ?? "")) {
700 return current;
701 }
702 const versions = yield* listVersions(name);
703 let usable = versions.find((version) =>
704 USABLE_VERSION_STATES.has(version.state ?? ""),
705 );
706 if (usable === undefined) {
707 usable =
708 yield* kms.createProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({
709 parent: name,
710 body: {},
711 });
712 }
713 if (!symmetric) return current;
714 const versionId = lastSegment(usable.name ?? "");
715 yield* kms.updatePrimaryVersionProjectsLocationsKeyRingsCryptoKeys({
716 name,
717 body: { cryptoKeyVersionId: versionId },
718 });
719 return yield* waitPrimaryReady(name);
720 });
721
722export const CryptoKeyProvider = () =>
723 Provider.succeed(CryptoKey, {
724 stables: [
725 "name",
726 "cryptoKeyId",
727 "keyRing",
728 "location",
729 "project",
730 "purpose",
731 "destroyScheduledDuration",
732 "importOnly",
733 "cryptoKeyBackend",
734 "createTime",
735 ],
736
737 diff: Effect.fn(function* ({ news, olds, output }) {
738 if (!isResolved(news)) return undefined;
739 const env = yield* GcpEnvironment.current;
740
741 const previousId = olds?.cryptoKeyId ?? output?.cryptoKeyId;
742 const nextId = news.cryptoKeyId ?? previousId;
743 const idChanged =
744 previousId !== undefined &&
745 nextId !== undefined &&
746 nextId !== previousId;
747
748 const previousParent =
749 output?.keyRing ??
750 (olds?.keyRing
751 ? resolveParent("", olds.keyRing, olds.location, env.region).parent
752 : undefined);
753 const nextParent = resolveParent(
754 output?.project ?? "",
755 news.keyRing,
756 news.location ?? output?.location,
757 env.region,
758 ).parent;
759 const parentChanged =
760 previousParent !== undefined && previousParent !== nextParent;
761
762 const previousPurpose =
763 olds?.purpose ?? output?.purpose ?? DEFAULT_PURPOSE;
764 const nextPurpose = news.purpose ?? DEFAULT_PURPOSE;
765 const previousProtection = normalizeProtection(
766 olds?.versionTemplate?.protectionLevel ??
767 output?.versionTemplate?.protectionLevel,
768 );
769 const nextProtection = normalizeProtection(
770 news.versionTemplate?.protectionLevel,
771 );
772 const previousImportOnly =
773 olds?.importOnly ?? output?.importOnly ?? false;
774 const nextImportOnly = news.importOnly === true;
775 const previousDuration =
776 olds?.destroyScheduledDuration ?? output?.destroyScheduledDuration;
777 const nextDuration = news.destroyScheduledDuration;
778 const previousBackend =
779 olds?.cryptoKeyBackend ?? output?.cryptoKeyBackend;
780 const nextBackend = news.cryptoKeyBackend;
781
782 const replace =
783 idChanged ||
784 parentChanged ||
785 previousPurpose !== nextPurpose ||
786 previousProtection !== nextProtection ||
787 previousImportOnly !== nextImportOnly ||
788 (previousDuration !== undefined &&
789 nextDuration !== undefined &&
790 previousDuration !== nextDuration) ||
791 (previousBackend ?? "") !== (nextBackend ?? "");
792
793 if (!replace) return undefined;
794 return {
795 action: "replace" as const,
796 // A generated id folds every replacement trigger except the parent
797 // into its hash, and a parent change moves rings, so the new
798 // generation never collides with the old one. Only a fixed id
799 // reused in the same ring must release the old key first.
800 deleteFirst:
801 news.cryptoKeyId !== undefined &&
802 previousId !== undefined &&
803 nextId === previousId &&
804 !parentChanged,
805 };
806 }),
807
808 read: Effect.fn(function* ({ id, olds, output }) {
809 const env = yield* GcpEnvironment.current;
810 const cryptoKeyId = yield* toId(id, olds, output?.cryptoKeyId);
811 const name =
812 output?.name ??
813 (olds?.keyRing || output?.keyRing
814 ? resourceName(
815 resolveParent(
816 env.project,
817 olds?.keyRing ?? output?.keyRing ?? "",
818 olds?.location ?? output?.location,
819 env.region,
820 ).parent,
821 cryptoKeyId,
822 )
823 : undefined);
824 if (name === undefined) return undefined;
825 const existing = yield* getByName(name);
826 if (existing === undefined) return undefined;
827 const attrs = toAttrs(existing, env.project, env.region);
828 return isReleased(existing) ||
829 (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
830 ? attrs
831 : Unowned(attrs);
832 }),
833
834 list: () =>
835 Effect.gen(function* () {
836 const env = yield* GcpEnvironment.current;
837 const found: ReturnType<typeof toAttrs>[] = [];
838 let pageToken: string | undefined;
839 for (let page = 0; page < 10; page++) {
840 const response = yield* kms.listProjectsLocations({
841 name: `projects/${env.project}`,
842 pageSize: 100,
843 pageToken,
844 });
845 const parents = (response.locations ?? [])
846 .map((location) => location.name)
847 .filter((name): name is string => !!name);
848 const batches = yield* Effect.forEach(
849 parents,
850 (parent) => listCryptoKeysAt(parent),
851 { concurrency: 4 },
852 );
853 for (const keys of batches) {
854 for (const key of keys) {
855 found.push(toAttrs(key, env.project, env.region));
856 }
857 }
858 pageToken = response.nextPageToken;
859 if (pageToken === undefined || pageToken === "") break;
860 }
861 return found;
862 }),
863
864 reconcile: Effect.fn(function* ({ id, news, output }) {
865 const env = yield* GcpEnvironment.current;
866 const cryptoKeyId = yield* toId(id, news, output?.cryptoKeyId);
867 const parent = resolveParent(
868 env.project,
869 news.keyRing,
870 news.location ?? output?.location,
871 env.region,
872 );
873 const name = resourceName(parent.parent, cryptoKeyId);
874 const purpose = news.purpose ?? DEFAULT_PURPOSE;
875 const algorithm = desiredAlgorithm(purpose, news.versionTemplate);
876 const protectionLevel = news.versionTemplate?.protectionLevel;
877 const desiredLabels = {
878 ...toLabels(news.labels),
879 ...(yield* createInternalLabels(id)),
880 };
881 const rotationPeriod = news.rotationPeriod;
882 const nextRotationTime = rotationPeriod
883 ? (news.nextRotationTime ??
884 (yield* nextRotationFromPeriod(rotationPeriod)))
885 : news.nextRotationTime;
886
887 let current = yield* getByName(name);
888
889 if (current === undefined) {
890 const created = yield* kms
891 .createProjectsLocationsKeyRingsCryptoKeys({
892 parent: parent.parent,
893 cryptoKeyId,
894 skipInitialVersionCreation: news.skipInitialVersionCreation,
895 body: {
896 purpose,
897 labels: desiredLabels,
898 versionTemplate: {
899 algorithm,
900 protectionLevel,
901 },
902 rotationPeriod,
903 nextRotationTime,
904 destroyScheduledDuration: news.destroyScheduledDuration,
905 importOnly: news.importOnly,
906 cryptoKeyBackend: news.cryptoKeyBackend,
907 },
908 })
909 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
910 current = created ?? undefined;
911 if (
912 current !== undefined &&
913 news.skipInitialVersionCreation !== true &&
914 current.primary?.state === "PENDING_GENERATION"
915 ) {
916 current = yield* waitPrimaryReady(name);
917 }
918 }
919
920 if (current === undefined) {
921 return yield* new CryptoKeyNotResolved({ name });
922 }
923
924 const observedLabels = tagRecord(current.labels);
925 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
926 const labelsChanged = upsert.length > 0 || removed.length > 0;
927 const observedAlgorithm = current.versionTemplate?.algorithm;
928 const algorithmChanged =
929 algorithm !== undefined && observedAlgorithm !== algorithm;
930 const rotationChanged =
931 (current.rotationPeriod ?? "") !== (rotationPeriod ?? "") ||
932 (news.nextRotationTime !== undefined &&
933 (current.nextRotationTime ?? "") !== news.nextRotationTime);
934
935 if (labelsChanged || algorithmChanged || rotationChanged) {
936 const updateMask = [
937 labelsChanged ? "labels" : undefined,
938 algorithmChanged ? "versionTemplate.algorithm" : undefined,
939 rotationChanged ? "rotationPeriod" : undefined,
940 rotationChanged ? "nextRotationTime" : undefined,
941 ]
942 .filter((field): field is string => field !== undefined)
943 .join(",");
944 current = yield* kms.patchProjectsLocationsKeyRingsCryptoKeys({
945 name,
946 updateMask,
947 body: {
948 labels: desiredLabels,
949 versionTemplate: algorithmChanged
950 ? {
951 algorithm,
952 protectionLevel:
953 protectionLevel ?? current.versionTemplate?.protectionLevel,
954 }
955 : undefined,
956 rotationPeriod,
957 nextRotationTime,
958 },
959 });
960 }
961
962 if (
963 news.skipInitialVersionCreation !== true &&
964 news.importOnly !== true
965 ) {
966 current = yield* ensureUsableVersion(name, current, purpose);
967 }
968
969 return toAttrs(current, env.project, env.region);
970 }),
971
972 delete: Effect.fn(function* ({ output }) {
973 const name = output.name;
974 const current = yield* getByName(name);
975 if (current === undefined) return;
976
977 yield* clearRotation(name, current);
978 yield* releaseKey(name, current);
979
980 const versions = yield* listVersions(name);
981 yield* Effect.forEach(
982 versions,
983 (version) => {
984 const versionName = version.name;
985 if (versionName === undefined) return Effect.void;
986 const state = version.state ?? "";
987 if (DESTROYABLE_VERSION_STATES.has(state)) {
988 return destroyVersion(versionName);
989 }
990 if (DELETABLE_VERSION_STATES.has(state)) {
991 return deleteVersion(versionName);
992 }
993 return Effect.void;
994 },
995 { concurrency: 4 },
996 );
997
998 // The key itself is never deleted: KMS retires a deleted key's name
999 // forever, which would make this id (fixed or the deterministic
1000 // default) undeployable. The released key is reclaimed on redeploy.
1001 }),
1002 });