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 { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 alchemyLabelKeys,
14 createInternalLabels,
15 hasAlchemyLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import { waitForDeleteOperation } from "./operations.ts";
19
20const MAX_NAME_LENGTH = 100;
21const DEFAULT_LOCATION = "global";
22const DEFAULT_RETENTION_DAYS = 30;
23
24export interface LogBucketIndexConfig {
25 /**
26 * LogEntry field path to index (e.g. `jsonPayload.request.status`).
27 */
28 fieldPath: string;
29 /**
30 * Indexed value type.
31 */
32 type: "INDEX_TYPE_STRING" | "INDEX_TYPE_INTEGER";
33}
34
35export interface LogBucketCmekSettings {
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 LogBucketProps = {
45 /**
46 * Bucket id (the `{bucket}` segment of
47 * `projects/{project}/locations/{location}/buckets/{bucket}`). If omitted,
48 * a unique name is generated from the stack, stage, and logical id.
49 * Limited to 100 characters: letters, digits, underscores, hyphens,
50 * periods; first character must be alphanumeric. Immutable — changing
51 * it replaces the bucket.
52 */
53 bucketId?: string;
54 /**
55 * Location that stores log entries. `global` leaves placement unspecified.
56 * Immutable after create — changing it replaces the bucket.
57 * @default "global"
58 */
59 location?: string;
60 /**
61 * Human-readable description. Log buckets have no labels field, so
62 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
63 * from attributes.
64 */
65 description?: string;
66 /**
67 * Days to retain log entries before they are deleted. Minimum 1. `0` at
68 * create uses the GCP default of 30. Cannot be changed while the bucket
69 * is locked.
70 * @default 30
71 */
72 retentionDays?: number;
73 /**
74 * When true, retention cannot be reduced and the bucket can only be
75 * deleted if empty. Locking cannot be undone.
76 * @default false
77 */
78 locked?: boolean;
79 /**
80 * Enable Log Analytics. Once enabled, it cannot be disabled.
81 * @default false
82 */
83 analyticsEnabled?: boolean;
84 /**
85 * Log entry field paths denied in this bucket (e.g. `textPayload`,
86 * `jsonPayload`).
87 */
88 restrictedFields?: string[];
89 /**
90 * Custom indexed fields. Automatically indexed paths cannot be changed.
91 */
92 indexConfigs?: LogBucketIndexConfig[];
93 /**
94 * Customer-managed encryption for new log entries. Once configured,
95 * CMEK cannot be disabled.
96 */
97 cmekSettings?: LogBucketCmekSettings;
98};
99
100export type LogBucket = Resource<
101 "GCP.Logging.LogBucket",
102 LogBucketProps,
103 {
104 /** Full resource name `projects/{project}/locations/{location}/buckets/{bucketId}`. */
105 name: string;
106 /** Bucket id (last path segment). */
107 bucketId: string;
108 /** Project id. */
109 project: string;
110 /** Location that stores log entries. */
111 location: string;
112 /** User description with the Alchemy ownership prefix stripped. */
113 description: string | undefined;
114 /** Retention period in days. */
115 retentionDays: number;
116 /** Whether the bucket is locked. */
117 locked: boolean;
118 /** Whether Log Analytics is enabled. */
119 analyticsEnabled: boolean;
120 /** Restricted field paths. */
121 restrictedFields: string[];
122 /** Custom index configs. */
123 indexConfigs: LogBucketIndexConfig[];
124 /** CMEK key name, if configured. */
125 cmekSettings: LogBucketCmekSettings | undefined;
126 /** Bucket lifecycle (`ACTIVE`, `DELETE_REQUESTED`, …). */
127 lifecycleState: string | undefined;
128 /** RFC3339 creation timestamp. Unset on GCP default buckets. */
129 createTime: string | undefined;
130 /** RFC3339 last-update timestamp. */
131 updateTime: string | undefined;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * A Cloud Logging log bucket that stores log entries in a location.
139 *
140 * Log buckets have no labels field, so Alchemy stamps ownership into the
141 * description for `list` / nuke. `bucketId` and `location` are identity —
142 * changing either replaces the bucket. Delete marks the bucket
143 * `DELETE_REQUESTED`; GCP purges it after 7 days. Reconcile undeletes a
144 * bucket still in that grace period so the same name can be reused.
145 *
146 * ### Creating a Log Bucket
147 * **Example:** Generated name in `global`
148 * ```typescript
149 * const bucket = yield* GCP.Logging.LogBucket("AppLogs", {
150 * description: "application logs",
151 * });
152 * ```
153 *
154 * **Example:** Named bucket with custom retention
155 * ```typescript
156 * const bucket = yield* GCP.Logging.LogBucket("AppLogs", {
157 * bucketId: "app-logs",
158 * location: "global",
159 * description: "application logs",
160 * retentionDays: 31,
161 * });
162 * ```
163 *
164 * ### Updating a Log Bucket
165 * **Example:** Change description and retention
166 * ```typescript
167 * const bucket = yield* GCP.Logging.LogBucket("AppLogs", {
168 * location: "global",
169 * description: "retained application logs",
170 * retentionDays: 60,
171 * });
172 * ```
173 *
174 * @resource
175 * @category Logging
176 */
177export const LogBucket = Resource<LogBucket>("GCP.Logging.LogBucket");
178
179export class LogBucketNotResolved extends Data.TaggedError(
180 "GCP.Logging.LogBucketNotResolved",
181)<{
182 name: string;
183}> {}
184
185export class LogBucketFailed extends Data.TaggedError(
186 "GCP.Logging.LogBucketFailed",
187)<{
188 name: string;
189 state: string | undefined;
190}> {}
191
192const lastSegment = (value: string) => {
193 const trimmed = value.replace(/\/+$/, "");
194 const parts = trimmed.split("/");
195 return parts[parts.length - 1] || trimmed;
196};
197
198const resourceName = (project: string, location: string, bucketId: string) =>
199 `projects/${project}/locations/${location}/buckets/${bucketId}`;
200
201const parseBucketName = (name: string) => {
202 const match = name.match(
203 /^projects\/([^/]+)\/locations\/([^/]+)\/buckets\/([^/]+)$/,
204 );
205 if (!match) return undefined;
206 return { project: match[1]!, location: match[2]!, bucketId: match[3]! };
207};
208
209const bucketIdOf = (bucket: logging.LogBucket, fallback?: string) => {
210 const parsed = parseBucketName(bucket.name ?? "");
211 return parsed?.bucketId ?? fallback ?? lastSegment(bucket.name ?? "");
212};
213
214const locationOf = (bucket: logging.LogBucket, fallback: string) => {
215 const parsed = parseBucketName(bucket.name ?? "");
216 return parsed?.location ?? fallback;
217};
218
219const toId = (id: string, bucketId: string | undefined, existing?: string) =>
220 Effect.gen(function* () {
221 if (bucketId !== undefined) return bucketId;
222 if (existing !== undefined) return existing;
223 const generated = yield* createPhysicalName({
224 id,
225 maxLength: MAX_NAME_LENGTH,
226 lowercase: true,
227 });
228 return /^[a-z0-9]/.test(generated)
229 ? generated
230 : `b${generated}`.slice(0, MAX_NAME_LENGTH);
231 });
232
233const encodeDescription = (
234 labels: Record<string, string>,
235 description: string | undefined,
236): string => {
237 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
238 return description ? `${marker}\n${description}` : marker;
239};
240
241const parseDescription = (
242 description: string | undefined,
243): {
244 labels: Record<string, string>;
245 description: string | undefined;
246} => {
247 if (!description?.startsWith("[alchemy ")) {
248 return { labels: {}, description };
249 }
250 const end = description.indexOf("]");
251 if (end < 0) return { labels: {}, description };
252 const labels: Record<string, string> = {};
253 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
254 const eq = part.indexOf("=");
255 if (eq > 0) {
256 labels[part.slice(0, eq)] = part.slice(eq + 1);
257 }
258 }
259 const rest = description.slice(end + 1).replace(/^\n/, "");
260 return { labels, description: rest.length > 0 ? rest : undefined };
261};
262
263const hasOwnershipMarker = (description: string | undefined) =>
264 Object.keys(parseDescription(description).labels).some((key) =>
265 key.startsWith("alchemy-"),
266 );
267
268const isDeleted = (
269 bucket: logging.LogBucket | undefined,
270): bucket is undefined =>
271 bucket === undefined || bucket.lifecycleState === "DELETE_REQUESTED";
272
273const isPending = (state: string | undefined) =>
274 state === "CREATING" || state === "UPDATING";
275
276const jsonEqual = (left: unknown, right: unknown) =>
277 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
278
279const canonRestricted = (fields: readonly string[] | undefined) =>
280 [...(fields ?? [])].slice().sort();
281
282const canonIndexConfigs = (
283 configs:
284 | readonly logging.IndexConfig[]
285 | readonly LogBucketIndexConfig[]
286 | undefined,
287): LogBucketIndexConfig[] =>
288 [...(configs ?? [])]
289 .flatMap((config) =>
290 config.fieldPath
291 ? [
292 {
293 fieldPath: config.fieldPath,
294 type: (config.type ??
295 "INDEX_TYPE_STRING") as LogBucketIndexConfig["type"],
296 },
297 ]
298 : [],
299 )
300 .sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
301
302const toAttrs = (
303 bucket: logging.LogBucket,
304 project: string,
305 location: string,
306) => {
307 const bucketId = bucketIdOf(bucket);
308 const resolvedLocation = locationOf(bucket, location);
309 const parsed = parseDescription(bucket.description);
310 const cmekKey = bucket.cmekSettings?.kmsKeyName;
311 return {
312 name:
313 bucket.name ??
314 (bucketId ? resourceName(project, resolvedLocation, bucketId) : ""),
315 bucketId,
316 project,
317 location: resolvedLocation,
318 description: parsed.description,
319 retentionDays: bucket.retentionDays ?? DEFAULT_RETENTION_DAYS,
320 locked: bucket.locked === true,
321 analyticsEnabled: bucket.analyticsEnabled === true,
322 restrictedFields: [...(bucket.restrictedFields ?? [])],
323 indexConfigs: canonIndexConfigs(bucket.indexConfigs),
324 cmekSettings: cmekKey ? { kmsKeyName: cmekKey } : undefined,
325 lifecycleState: bucket.lifecycleState,
326 createTime: bucket.createTime,
327 updateTime: bucket.updateTime,
328 };
329};
330
331const getByName = (name: string) =>
332 logging
333 .getProjectsLocationsBuckets({ name })
334 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
335
336const undelete = (name: string) =>
337 logging
338 .undeleteProjectsLocationsBuckets({ name, body: {} })
339 .pipe(Effect.catchTag(["NotFound", "Conflict"], () => Effect.void));
340
341const waitUntilActive = (name: string) =>
342 Effect.gen(function* () {
343 const bucket = yield* getByName(name);
344 if (bucket === undefined || isDeleted(bucket)) {
345 return yield* new LogBucketNotResolved({ name });
346 }
347 if (bucket.lifecycleState === "FAILED") {
348 return yield* new LogBucketFailed({
349 name,
350 state: bucket.lifecycleState,
351 });
352 }
353 if (isPending(bucket.lifecycleState)) {
354 return yield* new LogBucketNotResolved({ name });
355 }
356 return bucket;
357 }).pipe(
358 Effect.retry({
359 while: (error) => error._tag === "GCP.Logging.LogBucketNotResolved",
360 times: 8,
361 schedule: Schedule.spaced("1 second"),
362 }),
363 );
364
365const waitUntilDeleted = (name: string) =>
366 getByName(name).pipe(
367 Effect.flatMap((bucket) =>
368 isDeleted(bucket)
369 ? Effect.void
370 : Effect.fail(new LogBucketNotResolved({ name })),
371 ),
372 Effect.retry({
373 while: (error) => error._tag === "GCP.Logging.LogBucketNotResolved",
374 times: 8,
375 schedule: Schedule.spaced("1 second"),
376 }),
377 );
378
379const toCreateBody = (
380 props: LogBucketProps,
381 description: string,
382): logging.LogBucket => ({
383 description,
384 retentionDays: props.retentionDays,
385 locked: props.locked === true ? true : undefined,
386 analyticsEnabled: props.analyticsEnabled === true ? true : undefined,
387 restrictedFields:
388 props.restrictedFields && props.restrictedFields.length > 0
389 ? [...props.restrictedFields]
390 : undefined,
391 indexConfigs:
392 props.indexConfigs && props.indexConfigs.length > 0
393 ? props.indexConfigs.map((config) => ({
394 fieldPath: config.fieldPath,
395 type: config.type,
396 }))
397 : undefined,
398 cmekSettings: props.cmekSettings
399 ? { kmsKeyName: props.cmekSettings.kmsKeyName }
400 : undefined,
401});
402
403export const LogBucketProvider = () =>
404 Provider.succeed(LogBucket, {
405 stables: ["name", "bucketId", "project", "location", "createTime"],
406
407 diff: Effect.fn(function* ({ news, olds, output }) {
408 if (!isResolved(news)) return undefined;
409 const previousId = olds?.bucketId ?? output?.bucketId;
410 const idChanged =
411 previousId !== undefined &&
412 news.bucketId !== undefined &&
413 news.bucketId !== previousId;
414 const previousLocation = olds?.location ?? output?.location;
415 const locationChanged =
416 previousLocation !== undefined &&
417 news.location !== undefined &&
418 news.location !== previousLocation;
419 if (!idChanged && !locationChanged) return undefined;
420 return { action: "replace" as const, deleteFirst: false };
421 }),
422
423 read: Effect.fn(function* ({ id, olds, output }) {
424 const env = yield* GcpEnvironment.current;
425 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
426 const bucketId = yield* toId(id, olds?.bucketId, output?.bucketId);
427 const name =
428 output?.name ?? resourceName(env.project, location, bucketId);
429 const existing = yield* getByName(name);
430 if (isDeleted(existing)) return undefined;
431 const attrs = toAttrs(existing, env.project, location);
432 const { labels } = parseDescription(existing.description);
433 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
434 }),
435
436 list: () =>
437 Effect.gen(function* () {
438 const env = yield* GcpEnvironment.current;
439 return yield* logging.listProjectsLocationsBuckets
440 .pages({
441 parent: `projects/${env.project}/locations/-`,
442 pageSize: 1000,
443 })
444 .pipe(
445 Stream.flatMap((page) => Stream.fromIterable(page.buckets ?? [])),
446 Stream.filter(
447 (bucket) =>
448 !isDeleted(bucket) && hasOwnershipMarker(bucket.description),
449 ),
450 Stream.map((bucket) =>
451 toAttrs(
452 bucket,
453 env.project,
454 locationOf(bucket, DEFAULT_LOCATION),
455 ),
456 ),
457 Stream.runCollect,
458 Effect.map((chunk) => Array.from(chunk)),
459 );
460 }),
461
462 reconcile: Effect.fn(function* ({ id, news, output }) {
463 const env = yield* GcpEnvironment.current;
464 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
465 const bucketId = yield* toId(id, news.bucketId, output?.bucketId);
466 const name = resourceName(env.project, location, bucketId);
467 const ownership = yield* createInternalLabels(id);
468 const desiredDescription = encodeDescription(ownership, news.description);
469
470 let current = yield* getByName(output?.name ?? name);
471
472 if (
473 current !== undefined &&
474 current.lifecycleState === "DELETE_REQUESTED"
475 ) {
476 yield* undelete(current.name ?? name);
477 current = yield* waitUntilActive(current.name ?? name);
478 }
479
480 if (current === undefined) {
481 const created = yield* logging
482 .createProjectsLocationsBuckets({
483 parent: `projects/${env.project}/locations/${location}`,
484 bucketId,
485 body: toCreateBody(news, desiredDescription),
486 })
487 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
488 current = created ?? undefined;
489 if (
490 current !== undefined &&
491 current.lifecycleState === "DELETE_REQUESTED"
492 ) {
493 yield* undelete(current.name ?? name);
494 current = yield* waitUntilActive(current.name ?? name);
495 } else if (current !== undefined && isPending(current.lifecycleState)) {
496 current = yield* waitUntilActive(current.name ?? name);
497 }
498 }
499
500 if (isDeleted(current)) {
501 return yield* new LogBucketNotResolved({ name });
502 }
503
504 const desiredLocked = news.locked === true;
505 const desiredAnalytics = news.analyticsEnabled === true;
506 const descriptionChanged =
507 (current.description ?? "") !== desiredDescription;
508 const retentionChanged =
509 news.retentionDays !== undefined &&
510 current.locked !== true &&
511 (current.retentionDays ?? DEFAULT_RETENTION_DAYS) !==
512 news.retentionDays;
513 const restrictedChanged =
514 news.restrictedFields !== undefined &&
515 !jsonEqual(
516 canonRestricted(current.restrictedFields),
517 canonRestricted(news.restrictedFields),
518 );
519 const indexChanged =
520 news.indexConfigs !== undefined &&
521 !jsonEqual(
522 canonIndexConfigs(current.indexConfigs),
523 canonIndexConfigs(news.indexConfigs),
524 );
525 const analyticsChanged =
526 news.analyticsEnabled !== undefined &&
527 desiredAnalytics &&
528 current.analyticsEnabled !== true;
529 const cmekChanged =
530 news.cmekSettings !== undefined &&
531 (current.cmekSettings?.kmsKeyName ?? "") !==
532 news.cmekSettings.kmsKeyName;
533 const lockedChanged =
534 news.locked !== undefined && desiredLocked && current.locked !== true;
535
536 const syncMask = [
537 descriptionChanged ? "description" : undefined,
538 retentionChanged ? "retentionDays" : undefined,
539 restrictedChanged ? "restrictedFields" : undefined,
540 indexChanged ? "indexConfigs" : undefined,
541 analyticsChanged ? "analyticsEnabled" : undefined,
542 cmekChanged ? "cmekSettings" : undefined,
543 ].filter((field): field is string => field !== undefined);
544
545 if (syncMask.length > 0) {
546 const body: logging.LogBucket = {};
547 if (descriptionChanged) body.description = desiredDescription;
548 if (retentionChanged) body.retentionDays = news.retentionDays;
549 if (restrictedChanged) {
550 body.restrictedFields = [...(news.restrictedFields ?? [])];
551 }
552 if (indexChanged) {
553 body.indexConfigs = (news.indexConfigs ?? []).map((config) => ({
554 fieldPath: config.fieldPath,
555 type: config.type,
556 }));
557 }
558 if (analyticsChanged) body.analyticsEnabled = true;
559 if (cmekChanged) {
560 body.cmekSettings = { kmsKeyName: news.cmekSettings?.kmsKeyName };
561 }
562 current = yield* logging.patchProjectsLocationsBuckets({
563 name: current.name ?? name,
564 updateMask: syncMask.join(","),
565 body,
566 });
567 if (isPending(current.lifecycleState)) {
568 current = yield* waitUntilActive(current.name ?? name);
569 }
570 }
571
572 if (lockedChanged) {
573 current = yield* logging.patchProjectsLocationsBuckets({
574 name: current.name ?? name,
575 updateMask: "locked",
576 body: { locked: true },
577 });
578 if (isPending(current.lifecycleState)) {
579 current = yield* waitUntilActive(current.name ?? name);
580 }
581 }
582
583 return toAttrs(current, env.project, location);
584 }),
585
586 delete: Effect.fn(function* ({ output }) {
587 const name = output.name;
588 if (!name) return;
589
590 yield* getByName(name).pipe(
591 Effect.flatMap((bucket) => {
592 if (isDeleted(bucket)) return Effect.void;
593 if (bucket !== undefined && isPending(bucket.lifecycleState)) {
594 return Effect.fail(new LogBucketNotResolved({ name }));
595 }
596 return Effect.void;
597 }),
598 Effect.retry({
599 while: (error) => error._tag === "GCP.Logging.LogBucketNotResolved",
600 times: 10,
601 schedule: Schedule.spaced("2 seconds"),
602 }),
603 );
604
605 const live = yield* getByName(name);
606 if (isDeleted(live)) return;
607
608 const ignoreIfGone = (error: { readonly _tag: "BadRequest" }) =>
609 getByName(name).pipe(
610 Effect.flatMap((bucket) =>
611 isDeleted(bucket) ? Effect.void : Effect.fail(error),
612 ),
613 );
614
615 const deleteBucket = logging
616 .deleteProjectsLocationsBuckets({ name })
617 .pipe(
618 Effect.catchTag("NotFound", () => Effect.void),
619 Effect.catchTag("BadRequest", ignoreIfGone),
620 );
621
622 yield* deleteBucket.pipe(
623 Effect.catchTag("BadRequest", () =>
624 Effect.gen(function* () {
625 const links = yield* logging.listProjectsLocationsBucketsLinks
626 .pages({
627 parent: name,
628 pageSize: 100,
629 })
630 .pipe(
631 Stream.flatMap((page) => Stream.fromIterable(page.links ?? [])),
632 Stream.runCollect,
633 Effect.map((chunk) => Array.from(chunk)),
634 Effect.catchTag("NotFound", () =>
635 Effect.succeed([] as logging.Link[]),
636 ),
637 );
638 yield* Effect.forEach(
639 links,
640 (link) =>
641 link.name
642 ? logging
643 .deleteProjectsLocationsBucketsLinks({
644 name: link.name,
645 })
646 .pipe(
647 Effect.flatMap((operation) =>
648 waitForDeleteOperation(operation),
649 ),
650 Effect.catchTag(
651 ["NotFound", "BadRequest"],
652 () => Effect.void,
653 ),
654 )
655 : Effect.void,
656 { concurrency: 4 },
657 );
658 yield* deleteBucket.pipe(
659 Effect.retry({
660 while: (error) => error._tag === "BadRequest",
661 times: 8,
662 schedule: Schedule.spaced("2 seconds"),
663 }),
664 Effect.catchTag("BadRequest", ignoreIfGone),
665 );
666 }),
667 ),
668 );
669 yield* waitUntilDeleted(name);
670 }),
671 });