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 DEFAULT_LOCATION,
13 createOwnership,
14 encodeDescription,
15 hasOwnershipMarker,
16 jsonEqual,
17 lastSegment,
18 locationParent,
19 ownedBy,
20 parseDescription,
21 parseLoggingName,
22 scopeParent,
23 toPhysicalId,
24} from "./internal.ts";
25
26export type FolderLogScopeProps = {
27 /**
28 * Folder id (`folders/{folder}` or the numeric id). When omitted, the
29 * stack project is used. Immutable — changing it replaces the log
30 * scope.
31 */
32 folderId?: string;
33 /**
34 * Location of the log scope. Log scopes are only available in `global`.
35 * Immutable — changing it replaces the log scope.
36 * @default "global"
37 */
38 location?: string;
39 /**
40 * Log scope id (the `{logScope}` segment of
41 * `{parent}/locations/{location}/logScopes/{logScope}`). If omitted, a
42 * unique name is generated. Limited to 100 characters: letters, digits,
43 * underscores, hyphens, periods; first character must be alphanumeric.
44 * Immutable — changing it replaces the log scope.
45 */
46 logScopeId?: string;
47 /**
48 * Parent resources to read log entries from. Project names
49 * (`projects/{project}`) and/or log views
50 * (`projects/{project}/locations/{location}/buckets/{bucket}/views/{view}`).
51 * Organizations and folders are not supported. Max 5 projects and 100
52 * resources total.
53 */
54 resourceNames: string[];
55 /**
56 * Human-readable description (max 8000 characters). Log scopes have no
57 * labels field, so Alchemy ownership is stored in a `[alchemy …]` prefix
58 * and stripped from attributes.
59 */
60 description?: string;
61};
62
63export type FolderLogScope = Resource<
64 "GCP.Logging.FolderLogScope",
65 FolderLogScopeProps,
66 {
67 /** Full resource name `{parent}/locations/{location}/logScopes/{logScopeId}`. */
68 name: string;
69 /** Log scope id (last path segment). */
70 logScopeId: string;
71 /** Parent resource (`folders/{folder}` or `projects/{project}`). */
72 parent: string;
73 /** Folder id when the parent is a folder. */
74 folderId: string | undefined;
75 /** Location (`global`). */
76 location: string;
77 /** Resource names included in the scope. */
78 resourceNames: string[];
79 /** User description with the Alchemy ownership prefix stripped. */
80 description: string | undefined;
81 /** RFC3339 creation timestamp. */
82 createTime: string | undefined;
83 /** RFC3339 last-update timestamp. */
84 updateTime: string | undefined;
85 },
86 never,
87 Providers
88>;
89
90/**
91 * A Cloud Logging log scope grouping projects and views to read together.
92 *
93 * Log scopes exist only in `global`. They have no labels field, so Alchemy
94 * stamps ownership into the description for `list` / nuke. `folderId`,
95 * `logScopeId`, and `location` are identity — changing any of them
96 * replaces the log scope. `resourceNames` and `description` update in
97 * place. The `_Default` log scope cannot be modified or deleted.
98 *
99 * ### Creating a Folder Log Scope
100 * **Example:** Scope the stack project
101 * ```typescript
102 * const scope = yield* GCP.Logging.FolderLogScope("App", {
103 * resourceNames: ["projects/my-project"],
104 * description: "application logs",
105 * });
106 * ```
107 *
108 * ### Updating a Folder Log Scope
109 * **Example:** Change the description
110 * ```typescript
111 * const scope = yield* GCP.Logging.FolderLogScope("App", {
112 * resourceNames: ["projects/my-project"],
113 * description: "updated application logs",
114 * });
115 * ```
116 *
117 * @resource
118 * @category Logging
119 */
120export const FolderLogScope = Resource<FolderLogScope>(
121 "GCP.Logging.FolderLogScope",
122);
123
124export class FolderLogScopeNotResolved extends Data.TaggedError(
125 "GCP.Logging.FolderLogScopeNotResolved",
126)<{
127 name: string;
128}> {}
129
130const resourceName = (parent: string, location: string, logScopeId: string) =>
131 `${locationParent(parent, location)}/logScopes/${logScopeId}`;
132
133const folderIdOf = (parent: string) =>
134 parent.startsWith("folders/") ? lastSegment(parent) : undefined;
135
136const sortedNames = (names: readonly string[] | undefined) =>
137 [...(names ?? [])].slice().sort();
138
139const toAttrs = (scope: logging.LogScope, parent: string, location: string) => {
140 const parsed = parseLoggingName(scope.name ?? "");
141 const logScopeId = parsed.logScopeId ?? lastSegment(scope.name ?? "");
142 const resolvedParent = parsed.parent || parent;
143 const resolvedLocation = parsed.location ?? location;
144 const description = parseDescription(scope.description);
145 return {
146 name:
147 scope.name ??
148 (logScopeId
149 ? resourceName(resolvedParent, resolvedLocation, logScopeId)
150 : ""),
151 logScopeId,
152 parent: resolvedParent,
153 folderId: folderIdOf(resolvedParent),
154 location: resolvedLocation,
155 resourceNames: [...(scope.resourceNames ?? [])],
156 description: description.description,
157 createTime: scope.createTime,
158 updateTime: scope.updateTime,
159 };
160};
161
162const getByName = (name: string) =>
163 logging
164 .getFoldersLocationsLogScopes({ name })
165 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
166
167export const FolderLogScopeProvider = () =>
168 Provider.succeed(FolderLogScope, {
169 stables: [
170 "name",
171 "logScopeId",
172 "parent",
173 "folderId",
174 "location",
175 "createTime",
176 ],
177
178 diff: Effect.fn(function* ({ news, olds, output }) {
179 if (!isResolved(news)) return undefined;
180 const previousId = olds?.logScopeId ?? output?.logScopeId;
181 const idChanged =
182 previousId !== undefined &&
183 news.logScopeId !== undefined &&
184 news.logScopeId !== previousId;
185 const previousLocation = olds?.location ?? output?.location;
186 const locationChanged =
187 previousLocation !== undefined &&
188 news.location !== undefined &&
189 news.location !== previousLocation;
190 const previousFolder = olds?.folderId ?? output?.folderId;
191 const folderChanged =
192 news.folderId !== undefined && news.folderId !== previousFolder;
193 if (!idChanged && !locationChanged && !folderChanged) return undefined;
194 return { action: "replace" as const, deleteFirst: false };
195 }),
196
197 read: Effect.fn(function* ({ id, olds, output }) {
198 const env = yield* GcpEnvironment.current;
199 const parent = scopeParent(
200 env.project,
201 olds?.folderId ?? output?.folderId,
202 );
203 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
204 const logScopeId = yield* toPhysicalId(
205 id,
206 olds?.logScopeId,
207 output?.logScopeId,
208 "s",
209 );
210 const name = output?.name ?? resourceName(parent, location, logScopeId);
211 const existing = yield* getByName(name);
212 if (existing === undefined) return undefined;
213 const attrs = toAttrs(existing, parent, location);
214 const { labels } = parseDescription(existing.description);
215 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
216 }),
217
218 list: () =>
219 Effect.gen(function* () {
220 const env = yield* GcpEnvironment.current;
221 return yield* logging.listFoldersLocationsLogScopes
222 .pages({
223 parent: `projects/${env.project}/locations/-`,
224 pageSize: 1000,
225 })
226 .pipe(
227 Stream.flatMap((page) => Stream.fromIterable(page.logScopes ?? [])),
228 Stream.filter((scope) => hasOwnershipMarker(scope.description)),
229 Stream.map((scope) =>
230 toAttrs(
231 scope,
232 parseLoggingName(scope.name ?? "").parent ||
233 `projects/${env.project}`,
234 parseLoggingName(scope.name ?? "").location ?? DEFAULT_LOCATION,
235 ),
236 ),
237 Stream.runCollect,
238 Effect.map((chunk) => Array.from(chunk)),
239 );
240 }),
241
242 reconcile: Effect.fn(function* ({ id, news, output }) {
243 const env = yield* GcpEnvironment.current;
244 const parent = scopeParent(
245 env.project,
246 news.folderId ?? output?.folderId,
247 );
248 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
249 const logScopeId = yield* toPhysicalId(
250 id,
251 news.logScopeId,
252 output?.logScopeId,
253 "s",
254 );
255 const name = resourceName(parent, location, logScopeId);
256 const ownership = yield* createOwnership(id);
257 const desiredDescription = encodeDescription(ownership, news.description);
258
259 let current = yield* getByName(output?.name ?? name);
260
261 if (current === undefined) {
262 const created = yield* logging
263 .createFoldersLocationsLogScopes({
264 parent: locationParent(parent, location),
265 logScopeId,
266 body: {
267 resourceNames: [...news.resourceNames],
268 description: desiredDescription,
269 },
270 })
271 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
272 current = created ?? undefined;
273 }
274
275 if (current === undefined) {
276 return yield* new FolderLogScopeNotResolved({ name });
277 }
278
279 const namesChanged = !jsonEqual(
280 sortedNames(current.resourceNames),
281 sortedNames(news.resourceNames),
282 );
283 const descriptionChanged =
284 (current.description ?? "") !== desiredDescription;
285 const updateMask = [
286 namesChanged ? "resourceNames" : undefined,
287 descriptionChanged ? "description" : undefined,
288 ].filter((field): field is string => field !== undefined);
289
290 if (updateMask.length > 0) {
291 current = yield* logging.patchFoldersLocationsLogScopes({
292 name: current.name ?? name,
293 updateMask: updateMask.join(","),
294 body: {
295 resourceNames: [...news.resourceNames],
296 description: desiredDescription,
297 },
298 });
299 }
300
301 return toAttrs(current, parent, location);
302 }),
303
304 delete: Effect.fn(function* ({ output }) {
305 yield* logging
306 .deleteFoldersLocationsLogScopes({ name: output.name })
307 .pipe(Effect.catchTag("NotFound", () => Effect.void));
308 }),
309 });