1import * as logging from "@distilled.cloud/gcp/logging_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.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 { listProjectBuckets } from "./operations.ts";
14import {
15 encodeDescription,
16 hasOwnershipMarker,
17 lastSegment,
18 parseDescription,
19} from "./ownership.ts";
20
21const MAX_NAME_LENGTH = 100;
22const DEFAULT_LOCATION = "global";
23
24export type BucketsViewProps = {
25 /**
26 * Parent log bucket. Full name
27 * `projects/{project}/locations/{location}/buckets/{bucket}` or the
28 * bucket id (combined with `location`). Immutable — changing it
29 * replaces the view.
30 */
31 bucket: string;
32 /**
33 * View id (the `{view}` segment of
34 * `.../buckets/{bucket}/views/{view}`). If omitted, a unique name is
35 * generated from the stack, stage, and logical id. Limited to 100
36 * characters: letters, digits, underscores, hyphens. Immutable —
37 * changing it replaces the view.
38 */
39 viewId?: string;
40 /**
41 * Bucket location used when `bucket` is a bare id. Immutable — changing
42 * it replaces the view.
43 * @default "global"
44 */
45 location?: string;
46 /**
47 * Filter restricting which log entries in the bucket are visible.
48 * Must be a conjunction of `SOURCE()`, `resource.type`, and
49 * `LOG_ID()` (and their `NOT` negations). Empty shows every entry.
50 */
51 filter?: string;
52 /**
53 * Human-readable description. Log views have no labels field, so
54 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
55 * from attributes.
56 */
57 description?: string;
58};
59
60export type BucketsView = Resource<
61 "GCP.Logging.BucketsView",
62 BucketsViewProps,
63 {
64 /** Full resource name `projects/{project}/locations/{location}/buckets/{bucket}/views/{viewId}`. */
65 name: string;
66 /** View id (last path segment). */
67 viewId: string;
68 /** Parent log bucket resource name. */
69 bucket: string;
70 /** Bucket id. */
71 bucketId: string;
72 /** Project id. */
73 project: string;
74 /** Location of the parent bucket. */
75 location: string;
76 /** View filter, if set. */
77 filter: string | undefined;
78 /** User description with the Alchemy ownership prefix stripped. */
79 description: string | undefined;
80 /** RFC3339 creation timestamp. */
81 createTime: string | undefined;
82 /** RFC3339 last-update timestamp. */
83 updateTime: string | undefined;
84 },
85 never,
86 Providers
87>;
88
89/**
90 * A Cloud Logging view over log entries in a log bucket.
91 *
92 * A bucket may contain at most 30 views. Log views have no labels field,
93 * so Alchemy stamps ownership into the description for `list` / nuke.
94 * `viewId` and the parent bucket are identity — changing either replaces
95 * the view. Filter and description update in place.
96 *
97 * ### Creating a View
98 * **Example:** Generated name over a custom bucket
99 * ```typescript
100 * const bucket = yield* GCP.Logging.LogBucket("AppLogs", {
101 * description: "application logs",
102 * });
103 * const view = yield* GCP.Logging.BucketsView("Stdout", {
104 * bucket: bucket.name,
105 * filter: 'LOG_ID("stdout")',
106 * description: "stdout only",
107 * });
108 * ```
109 *
110 * **Example:** Named view
111 * ```typescript
112 * const view = yield* GCP.Logging.BucketsView("Stdout", {
113 * bucket: bucket.name,
114 * viewId: "stdout",
115 * filter: 'LOG_ID("stdout")',
116 * });
117 * ```
118 *
119 * ### Updating a View
120 * **Example:** Change the filter
121 * ```typescript
122 * const view = yield* GCP.Logging.BucketsView("Stdout", {
123 * bucket: bucket.name,
124 * filter: 'LOG_ID("stderr")',
125 * description: "stderr only",
126 * });
127 * ```
128 *
129 * @resource
130 * @category Logging
131 */
132export const BucketsView = Resource<BucketsView>("GCP.Logging.BucketsView");
133
134export class BucketsViewNotResolved extends Data.TaggedError(
135 "GCP.Logging.BucketsViewNotResolved",
136)<{
137 name: string;
138}> {}
139
140const parseViewName = (name: string) => {
141 const match = name.match(
142 /^projects\/([^/]+)\/locations\/([^/]+)\/buckets\/([^/]+)\/views\/([^/]+)$/,
143 );
144 if (!match) return undefined;
145 return {
146 project: match[1]!,
147 location: match[2]!,
148 bucketId: match[3]!,
149 viewId: match[4]!,
150 };
151};
152
153const parseBucket = (bucket: string, project: string, location: string) => {
154 const match = bucket.match(
155 /^(projects\/([^/]+)\/locations\/([^/]+)\/buckets\/([^/]+))$/,
156 );
157 if (match) {
158 return {
159 name: match[1]!,
160 project: match[2]!,
161 location: match[3]!,
162 bucketId: match[4]!,
163 };
164 }
165 return {
166 name: `projects/${project}/locations/${location}/buckets/${bucket}`,
167 project,
168 location,
169 bucketId: lastSegment(bucket),
170 };
171};
172
173const resourceName = (
174 project: string,
175 location: string,
176 bucketId: string,
177 viewId: string,
178) =>
179 `projects/${project}/locations/${location}/buckets/${bucketId}/views/${viewId}`;
180
181const viewIdOf = (view: logging.LogView, fallback?: string) => {
182 const parsed = parseViewName(view.name ?? "");
183 return parsed?.viewId ?? fallback ?? lastSegment(view.name ?? "");
184};
185
186const toId = (id: string, viewId: string | undefined, existing?: string) =>
187 Effect.gen(function* () {
188 if (viewId !== undefined) return viewId;
189 if (existing !== undefined) return existing;
190 const generated = yield* createPhysicalName({
191 id,
192 maxLength: MAX_NAME_LENGTH,
193 lowercase: true,
194 });
195 return /^[a-z0-9]/.test(generated)
196 ? generated
197 : `v${generated}`.slice(0, MAX_NAME_LENGTH);
198 });
199
200const toAttrs = (
201 view: logging.LogView,
202 project: string,
203 location: string,
204 bucketId: string,
205) => {
206 const viewId = viewIdOf(view);
207 const parsed = parseDescription(view.description);
208 const parsedName = parseViewName(view.name ?? "");
209 const resolvedLocation = parsedName?.location ?? location;
210 const resolvedBucketId = parsedName?.bucketId ?? bucketId;
211 const resolvedProject = parsedName?.project ?? project;
212 return {
213 name:
214 view.name ??
215 (viewId
216 ? resourceName(
217 resolvedProject,
218 resolvedLocation,
219 resolvedBucketId,
220 viewId,
221 )
222 : ""),
223 viewId,
224 bucket: `projects/${resolvedProject}/locations/${resolvedLocation}/buckets/${resolvedBucketId}`,
225 bucketId: resolvedBucketId,
226 project: resolvedProject,
227 location: resolvedLocation,
228 filter: view.filter,
229 description: parsed.description,
230 createTime: view.createTime,
231 updateTime: view.updateTime,
232 };
233};
234
235const getByName = (name: string) =>
236 logging
237 .getProjectsLocationsBucketsViews({ name })
238 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
239
240export const BucketsViewProvider = () =>
241 Provider.succeed(BucketsView, {
242 stables: ["name", "viewId", "bucket", "bucketId", "project", "location"],
243
244 diff: Effect.fn(function* ({ news, olds, output }) {
245 if (!isResolved(news)) return undefined;
246 const previousId = olds?.viewId ?? output?.viewId;
247 const idChanged =
248 previousId !== undefined &&
249 news.viewId !== undefined &&
250 news.viewId !== previousId;
251 const previousBucket = olds?.bucket ?? output?.bucket;
252 const bucketChanged =
253 previousBucket !== undefined &&
254 news.bucket.includes("/buckets/") &&
255 news.bucket !== previousBucket;
256 if (!idChanged && !bucketChanged) return undefined;
257 return { action: "replace" as const, deleteFirst: false };
258 }),
259
260 read: Effect.fn(function* ({ id, olds, output }) {
261 const env = yield* GcpEnvironment.current;
262 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
263 const parent = parseBucket(
264 olds?.bucket ?? output?.bucket ?? "",
265 env.project,
266 location,
267 );
268 const viewId = yield* toId(id, olds?.viewId, output?.viewId);
269 const name =
270 output?.name ??
271 resourceName(parent.project, parent.location, parent.bucketId, viewId);
272 const existing = yield* getByName(name);
273 if (existing === undefined) return undefined;
274 const attrs = toAttrs(
275 existing,
276 parent.project,
277 parent.location,
278 parent.bucketId,
279 );
280 const { labels } = parseDescription(existing.description);
281 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
282 }),
283
284 list: () =>
285 Effect.gen(function* () {
286 const env = yield* GcpEnvironment.current;
287 const buckets = yield* listProjectBuckets();
288 const pages = yield* Effect.forEach(
289 buckets,
290 (bucket) =>
291 logging.listProjectsLocationsBucketsViews
292 .pages({
293 parent: bucket.name!,
294 pageSize: 1000,
295 })
296 .pipe(
297 Stream.flatMap((page) => Stream.fromIterable(page.views ?? [])),
298 Stream.filter((view) => hasOwnershipMarker(view.description)),
299 Stream.map((view) => {
300 const parsed = parseViewName(view.name ?? "");
301 return toAttrs(
302 view,
303 parsed?.project ?? env.project,
304 parsed?.location ?? DEFAULT_LOCATION,
305 parsed?.bucketId ?? lastSegment(bucket.name ?? ""),
306 );
307 }),
308 Stream.runCollect,
309 Effect.map((chunk) => Array.from(chunk)),
310 Effect.catchTag("NotFound", () =>
311 Effect.succeed([] as ReturnType<typeof toAttrs>[]),
312 ),
313 ),
314 { concurrency: 4 },
315 );
316 return pages.flat();
317 }),
318
319 reconcile: Effect.fn(function* ({ id, news, output }) {
320 const env = yield* GcpEnvironment.current;
321 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
322 const parent = parseBucket(news.bucket, env.project, location);
323 const viewId = yield* toId(id, news.viewId, output?.viewId);
324 const name = resourceName(
325 parent.project,
326 parent.location,
327 parent.bucketId,
328 viewId,
329 );
330 const ownership = yield* createInternalLabels(id);
331 const desiredDescription = encodeDescription(ownership, news.description);
332
333 let current = yield* getByName(output?.name ?? name);
334
335 if (current === undefined) {
336 const created = yield* logging
337 .createProjectsLocationsBucketsViews({
338 parent: parent.name,
339 viewId,
340 body: {
341 filter: news.filter,
342 description: desiredDescription,
343 },
344 })
345 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
346 current = created ?? undefined;
347 }
348
349 if (current === undefined) {
350 return yield* new BucketsViewNotResolved({ name });
351 }
352
353 const filterChanged = (current.filter ?? "") !== (news.filter ?? "");
354 const descriptionChanged =
355 (current.description ?? "") !== desiredDescription;
356 const updateMask = [
357 filterChanged ? "filter" : undefined,
358 descriptionChanged ? "description" : undefined,
359 ].filter((field): field is string => field !== undefined);
360
361 if (updateMask.length > 0) {
362 current = yield* logging.patchProjectsLocationsBucketsViews({
363 name: current.name ?? name,
364 updateMask: updateMask.join(","),
365 body: {
366 filter: news.filter ?? "",
367 description: desiredDescription,
368 },
369 });
370 }
371
372 return toAttrs(current, parent.project, parent.location, parent.bucketId);
373 }),
374
375 delete: Effect.fn(function* ({ output }) {
376 yield* logging
377 .deleteProjectsLocationsBucketsViews({ name: output.name })
378 .pipe(Effect.catchTag("NotFound", () => Effect.void));
379 }),
380 });