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