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