1import * as logging from "@distilled.cloud/gcp/logging_v2";
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 * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 DEFAULT_LOCATION,
14 DEFAULT_RETENTION_DAYS,
15 canonIndexConfigs,
16 canonRestricted,
17 createOwnership,
18 encodeDescription,
19 hasOwnershipMarker,
20 isDeletedBucket,
21 isPendingBucket,
22 jsonEqual,
23 lastSegment,
24 locationParent,
25 ownedBy,
26 parseDescription,
27 parseLoggingName,
28 scopeParent,
29 toPhysicalId,
30 type LogBucketIndexConfig,
31} from "./internal.ts";
32
33export type FolderBucketIndexConfig = LogBucketIndexConfig;
34
35export type FolderBucketCmekSettings = {
36 /**
37 * Cloud KMS key used to encrypt new log entries
38 * (`projects/{project}/locations/{location}/keyRings/{ring}/cryptoKeys/{key}`).
39 * Once set, CMEK cannot be disabled; the key may be rotated.
40 */
41 kmsKeyName: string;
42};
43
44export type FolderBucketProps = {
45 /**
46 * Folder id (`folders/{folder}` or the numeric id). When omitted, the
47 * stack project is used so the same distilled folder-location APIs can
48 * manage project buckets. Immutable — changing it replaces the bucket.
49 */
50 folderId?: string;
51 /**
52 * Bucket id (the `{bucket}` segment of
53 * `{parent}/locations/{location}/buckets/{bucket}`). If omitted, a unique
54 * name is generated from the stack, stage, and logical id. Limited to
55 * 100 characters: letters, digits, underscores, hyphens, periods; first
56 * character must be alphanumeric. Immutable — changing it replaces the
57 * bucket.
58 */
59 bucketId?: string;
60 /**
61 * Location that stores log entries. `global` leaves placement unspecified.
62 * Immutable after create — changing it replaces the bucket.
63 * @default "global"
64 */
65 location?: string;
66 /**
67 * Human-readable description. Log buckets have no labels field, so
68 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
69 * from attributes.
70 */
71 description?: string;
72 /**
73 * Days to retain log entries before they are deleted. Minimum 1. `0` at
74 * create uses the GCP default of 30. Cannot be changed while the bucket
75 * is locked.
76 * @default 30
77 */
78 retentionDays?: number;
79 /**
80 * When true, retention cannot be reduced and the bucket can only be
81 * deleted if empty. Locking cannot be undone.
82 * @default false
83 */
84 locked?: boolean;
85 /**
86 * Enable Log Analytics. Once enabled, it cannot be disabled.
87 * @default false
88 */
89 analyticsEnabled?: boolean;
90 /**
91 * Log entry field paths denied in this bucket (e.g. `textPayload`,
92 * `jsonPayload`).
93 */
94 restrictedFields?: string[];
95 /**
96 * Custom indexed fields. Automatically indexed paths cannot be changed.
97 */
98 indexConfigs?: FolderBucketIndexConfig[];
99 /**
100 * Customer-managed encryption for new log entries. Once configured,
101 * CMEK cannot be disabled.
102 */
103 cmekSettings?: FolderBucketCmekSettings;
104};
105
106export type FolderBucket = Resource<
107 "GCP.Logging.FolderBucket",
108 FolderBucketProps,
109 {
110 /** Full resource name `{parent}/locations/{location}/buckets/{bucketId}`. */
111 name: string;
112 /** Bucket id (last path segment). */
113 bucketId: string;
114 /** Parent resource (`folders/{folder}` or `projects/{project}`). */
115 parent: string;
116 /** Folder id when the parent is a folder. */
117 folderId: string | undefined;
118 /** Location that stores log entries. */
119 location: string;
120 /** User description with the Alchemy ownership prefix stripped. */
121 description: string | undefined;
122 /** Retention period in days. */
123 retentionDays: number;
124 /** Whether the bucket is locked. */
125 locked: boolean;
126 /** Whether Log Analytics is enabled. */
127 analyticsEnabled: boolean;
128 /** Restricted field paths. */
129 restrictedFields: string[];
130 /** Custom index configs. */
131 indexConfigs: FolderBucketIndexConfig[];
132 /** CMEK key name, if configured. */
133 cmekSettings: FolderBucketCmekSettings | undefined;
134 /** Bucket lifecycle (`ACTIVE`, `DELETE_REQUESTED`, …). */
135 lifecycleState: string | undefined;
136 /** RFC3339 creation timestamp. Unset on GCP default buckets. */
137 createTime: string | undefined;
138 /** RFC3339 last-update timestamp. */
139 updateTime: string | undefined;
140 },
141 never,
142 Providers
143>;
144
145/**
146 * A Cloud Logging log bucket owned by a folder (or the stack project).
147 *
148 * Uses the folder-location Logging APIs (`createFoldersLocationsBuckets`
149 * and siblings). Log buckets have no labels field, so Alchemy stamps
150 * ownership into the description for `list` / nuke. `folderId`, `bucketId`,
151 * and `location` are identity — changing any of them replaces the bucket.
152 * Delete marks the bucket `DELETE_REQUESTED`; GCP purges it after 7 days.
153 *
154 * ### Creating a Folder Bucket
155 * **Example:** Generated name in `global` on the stack project
156 * ```typescript
157 * const bucket = yield* GCP.Logging.FolderBucket("AppLogs", {
158 * description: "application logs",
159 * });
160 * ```
161 *
162 * **Example:** Named bucket on a folder
163 * ```typescript
164 * const bucket = yield* GCP.Logging.FolderBucket("AppLogs", {
165 * folderId: "123456789",
166 * bucketId: "app-logs",
167 * location: "global",
168 * description: "application logs",
169 * retentionDays: 31,
170 * });
171 * ```
172 *
173 * ### Updating a Folder Bucket
174 * **Example:** Change description and retention
175 * ```typescript
176 * const bucket = yield* GCP.Logging.FolderBucket("AppLogs", {
177 * location: "global",
178 * description: "retained application logs",
179 * retentionDays: 60,
180 * });
181 * ```
182 *
183 * @resource
184 * @category Logging
185 */
186export const FolderBucket = Resource<FolderBucket>("GCP.Logging.FolderBucket");
187
188export class FolderBucketNotResolved extends Data.TaggedError(
189 "GCP.Logging.FolderBucketNotResolved",
190)<{
191 name: string;
192}> {}
193
194export class FolderBucketFailed extends Data.TaggedError(
195 "GCP.Logging.FolderBucketFailed",
196)<{
197 name: string;
198 state: string | undefined;
199}> {}
200
201const resourceName = (parent: string, location: string, bucketId: string) =>
202 `${locationParent(parent, location)}/buckets/${bucketId}`;
203
204const folderIdOf = (parent: string) =>
205 parent.startsWith("folders/") ? lastSegment(parent) : undefined;
206
207const toAttrs = (
208 bucket: logging.LogBucket,
209 parent: string,
210 location: string,
211) => {
212 const parsed = parseLoggingName(bucket.name ?? "");
213 const bucketId = parsed.bucketId ?? lastSegment(bucket.name ?? "");
214 const resolvedParent = parsed.parent || parent;
215 const resolvedLocation = parsed.location ?? location;
216 const description = parseDescription(bucket.description);
217 const cmekKey = bucket.cmekSettings?.kmsKeyName;
218 return {
219 name:
220 bucket.name ??
221 (bucketId
222 ? resourceName(resolvedParent, resolvedLocation, bucketId)
223 : ""),
224 bucketId,
225 parent: resolvedParent,
226 folderId: folderIdOf(resolvedParent),
227 location: resolvedLocation,
228 description: description.description,
229 retentionDays: bucket.retentionDays ?? DEFAULT_RETENTION_DAYS,
230 locked: bucket.locked === true,
231 analyticsEnabled: bucket.analyticsEnabled === true,
232 restrictedFields: [...(bucket.restrictedFields ?? [])],
233 indexConfigs: canonIndexConfigs(bucket.indexConfigs),
234 cmekSettings: cmekKey ? { kmsKeyName: cmekKey } : undefined,
235 lifecycleState: bucket.lifecycleState,
236 createTime: bucket.createTime,
237 updateTime: bucket.updateTime,
238 };
239};
240
241const getByName = (name: string) =>
242 logging
243 .getFoldersLocationsBuckets({ name })
244 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
245
246const undelete = (name: string) =>
247 logging
248 .undeleteFoldersLocationsBuckets({ name, body: {} })
249 .pipe(Effect.catchTag(["NotFound", "Conflict"], () => Effect.void));
250
251const waitUntilActive = (name: string) =>
252 Effect.gen(function* () {
253 const bucket = yield* getByName(name);
254 if (bucket === undefined || isDeletedBucket(bucket)) {
255 return yield* new FolderBucketNotResolved({ name });
256 }
257 if (bucket.lifecycleState === "FAILED") {
258 return yield* new FolderBucketFailed({
259 name,
260 state: bucket.lifecycleState,
261 });
262 }
263 if (isPendingBucket(bucket.lifecycleState)) {
264 return yield* new FolderBucketNotResolved({ name });
265 }
266 return bucket;
267 }).pipe(
268 Effect.retry({
269 while: (error) => error._tag === "GCP.Logging.FolderBucketNotResolved",
270 times: 8,
271 schedule: Schedule.spaced("1 second"),
272 }),
273 );
274
275const waitUntilDeleted = (name: string) =>
276 getByName(name).pipe(
277 Effect.flatMap((bucket) =>
278 isDeletedBucket(bucket)
279 ? Effect.void
280 : Effect.fail(new FolderBucketNotResolved({ name })),
281 ),
282 Effect.retry({
283 while: (error) => error._tag === "GCP.Logging.FolderBucketNotResolved",
284 times: 8,
285 schedule: Schedule.spaced("1 second"),
286 }),
287 );
288
289const toCreateBody = (
290 props: FolderBucketProps,
291 description: string,
292): logging.LogBucket => ({
293 description,
294 retentionDays: props.retentionDays,
295 locked: props.locked === true ? true : undefined,
296 analyticsEnabled: props.analyticsEnabled === true ? true : undefined,
297 restrictedFields:
298 props.restrictedFields && props.restrictedFields.length > 0
299 ? [...props.restrictedFields]
300 : undefined,
301 indexConfigs:
302 props.indexConfigs && props.indexConfigs.length > 0
303 ? props.indexConfigs.map((config) => ({
304 fieldPath: config.fieldPath,
305 type: config.type,
306 }))
307 : undefined,
308 cmekSettings: props.cmekSettings
309 ? { kmsKeyName: props.cmekSettings.kmsKeyName }
310 : undefined,
311});
312
313export const FolderBucketProvider = () =>
314 Provider.succeed(FolderBucket, {
315 stables: [
316 "name",
317 "bucketId",
318 "parent",
319 "folderId",
320 "location",
321 "createTime",
322 ],
323
324 diff: Effect.fn(function* ({ news, olds, output }) {
325 if (!isResolved(news)) return undefined;
326 const previousId = olds?.bucketId ?? output?.bucketId;
327 const idChanged =
328 previousId !== undefined &&
329 news.bucketId !== undefined &&
330 news.bucketId !== previousId;
331 const previousLocation = olds?.location ?? output?.location;
332 const locationChanged =
333 previousLocation !== undefined &&
334 news.location !== undefined &&
335 news.location !== previousLocation;
336 const previousFolder = olds?.folderId ?? output?.folderId;
337 const folderChanged =
338 news.folderId !== undefined && news.folderId !== previousFolder;
339 if (!idChanged && !locationChanged && !folderChanged) return undefined;
340 return { action: "replace" as const, deleteFirst: false };
341 }),
342
343 read: Effect.fn(function* ({ id, olds, output }) {
344 const env = yield* GcpEnvironment.current;
345 const parent = scopeParent(
346 env.project,
347 olds?.folderId ?? output?.folderId,
348 );
349 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
350 const bucketId = yield* toPhysicalId(
351 id,
352 olds?.bucketId,
353 output?.bucketId,
354 "b",
355 );
356 const name = output?.name ?? resourceName(parent, location, bucketId);
357 const existing = yield* getByName(name);
358 if (isDeletedBucket(existing)) return undefined;
359 const attrs = toAttrs(existing, parent, location);
360 const { labels } = parseDescription(existing.description);
361 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
362 }),
363
364 list: () =>
365 Effect.gen(function* () {
366 const env = yield* GcpEnvironment.current;
367 return yield* logging.listFoldersLocationsBuckets
368 .pages({
369 parent: `projects/${env.project}/locations/-`,
370 pageSize: 1000,
371 })
372 .pipe(
373 Stream.flatMap((page) => Stream.fromIterable(page.buckets ?? [])),
374 Stream.filter(
375 (bucket) =>
376 !isDeletedBucket(bucket) &&
377 hasOwnershipMarker(bucket.description),
378 ),
379 Stream.map((bucket) =>
380 toAttrs(
381 bucket,
382 parseLoggingName(bucket.name ?? "").parent ||
383 `projects/${env.project}`,
384 parseLoggingName(bucket.name ?? "").location ??
385 DEFAULT_LOCATION,
386 ),
387 ),
388 Stream.runCollect,
389 Effect.map((chunk) => Array.from(chunk)),
390 );
391 }),
392
393 reconcile: Effect.fn(function* ({ id, news, output }) {
394 const env = yield* GcpEnvironment.current;
395 const parent = scopeParent(
396 env.project,
397 news.folderId ?? output?.folderId,
398 );
399 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
400 const bucketId = yield* toPhysicalId(
401 id,
402 news.bucketId,
403 output?.bucketId,
404 "b",
405 );
406 const name = resourceName(parent, location, bucketId);
407 const ownership = yield* createOwnership(id);
408 const desiredDescription = encodeDescription(ownership, news.description);
409
410 let current = yield* getByName(output?.name ?? name);
411
412 if (
413 current !== undefined &&
414 current.lifecycleState === "DELETE_REQUESTED"
415 ) {
416 yield* undelete(current.name ?? name);
417 current = yield* waitUntilActive(current.name ?? name);
418 }
419
420 if (current === undefined) {
421 const created = yield* logging
422 .createFoldersLocationsBuckets({
423 parent: locationParent(parent, location),
424 bucketId,
425 body: toCreateBody(news, desiredDescription),
426 })
427 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
428 current = created ?? undefined;
429 if (
430 current !== undefined &&
431 current.lifecycleState === "DELETE_REQUESTED"
432 ) {
433 yield* undelete(current.name ?? name);
434 current = yield* waitUntilActive(current.name ?? name);
435 } else if (
436 current !== undefined &&
437 isPendingBucket(current.lifecycleState)
438 ) {
439 current = yield* waitUntilActive(current.name ?? name);
440 }
441 }
442
443 if (isDeletedBucket(current)) {
444 return yield* new FolderBucketNotResolved({ name });
445 }
446
447 const desiredLocked = news.locked === true;
448 const desiredAnalytics = news.analyticsEnabled === true;
449 const descriptionChanged =
450 (current.description ?? "") !== desiredDescription;
451 const retentionChanged =
452 news.retentionDays !== undefined &&
453 current.locked !== true &&
454 (current.retentionDays ?? DEFAULT_RETENTION_DAYS) !==
455 news.retentionDays;
456 const restrictedChanged =
457 news.restrictedFields !== undefined &&
458 !jsonEqual(
459 canonRestricted(current.restrictedFields),
460 canonRestricted(news.restrictedFields),
461 );
462 const indexChanged =
463 news.indexConfigs !== undefined &&
464 !jsonEqual(
465 canonIndexConfigs(current.indexConfigs),
466 canonIndexConfigs(news.indexConfigs),
467 );
468 const analyticsChanged =
469 news.analyticsEnabled !== undefined &&
470 desiredAnalytics &&
471 current.analyticsEnabled !== true;
472 const cmekChanged =
473 news.cmekSettings !== undefined &&
474 (current.cmekSettings?.kmsKeyName ?? "") !==
475 news.cmekSettings.kmsKeyName;
476 const lockedChanged =
477 news.locked !== undefined && desiredLocked && current.locked !== true;
478
479 const syncMask = [
480 descriptionChanged ? "description" : undefined,
481 retentionChanged ? "retentionDays" : undefined,
482 restrictedChanged ? "restrictedFields" : undefined,
483 indexChanged ? "indexConfigs" : undefined,
484 analyticsChanged ? "analyticsEnabled" : undefined,
485 cmekChanged ? "cmekSettings" : undefined,
486 ].filter((field): field is string => field !== undefined);
487
488 if (syncMask.length > 0) {
489 const body: logging.LogBucket = {};
490 if (descriptionChanged) body.description = desiredDescription;
491 if (retentionChanged) body.retentionDays = news.retentionDays;
492 if (restrictedChanged) {
493 body.restrictedFields = [...(news.restrictedFields ?? [])];
494 }
495 if (indexChanged) {
496 body.indexConfigs = (news.indexConfigs ?? []).map((config) => ({
497 fieldPath: config.fieldPath,
498 type: config.type,
499 }));
500 }
501 if (analyticsChanged) body.analyticsEnabled = true;
502 if (cmekChanged) {
503 body.cmekSettings = { kmsKeyName: news.cmekSettings?.kmsKeyName };
504 }
505 current = yield* logging.patchFoldersLocationsBuckets({
506 name: current.name ?? name,
507 updateMask: syncMask.join(","),
508 body,
509 });
510 if (isPendingBucket(current.lifecycleState)) {
511 current = yield* waitUntilActive(current.name ?? name);
512 }
513 }
514
515 if (lockedChanged) {
516 current = yield* logging.patchFoldersLocationsBuckets({
517 name: current.name ?? name,
518 updateMask: "locked",
519 body: { locked: true },
520 });
521 if (isPendingBucket(current.lifecycleState)) {
522 current = yield* waitUntilActive(current.name ?? name);
523 }
524 }
525
526 return toAttrs(current, parent, location);
527 }),
528
529 delete: Effect.fn(function* ({ output }) {
530 if (output.lifecycleState === "DELETE_REQUESTED") return;
531 yield* logging.deleteFoldersLocationsBuckets({ name: output.name }).pipe(
532 Effect.catchTag("NotFound", () => Effect.void),
533 Effect.retry({
534 while: (error) =>
535 error._tag === "BadRequest" || error._tag === "Conflict",
536 times: 10,
537 schedule: Schedule.spaced("3 seconds"),
538 }),
539 );
540 yield* waitUntilDeleted(output.name);
541 }),
542 });