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