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 encodeDescription,
15 hasOwnershipMarker,
16 lastSegment,
17 organizationIdOf,
18 parseDescription,
19 resolveOrganization,
20 toPhysicalId,
21 tryResolveOrganization,
22} from "./internal.ts";
23
24export type OrganizationSavedQueryLoggingQuery = {
25 /**
26 * Advanced Logging Query Language filter (max 20000 characters).
27 */
28 filter: string;
29 /**
30 * Summary field start character offset.
31 */
32 summaryFieldStart?: number;
33 /**
34 * Summary field end character offset.
35 */
36 summaryFieldEnd?: number;
37 /**
38 * LogEntry fields to include in the Logs Explorer summary line.
39 */
40 summaryFields?: string[];
41};
42
43export type OrganizationSavedQueryProps = {
44 /**
45 * Saved query id (the last segment of the resource name). If omitted,
46 * a unique name is generated from the stack, stage, and logical id.
47 * Limited to 100 characters: letters, digits, underscores, hyphens,
48 * periods; first character must be alphanumeric. Immutable — changing
49 * it replaces the query.
50 */
51 savedQueryId?: string;
52 /**
53 * Parent organization (`organizations/{organization}` or the numeric
54 * id). Defaults to the project ancestor organization. Immutable —
55 * changing it replaces the query.
56 */
57 organization?: string;
58 /**
59 * Location of the saved query. Immutable after create — changing it
60 * replaces the query.
61 * @default "global"
62 */
63 location?: string;
64 /**
65 * User-visible title. If omitted, the saved query id is used.
66 */
67 displayName?: string;
68 /**
69 * Visibility of the query (`PRIVATE` or `SHARED`).
70 * @default "PRIVATE"
71 */
72 visibility?: "PRIVATE" | "SHARED" | "VISIBILITY_UNSPECIFIED";
73 /**
74 * Logs Explorer query. At least one of `loggingQuery` or
75 * `opsAnalyticsQuery` is required.
76 */
77 loggingQuery?: OrganizationSavedQueryLoggingQuery;
78 /**
79 * Log Analytics SQL query text. Used when `loggingQuery` is omitted.
80 */
81 sqlQueryText?: string;
82 /**
83 * Human-readable description. Saved queries have no labels field, so
84 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
85 * from attributes.
86 */
87 description?: string;
88};
89
90export type OrganizationSavedQuery = Resource<
91 "GCP.Logging.OrganizationSavedQuery",
92 OrganizationSavedQueryProps,
93 {
94 /** Full resource name `organizations/{organization}/locations/{location}/savedQueries/{query}`. */
95 name: string;
96 /** Saved query id (last path segment). */
97 savedQueryId: string;
98 /** Organization resource name. */
99 organization: string;
100 /** Organization id. */
101 organizationId: string;
102 /** Project id of the deploying stack. */
103 project: string;
104 /** Location of the saved query. */
105 location: string;
106 /** User-visible title. */
107 displayName: string | undefined;
108 /** Visibility (`PRIVATE` or `SHARED`). */
109 visibility: string | undefined;
110 /** Logs Explorer filter, if set. */
111 loggingQuery: OrganizationSavedQueryLoggingQuery | undefined;
112 /** Log Analytics SQL, if set. */
113 sqlQueryText: string | undefined;
114 /** User description with the Alchemy ownership prefix stripped. */
115 description: string | undefined;
116 /** RFC3339 creation timestamp. */
117 createTime: string | undefined;
118 /** RFC3339 last-update timestamp. */
119 updateTime: string | undefined;
120 },
121 never,
122 Providers
123>;
124
125/**
126 * A Cloud Logging saved query on an organization.
127 *
128 * Saved queries have no labels field, so Alchemy stamps ownership into
129 * the description for `list` / nuke. `savedQueryId`, organization, and
130 * location are identity — changing any replaces the query.
131 *
132 * ### Creating an Organization Saved Query
133 * **Example:** Generated name
134 * ```typescript
135 * const query = yield* GCP.Logging.OrganizationSavedQuery("Errors", {
136 * displayName: "org errors",
137 * loggingQuery: { filter: "severity>=ERROR" },
138 * description: "organization errors",
139 * });
140 * ```
141 *
142 * **Example:** Named shared query
143 * ```typescript
144 * const query = yield* GCP.Logging.OrganizationSavedQuery("Errors", {
145 * savedQueryId: "org-errors",
146 * visibility: "SHARED",
147 * loggingQuery: { filter: "severity>=ERROR" },
148 * });
149 * ```
150 *
151 * ### Updating an Organization Saved Query
152 * **Example:** Change the filter and title
153 * ```typescript
154 * const query = yield* GCP.Logging.OrganizationSavedQuery("Errors", {
155 * displayName: "org warnings",
156 * loggingQuery: { filter: "severity>=WARNING" },
157 * description: "warnings and errors",
158 * });
159 * ```
160 *
161 * @resource
162 * @category Logging
163 */
164export const OrganizationSavedQuery = Resource<OrganizationSavedQuery>(
165 "GCP.Logging.OrganizationSavedQuery",
166);
167
168export class OrganizationSavedQueryNotResolved extends Data.TaggedError(
169 "GCP.Logging.OrganizationSavedQueryNotResolved",
170)<{
171 name: string;
172}> {}
173
174const resourceName = (
175 organization: string,
176 location: string,
177 savedQueryId: string,
178) => `${organization}/locations/${location}/savedQueries/${savedQueryId}`;
179
180const parseQueryName = (name: string) => {
181 const match = name.match(
182 /^(organizations\/[^/]+)\/locations\/([^/]+)\/savedQueries\/([^/]+)$/,
183 );
184 if (!match) return undefined;
185 return {
186 organization: match[1]!,
187 location: match[2]!,
188 savedQueryId: match[3]!,
189 };
190};
191
192const toLoggingQuery = (
193 query: logging.LoggingQuery | undefined,
194): OrganizationSavedQueryLoggingQuery | undefined => {
195 if (query === undefined) return undefined;
196 return {
197 filter: query.filter ?? "",
198 summaryFieldStart: query.summaryFieldStart,
199 summaryFieldEnd: query.summaryFieldEnd,
200 summaryFields: query.summaryFields
201 ?.map((field) => field.field)
202 .filter((field): field is string => field !== undefined),
203 };
204};
205
206const fromLoggingQuery = (
207 query: OrganizationSavedQueryLoggingQuery | undefined,
208): logging.LoggingQuery | undefined => {
209 if (query === undefined) return undefined;
210 return {
211 filter: query.filter,
212 summaryFieldStart: query.summaryFieldStart,
213 summaryFieldEnd: query.summaryFieldEnd,
214 summaryFields: query.summaryFields?.map((field) => ({ field })),
215 };
216};
217
218const toAttrs = (
219 query: logging.SavedQuery,
220 organization: string,
221 project: string,
222 location: string,
223) => {
224 const parsed = parseQueryName(query.name ?? "");
225 const savedQueryId = parsed?.savedQueryId ?? lastSegment(query.name ?? "");
226 const description = parseDescription(query.description);
227 const resolvedOrg = parsed?.organization ?? organization;
228 const resolvedLocation = parsed?.location ?? location;
229 return {
230 name:
231 query.name ??
232 (savedQueryId
233 ? resourceName(resolvedOrg, resolvedLocation, savedQueryId)
234 : ""),
235 savedQueryId,
236 organization: resolvedOrg,
237 organizationId: organizationIdOf(resolvedOrg),
238 project,
239 location: resolvedLocation,
240 displayName: query.displayName,
241 visibility: query.visibility,
242 loggingQuery: toLoggingQuery(query.loggingQuery),
243 sqlQueryText: query.opsAnalyticsQuery?.sqlQueryText,
244 description: description.description,
245 createTime: query.createTime,
246 updateTime: query.updateTime,
247 };
248};
249
250const getByName = (name: string) =>
251 logging
252 .getOrganizationsLocationsSavedQueries({ name })
253 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
254
255const toCreateBody = (
256 props: OrganizationSavedQueryProps,
257 savedQueryId: string,
258 description: string,
259): logging.SavedQuery => {
260 const displayName = props.displayName ?? savedQueryId;
261 const visibility = props.visibility ?? "PRIVATE";
262 const loggingQuery =
263 props.loggingQuery ??
264 (props.sqlQueryText === undefined
265 ? { filter: "severity>=DEFAULT" }
266 : undefined);
267 return {
268 displayName,
269 visibility,
270 description,
271 loggingQuery: fromLoggingQuery(loggingQuery),
272 opsAnalyticsQuery:
273 props.sqlQueryText !== undefined
274 ? { sqlQueryText: props.sqlQueryText }
275 : undefined,
276 };
277};
278
279export const OrganizationSavedQueryProvider = () =>
280 Provider.succeed(OrganizationSavedQuery, {
281 stables: [
282 "name",
283 "savedQueryId",
284 "organization",
285 "organizationId",
286 "project",
287 "location",
288 "createTime",
289 ],
290
291 diff: Effect.fn(function* ({ news, olds, output }) {
292 if (!isResolved(news)) return undefined;
293 const previousId = olds?.savedQueryId ?? output?.savedQueryId;
294 const idChanged =
295 previousId !== undefined &&
296 news.savedQueryId !== undefined &&
297 news.savedQueryId !== previousId;
298 const previousLocation = olds?.location ?? output?.location;
299 const locationChanged =
300 previousLocation !== undefined &&
301 news.location !== undefined &&
302 news.location !== previousLocation;
303 const previousOrg = olds?.organization ?? output?.organization;
304 const orgChanged =
305 previousOrg !== undefined &&
306 news.organization !== undefined &&
307 news.organization !== previousOrg;
308 if (!idChanged && !locationChanged && !orgChanged) return undefined;
309 return { action: "replace" as const, deleteFirst: false };
310 }),
311
312 read: Effect.fn(function* ({ id, olds, output }) {
313 const env = yield* GcpEnvironment.current;
314 const organization = yield* resolveOrganization(
315 olds?.organization ?? output?.organization,
316 output?.organization,
317 );
318 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
319 const savedQueryId = yield* toPhysicalId(
320 id,
321 olds?.savedQueryId,
322 output?.savedQueryId,
323 "q",
324 );
325 const name =
326 output?.name ?? resourceName(organization, location, savedQueryId);
327 const existing = yield* getByName(name);
328 if (existing === undefined) return undefined;
329 const attrs = toAttrs(existing, organization, env.project, location);
330 const { labels } = parseDescription(existing.description);
331 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
332 }),
333
334 list: () =>
335 Effect.gen(function* () {
336 const env = yield* GcpEnvironment.current;
337 const organization = yield* tryResolveOrganization();
338 if (organization === undefined) return [];
339 return yield* logging.listOrganizationsLocationsSavedQueries
340 .pages({
341 parent: `${organization}/locations/-`,
342 pageSize: 1000,
343 })
344 .pipe(
345 Stream.flatMap((page) =>
346 Stream.fromIterable(page.savedQueries ?? []),
347 ),
348 Stream.filter((query) => hasOwnershipMarker(query.description)),
349 Stream.map((query) =>
350 toAttrs(query, organization, env.project, DEFAULT_LOCATION),
351 ),
352 Stream.runCollect,
353 Effect.map((chunk) => Array.from(chunk)),
354 Effect.catchTag("NotFound", () => Effect.succeed([])),
355 );
356 }),
357
358 reconcile: Effect.fn(function* ({ id, news, output }) {
359 const env = yield* GcpEnvironment.current;
360 const organization = yield* resolveOrganization(
361 news.organization,
362 output?.organization,
363 );
364 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
365 const savedQueryId = yield* toPhysicalId(
366 id,
367 news.savedQueryId,
368 output?.savedQueryId,
369 "q",
370 );
371 const name = resourceName(organization, location, savedQueryId);
372 const ownership = yield* createInternalLabels(id);
373 const desiredDescription = encodeDescription(ownership, news.description);
374 const desiredDisplayName = news.displayName ?? savedQueryId;
375 const desiredVisibility = news.visibility ?? "PRIVATE";
376 const desiredLogging =
377 news.loggingQuery ??
378 (news.sqlQueryText === undefined
379 ? { filter: "severity>=DEFAULT" }
380 : undefined);
381
382 let current = yield* getByName(output?.name ?? name);
383
384 if (current === undefined) {
385 const created = yield* logging
386 .createOrganizationsLocationsSavedQueries({
387 parent: `${organization}/locations/${location}`,
388 savedQueryId,
389 body: toCreateBody(news, savedQueryId, desiredDescription),
390 })
391 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
392 current = created ?? undefined;
393 }
394
395 if (current === undefined) {
396 return yield* new OrganizationSavedQueryNotResolved({ name });
397 }
398
399 const descriptionChanged =
400 (current.description ?? "") !== desiredDescription;
401 const displayNameChanged =
402 (current.displayName ?? "") !== desiredDisplayName;
403 const visibilityChanged =
404 (current.visibility ?? "PRIVATE") !== desiredVisibility;
405 const filterChanged =
406 (current.loggingQuery?.filter ?? "") !== (desiredLogging?.filter ?? "");
407 const sqlChanged =
408 (current.opsAnalyticsQuery?.sqlQueryText ?? "") !==
409 (news.sqlQueryText ?? "");
410
411 const updateMask = [
412 descriptionChanged ? "description" : undefined,
413 displayNameChanged ? "displayName" : undefined,
414 visibilityChanged ? "visibility" : undefined,
415 filterChanged ? "loggingQuery" : undefined,
416 sqlChanged ? "opsAnalyticsQuery" : undefined,
417 ].filter((field): field is string => field !== undefined);
418
419 if (updateMask.length > 0) {
420 current = yield* logging.patchOrganizationsLocationsSavedQueries({
421 name: current.name ?? name,
422 updateMask: updateMask.join(","),
423 body: {
424 description: desiredDescription,
425 displayName: desiredDisplayName,
426 visibility: desiredVisibility,
427 loggingQuery: fromLoggingQuery(desiredLogging),
428 opsAnalyticsQuery:
429 news.sqlQueryText !== undefined
430 ? { sqlQueryText: news.sqlQueryText }
431 : undefined,
432 },
433 });
434 }
435
436 return toAttrs(current, organization, env.project, location);
437 }),
438
439 delete: Effect.fn(function* ({ output }) {
440 yield* logging
441 .deleteOrganizationsLocationsSavedQueries({ name: output.name })
442 .pipe(Effect.catchTag("NotFound", () => Effect.void));
443 }),
444 });