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 toPhysicalId,
29 type LogBucketIndexConfig,
30} from "./internal.ts";
31
32export type LocationsBucketIndexConfig = LogBucketIndexConfig;
33
34export type LocationsBucketCmekSettings = {
35 /**
36 * Cloud KMS key used to encrypt new log entries
37 * (`projects/{project}/locations/{location}/keyRings/{ring}/cryptoKeys/{key}`).
38 * Once set, CMEK cannot be disabled; the key may be rotated.
39 */
40 kmsKeyName: string;
41};
42
43export type LocationsBucketProps = {
44 /**
45 * Parent resource (`projects/{project}`, `folders/{folder}`,
46 * `organizations/{organization}`, or `billingAccounts/{account}`).
47 * Defaults to the stack project. Immutable — changing it replaces the
48 * bucket.
49 */
50 parent?: 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?: LocationsBucketIndexConfig[];
99 /**
100 * Customer-managed encryption for new log entries. Once configured,
101 * CMEK cannot be disabled.
102 */
103 cmekSettings?: LocationsBucketCmekSettings;
104};
105
106export type LocationsBucket = Resource<
107 "GCP.Logging.LocationsBucket",
108 LocationsBucketProps,
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 (`projects/{project}`, `folders/{folder}`, …). */
115 parent: string;
116 /** Location that stores log entries. */
117 location: string;
118 /** User description with the Alchemy ownership prefix stripped. */
119 description: string | undefined;
120 /** Retention period in days. */
121 retentionDays: number;
122 /** Whether the bucket is locked. */
123 locked: boolean;
124 /** Whether Log Analytics is enabled. */
125 analyticsEnabled: boolean;
126 /** Restricted field paths. */
127 restrictedFields: string[];
128 /** Custom index configs. */
129 indexConfigs: LocationsBucketIndexConfig[];
130 /** CMEK key name, if configured. */
131 cmekSettings: LocationsBucketCmekSettings | undefined;
132 /** Bucket lifecycle (`ACTIVE`, `DELETE_REQUESTED`, …). */
133 lifecycleState: string | undefined;
134 /** RFC3339 creation timestamp. Unset on GCP default buckets. */
135 createTime: string | undefined;
136 /** RFC3339 last-update timestamp. */
137 updateTime: string | undefined;
138 },
139 never,
140 Providers
141>;
142
143/**
144 * A Cloud Logging log bucket addressed through the generic locations APIs.
145 *
146 * Uses `createLocationsBuckets` / `getLocationsBuckets` / `deleteLocationsBuckets`.
147 * Log buckets have no labels field, so Alchemy stamps ownership into the
148 * description for `list` / nuke. `parent`, `bucketId`, and `location` are
149 * identity — changing any of them replaces the bucket. Delete marks the
150 * bucket `DELETE_REQUESTED`; GCP purges it after 7 days.
151 *
152 * ### Creating a Locations Bucket
153 * **Example:** Generated name in `global`
154 * ```typescript
155 * const bucket = yield* GCP.Logging.LocationsBucket("AppLogs", {
156 * description: "application logs",
157 * });
158 * ```
159 *
160 * **Example:** Named bucket with custom retention
161 * ```typescript
162 * const bucket = yield* GCP.Logging.LocationsBucket("AppLogs", {
163 * bucketId: "app-logs",
164 * location: "global",
165 * description: "application logs",
166 * retentionDays: 31,
167 * });
168 * ```
169 *
170 * ### Updating a Locations Bucket
171 * **Example:** Change description and retention
172 * ```typescript
173 * const bucket = yield* GCP.Logging.LocationsBucket("AppLogs", {
174 * location: "global",
175 * description: "retained application logs",
176 * retentionDays: 60,
177 * });
178 * ```
179 *
180 * @resource
181 * @category Logging
182 */
183export const LocationsBucket = Resource<LocationsBucket>(
184 "GCP.Logging.LocationsBucket",
185);
186
187export class LocationsBucketNotResolved extends Data.TaggedError(
188 "GCP.Logging.LocationsBucketNotResolved",
189)<{
190 name: string;
191}> {}
192
193export class LocationsBucketFailed extends Data.TaggedError(
194 "GCP.Logging.LocationsBucketFailed",
195)<{
196 name: string;
197 state: string | undefined;
198}> {}
199
200const resourceName = (parent: string, location: string, bucketId: string) =>
201 `${locationParent(parent, location)}/buckets/${bucketId}`;
202
203const normalizeParent = (project: string, parent: string | undefined) => {
204 if (parent === undefined || parent.length === 0) {
205 return `projects/${project}`;
206 }
207 if (
208 parent.startsWith("projects/") ||
209 parent.startsWith("folders/") ||
210 parent.startsWith("organizations/") ||
211 parent.startsWith("billingAccounts/")
212 ) {
213 return parent.split("/").slice(0, 2).join("/");
214 }
215 return `projects/${parent}`;
216};
217
218const toAttrs = (
219 bucket: logging.LogBucket,
220 parent: string,
221 location: string,
222) => {
223 const parsed = parseLoggingName(bucket.name ?? "");
224 const bucketId = parsed.bucketId ?? lastSegment(bucket.name ?? "");
225 const resolvedParent = parsed.parent || parent;
226 const resolvedLocation = parsed.location ?? location;
227 const description = parseDescription(bucket.description);
228 const cmekKey = bucket.cmekSettings?.kmsKeyName;
229 return {
230 name:
231 bucket.name ??
232 (bucketId
233 ? resourceName(resolvedParent, resolvedLocation, bucketId)
234 : ""),
235 bucketId,
236 parent: resolvedParent,
237 location: resolvedLocation,
238 description: description.description,
239 retentionDays: bucket.retentionDays ?? DEFAULT_RETENTION_DAYS,
240 locked: bucket.locked === true,
241 analyticsEnabled: bucket.analyticsEnabled === true,
242 restrictedFields: [...(bucket.restrictedFields ?? [])],
243 indexConfigs: canonIndexConfigs(bucket.indexConfigs),
244 cmekSettings: cmekKey ? { kmsKeyName: cmekKey } : undefined,
245 lifecycleState: bucket.lifecycleState,
246 createTime: bucket.createTime,
247 updateTime: bucket.updateTime,
248 };
249};
250
251const getByName = (name: string) =>
252 logging
253 .getLocationsBuckets({ name })
254 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
255
256const undelete = (name: string) =>
257 logging
258 .undeleteLocationsBuckets({ name, body: {} })
259 .pipe(Effect.catchTag(["NotFound", "Conflict"], () => Effect.void));
260
261const waitUntilActive = (name: string) =>
262 Effect.gen(function* () {
263 const bucket = yield* getByName(name);
264 if (bucket === undefined || isDeletedBucket(bucket)) {
265 return yield* new LocationsBucketNotResolved({ name });
266 }
267 if (bucket.lifecycleState === "FAILED") {
268 return yield* new LocationsBucketFailed({
269 name,
270 state: bucket.lifecycleState,
271 });
272 }
273 if (isPendingBucket(bucket.lifecycleState)) {
274 return yield* new LocationsBucketNotResolved({ name });
275 }
276 return bucket;
277 }).pipe(
278 Effect.retry({
279 while: (error) => error._tag === "GCP.Logging.LocationsBucketNotResolved",
280 times: 8,
281 schedule: Schedule.spaced("1 second"),
282 }),
283 );
284
285const waitUntilDeleted = (name: string) =>
286 getByName(name).pipe(
287 Effect.flatMap((bucket) =>
288 isDeletedBucket(bucket)
289 ? Effect.void
290 : Effect.fail(new LocationsBucketNotResolved({ name })),
291 ),
292 Effect.retry({
293 while: (error) => error._tag === "GCP.Logging.LocationsBucketNotResolved",
294 times: 8,
295 schedule: Schedule.spaced("1 second"),
296 }),
297 );
298
299const toCreateBody = (
300 props: LocationsBucketProps,
301 description: string,
302): logging.LogBucket => ({
303 description,
304 retentionDays: props.retentionDays,
305 locked: props.locked === true ? true : undefined,
306 analyticsEnabled: props.analyticsEnabled === true ? true : undefined,
307 restrictedFields:
308 props.restrictedFields && props.restrictedFields.length > 0
309 ? [...props.restrictedFields]
310 : undefined,
311 indexConfigs:
312 props.indexConfigs && props.indexConfigs.length > 0
313 ? props.indexConfigs.map((config) => ({
314 fieldPath: config.fieldPath,
315 type: config.type,
316 }))
317 : undefined,
318 cmekSettings: props.cmekSettings
319 ? { kmsKeyName: props.cmekSettings.kmsKeyName }
320 : undefined,
321});
322
323export const LocationsBucketProvider = () =>
324 Provider.succeed(LocationsBucket, {
325 stables: ["name", "bucketId", "parent", "location", "createTime"],
326
327 diff: Effect.fn(function* ({ news, olds, output }) {
328 if (!isResolved(news)) return undefined;
329 const previousId = olds?.bucketId ?? output?.bucketId;
330 const idChanged =
331 previousId !== undefined &&
332 news.bucketId !== undefined &&
333 news.bucketId !== previousId;
334 const previousLocation = olds?.location ?? output?.location;
335 const locationChanged =
336 previousLocation !== undefined &&
337 news.location !== undefined &&
338 news.location !== previousLocation;
339 const previousParent = olds?.parent ?? output?.parent;
340 const parentChanged =
341 news.parent !== undefined &&
342 previousParent !== undefined &&
343 news.parent !== previousParent;
344 if (!idChanged && !locationChanged && !parentChanged) return undefined;
345 return { action: "replace" as const, deleteFirst: false };
346 }),
347
348 read: Effect.fn(function* ({ id, olds, output }) {
349 const env = yield* GcpEnvironment.current;
350 const parent = normalizeParent(
351 env.project,
352 olds?.parent ?? output?.parent,
353 );
354 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
355 const bucketId = yield* toPhysicalId(
356 id,
357 olds?.bucketId,
358 output?.bucketId,
359 "b",
360 );
361 const name = output?.name ?? resourceName(parent, location, bucketId);
362 const existing = yield* getByName(name);
363 if (isDeletedBucket(existing)) return undefined;
364 const attrs = toAttrs(existing, parent, location);
365 const { labels } = parseDescription(existing.description);
366 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
367 }),
368
369 list: () =>
370 Effect.gen(function* () {
371 const env = yield* GcpEnvironment.current;
372 return yield* logging.listLocationsBuckets
373 .pages({
374 parent: `projects/${env.project}/locations/-`,
375 pageSize: 1000,
376 })
377 .pipe(
378 Stream.flatMap((page) => Stream.fromIterable(page.buckets ?? [])),
379 Stream.filter(
380 (bucket) =>
381 !isDeletedBucket(bucket) &&
382 hasOwnershipMarker(bucket.description),
383 ),
384 Stream.map((bucket) =>
385 toAttrs(
386 bucket,
387 parseLoggingName(bucket.name ?? "").parent ||
388 `projects/${env.project}`,
389 parseLoggingName(bucket.name ?? "").location ??
390 DEFAULT_LOCATION,
391 ),
392 ),
393 Stream.runCollect,
394 Effect.map((chunk) => Array.from(chunk)),
395 );
396 }),
397
398 reconcile: Effect.fn(function* ({ id, news, output }) {
399 const env = yield* GcpEnvironment.current;
400 const parent = normalizeParent(
401 env.project,
402 news.parent ?? output?.parent,
403 );
404 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
405 const bucketId = yield* toPhysicalId(
406 id,
407 news.bucketId,
408 output?.bucketId,
409 "b",
410 );
411 const name = resourceName(parent, location, bucketId);
412 const ownership = yield* createOwnership(id);
413 const desiredDescription = encodeDescription(ownership, news.description);
414
415 let current = yield* getByName(output?.name ?? name);
416
417 if (
418 current !== undefined &&
419 current.lifecycleState === "DELETE_REQUESTED"
420 ) {
421 yield* undelete(current.name ?? name);
422 current = yield* waitUntilActive(current.name ?? name);
423 }
424
425 if (current === undefined) {
426 const created = yield* logging
427 .createLocationsBuckets({
428 parent: locationParent(parent, location),
429 bucketId,
430 body: toCreateBody(news, desiredDescription),
431 })
432 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
433 current = created ?? undefined;
434 if (
435 current !== undefined &&
436 current.lifecycleState === "DELETE_REQUESTED"
437 ) {
438 yield* undelete(current.name ?? name);
439 current = yield* waitUntilActive(current.name ?? name);
440 } else if (
441 current !== undefined &&
442 isPendingBucket(current.lifecycleState)
443 ) {
444 current = yield* waitUntilActive(current.name ?? name);
445 }
446 }
447
448 if (isDeletedBucket(current)) {
449 return yield* new LocationsBucketNotResolved({ name });
450 }
451
452 const desiredLocked = news.locked === true;
453 const desiredAnalytics = news.analyticsEnabled === true;
454 const descriptionChanged =
455 (current.description ?? "") !== desiredDescription;
456 const retentionChanged =
457 news.retentionDays !== undefined &&
458 current.locked !== true &&
459 (current.retentionDays ?? DEFAULT_RETENTION_DAYS) !==
460 news.retentionDays;
461 const restrictedChanged =
462 news.restrictedFields !== undefined &&
463 !jsonEqual(
464 canonRestricted(current.restrictedFields),
465 canonRestricted(news.restrictedFields),
466 );
467 const indexChanged =
468 news.indexConfigs !== undefined &&
469 !jsonEqual(
470 canonIndexConfigs(current.indexConfigs),
471 canonIndexConfigs(news.indexConfigs),
472 );
473 const analyticsChanged =
474 news.analyticsEnabled !== undefined &&
475 desiredAnalytics &&
476 current.analyticsEnabled !== true;
477 const cmekChanged =
478 news.cmekSettings !== undefined &&
479 (current.cmekSettings?.kmsKeyName ?? "") !==
480 news.cmekSettings.kmsKeyName;
481 const lockedChanged =
482 news.locked !== undefined && desiredLocked && current.locked !== true;
483
484 const syncMask = [
485 descriptionChanged ? "description" : undefined,
486 retentionChanged ? "retentionDays" : undefined,
487 restrictedChanged ? "restrictedFields" : undefined,
488 indexChanged ? "indexConfigs" : undefined,
489 analyticsChanged ? "analyticsEnabled" : undefined,
490 cmekChanged ? "cmekSettings" : undefined,
491 ].filter((field): field is string => field !== undefined);
492
493 if (syncMask.length > 0) {
494 const body: logging.LogBucket = {};
495 if (descriptionChanged) body.description = desiredDescription;
496 if (retentionChanged) body.retentionDays = news.retentionDays;
497 if (restrictedChanged) {
498 body.restrictedFields = [...(news.restrictedFields ?? [])];
499 }
500 if (indexChanged) {
501 body.indexConfigs = (news.indexConfigs ?? []).map((config) => ({
502 fieldPath: config.fieldPath,
503 type: config.type,
504 }));
505 }
506 if (analyticsChanged) body.analyticsEnabled = true;
507 if (cmekChanged) {
508 body.cmekSettings = { kmsKeyName: news.cmekSettings?.kmsKeyName };
509 }
510 current = yield* logging.patchLocationsBuckets({
511 name: current.name ?? name,
512 updateMask: syncMask.join(","),
513 body,
514 });
515 if (isPendingBucket(current.lifecycleState)) {
516 current = yield* waitUntilActive(current.name ?? name);
517 }
518 }
519
520 if (lockedChanged) {
521 current = yield* logging.patchLocationsBuckets({
522 name: current.name ?? name,
523 updateMask: "locked",
524 body: { locked: true },
525 });
526 if (isPendingBucket(current.lifecycleState)) {
527 current = yield* waitUntilActive(current.name ?? name);
528 }
529 }
530
531 return toAttrs(current, parent, location);
532 }),
533
534 delete: Effect.fn(function* ({ output }) {
535 if (output.lifecycleState === "DELETE_REQUESTED") return;
536 yield* logging.deleteLocationsBuckets({ name: output.name }).pipe(
537 Effect.catchTag("NotFound", () => Effect.void),
538 Effect.retry({
539 while: (error) =>
540 error._tag === "BadRequest" || error._tag === "Conflict",
541 times: 10,
542 schedule: Schedule.spaced("3 seconds"),
543 }),
544 );
545 yield* waitUntilDeleted(output.name);
546 }),
547 });