1import * as kms from "@distilled.cloud/gcp/cloudkms_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import { deterministicKmsId } from "./internal.ts";
10
11export type KeyRingProps = {
12 /**
13 * Key ring id (the `{keyRing}` segment of
14 * `projects/{project}/locations/{location}/keyRings/{keyRing}`).
15 * If omitted, a deterministic name `{stack}-{id}-{stage}` is derived
16 * (no random suffix), so a redeploy after destroy reuses the same ring
17 * instead of leaking a new one. Must match `[a-zA-Z0-9_-]{1,63}`.
18 * Immutable — changing it replaces the key ring.
19 */
20 keyRingId?: string;
21 /**
22 * Cloud KMS location (`us-central1`, `global`, `us`, …). Immutable —
23 * changing it replaces the key ring. `US-CENTRAL1` is accepted and
24 * normalized to `us-central1`.
25 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
26 */
27 location?: string;
28};
29
30export type KeyRing = Resource<
31 "GCP.KMS.KeyRing",
32 KeyRingProps,
33 {
34 /** Full resource name `projects/{project}/locations/{location}/keyRings/{keyRing}`. */
35 name: string;
36 /** Key ring id (last path segment). */
37 keyRingId: string;
38 /** Location id (`us-central1`, `global`, …). */
39 location: string;
40 /** Project id. */
41 project: string;
42 /** RFC3339 creation timestamp. */
43 createTime: string | undefined;
44 },
45 never,
46 Providers
47>;
48
49/**
50 * A Cloud KMS KeyRing — a location-scoped container for CryptoKeys.
51 *
52 * Key rings have no labels and no update API. Name and location are
53 * identity; Cloud KMS also has no delete API, so destroy removes the
54 * resource from state only. Account-wide nuke skips this type for the
55 * same reason.
56 *
57 * Because the ring outlives destroy, the default `keyRingId` is
58 * deterministic per stack, stage, and logical id: deploying again after a
59 * destroy adopts the ring left behind rather than creating another one.
60 *
61 * ### Creating a KeyRing
62 * **Example:** Generated name
63 * ```typescript
64 * const keys = yield* GCP.KMS.KeyRing("Keys", {});
65 * ```
66 *
67 * **Example:** Explicit id and location
68 * ```typescript
69 * const keys = yield* GCP.KMS.KeyRing("Keys", {
70 * keyRingId: "app-keys",
71 * location: "us-central1",
72 * });
73 * ```
74 *
75 * @resource
76 * @category KMS
77 */
78export const KeyRing = Resource<KeyRing>("GCP.KMS.KeyRing");
79
80export class KeyRingNotResolved extends Data.TaggedError(
81 "GCP.KMS.KeyRingNotResolved",
82)<{
83 name: string;
84}> {}
85
86const lastSegment = (value: string) => {
87 const trimmed = value.replace(/\/+$/, "");
88 const parts = trimmed.split("/");
89 return parts[parts.length - 1] || trimmed;
90};
91
92const normalizeLocation = (location: string | undefined, fallback: string) =>
93 lastSegment(location ?? fallback).toLowerCase();
94
95const resourceName = (project: string, location: string, keyRingId: string) =>
96 `projects/${project}/locations/${location}/keyRings/${keyRingId}`;
97
98const parseName = (name: string, fallbackLocation: string) => {
99 const parts = name.split("/").filter((part) => part.length > 0);
100 const keyRingsAt = parts.lastIndexOf("keyRings");
101 const locationsAt = parts.lastIndexOf("locations");
102 const projectsAt = parts.lastIndexOf("projects");
103 return {
104 project:
105 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
106 location:
107 locationsAt >= 0 && parts[locationsAt + 1]
108 ? parts[locationsAt + 1]!
109 : fallbackLocation,
110 keyRingId:
111 keyRingsAt >= 0 && parts[keyRingsAt + 1]
112 ? parts[keyRingsAt + 1]!
113 : lastSegment(name),
114 };
115};
116
117const toId = (id: string, keyRingId: string | undefined, existing?: string) =>
118 Effect.gen(function* () {
119 return keyRingId ?? existing ?? (yield* deterministicKmsId(id));
120 });
121
122const toAttrs = (keyRing: kms.KeyRing, project: string, region: string) => {
123 const name = keyRing.name ?? "";
124 const parsed = parseName(name, region);
125 return {
126 name,
127 keyRingId: parsed.keyRingId,
128 location: parsed.location,
129 project: parsed.project || project,
130 createTime: keyRing.createTime,
131 };
132};
133
134const getByName = (name: string) =>
135 kms
136 .getProjectsLocationsKeyRings({ name })
137 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
138
139const listKeyRingsAt = (parent: string) =>
140 Effect.gen(function* () {
141 const found: kms.KeyRing[] = [];
142 let pageToken: string | undefined;
143 for (let page = 0; page < 10; page++) {
144 const response = yield* kms.listProjectsLocationsKeyRings({
145 parent,
146 pageSize: 1000,
147 pageToken,
148 });
149 found.push(...(response.keyRings ?? []));
150 pageToken = response.nextPageToken;
151 if (pageToken === undefined || pageToken === "") break;
152 }
153 return found;
154 }).pipe(
155 Effect.catchTag("NotFound", () => Effect.succeed([] as kms.KeyRing[])),
156 );
157
158export const KeyRingProvider = () =>
159 Provider.succeed(KeyRing, {
160 // Cloud KMS has no KeyRing delete API. Destroy forgets state only, so
161 // nuke would loop forever on "deleted but still there".
162 nuke: { skip: true },
163 stables: ["name", "keyRingId", "location", "project", "createTime"],
164
165 diff: Effect.fn(function* ({ news, olds, output }) {
166 if (!isResolved(news)) return undefined;
167 const env = yield* GcpEnvironment.current;
168 const previousId = olds?.keyRingId ?? output?.keyRingId;
169 const nextId = news.keyRingId ?? previousId;
170 const idChanged =
171 previousId !== undefined &&
172 nextId !== undefined &&
173 nextId !== previousId;
174
175 const previousLocation = normalizeLocation(
176 olds?.location ?? output?.location,
177 env.region,
178 );
179 const nextLocation = normalizeLocation(
180 news.location ?? output?.location,
181 env.region,
182 );
183 const locationChanged = previousLocation !== nextLocation;
184
185 if (idChanged || locationChanged) {
186 // Cannot delete the old ring; create the replacement first.
187 return { action: "replace" as const, deleteFirst: false };
188 }
189 return undefined;
190 }),
191
192 read: Effect.fn(function* ({ id, olds, output }) {
193 const env = yield* GcpEnvironment.current;
194 const keyRingId = yield* toId(id, olds?.keyRingId, output?.keyRingId);
195 const location = normalizeLocation(
196 olds?.location ?? output?.location,
197 env.region,
198 );
199 const name =
200 output?.name ?? resourceName(env.project, location, keyRingId);
201 const existing = yield* getByName(name);
202 if (existing === undefined) return undefined;
203 // KeyRings have no labels, so ownership cannot be checked. A ring at
204 // the deterministic `{stack}-{id}-{stage}` name can only come from an
205 // earlier deploy of this same logical resource (rings are never
206 // deleted, so destroy leaves it behind); adopt it. A ring is an empty
207 // namespace — the keys inside carry their own ownership labels.
208 return toAttrs(existing, env.project, env.region);
209 }),
210
211 list: () =>
212 Effect.gen(function* () {
213 const env = yield* GcpEnvironment.current;
214 const found: ReturnType<typeof toAttrs>[] = [];
215 let pageToken: string | undefined;
216 for (let page = 0; page < 10; page++) {
217 const response = yield* kms.listProjectsLocations({
218 name: `projects/${env.project}`,
219 pageSize: 100,
220 pageToken,
221 });
222 const parents = (response.locations ?? [])
223 .map((location) => location.name)
224 .filter((name): name is string => !!name);
225 const pages = yield* Effect.forEach(
226 parents,
227 (parent) => listKeyRingsAt(parent),
228 { concurrency: 4 },
229 );
230 for (const keyRings of pages) {
231 for (const keyRing of keyRings) {
232 found.push(toAttrs(keyRing, env.project, env.region));
233 }
234 }
235 pageToken = response.nextPageToken;
236 if (pageToken === undefined || pageToken === "") break;
237 }
238 return found;
239 }),
240
241 reconcile: Effect.fn(function* ({ id, news, output }) {
242 const env = yield* GcpEnvironment.current;
243 const keyRingId = yield* toId(id, news.keyRingId, output?.keyRingId);
244 const location = normalizeLocation(
245 news.location ?? output?.location,
246 env.region,
247 );
248 const name = resourceName(env.project, location, keyRingId);
249
250 // A ring already at this name is adopted as ours (see `read`).
251 let current = yield* getByName(name);
252
253 if (current === undefined) {
254 const created = yield* kms
255 .createProjectsLocationsKeyRings({
256 parent: `projects/${env.project}/locations/${location}`,
257 keyRingId,
258 body: {},
259 })
260 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
261 current = created ?? undefined;
262 }
263
264 if (current === undefined) {
265 return yield* new KeyRingNotResolved({ name });
266 }
267
268 return toAttrs(current, env.project, env.region);
269 }),
270
271 delete: Effect.fn(function* ({ output }) {
272 // Cloud KMS has no KeyRing delete API. Forget it from state; the
273 // empty ring remains in the project until Google adds deletion.
274 yield* Effect.logWarning(
275 `GCP Cloud KMS has no KeyRing delete API — "${output.name}" was removed from state but still exists.`,
276 );
277 }),
278 });