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