1import * as secretmanager from "@distilled.cloud/gcp/secretmanager_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { DeleteNotConfirmed } from "../Errors.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 diffLabels,
17 hasAlchemyLabels,
18 stripInternalLabels,
19 toLabels,
20} from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22
23export type CustomerManagedEncryption = {
24 /**
25 * Cloud KMS CryptoKey used to encrypt secret payloads
26 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
27 */
28 kmsKeyName?: string;
29};
30
31export type Replica = {
32 /**
33 * Replica location (e.g. `"us-central1"`).
34 */
35 location?: string;
36 /**
37 * Optional customer-managed encryption for this replica.
38 */
39 customerManagedEncryption?: CustomerManagedEncryption;
40};
41
42export type Replication = {
43 /**
44 * Replicate the secret without location restrictions.
45 */
46 automatic?: {
47 customerManagedEncryption?: CustomerManagedEncryption;
48 };
49 /**
50 * Replicate the secret only into the listed locations.
51 */
52 userManaged?: {
53 replicas?: Replica[];
54 };
55};
56
57export type Rotation = {
58 /**
59 * Timestamp in UTC when the next rotation notification should fire.
60 */
61 nextRotationTime?: string;
62 /**
63 * Period between rotation notifications (e.g. `"3600s"`).
64 */
65 rotationPeriod?: string;
66};
67
68export type SecretTopic = {
69 /**
70 * Pub/Sub topic resource name (`projects/{project}/topics/{topic}`).
71 */
72 name?: string;
73};
74
75export type SecretProps = {
76 /**
77 * Secret id (the `{secret}` segment of
78 * `projects/{project}/secrets/{secret}`). If omitted, a unique name is
79 * generated from the stack, stage, and logical id. Max 255 characters;
80 * letters, numbers, hyphens, and underscores. Changing it replaces the
81 * secret.
82 */
83 secretId?: string;
84 /**
85 * User labels. Alchemy ownership labels are merged in automatically.
86 */
87 labels?: Record<string, string>;
88 /**
89 * Custom metadata. Distinct from labels; not used for ownership.
90 */
91 annotations?: Record<string, string>;
92 /**
93 * Immutable replication policy. Defaults to automatic replication.
94 * Changing it replaces the secret.
95 * @default { automatic: {} }
96 */
97 replication?: Replication;
98 /**
99 * Timestamp in UTC when the secret expires.
100 */
101 expireTime?: string;
102 /**
103 * Input-only TTL used to compute `expireTime` (e.g. `"86400s"`). Ignored
104 * when `expireTime` is set.
105 */
106 ttl?: string;
107 /**
108 * Pub/Sub topics notified on control-plane events. Required when
109 * `rotation` is set.
110 */
111 topics?: SecretTopic[];
112 /**
113 * Rotation policy. Requires `topics`.
114 */
115 rotation?: Rotation;
116 /**
117 * Mapping from version alias to version id.
118 */
119 versionAliases?: Record<string, string>;
120 /**
121 * Delay before a destroyed version is permanently deleted (e.g.
122 * `"86400s"`).
123 */
124 versionDestroyTtl?: string;
125};
126
127export type Secret = Resource<
128 "GCP.SecretManager.Secret",
129 SecretProps,
130 {
131 /** Full resource name `projects/{project}/secrets/{secret}`. */
132 name: string;
133 /** Secret id (last path segment). */
134 secretId: string;
135 /** Project id. */
136 project: string;
137 /** User labels (Alchemy ownership labels stripped). */
138 labels: Record<string, string>;
139 /** Custom annotations. */
140 annotations: Record<string, string>;
141 /** Replication policy. Immutable after create. */
142 replication: Replication | undefined;
143 /** Expiration timestamp, if set. */
144 expireTime: string | undefined;
145 /** RFC3339 creation timestamp. */
146 createTime: string | undefined;
147 /** Server etag. */
148 etag: string | undefined;
149 /** Pub/Sub notification topics. */
150 topics: SecretTopic[];
151 /** Rotation policy, if set. */
152 rotation: Rotation | undefined;
153 /** Version alias map. */
154 versionAliases: Record<string, string>;
155 /** Delayed-destroy TTL, if set. */
156 versionDestroyTtl: string | undefined;
157 },
158 never,
159 Providers
160>;
161
162/**
163 * A Google Cloud Secret Manager secret (metadata and replication). Secret
164 * payloads live on versions — use `AddSecretVersion` and
165 * `AccessSecretVersion` to write and read them.
166 *
167 * Changing `secretId` or `replication` replaces the secret.
168 *
169 * ### Creating a Secret
170 * **Example:** Generated name
171 * ```typescript
172 * const secret = yield* GCP.SecretManager.Secret("ApiKey", {});
173 * ```
174 *
175 * **Example:** Explicit id, labels, and annotations
176 * ```typescript
177 * const secret = yield* GCP.SecretManager.Secret("ApiKey", {
178 * secretId: "order-api-key",
179 * labels: { env: "prod" },
180 * annotations: { owner: "payments" },
181 * });
182 * ```
183 *
184 * ### Replication
185 * **Example:** User-managed replicas
186 * ```typescript
187 * const secret = yield* GCP.SecretManager.Secret("RegionalKey", {
188 * replication: {
189 * userManaged: {
190 * replicas: [{ location: "us-central1" }],
191 * },
192 * },
193 * });
194 * ```
195 *
196 * ### Secret Versions
197 * **Example:** Add and access a version
198 * ```typescript
199 * const addVersion = yield* GCP.SecretManager.AddSecretVersion(secret);
200 * yield* addVersion({ payload: { data: btoa("hello") } });
201 * const access = yield* GCP.SecretManager.AccessSecretVersion(secret);
202 * const { payload } = yield* access();
203 * ```
204 *
205 * @resource
206 * @category SecretManager
207 */
208export const Secret = Resource<Secret>("GCP.SecretManager.Secret");
209
210export class SecretNotResolved extends Data.TaggedError(
211 "GCP.SecretManager.SecretNotResolved",
212)<{
213 name: string;
214}> {}
215
216const DEFAULT_REPLICATION: Replication = { automatic: {} };
217
218const secretIdOf = (name: string) => name.split("/").pop() ?? name;
219
220const resourceName = (project: string, secretId: string) =>
221 `projects/${project}/secrets/${secretId}`;
222
223const userLabels = (
224 labels: Record<string, string | undefined> | null | undefined,
225): Record<string, string> => stripInternalLabels(tagRecord(labels));
226
227const userAnnotations = (
228 annotations: Record<string, string | undefined> | null | undefined,
229): Record<string, string> => tagRecord(annotations);
230
231const userAliases = (
232 aliases: Record<string, string | undefined> | null | undefined,
233): Record<string, string> => tagRecord(aliases);
234
235const toId = (id: string, secretId: string | undefined, existing?: string) =>
236 Effect.gen(function* () {
237 return (
238 secretId ??
239 existing ??
240 (yield* createPhysicalName({ id, maxLength: 255, lowercase: true }))
241 );
242 });
243
244const toTopics = (topics: secretmanager.TopicList | null | undefined) =>
245 (topics ?? [])
246 .filter(
247 (topic): topic is { name: string } => typeof topic.name === "string",
248 )
249 .map((topic) => ({ name: topic.name }));
250
251const toRotation = (
252 rotation: secretmanager.Rotation | undefined,
253): Rotation | undefined => {
254 if (rotation === undefined) return undefined;
255 if (
256 rotation.nextRotationTime === undefined &&
257 rotation.rotationPeriod === undefined
258 ) {
259 return undefined;
260 }
261 return {
262 nextRotationTime: rotation.nextRotationTime,
263 rotationPeriod: rotation.rotationPeriod,
264 };
265};
266
267const toReplication = (
268 replication: secretmanager.Replication | undefined,
269): Replication | undefined => {
270 if (replication === undefined) return undefined;
271 if (replication.userManaged !== undefined) {
272 return {
273 userManaged: {
274 replicas: (replication.userManaged.replicas ?? []).map((replica) => ({
275 location: replica.location,
276 customerManagedEncryption: replica.customerManagedEncryption
277 ? {
278 kmsKeyName: replica.customerManagedEncryption.kmsKeyName,
279 }
280 : undefined,
281 })),
282 },
283 };
284 }
285 if (replication.automatic !== undefined) {
286 return {
287 automatic: {
288 customerManagedEncryption: replication.automatic
289 .customerManagedEncryption
290 ? {
291 kmsKeyName:
292 replication.automatic.customerManagedEncryption.kmsKeyName,
293 }
294 : undefined,
295 },
296 };
297 }
298 return undefined;
299};
300
301const desiredReplication = (
302 replication: Replication | undefined,
303): Replication => replication ?? DEFAULT_REPLICATION;
304
305const replicationFingerprint = (
306 replication: Replication | undefined,
307): string => {
308 const value = desiredReplication(replication);
309 if (value.userManaged !== undefined) {
310 const replicas = [...(value.userManaged.replicas ?? [])]
311 .map((replica) => ({
312 location: replica.location ?? "",
313 kms: replica.customerManagedEncryption?.kmsKeyName ?? "",
314 }))
315 .sort((a, b) => a.location.localeCompare(b.location));
316 return JSON.stringify({ userManaged: replicas });
317 }
318 return JSON.stringify({
319 automatic: {
320 kms: value.automatic?.customerManagedEncryption?.kmsKeyName ?? "",
321 },
322 });
323};
324
325const recordsEqual = (
326 left: Record<string, string>,
327 right: Record<string, string>,
328) => {
329 const leftKeys = Object.keys(left).sort();
330 const rightKeys = Object.keys(right).sort();
331 return (
332 leftKeys.length === rightKeys.length &&
333 leftKeys.every(
334 (key, index) => key === rightKeys[index] && left[key] === right[key],
335 )
336 );
337};
338
339const topicsEqual = (left: SecretTopic[], right: SecretTopic[]) => {
340 const a = left.map((topic) => topic.name ?? "").sort();
341 const b = right.map((topic) => topic.name ?? "").sort();
342 return a.length === b.length && a.every((name, index) => name === b[index]);
343};
344
345const rotationEqual = (
346 left: Rotation | undefined,
347 right: Rotation | undefined,
348) =>
349 (left?.nextRotationTime ?? "") === (right?.nextRotationTime ?? "") &&
350 (left?.rotationPeriod ?? "") === (right?.rotationPeriod ?? "");
351
352const toAttrs = (secret: secretmanager.Secret, project: string) => {
353 const name = secret.name ?? "";
354 return {
355 name,
356 secretId: secretIdOf(name),
357 project,
358 labels: userLabels(secret.labels),
359 annotations: userAnnotations(secret.annotations),
360 replication: toReplication(secret.replication),
361 expireTime: secret.expireTime,
362 createTime: secret.createTime,
363 etag: secret.etag,
364 topics: toTopics(secret.topics),
365 rotation: toRotation(secret.rotation),
366 versionAliases: userAliases(secret.versionAliases),
367 versionDestroyTtl: secret.versionDestroyTtl,
368 };
369};
370
371const getByName = (name: string) =>
372 secretmanager
373 .getProjectsSecrets({ name })
374 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
375
376const waitUntilMissing = (name: string) =>
377 getByName(name).pipe(
378 Effect.repeat({
379 schedule: Schedule.spaced("1 second"),
380 until: (secret): boolean => secret === undefined,
381 times: 30,
382 }),
383 Effect.flatMap((secret): Effect.Effect<void, DeleteNotConfirmed> =>
384 secret === undefined
385 ? Effect.void
386 : Effect.fail(new DeleteNotConfirmed({ resource: name })),
387 ),
388 );
389
390const toCreateBody = (
391 news: SecretProps,
392 labels: Record<string, string>,
393): secretmanager.Secret => ({
394 replication: desiredReplication(news.replication),
395 labels,
396 annotations: news.annotations,
397 expireTime: news.expireTime,
398 ttl: news.expireTime === undefined ? news.ttl : undefined,
399 topics: news.topics,
400 rotation: news.rotation,
401 versionAliases: news.versionAliases,
402 versionDestroyTtl: news.versionDestroyTtl,
403});
404
405export const SecretProvider = () =>
406 Provider.succeed(Secret, {
407 stables: ["name", "secretId", "project", "createTime", "replication"],
408
409 diff: Effect.fn(function* ({ news, olds, output }) {
410 if (!isResolved(news)) return undefined;
411 const previousId = olds?.secretId ?? output?.secretId;
412 const nextId = news.secretId ?? previousId;
413 const idChanged =
414 previousId !== undefined &&
415 news.secretId !== undefined &&
416 previousId !== news.secretId;
417 const replicationChanged =
418 replicationFingerprint(olds?.replication ?? output?.replication) !==
419 replicationFingerprint(news.replication);
420
421 if (!idChanged && !replicationChanged) return undefined;
422 return {
423 action: "replace" as const,
424 deleteFirst:
425 replicationChanged &&
426 !idChanged &&
427 previousId !== undefined &&
428 nextId === previousId,
429 };
430 }),
431
432 read: Effect.fn(function* ({ id, olds, output }) {
433 const env = yield* GcpEnvironment.current;
434 const secretId = yield* toId(id, olds?.secretId, output?.secretId);
435 const name = output?.name ?? resourceName(env.project, secretId);
436 const existing = yield* getByName(name);
437 if (existing === undefined) return undefined;
438 const attrs = toAttrs(existing, env.project);
439 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
440 ? attrs
441 : Unowned(attrs);
442 }),
443
444 list: () =>
445 Effect.gen(function* () {
446 const env = yield* GcpEnvironment.current;
447 const pages = yield* secretmanager.listProjectsSecrets
448 .pages({
449 parent: `projects/${env.project}`,
450 pageSize: 1000,
451 filter: "labels.alchemy-id:*",
452 })
453 .pipe(Stream.runCollect);
454 return Array.from(pages).flatMap((page) =>
455 (page.secrets ?? [])
456 .filter((secret) =>
457 Object.keys(secret.labels ?? {}).some((key) =>
458 key.startsWith("alchemy-"),
459 ),
460 )
461 .map((secret) => toAttrs(secret, env.project)),
462 );
463 }),
464
465 reconcile: Effect.fn(function* ({ id, news, output }) {
466 const env = yield* GcpEnvironment.current;
467 const secretId = yield* toId(id, news.secretId, output?.secretId);
468 const name = resourceName(env.project, secretId);
469 const desiredLabels = {
470 ...toLabels(news.labels),
471 ...(yield* createInternalLabels(id)),
472 };
473 const desiredAnnotations = news.annotations ?? {};
474 const desiredAliases = news.versionAliases ?? {};
475 const desiredTopics = news.topics ?? [];
476 const desiredRotation = news.rotation;
477 const desiredDestroyTtl = news.versionDestroyTtl;
478
479 let current = yield* getByName(name);
480 const desiredReplicationFp = replicationFingerprint(news.replication);
481
482 // Replication is immutable. If the observed secret has a different
483 // policy (replacement or adoption), delete and recreate.
484 if (
485 current !== undefined &&
486 replicationFingerprint(toReplication(current.replication)) !==
487 desiredReplicationFp
488 ) {
489 yield* secretmanager
490 .deleteProjectsSecrets({ name: current.name ?? name })
491 .pipe(Effect.catchTag("NotFound", () => Effect.void));
492 yield* waitUntilMissing(name);
493 current = undefined;
494 }
495
496 if (current === undefined) {
497 const created = yield* secretmanager
498 .createProjectsSecrets({
499 parent: `projects/${env.project}`,
500 secretId,
501 body: toCreateBody(news, desiredLabels),
502 })
503 .pipe(
504 // A just-deleted secret (replacement) keeps its id reserved for
505 // a few seconds after reads report it missing.
506 Effect.retry({
507 while: (error) => error._tag === "Conflict",
508 schedule: Schedule.spaced("1 second"),
509 times: 10,
510 }),
511 Effect.catchTag("Conflict", () => getByName(name)),
512 );
513 current = created ?? undefined;
514 }
515
516 if (current === undefined) {
517 return yield* new SecretNotResolved({ name });
518 }
519
520 if (
521 replicationFingerprint(toReplication(current.replication)) !==
522 desiredReplicationFp
523 ) {
524 return yield* new SecretNotResolved({ name });
525 }
526
527 const observedLabels = tagRecord(current.labels);
528 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
529 const labelsChanged = upsert.length > 0 || removed.length > 0;
530 const annotationsChanged = !recordsEqual(
531 userAnnotations(current.annotations),
532 desiredAnnotations,
533 );
534 const aliasesChanged = !recordsEqual(
535 userAliases(current.versionAliases),
536 desiredAliases,
537 );
538 const topicsChanged = !topicsEqual(
539 toTopics(current.topics),
540 desiredTopics,
541 );
542 const rotationChanged = !rotationEqual(
543 toRotation(current.rotation),
544 desiredRotation,
545 );
546 const destroyTtlChanged =
547 (current.versionDestroyTtl ?? "") !== (desiredDestroyTtl ?? "");
548 const expireTimeChanged =
549 news.expireTime !== undefined &&
550 (current.expireTime ?? "") !== news.expireTime;
551 // `ttl` is input-only and recomputes expireTime from now. Apply it only
552 // when the observed secret has no expiration, otherwise every reconcile
553 // would reset the clock.
554 const ttlChanged =
555 news.expireTime === undefined &&
556 news.ttl !== undefined &&
557 (current.expireTime === undefined || current.expireTime === "");
558
559 if (
560 labelsChanged ||
561 annotationsChanged ||
562 aliasesChanged ||
563 topicsChanged ||
564 rotationChanged ||
565 destroyTtlChanged ||
566 expireTimeChanged ||
567 ttlChanged
568 ) {
569 current = yield* secretmanager.patchProjectsSecrets({
570 name,
571 updateMask: [
572 labelsChanged ? "labels" : undefined,
573 annotationsChanged ? "annotations" : undefined,
574 aliasesChanged ? "versionAliases" : undefined,
575 topicsChanged ? "topics" : undefined,
576 rotationChanged ? "rotation" : undefined,
577 destroyTtlChanged ? "versionDestroyTtl" : undefined,
578 expireTimeChanged ? "expireTime" : undefined,
579 ttlChanged ? "ttl" : undefined,
580 ]
581 .filter((field): field is string => field !== undefined)
582 .join(","),
583 body: {
584 name,
585 labels: desiredLabels,
586 annotations: desiredAnnotations,
587 versionAliases: desiredAliases,
588 topics: desiredTopics,
589 rotation: desiredRotation,
590 versionDestroyTtl: desiredDestroyTtl,
591 expireTime: news.expireTime,
592 ttl: ttlChanged ? news.ttl : undefined,
593 },
594 });
595 }
596
597 return toAttrs(current, env.project);
598 }),
599
600 delete: Effect.fn(function* ({ output }) {
601 yield* secretmanager
602 .deleteProjectsSecrets({ name: output.name })
603 .pipe(Effect.catchTag("NotFound", () => Effect.void));
604 yield* waitUntilMissing(output.name);
605 }),
606 });