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