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 LocationsBucketsViewProps = {
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 LocationsBucketsView = Resource<
51 "GCP.Logging.LocationsBucketsView",
52 LocationsBucketsViewProps,
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 log bucket, managed through
75 * the generic locations APIs.
76 *
77 * A bucket may contain at most 30 views. Views have no labels field, so
78 * Alchemy stamps ownership into the description for `list` / nuke.
79 * `viewId` and `bucketName` are identity — changing either replaces the
80 * view.
81 *
82 * ### Creating a Locations Bucket View
83 * **Example:** View over stdout
84 * ```typescript
85 * const bucket = yield* GCP.Logging.LocationsBucket("AppLogs", {
86 * description: "application logs",
87 * });
88 * const view = yield* GCP.Logging.LocationsBucketsView("Stdout", {
89 * bucketName: bucket.name,
90 * filter: 'LOG_ID("stdout")',
91 * description: "stdout only",
92 * });
93 * ```
94 *
95 * **Example:** Named view
96 * ```typescript
97 * const view = yield* GCP.Logging.LocationsBucketsView("Stdout", {
98 * bucketName: bucket.name,
99 * viewId: "stdout",
100 * filter: 'LOG_ID("stdout")',
101 * });
102 * ```
103 *
104 * ### Updating a Locations Bucket View
105 * **Example:** Change the filter
106 * ```typescript
107 * const view = yield* GCP.Logging.LocationsBucketsView("Stdout", {
108 * bucketName: bucket.name,
109 * filter: 'LOG_ID("stderr")',
110 * description: "stderr only",
111 * });
112 * ```
113 *
114 * @resource
115 * @category Logging
116 */
117export const LocationsBucketsView = Resource<LocationsBucketsView>(
118 "GCP.Logging.LocationsBucketsView",
119);
120
121export class LocationsBucketsViewNotResolved extends Data.TaggedError(
122 "GCP.Logging.LocationsBucketsViewNotResolved",
123)<{
124 name: string;
125}> {}
126
127const resourceName = (bucketName: string, viewId: string) =>
128 `${bucketName}/views/${viewId}`;
129
130const toAttrs = (view: logging.LogView, bucketName: string) => {
131 const parsed = parseLoggingName(view.name ?? "");
132 const viewId = parsed.viewId ?? lastSegment(view.name ?? "");
133 const description = parseDescription(view.description);
134 return {
135 name: view.name ?? resourceName(bucketName, viewId),
136 viewId,
137 bucketName,
138 filter: view.filter,
139 description: description.description,
140 createTime: view.createTime,
141 updateTime: view.updateTime,
142 };
143};
144
145const getByName = (name: string) =>
146 logging
147 .getLocationsBucketsViews({ name })
148 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
149
150export const LocationsBucketsViewProvider = () =>
151 Provider.succeed(LocationsBucketsView, {
152 stables: ["name", "viewId", "bucketName", "createTime"],
153
154 diff: Effect.fn(function* ({ news, olds, output }) {
155 if (!isResolved(news)) return undefined;
156 const previousId = olds?.viewId ?? output?.viewId;
157 const idChanged =
158 previousId !== undefined &&
159 news.viewId !== undefined &&
160 news.viewId !== previousId;
161 const previousBucket = olds?.bucketName ?? output?.bucketName;
162 const bucketChanged =
163 previousBucket !== undefined && news.bucketName !== previousBucket;
164 if (!idChanged && !bucketChanged) return undefined;
165 return { action: "replace" as const, deleteFirst: false };
166 }),
167
168 read: Effect.fn(function* ({ id, olds, output }) {
169 const viewId = yield* toPhysicalId(id, olds?.viewId, output?.viewId, "v");
170 const bucketName = olds?.bucketName ?? output?.bucketName;
171 if (bucketName === undefined) return undefined;
172 const name = output?.name ?? resourceName(bucketName, viewId);
173 const existing = yield* getByName(name);
174 if (existing === undefined) return undefined;
175 const attrs = toAttrs(existing, bucketName);
176 const { labels } = parseDescription(existing.description);
177 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
178 }),
179
180 list: () =>
181 Effect.gen(function* () {
182 const env = yield* GcpEnvironment.current;
183 const buckets = yield* logging.listLocationsBuckets
184 .pages({
185 parent: `projects/${env.project}/locations/-`,
186 pageSize: 1000,
187 })
188 .pipe(
189 Stream.flatMap((page) => Stream.fromIterable(page.buckets ?? [])),
190 Stream.runCollect,
191 Effect.map((chunk) => Array.from(chunk)),
192 );
193 const views: ReturnType<typeof toAttrs>[] = [];
194 for (const bucket of buckets) {
195 if (bucket.name === undefined) continue;
196 const listed = yield* logging.listLocationsBucketsViews
197 .pages({
198 parent: bucket.name,
199 pageSize: 1000,
200 })
201 .pipe(
202 Stream.flatMap((page) => Stream.fromIterable(page.views ?? [])),
203 Stream.filter((view) => hasOwnershipMarker(view.description)),
204 Stream.map((view) => toAttrs(view, bucket.name ?? "")),
205 Stream.runCollect,
206 Effect.map((chunk) => Array.from(chunk)),
207 Effect.catchTag("NotFound", () =>
208 Effect.succeed([] as ReturnType<typeof toAttrs>[]),
209 ),
210 );
211 views.push(...listed);
212 }
213 return views;
214 }),
215
216 reconcile: Effect.fn(function* ({ id, news, output }) {
217 const viewId = yield* toPhysicalId(id, news.viewId, output?.viewId, "v");
218 const name = resourceName(news.bucketName, viewId);
219 const ownership = yield* createOwnership(id);
220 const desiredDescription = encodeDescription(ownership, news.description);
221
222 let current = yield* getByName(output?.name ?? name);
223
224 if (current === undefined) {
225 const created = yield* logging
226 .createLocationsBucketsViews({
227 parent: news.bucketName,
228 viewId,
229 body: {
230 filter: news.filter,
231 description: desiredDescription,
232 },
233 })
234 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
235 current = created ?? undefined;
236 }
237
238 if (current === undefined) {
239 return yield* new LocationsBucketsViewNotResolved({ name });
240 }
241
242 const filterChanged = (current.filter ?? "") !== (news.filter ?? "");
243 const descriptionChanged =
244 (current.description ?? "") !== desiredDescription;
245 const updateMask = [
246 filterChanged ? "filter" : undefined,
247 descriptionChanged ? "description" : undefined,
248 ].filter((field): field is string => field !== undefined);
249
250 if (updateMask.length > 0) {
251 current = yield* logging.patchLocationsBucketsViews({
252 name: current.name ?? name,
253 updateMask: updateMask.join(","),
254 body: {
255 filter: news.filter ?? "",
256 description: desiredDescription,
257 },
258 });
259 }
260
261 return toAttrs(current, news.bucketName);
262 }),
263
264 delete: Effect.fn(function* ({ output }) {
265 yield* logging
266 .deleteLocationsBucketsViews({ name: output.name })
267 .pipe(Effect.catchTag("NotFound", () => Effect.void));
268 }),
269 });