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 { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11
12const DEFAULT_PROTECTION: kms.ImportJobProtectionLevelEnum = "SOFTWARE";
13const DEFAULT_IMPORT_METHOD: kms.ImportJobImportMethodEnum =
14 "RSA_OAEP_3072_SHA256_AES_256";
15const MAX_NAME_LENGTH = 63;
16
17export type ImportJobProps = {
18 /**
19 * Parent KeyRing. Full name
20 * `projects/{project}/locations/{location}/keyRings/{keyRing}` or the
21 * key ring id (combined with `location`). Immutable — changing it
22 * replaces the import job.
23 */
24 keyRing: string;
25 /**
26 * Cloud KMS location (`us-central1`, `global`, `us`, …). Used when
27 * `keyRing` is a bare id. Immutable — changing it replaces the import
28 * job. `US-CENTRAL1` is accepted and normalized to `us-central1`.
29 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
30 */
31 location?: string;
32 /**
33 * Import job id (the last path segment). If omitted, a unique name is
34 * generated from the stack, stage, and logical id. Must match
35 * `[a-zA-Z0-9_-]{1,63}`. Immutable — changing it replaces the job.
36 */
37 importJobId?: string;
38 /**
39 * Wrapping method for incoming key material. Immutable. Cloud KMS has
40 * no update API, so a later change is ignored unless `importJobId`
41 * also changes.
42 * @default "RSA_OAEP_3072_SHA256_AES_256"
43 */
44 importMethod?: kms.ImportJobImportMethodEnum;
45 /**
46 * Protection level of the wrapping key. Must match the CryptoKey
47 * version template when importing. Immutable.
48 * @default "SOFTWARE"
49 */
50 protectionLevel?: kms.ImportJobProtectionLevelEnum;
51 /**
52 * Backend for `HSM_SINGLE_TENANT` wrapping keys. Immutable.
53 */
54 cryptoKeyBackend?: string;
55};
56
57export type ImportJobAttrs = {
58 /** Full resource name `projects/.../importJobs/{importJob}`. */
59 name: string;
60 /** Import job id (last path segment). */
61 importJobId: string;
62 /** Parent KeyRing resource name. */
63 keyRing: string;
64 /** Location id (`us-central1`, `global`, …). */
65 location: string;
66 /** Project id. */
67 project: string;
68 /** Wrapping method. */
69 importMethod: string;
70 /** Protection level of the wrapping key. */
71 protectionLevel: string;
72 /** Current job state (`PENDING_GENERATION`, `ACTIVE`, `EXPIRED`). */
73 state: string | undefined;
74 /** RFC3339 scheduled expiration (typically create + 3 days). */
75 expireTime: string | undefined;
76 /** RFC3339 time the job expired, if `state` is `EXPIRED`. */
77 expireEventTime: string | undefined;
78 /** RFC3339 time the wrapping key was generated. */
79 generateTime: string | undefined;
80 /** RFC3339 creation timestamp. */
81 createTime: string | undefined;
82 /** PEM wrapping public key. Present when `state` is `ACTIVE`. */
83 publicKeyPem: string | undefined;
84 /** Wrapping public key in the requested format, if any. */
85 publicKeyData: string | undefined;
86 /** External / single-tenant HSM backend, if any. */
87 cryptoKeyBackend: string | undefined;
88};
89
90export type ImportJob = Resource<
91 "GCP.KMS.ImportJob",
92 ImportJobProps,
93 ImportJobAttrs,
94 never,
95 Providers
96>;
97
98/**
99 * A Cloud KMS ImportJob — a wrapping-key job used to import pre-existing
100 * key material into a CryptoKeyVersion.
101 *
102 * Parent KeyRing, location, and id are identity (changing them replaces
103 * the job). `importMethod`, `protectionLevel`, and `cryptoKeyBackend` are
104 * immutable create-only fields. Cloud KMS has no ImportJob delete or
105 * update API; destroy removes the resource from state only. Jobs expire
106 * about 3 days after create and remain as `EXPIRED` residue until Google
107 * garbage-collects them. Account-wide nuke skips this type for the same
108 * reason. ImportJobs have no labels, so `list` returns every job under
109 * listed KeyRings.
110 *
111 * ### Creating an ImportJob
112 * **Example:** Generated name on an existing KeyRing
113 * ```typescript
114 * const ring = yield* GCP.KMS.KeyRing("Keys", {});
115 * const job = yield* GCP.KMS.ImportJob("Wrap", {
116 * keyRing: ring.name,
117 * });
118 * ```
119 *
120 * **Example:** Explicit id, method, and protection level
121 * ```typescript
122 * const job = yield* GCP.KMS.ImportJob("Wrap", {
123 * keyRing: ring.name,
124 * importJobId: "wrap-keys",
125 * importMethod: "RSA_OAEP_4096_SHA256_AES_256",
126 * protectionLevel: "HSM",
127 * });
128 * ```
129 *
130 * @resource
131 * @category KMS
132 */
133export const ImportJob = Resource<ImportJob>("GCP.KMS.ImportJob");
134
135export class ImportJobNotResolved extends Data.TaggedError(
136 "GCP.KMS.ImportJobNotResolved",
137)<{
138 name: string;
139}> {}
140
141export class ImportJobPending extends Data.TaggedError(
142 "GCP.KMS.ImportJobPending",
143)<{
144 name: string;
145 state: string;
146}> {}
147
148const lastSegment = (value: string) => {
149 const trimmed = value.replace(/\/+$/, "");
150 const parts = trimmed.split("/");
151 return parts[parts.length - 1] || trimmed;
152};
153
154const normalizeLocation = (location: string | undefined, fallback: string) =>
155 lastSegment(location ?? fallback).toLowerCase();
156
157const normalizeProtection = (
158 value: string | undefined,
159): kms.ImportJobProtectionLevelEnum =>
160 !value || value === "PROTECTION_LEVEL_UNSPECIFIED"
161 ? DEFAULT_PROTECTION
162 : (value as kms.ImportJobProtectionLevelEnum);
163
164const parseName = (name: string, fallbackLocation: string) => {
165 const parts = name.split("/").filter((part) => part.length > 0);
166 const importJobsAt = parts.lastIndexOf("importJobs");
167 const keyRingsAt = parts.lastIndexOf("keyRings");
168 const locationsAt = parts.lastIndexOf("locations");
169 const projectsAt = parts.lastIndexOf("projects");
170 const keyRing =
171 keyRingsAt >= 0 ? parts.slice(0, keyRingsAt + 2).join("/") : "";
172 return {
173 project:
174 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
175 location:
176 locationsAt >= 0 && parts[locationsAt + 1]
177 ? parts[locationsAt + 1]!
178 : fallbackLocation,
179 keyRing,
180 importJobId:
181 importJobsAt >= 0 && parts[importJobsAt + 1]
182 ? parts[importJobsAt + 1]!
183 : lastSegment(name),
184 };
185};
186
187const resolveParent = (
188 project: string,
189 keyRing: string,
190 location: string | undefined,
191 region: string,
192) => {
193 if (keyRing.includes("/")) {
194 const parsed = parseName(
195 keyRing.includes("/importJobs/") ? keyRing : `${keyRing}/importJobs/_`,
196 region,
197 );
198 return {
199 parent: parsed.keyRing,
200 location: parsed.location,
201 project: parsed.project || project,
202 };
203 }
204 const loc = normalizeLocation(location, region);
205 return {
206 parent: `projects/${project}/locations/${loc}/keyRings/${keyRing}`,
207 location: loc,
208 project,
209 };
210};
211
212const resourceName = (parent: string, importJobId: string) =>
213 `${parent}/importJobs/${importJobId}`;
214
215const toId = (id: string, importJobId: string | undefined, existing?: string) =>
216 Effect.gen(function* () {
217 return (
218 importJobId ??
219 existing ??
220 (yield* createPhysicalName({
221 id,
222 maxLength: MAX_NAME_LENGTH,
223 lowercase: true,
224 }))
225 );
226 });
227
228const toAttrs = (
229 job: kms.ImportJob,
230 project: string,
231 region: string,
232): ImportJobAttrs => {
233 const name = job.name ?? "";
234 const parsed = parseName(name, region);
235 return {
236 name,
237 importJobId: parsed.importJobId,
238 keyRing: parsed.keyRing,
239 location: parsed.location,
240 project: parsed.project || project,
241 importMethod: job.importMethod ?? DEFAULT_IMPORT_METHOD,
242 protectionLevel: job.protectionLevel ?? DEFAULT_PROTECTION,
243 state: job.state,
244 expireTime: job.expireTime,
245 expireEventTime: job.expireEventTime,
246 generateTime: job.generateTime,
247 createTime: job.createTime,
248 publicKeyPem: job.publicKey?.pem,
249 publicKeyData: job.publicKey?.data,
250 cryptoKeyBackend: job.cryptoKeyBackend,
251 };
252};
253
254const getByName = (name: string) =>
255 kms
256 .getProjectsLocationsKeyRingsImportJobs({ name })
257 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
258
259const paginate = <A, E, R>(
260 fetch: (
261 pageToken: string | undefined,
262 ) => Effect.Effect<{ items: A[]; nextPageToken?: string }, E, R>,
263) =>
264 Effect.gen(function* () {
265 const found: A[] = [];
266 let pageToken: string | undefined;
267 for (let page = 0; page < 10; page++) {
268 const response = yield* fetch(pageToken);
269 found.push(...response.items);
270 pageToken = response.nextPageToken;
271 if (pageToken === undefined || pageToken === "") break;
272 }
273 return found;
274 });
275
276const listKeyRingsAt = (parent: string) =>
277 paginate((pageToken) =>
278 kms
279 .listProjectsLocationsKeyRings({
280 parent,
281 pageSize: 1000,
282 pageToken,
283 })
284 .pipe(
285 Effect.map((response) => ({
286 items: response.keyRings ?? [],
287 nextPageToken: response.nextPageToken,
288 })),
289 Effect.catchTag("NotFound", () =>
290 Effect.succeed({
291 items: [] as kms.KeyRing[],
292 nextPageToken: undefined,
293 }),
294 ),
295 ),
296 );
297
298const listImportJobsInRing = (parent: string) =>
299 paginate((pageToken) =>
300 kms
301 .listProjectsLocationsKeyRingsImportJobs({
302 parent,
303 pageSize: 1000,
304 pageToken,
305 })
306 .pipe(
307 Effect.map((response) => ({
308 items: response.importJobs ?? [],
309 nextPageToken: response.nextPageToken,
310 })),
311 Effect.catchTag("NotFound", () =>
312 Effect.succeed({
313 items: [] as kms.ImportJob[],
314 nextPageToken: undefined,
315 }),
316 ),
317 ),
318 );
319
320const listImportJobsAt = (locationParent: string) =>
321 Effect.gen(function* () {
322 const rings = yield* listKeyRingsAt(locationParent);
323 const pages = yield* Effect.forEach(
324 rings,
325 (ring) =>
326 ring.name
327 ? listImportJobsInRing(ring.name)
328 : Effect.succeed([] as kms.ImportJob[]),
329 { concurrency: 4 },
330 );
331 return pages.flat();
332 });
333
334const waitReady = (
335 name: string,
336): Effect.Effect<
337 kms.ImportJob,
338 | ImportJobNotResolved
339 | ImportJobPending
340 | kms.GetProjectsLocationsKeyRingsImportJobsError,
341 kms.GcpOpContext
342> => {
343 const probe: Effect.Effect<
344 kms.ImportJob,
345 | ImportJobNotResolved
346 | ImportJobPending
347 | kms.GetProjectsLocationsKeyRingsImportJobsError,
348 kms.GcpOpContext
349 > = getByName(name).pipe(
350 Effect.flatMap(
351 (
352 job,
353 ): Effect.Effect<
354 kms.ImportJob,
355 ImportJobNotResolved | ImportJobPending
356 > => {
357 if (job === undefined) {
358 return Effect.fail(new ImportJobNotResolved({ name }));
359 }
360 if (job.state === "PENDING_GENERATION") {
361 return Effect.fail(
362 new ImportJobPending({
363 name,
364 state: job.state,
365 }),
366 );
367 }
368 return Effect.succeed(job);
369 },
370 ),
371 );
372 return probe.pipe(
373 Effect.retry({
374 while: (error) => error._tag === "GCP.KMS.ImportJobPending",
375 times: 8,
376 schedule: Schedule.spaced("500 millis"),
377 }),
378 );
379};
380
381export const ImportJobProvider = () =>
382 Provider.succeed(ImportJob, {
383 // Cloud KMS has no ImportJob delete API. Destroy forgets state only,
384 // so nuke would loop forever on "deleted but still there".
385 nuke: { skip: true },
386 stables: [
387 "name",
388 "importJobId",
389 "keyRing",
390 "location",
391 "project",
392 "importMethod",
393 "protectionLevel",
394 "cryptoKeyBackend",
395 "createTime",
396 ],
397
398 diff: Effect.fn(function* ({ news, olds, output }) {
399 if (!isResolved(news)) return undefined;
400 const env = yield* GcpEnvironment.current;
401
402 const previousId = olds?.importJobId ?? output?.importJobId;
403 const nextId = news.importJobId ?? previousId;
404 const idChanged =
405 previousId !== undefined &&
406 nextId !== undefined &&
407 nextId !== previousId;
408
409 const previousParent =
410 output?.keyRing ??
411 (olds?.keyRing
412 ? resolveParent("", olds.keyRing, olds.location, env.region).parent
413 : undefined);
414 const nextParent = resolveParent(
415 output?.project ?? "",
416 news.keyRing,
417 news.location ?? output?.location,
418 env.region,
419 ).parent;
420 const parentChanged =
421 previousParent !== undefined && previousParent !== nextParent;
422
423 if (!idChanged && !parentChanged) return undefined;
424 // Cannot delete the old job; create the replacement first.
425 return { action: "replace" as const, deleteFirst: false };
426 }),
427
428 read: Effect.fn(function* ({ id, olds, output }) {
429 const env = yield* GcpEnvironment.current;
430 const importJobId = yield* toId(
431 id,
432 olds?.importJobId,
433 output?.importJobId,
434 );
435 const name =
436 output?.name ??
437 (olds?.keyRing || output?.keyRing
438 ? resourceName(
439 resolveParent(
440 env.project,
441 olds?.keyRing ?? output?.keyRing ?? "",
442 olds?.location ?? output?.location,
443 env.region,
444 ).parent,
445 importJobId,
446 )
447 : undefined);
448 if (name === undefined) return undefined;
449 const existing = yield* getByName(name);
450 if (existing === undefined) return undefined;
451 // ImportJobs have no labels, so existence at the computed name is
452 // ownership. Adopting an expired job is harmless.
453 return toAttrs(existing, env.project, env.region);
454 }),
455
456 list: () =>
457 Effect.gen(function* () {
458 const env = yield* GcpEnvironment.current;
459 const found: ImportJobAttrs[] = [];
460 let pageToken: string | undefined;
461 for (let page = 0; page < 10; page++) {
462 const response = yield* kms.listProjectsLocations({
463 name: `projects/${env.project}`,
464 pageSize: 100,
465 pageToken,
466 });
467 const parents = (response.locations ?? [])
468 .map((location) => location.name)
469 .filter((name): name is string => !!name);
470 const batches = yield* Effect.forEach(
471 parents,
472 (parent) => listImportJobsAt(parent),
473 { concurrency: 4 },
474 );
475 for (const jobs of batches) {
476 for (const job of jobs) {
477 found.push(toAttrs(job, env.project, env.region));
478 }
479 }
480 pageToken = response.nextPageToken;
481 if (pageToken === undefined || pageToken === "") break;
482 }
483 return found;
484 }),
485
486 reconcile: Effect.fn(function* ({ id, news, output }) {
487 const env = yield* GcpEnvironment.current;
488 const importJobId = yield* toId(
489 id,
490 news.importJobId,
491 output?.importJobId,
492 );
493 const parent = resolveParent(
494 env.project,
495 news.keyRing,
496 news.location ?? output?.location,
497 env.region,
498 );
499 const name = resourceName(parent.parent, importJobId);
500 const importMethod = news.importMethod ?? DEFAULT_IMPORT_METHOD;
501 const protectionLevel = normalizeProtection(news.protectionLevel);
502
503 let current = yield* getByName(name);
504
505 if (current === undefined) {
506 const created = yield* kms
507 .createProjectsLocationsKeyRingsImportJobs({
508 parent: parent.parent,
509 importJobId,
510 body: {
511 importMethod,
512 protectionLevel,
513 cryptoKeyBackend: news.cryptoKeyBackend,
514 },
515 })
516 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
517 current = created ?? undefined;
518 }
519
520 if (current === undefined) {
521 return yield* new ImportJobNotResolved({ name });
522 }
523
524 if (current.state === "PENDING_GENERATION") {
525 current = yield* waitReady(name);
526 }
527
528 return toAttrs(current, env.project, env.region);
529 }),
530
531 delete: Effect.fn(function* ({ output }) {
532 // Cloud KMS has no ImportJob delete API. Forget it from state; the
533 // job remains until it expires (~3 days) and Google GC's it.
534 yield* Effect.logWarning(
535 `GCP Cloud KMS has no ImportJob delete API — "${output.name}" was removed from state but still exists.`,
536 );
537 }),
538 });