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 * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 createOwnership,
13 encodeDescription,
14 hasOwnershipMarker,
15 lastSegment,
16 ownedBy,
17 parseDescription,
18 parseLoggingName,
19 toPhysicalId,
20} from "./internal.ts";
21
22export type FolderBucketsViewProps = {
23 /**
24 * Full resource name of the parent log bucket
25 * (`{parent}/locations/{location}/buckets/{bucket}`). Immutable —
26 * changing it replaces the view.
27 */
28 bucketName: string;
29 /**
30 * View id (the `{view}` segment of `{bucket}/views/{view}`). If omitted,
31 * a unique name is generated from the stack, stage, and logical id.
32 * Limited to 100 characters: letters, digits, underscores, hyphens.
33 * Immutable — changing it replaces the view.
34 */
35 viewId?: string;
36 /**
37 * Filter that restricts which log entries in the bucket are visible.
38 * Must be a logical conjunction of `SOURCE()`, `resource.type`, and
39 * `LOG_ID()`, optionally negated with `NOT`.
40 */
41 filter?: string;
42 /**
43 * Human-readable description. Log views have no labels field, so
44 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
45 * from attributes.
46 */
47 description?: string;
48};
49
50export type FolderBucketsView = Resource<
51 "GCP.Logging.FolderBucketsView",
52 FolderBucketsViewProps,
53 {
54 /** Full resource name `{bucket}/views/{viewId}`. */
55 name: string;
56 /** View id (last path segment). */
57 viewId: string;
58 /** Parent bucket resource name. */
59 bucketName: string;
60 /** View filter, if set. */
61 filter: string | undefined;
62 /** User description with the Alchemy ownership prefix stripped. */
63 description: string | undefined;
64 /** RFC3339 creation timestamp. */
65 createTime: string | undefined;
66 /** RFC3339 last-update timestamp. */
67 updateTime: string | undefined;
68 },
69 never,
70 Providers
71>;
72
73/**
74 * A Cloud Logging view over log entries in a folder (or project) log bucket.
75 *
76 * A bucket may contain at most 30 views. Views have no labels field, so
77 * Alchemy stamps ownership into the description for `list` / nuke. `viewId`
78 * and `bucketName` are identity — changing either replaces the view.
79 *
80 * ### Creating a Folder Bucket View
81 * **Example:** View over error entries
82 * ```typescript
83 * const bucket = yield* GCP.Logging.FolderBucket("AppLogs", {});
84 * const view = yield* GCP.Logging.FolderBucketsView("Errors", {
85 * bucketName: bucket.name,
86 * filter: "severity>=ERROR",
87 * description: "errors only",
88 * });
89 * ```
90 *
91 * ### Updating a Folder Bucket View
92 * **Example:** Change the filter
93 * ```typescript
94 * const view = yield* GCP.Logging.FolderBucketsView("Errors", {
95 * bucketName: bucket.name,
96 * filter: "severity>=WARNING",
97 * description: "warnings and errors",
98 * });
99 * ```
100 *
101 * @resource
102 * @category Logging
103 */
104export const FolderBucketsView = Resource<FolderBucketsView>(
105 "GCP.Logging.FolderBucketsView",
106);
107
108export class FolderBucketsViewNotResolved extends Data.TaggedError(
109 "GCP.Logging.FolderBucketsViewNotResolved",
110)<{
111 name: string;
112}> {}
113
114const resourceName = (bucketName: string, viewId: string) =>
115 `${bucketName}/views/${viewId}`;
116
117const toAttrs = (view: logging.LogView, bucketName: string) => {
118 const parsed = parseLoggingName(view.name ?? "");
119 const viewId = parsed.viewId ?? lastSegment(view.name ?? "");
120 const description = parseDescription(view.description);
121 return {
122 name: view.name ?? resourceName(bucketName, viewId),
123 viewId,
124 bucketName,
125 filter: view.filter,
126 description: description.description,
127 createTime: view.createTime,
128 updateTime: view.updateTime,
129 };
130};
131
132const getByName = (name: string) =>
133 logging
134 .getFoldersLocationsBucketsViews({ name })
135 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
136
137export const FolderBucketsViewProvider = () =>
138 Provider.succeed(FolderBucketsView, {
139 stables: ["name", "viewId", "bucketName", "createTime"],
140
141 diff: Effect.fn(function* ({ news, olds, output }) {
142 if (!isResolved(news)) return undefined;
143 const previousId = olds?.viewId ?? output?.viewId;
144 const idChanged =
145 previousId !== undefined &&
146 news.viewId !== undefined &&
147 news.viewId !== previousId;
148 const previousBucket = olds?.bucketName ?? output?.bucketName;
149 const bucketChanged =
150 previousBucket !== undefined && news.bucketName !== previousBucket;
151 if (!idChanged && !bucketChanged) return undefined;
152 return { action: "replace" as const, deleteFirst: false };
153 }),
154
155 read: Effect.fn(function* ({ id, olds, output }) {
156 const viewId = yield* toPhysicalId(id, olds?.viewId, output?.viewId, "v");
157 const bucketName = olds?.bucketName ?? output?.bucketName;
158 if (bucketName === undefined) return undefined;
159 const name = output?.name ?? resourceName(bucketName, viewId);
160 const existing = yield* getByName(name);
161 if (existing === undefined) return undefined;
162 const attrs = toAttrs(existing, bucketName);
163 const { labels } = parseDescription(existing.description);
164 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
165 }),
166
167 list: () =>
168 Effect.gen(function* () {
169 const env = yield* GcpEnvironment.current;
170 const buckets = yield* logging.listFoldersLocationsBuckets
171 .pages({
172 parent: `projects/${env.project}/locations/-`,
173 pageSize: 1000,
174 })
175 .pipe(
176 Stream.flatMap((page) => Stream.fromIterable(page.buckets ?? [])),
177 Stream.runCollect,
178 Effect.map((chunk) => Array.from(chunk)),
179 );
180 const views: ReturnType<typeof toAttrs>[] = [];
181 for (const bucket of buckets) {
182 if (bucket.name === undefined) continue;
183 const listed = yield* logging.listFoldersLocationsBucketsViews
184 .pages({
185 parent: bucket.name,
186 pageSize: 1000,
187 })
188 .pipe(
189 Stream.flatMap((page) => Stream.fromIterable(page.views ?? [])),
190 Stream.filter((view) => hasOwnershipMarker(view.description)),
191 Stream.map((view) => toAttrs(view, bucket.name ?? "")),
192 Stream.runCollect,
193 Effect.map((chunk) => Array.from(chunk)),
194 Effect.catchTag("NotFound", () =>
195 Effect.succeed([] as ReturnType<typeof toAttrs>[]),
196 ),
197 );
198 views.push(...listed);
199 }
200 return views;
201 }),
202
203 reconcile: Effect.fn(function* ({ id, news, output }) {
204 const viewId = yield* toPhysicalId(id, news.viewId, output?.viewId, "v");
205 const name = resourceName(news.bucketName, viewId);
206 const ownership = yield* createOwnership(id);
207 const desiredDescription = encodeDescription(ownership, news.description);
208
209 let current = yield* getByName(output?.name ?? name);
210
211 if (current === undefined) {
212 const created = yield* logging
213 .createFoldersLocationsBucketsViews({
214 parent: news.bucketName,
215 viewId,
216 body: {
217 filter: news.filter,
218 description: desiredDescription,
219 },
220 })
221 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
222 current = created ?? undefined;
223 }
224
225 if (current === undefined) {
226 return yield* new FolderBucketsViewNotResolved({ name });
227 }
228
229 const filterChanged = (current.filter ?? "") !== (news.filter ?? "");
230 const descriptionChanged =
231 (current.description ?? "") !== desiredDescription;
232 const updateMask = [
233 filterChanged ? "filter" : undefined,
234 descriptionChanged ? "description" : undefined,
235 ].filter((field): field is string => field !== undefined);
236
237 if (updateMask.length > 0) {
238 current = yield* logging.patchFoldersLocationsBucketsViews({
239 name: current.name ?? name,
240 updateMask: updateMask.join(","),
241 body: {
242 filter: news.filter ?? "",
243 description: desiredDescription,
244 },
245 });
246 }
247
248 return toAttrs(current, news.bucketName);
249 }),
250
251 delete: Effect.fn(function* ({ output }) {
252 yield* logging
253 .deleteFoldersLocationsBucketsViews({ name: output.name })
254 .pipe(Effect.catchTag("NotFound", () => Effect.void));
255 }),
256 });