1import * as secretmanager from "@distilled.cloud/gcp/secretmanager_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 ALCHEMY_LABEL_PREFIX,
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import type {
22 CustomerManagedEncryption,
23 Rotation,
24 SecretTopic,
25} from "./Secret.ts";
26
27export type LocationsSecretProps = {
28 /**
29 * Secret id (the `{secret}` segment of
30 * `projects/{project}/locations/{location}/secrets/{secret}`). If omitted,
31 * a unique name is generated from the stack, stage, and logical id. Max
32 * 255 characters; letters, numbers, hyphens, and underscores. Changing it
33 * replaces the secret.
34 */
35 secretId?: string;
36 /**
37 * Secret Manager location (`us-central1`, `us-east1`, …). Immutable —
38 * changing it replaces the secret. `US-CENTRAL1` is accepted and
39 * normalized to `us-central1`. Regional secrets live only in this
40 * location (use {@link Secret} for automatic/user-managed replication).
41 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
42 */
43 location?: string;
44 /**
45 * User labels. Alchemy ownership labels are merged in automatically.
46 */
47 labels?: Record<string, string>;
48 /**
49 * Custom metadata. Distinct from labels; not used for ownership.
50 */
51 annotations?: Record<string, string>;
52 /**
53 * Cloud KMS CryptoKey used to encrypt versions
54 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
55 * Must live in the same location as the secret. Empty or omitted uses
56 * Google-managed encryption. Updates apply to versions added afterwards.
57 */
58 customerManagedEncryption?: CustomerManagedEncryption;
59 /**
60 * Timestamp in UTC when the secret expires.
61 */
62 expireTime?: string;
63 /**
64 * Input-only TTL used to compute `expireTime` (e.g. `"86400s"`). Ignored
65 * when `expireTime` is set.
66 */
67 ttl?: string;
68 /**
69 * Pub/Sub topics notified on control-plane events. Required when
70 * `rotation` is set.
71 */
72 topics?: SecretTopic[];
73 /**
74 * Rotation policy. Requires `topics`.
75 */
76 rotation?: Rotation;
77 /**
78 * Mapping from version alias to version id.
79 */
80 versionAliases?: Record<string, string>;
81 /**
82 * Delay before a destroyed version is permanently deleted (e.g.
83 * `"86400s"`).
84 */
85 versionDestroyTtl?: string;
86};
87
88export type LocationsSecret = Resource<
89 "GCP.SecretManager.LocationsSecret",
90 LocationsSecretProps,
91 {
92 /** Full resource name `projects/{project}/locations/{location}/secrets/{secret}`. */
93 name: string;
94 /** Secret id (last path segment). */
95 secretId: string;
96 /** Project id. */
97 project: string;
98 /** Location id (`us-central1`, …). */
99 location: string;
100 /** User labels (Alchemy ownership labels stripped). */
101 labels: Record<string, string>;
102 /** Custom annotations. */
103 annotations: Record<string, string>;
104 /** CMEK used for new versions, if set. */
105 customerManagedEncryption: CustomerManagedEncryption | undefined;
106 /** Expiration timestamp, if set. */
107 expireTime: string | undefined;
108 /** RFC3339 creation timestamp. */
109 createTime: string | undefined;
110 /** Server etag. */
111 etag: string | undefined;
112 /** Pub/Sub notification topics. */
113 topics: SecretTopic[];
114 /** Rotation policy, if set. */
115 rotation: Rotation | undefined;
116 /** Version alias map. */
117 versionAliases: Record<string, string>;
118 /** Delayed-destroy TTL, if set. */
119 versionDestroyTtl: string | undefined;
120 },
121 never,
122 Providers
123>;
124
125/**
126 * A regional Google Cloud Secret Manager secret. Payloads stay in one
127 * location (`projects/{project}/locations/{location}/secrets/{secret}`).
128 * For automatically replicated secrets, use {@link Secret}.
129 *
130 * Changing `secretId` or `location` replaces the secret.
131 *
132 * ### Creating a LocationsSecret
133 * **Example:** Generated name in us-central1
134 * ```typescript
135 * const secret = yield* GCP.SecretManager.LocationsSecret("ApiKey", {});
136 * ```
137 *
138 * **Example:** Explicit id, location, labels, and annotations
139 * ```typescript
140 * const secret = yield* GCP.SecretManager.LocationsSecret("ApiKey", {
141 * secretId: "order-api-key",
142 * location: "us-central1",
143 * labels: { env: "prod" },
144 * annotations: { owner: "payments" },
145 * });
146 * ```
147 *
148 * ### Secret Versions
149 * **Example:** Add and access a version
150 * ```typescript
151 * const addVersion = yield* GCP.SecretManager.AddSecretVersion(secret);
152 * yield* addVersion({ payload: { data: btoa("hello") } });
153 * const access = yield* GCP.SecretManager.AccessSecretVersion(secret);
154 * const { payload } = yield* access();
155 * ```
156 *
157 * @resource
158 * @category SecretManager
159 */
160export const LocationsSecret = Resource<LocationsSecret>(
161 "GCP.SecretManager.LocationsSecret",
162);
163
164export class LocationsSecretNotResolved extends Data.TaggedError(
165 "GCP.SecretManager.LocationsSecretNotResolved",
166)<{
167 name: string;
168}> {}
169
170const lastSegment = (value: string) => {
171 const trimmed = value.replace(/\/+$/, "");
172 const parts = trimmed.split("/");
173 return parts[parts.length - 1] || trimmed;
174};
175
176const normalizeLocation = (
177 location: string | undefined,
178 defaultLocation: string,
179) => lastSegment(location ?? defaultLocation).toLowerCase();
180
181const resourceName = (project: string, location: string, secretId: string) =>
182 `projects/${project}/locations/${location}/secrets/${secretId}`;
183
184const parseName = (name: string, defaultLocation: string) => {
185 const parts = name.split("/").filter((part) => part.length > 0);
186 const secretsAt = parts.lastIndexOf("secrets");
187 const locationsAt = parts.lastIndexOf("locations");
188 const projectsAt = parts.lastIndexOf("projects");
189 return {
190 project:
191 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
192 location:
193 locationsAt >= 0 && parts[locationsAt + 1]
194 ? parts[locationsAt + 1]!
195 : defaultLocation,
196 secretId:
197 secretsAt >= 0 && parts[secretsAt + 1]
198 ? parts[secretsAt + 1]!
199 : lastSegment(name),
200 };
201};
202
203const userLabels = (
204 labels: Record<string, string | undefined> | null | undefined,
205): Record<string, string> => stripInternalLabels(tagRecord(labels));
206
207const userAnnotations = (
208 annotations: Record<string, string | undefined> | null | undefined,
209): Record<string, string> => tagRecord(annotations);
210
211const userAliases = (
212 aliases: Record<string, string | undefined> | null | undefined,
213): Record<string, string> => tagRecord(aliases);
214
215const toId = (id: string, secretId: string | undefined, existing?: string) =>
216 Effect.gen(function* () {
217 return (
218 secretId ??
219 existing ??
220 (yield* createPhysicalName({ id, maxLength: 255, lowercase: true }))
221 );
222 });
223
224const toTopics = (topics: secretmanager.TopicList | null | undefined) =>
225 (topics ?? [])
226 .filter(
227 (topic): topic is { name: string } => typeof topic.name === "string",
228 )
229 .map((topic) => ({ name: topic.name }));
230
231const toRotation = (
232 rotation: secretmanager.Rotation | undefined,
233): Rotation | undefined => {
234 if (rotation === undefined) return undefined;
235 if (
236 rotation.nextRotationTime === undefined &&
237 rotation.rotationPeriod === undefined
238 ) {
239 return undefined;
240 }
241 return {
242 nextRotationTime: rotation.nextRotationTime,
243 rotationPeriod: rotation.rotationPeriod,
244 };
245};
246
247const toCmek = (
248 encryption: secretmanager.CustomerManagedEncryption | undefined,
249): CustomerManagedEncryption | undefined => {
250 if (encryption === undefined || encryption.kmsKeyName === undefined) {
251 return undefined;
252 }
253 return { kmsKeyName: encryption.kmsKeyName };
254};
255
256const recordsEqual = (
257 left: Record<string, string>,
258 right: Record<string, string>,
259) => {
260 const leftKeys = Object.keys(left).sort();
261 const rightKeys = Object.keys(right).sort();
262 return (
263 leftKeys.length === rightKeys.length &&
264 leftKeys.every(
265 (key, index) => key === rightKeys[index] && left[key] === right[key],
266 )
267 );
268};
269
270const topicsEqual = (left: SecretTopic[], right: SecretTopic[]) => {
271 const a = left.map((topic) => topic.name ?? "").sort();
272 const b = right.map((topic) => topic.name ?? "").sort();
273 return a.length === b.length && a.every((name, index) => name === b[index]);
274};
275
276const rotationEqual = (
277 left: Rotation | undefined,
278 right: Rotation | undefined,
279) =>
280 (left?.nextRotationTime ?? "") === (right?.nextRotationTime ?? "") &&
281 (left?.rotationPeriod ?? "") === (right?.rotationPeriod ?? "");
282
283const cmekEqual = (
284 left: CustomerManagedEncryption | undefined,
285 right: CustomerManagedEncryption | undefined,
286) => (left?.kmsKeyName ?? "") === (right?.kmsKeyName ?? "");
287
288const toAttrs = (
289 secret: secretmanager.Secret,
290 project: string,
291 region: string,
292) => {
293 const name = secret.name ?? "";
294 const parsed = parseName(name, region);
295 return {
296 name,
297 secretId: parsed.secretId,
298 project: parsed.project || project,
299 location: parsed.location,
300 labels: userLabels(secret.labels),
301 annotations: userAnnotations(secret.annotations),
302 customerManagedEncryption: toCmek(secret.customerManagedEncryption),
303 expireTime: secret.expireTime,
304 createTime: secret.createTime,
305 etag: secret.etag,
306 topics: toTopics(secret.topics),
307 rotation: toRotation(secret.rotation),
308 versionAliases: userAliases(secret.versionAliases),
309 versionDestroyTtl: secret.versionDestroyTtl,
310 };
311};
312
313const getByName = (name: string) =>
314 secretmanager
315 .getProjectsLocationsSecrets({ name })
316 .pipe(Effect.catchTag(["NotFound"], () => Effect.succeed(undefined)));
317
318const hasAlchemyLabelMap = (
319 labels: Record<string, string | undefined> | null | undefined,
320) =>
321 Object.keys(labels ?? {}).some((key) => key.startsWith(ALCHEMY_LABEL_PREFIX));
322
323const collectSecretPages = (parent: string) =>
324 secretmanager.listProjectsLocationsSecrets
325 .pages({
326 parent,
327 pageSize: 1000,
328 filter: "labels.alchemy-id:*",
329 })
330 .pipe(
331 Stream.runCollect,
332 Effect.map((pages) =>
333 Array.from(pages).flatMap((page) => page.secrets ?? []),
334 ),
335 Effect.catchTag("NotFound", () => Effect.succeed([])),
336 );
337
338const toCreateBody = (
339 news: LocationsSecretProps,
340 labels: Record<string, string>,
341): secretmanager.Secret => ({
342 labels,
343 annotations: news.annotations,
344 customerManagedEncryption: news.customerManagedEncryption,
345 expireTime: news.expireTime,
346 ttl: news.expireTime === undefined ? news.ttl : undefined,
347 topics: news.topics,
348 rotation: news.rotation,
349 versionAliases: news.versionAliases,
350 versionDestroyTtl: news.versionDestroyTtl,
351});
352
353export const LocationsSecretProvider = () =>
354 Provider.succeed(LocationsSecret, {
355 stables: ["name", "secretId", "project", "location", "createTime"],
356
357 diff: Effect.fn(function* ({ news, olds, output }) {
358 if (!isResolved(news)) return undefined;
359 const env = yield* GcpEnvironment.current;
360 const previousId = olds?.secretId ?? output?.secretId;
361 const previousLocation = normalizeLocation(
362 olds?.location ?? output?.location,
363 env.region,
364 );
365 const nextLocation = normalizeLocation(
366 news.location ?? olds?.location ?? output?.location,
367 env.region,
368 );
369 const idChanged =
370 previousId !== undefined &&
371 news.secretId !== undefined &&
372 previousId !== news.secretId;
373 const locationChanged = previousLocation !== nextLocation;
374 if (!idChanged && !locationChanged) return undefined;
375 return { action: "replace" as const };
376 }),
377
378 read: Effect.fn(function* ({ id, olds, output }) {
379 const env = yield* GcpEnvironment.current;
380 const secretId = yield* toId(id, olds?.secretId, output?.secretId);
381 const location = normalizeLocation(
382 olds?.location ?? output?.location,
383 env.region,
384 );
385 const name =
386 output?.name ?? resourceName(env.project, location, secretId);
387 const existing = yield* getByName(name);
388 if (existing === undefined) return undefined;
389 const attrs = toAttrs(existing, env.project, env.region);
390 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
391 ? attrs
392 : Unowned(attrs);
393 }),
394
395 list: () =>
396 Effect.gen(function* () {
397 const env = yield* GcpEnvironment.current;
398 const aggregated = yield* collectSecretPages(
399 `projects/${env.project}/locations/-`,
400 );
401 const secrets =
402 aggregated.length > 0
403 ? aggregated
404 : yield* collectSecretPages(
405 `projects/${env.project}/locations/${env.region}`,
406 );
407 return secrets
408 .filter(
409 (secret) =>
410 (secret.name ?? "").includes("/locations/") &&
411 hasAlchemyLabelMap(secret.labels),
412 )
413 .map((secret) => toAttrs(secret, env.project, env.region));
414 }),
415
416 reconcile: Effect.fn(function* ({ id, news, output }) {
417 const env = yield* GcpEnvironment.current;
418 const secretId = yield* toId(id, news.secretId, output?.secretId);
419 const location = normalizeLocation(
420 news.location ?? output?.location,
421 env.region,
422 );
423 const name = resourceName(env.project, location, secretId);
424 const parent = `projects/${env.project}/locations/${location}`;
425 const desiredLabels = {
426 ...toLabels(news.labels),
427 ...(yield* createInternalLabels(id)),
428 };
429 const desiredAnnotations = news.annotations ?? {};
430 const desiredAliases = news.versionAliases ?? {};
431 const desiredTopics = news.topics ?? [];
432 const desiredRotation = news.rotation;
433 const desiredDestroyTtl = news.versionDestroyTtl;
434 const desiredCmek = news.customerManagedEncryption;
435
436 let current = yield* getByName(name);
437
438 if (current === undefined) {
439 const created = yield* secretmanager
440 .createProjectsLocationsSecrets({
441 parent,
442 secretId,
443 body: toCreateBody(news, desiredLabels),
444 })
445 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
446 current = created ?? undefined;
447 }
448
449 if (current === undefined) {
450 return yield* new LocationsSecretNotResolved({ name });
451 }
452
453 const observedLabels = tagRecord(current.labels);
454 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
455 const labelsChanged = upsert.length > 0 || removed.length > 0;
456 const annotationsChanged = !recordsEqual(
457 userAnnotations(current.annotations),
458 desiredAnnotations,
459 );
460 const aliasesChanged = !recordsEqual(
461 userAliases(current.versionAliases),
462 desiredAliases,
463 );
464 const topicsChanged = !topicsEqual(
465 toTopics(current.topics),
466 desiredTopics,
467 );
468 const rotationChanged = !rotationEqual(
469 toRotation(current.rotation),
470 desiredRotation,
471 );
472 const destroyTtlChanged =
473 (current.versionDestroyTtl ?? "") !== (desiredDestroyTtl ?? "");
474 const cmekChanged = !cmekEqual(
475 toCmek(current.customerManagedEncryption),
476 desiredCmek,
477 );
478 const expireTimeChanged =
479 news.expireTime !== undefined &&
480 (current.expireTime ?? "") !== news.expireTime;
481 const ttlChanged =
482 news.expireTime === undefined &&
483 news.ttl !== undefined &&
484 (current.expireTime === undefined || current.expireTime === "");
485
486 if (
487 labelsChanged ||
488 annotationsChanged ||
489 aliasesChanged ||
490 topicsChanged ||
491 rotationChanged ||
492 destroyTtlChanged ||
493 cmekChanged ||
494 expireTimeChanged ||
495 ttlChanged
496 ) {
497 current = yield* secretmanager.patchProjectsLocationsSecrets({
498 name,
499 updateMask: [
500 labelsChanged ? "labels" : undefined,
501 annotationsChanged ? "annotations" : undefined,
502 aliasesChanged ? "versionAliases" : undefined,
503 topicsChanged ? "topics" : undefined,
504 rotationChanged ? "rotation" : undefined,
505 destroyTtlChanged ? "versionDestroyTtl" : undefined,
506 cmekChanged ? "customerManagedEncryption" : undefined,
507 expireTimeChanged ? "expireTime" : undefined,
508 ttlChanged ? "ttl" : undefined,
509 ]
510 .filter((field): field is string => field !== undefined)
511 .join(","),
512 body: {
513 name,
514 labels: desiredLabels,
515 annotations: desiredAnnotations,
516 versionAliases: desiredAliases,
517 topics: desiredTopics,
518 rotation: desiredRotation,
519 versionDestroyTtl: desiredDestroyTtl,
520 customerManagedEncryption: desiredCmek,
521 expireTime: news.expireTime,
522 ttl: ttlChanged ? news.ttl : undefined,
523 },
524 });
525 }
526
527 return toAttrs(current, env.project, env.region);
528 }),
529
530 delete: Effect.fn(function* ({ output }) {
531 yield* secretmanager
532 .deleteProjectsLocationsSecrets({ name: output.name })
533 .pipe(Effect.catchTag(["NotFound"], () => Effect.void));
534 }),
535 });