| 1 | import * as kms from "@distilled.cloud/gcp/cloudkms_v1"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { GcpEnvironment } from "../Environment.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | import { |
| 11 | type OperationFailed, |
| 12 | type OperationTimedOut, |
| 13 | waitForOperation as waitForGcpOperation, |
| 14 | } from "../Operation.ts"; |
| 15 | |
| 16 | const DEFAULT_STATE: kms.CryptoKeyVersionStateEnum = "ENABLED"; |
| 17 | const DELETABLE_STATES = new Set([ |
| 18 | "DESTROYED", |
| 19 | "IMPORT_FAILED", |
| 20 | "GENERATION_FAILED", |
| 21 | ]); |
| 22 | const DESTROYABLE_STATES = new Set(["ENABLED", "DISABLED"]); |
| 23 | const PATCHABLE_STATES = new Set(["ENABLED", "DISABLED"]); |
| 24 | |
| 25 | export 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 | |
| 39 | export 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 | |
| 82 | export 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 | |
| 121 | export 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 | */ |
| 167 | export const CryptoKeyVersion = Resource<CryptoKeyVersion>( |
| 168 | "GCP.KMS.CryptoKeyVersion", |
| 169 | ); |
| 170 | |
| 171 | export class CryptoKeyVersionNotResolved extends Data.TaggedError( |
| 172 | "GCP.KMS.CryptoKeyVersionNotResolved", |
| 173 | )<{ |
| 174 | name: string; |
| 175 | }> {} |
| 176 | |
| 177 | export class CryptoKeyVersionOperationFailed extends Data.TaggedError( |
| 178 | "GCP.KMS.CryptoKeyVersionOperationFailed", |
| 179 | )<{ |
| 180 | operation: string; |
| 181 | message: string; |
| 182 | }> {} |
| 183 | |
| 184 | export class CryptoKeyVersionNotReady extends Data.TaggedError( |
| 185 | "GCP.KMS.CryptoKeyVersionNotReady", |
| 186 | )<{ |
| 187 | name: string; |
| 188 | state: string; |
| 189 | }> {} |
| 190 | |
| 191 | const lastSegment = (value: string) => { |
| 192 | const trimmed = value.replace(/\/+$/, ""); |
| 193 | const parts = trimmed.split("/"); |
| 194 | return parts[parts.length - 1] || trimmed; |
| 195 | }; |
| 196 | |
| 197 | const normalizeLocation = (location: string | undefined, fallback: string) => |
| 198 | lastSegment(location ?? fallback).toLowerCase(); |
| 199 | |
| 200 | const 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 | |
| 227 | const 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 | |
| 262 | const resourceName = (parent: string, cryptoKeyVersionId: string) => |
| 263 | `${parent}/cryptoKeyVersions/${cryptoKeyVersionId}`; |
| 264 | |
| 265 | const 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 | |
| 272 | const 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 | |
| 307 | const getByName = (name: string) => |
| 308 | kms |
| 309 | .getProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({ name }) |
| 310 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 311 | |
| 312 | const 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 | |
| 329 | const 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 | |
| 351 | const 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 | |
| 377 | const 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 | |
| 399 | const 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 | |
| 422 | const 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 | |
| 436 | const 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 | |
| 510 | const optionsEqual = ( |
| 511 | observed: kms.ExternalProtectionLevelOptions | undefined, |
| 512 | desired: CryptoKeyVersionExternalProtectionLevelOptions | undefined, |
| 513 | ) => |
| 514 | (observed?.externalKeyUri ?? "") === (desired?.externalKeyUri ?? "") && |
| 515 | (observed?.ekmConnectionKeyPath ?? "") === |
| 516 | (desired?.ekmConnectionKeyPath ?? ""); |
| 517 | |
| 518 | const 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 | |
| 530 | const 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 | |
| 542 | const 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 | |
| 564 | const 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 | |
| 577 | export 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 | }); |