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 jsonEqual,
17 lastSegment,
18 organizationIdOf,
19 parseDescription,
20 resolveOrganization,
21 sorted,
22 toPhysicalId,
23 tryResolveOrganization,
24} from "./internal.ts";
25
26export type OrganizationLogScopeProps = {
27 /**
28 * Log scope id (the last segment of the resource name). If omitted, a
29 * unique name is generated from the stack, stage, and logical id.
30 * Limited to 100 characters: letters, digits, underscores, hyphens,
31 * periods; first character must be alphanumeric. Immutable — changing
32 * it replaces the log scope.
33 */
34 logScopeId?: string;
35 /**
36 * Parent organization (`organizations/{organization}` or the numeric
37 * id). Defaults to the project ancestor organization. Immutable —
38 * changing it replaces the log scope.
39 */
40 organization?: string;
41 /**
42 * Location. Log scopes are only available in `global`. Immutable —
43 * changing it replaces the log scope.
44 * @default "global"
45 */
46 location?: string;
47 /**
48 * Names of parent resources to read logs from. Projects and views are
49 * supported (organizations and folders are not). Defaults to the
50 * current GCP project. Maximum 5 projects and 100 resources total.
51 */
52 resourceNames?: string[];
53 /**
54 * Human-readable description (max 8000 characters). Log scopes have no
55 * labels field, so Alchemy ownership is stored in a `[alchemy …]`
56 * prefix and stripped from attributes.
57 */
58 description?: string;
59};
60
61export type OrganizationLogScope = Resource<
62 "GCP.Logging.OrganizationLogScope",
63 OrganizationLogScopeProps,
64 {
65 /** Full resource name `organizations/{organization}/locations/{location}/logScopes/{logScope}`. */
66 name: string;
67 /** Log scope id (last path segment). */
68 logScopeId: string;
69 /** Organization resource name. */
70 organization: string;
71 /** Organization id. */
72 organizationId: string;
73 /** Project id of the deploying stack. */
74 project: string;
75 /** Location (`global`). */
76 location: string;
77 /** Resource names included in this log 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 on an organization.
92 *
93 * A log scope groups projects (and views) to read logs from. Log scopes
94 * exist only in the `global` location. They have no labels field, so
95 * Alchemy stamps ownership into the description for `list` / nuke.
96 * `logScopeId`, organization, and location are identity.
97 *
98 * ### Creating an Organization Log Scope
99 * **Example:** Scope the current project
100 * ```typescript
101 * const scope = yield* GCP.Logging.OrganizationLogScope("App", {
102 * description: "application projects",
103 * });
104 * ```
105 *
106 * **Example:** Named scope with explicit resources
107 * ```typescript
108 * const scope = yield* GCP.Logging.OrganizationLogScope("App", {
109 * logScopeId: "app-scope",
110 * resourceNames: ["projects/my-project"],
111 * description: "application projects",
112 * });
113 * ```
114 *
115 * ### Updating an Organization Log Scope
116 * **Example:** Change description and resource names
117 * ```typescript
118 * const scope = yield* GCP.Logging.OrganizationLogScope("App", {
119 * resourceNames: ["projects/my-project", "projects/other"],
120 * description: "two application projects",
121 * });
122 * ```
123 *
124 * @resource
125 * @category Logging
126 */
127export const OrganizationLogScope = Resource<OrganizationLogScope>(
128 "GCP.Logging.OrganizationLogScope",
129);
130
131export class OrganizationLogScopeNotResolved extends Data.TaggedError(
132 "GCP.Logging.OrganizationLogScopeNotResolved",
133)<{
134 name: string;
135}> {}
136
137const resourceName = (
138 organization: string,
139 location: string,
140 logScopeId: string,
141) => `${organization}/locations/${location}/logScopes/${logScopeId}`;
142
143const parseScopeName = (name: string) => {
144 const match = name.match(
145 /^(organizations\/[^/]+)\/locations\/([^/]+)\/logScopes\/([^/]+)$/,
146 );
147 if (!match) return undefined;
148 return {
149 organization: match[1]!,
150 location: match[2]!,
151 logScopeId: match[3]!,
152 };
153};
154
155const toAttrs = (
156 scope: logging.LogScope,
157 organization: string,
158 project: string,
159 location: string,
160) => {
161 const parsed = parseScopeName(scope.name ?? "");
162 const logScopeId = parsed?.logScopeId ?? lastSegment(scope.name ?? "");
163 const description = parseDescription(scope.description);
164 const resolvedOrg = parsed?.organization ?? organization;
165 const resolvedLocation = parsed?.location ?? location;
166 return {
167 name:
168 scope.name ??
169 (logScopeId
170 ? resourceName(resolvedOrg, resolvedLocation, logScopeId)
171 : ""),
172 logScopeId,
173 organization: resolvedOrg,
174 organizationId: organizationIdOf(resolvedOrg),
175 project,
176 location: resolvedLocation,
177 resourceNames: [...(scope.resourceNames ?? [])],
178 description: description.description,
179 createTime: scope.createTime,
180 updateTime: scope.updateTime,
181 };
182};
183
184const getByName = (name: string) =>
185 logging
186 .getOrganizationsLocationsLogScopes({ name })
187 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
188
189export const OrganizationLogScopeProvider = () =>
190 Provider.succeed(OrganizationLogScope, {
191 stables: [
192 "name",
193 "logScopeId",
194 "organization",
195 "organizationId",
196 "project",
197 "location",
198 "createTime",
199 ],
200
201 diff: Effect.fn(function* ({ news, olds, output }) {
202 if (!isResolved(news)) return undefined;
203 const previousId = olds?.logScopeId ?? output?.logScopeId;
204 const idChanged =
205 previousId !== undefined &&
206 news.logScopeId !== undefined &&
207 news.logScopeId !== previousId;
208 const previousLocation = olds?.location ?? output?.location;
209 const locationChanged =
210 previousLocation !== undefined &&
211 news.location !== undefined &&
212 news.location !== previousLocation;
213 const previousOrg = olds?.organization ?? output?.organization;
214 const orgChanged =
215 previousOrg !== undefined &&
216 news.organization !== undefined &&
217 news.organization !== previousOrg;
218 if (!idChanged && !locationChanged && !orgChanged) return undefined;
219 return { action: "replace" as const, deleteFirst: false };
220 }),
221
222 read: Effect.fn(function* ({ id, olds, output }) {
223 const env = yield* GcpEnvironment.current;
224 const organization = yield* resolveOrganization(
225 olds?.organization ?? output?.organization,
226 output?.organization,
227 );
228 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
229 const logScopeId = yield* toPhysicalId(
230 id,
231 olds?.logScopeId,
232 output?.logScopeId,
233 "s",
234 );
235 const name =
236 output?.name ?? resourceName(organization, location, logScopeId);
237 const existing = yield* getByName(name);
238 if (existing === undefined) return undefined;
239 const attrs = toAttrs(existing, organization, env.project, location);
240 const { labels } = parseDescription(existing.description);
241 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
242 }),
243
244 list: () =>
245 Effect.gen(function* () {
246 const env = yield* GcpEnvironment.current;
247 const organization = yield* tryResolveOrganization();
248 if (organization === undefined) return [];
249 return yield* logging.listOrganizationsLocationsLogScopes
250 .pages({
251 parent: `${organization}/locations/${DEFAULT_LOCATION}`,
252 pageSize: 1000,
253 })
254 .pipe(
255 Stream.flatMap((page) => Stream.fromIterable(page.logScopes ?? [])),
256 Stream.filter((scope) => hasOwnershipMarker(scope.description)),
257 Stream.map((scope) =>
258 toAttrs(scope, organization, env.project, DEFAULT_LOCATION),
259 ),
260 Stream.runCollect,
261 Effect.map((chunk) => Array.from(chunk)),
262 Effect.catchTag("NotFound", () => Effect.succeed([])),
263 );
264 }),
265
266 reconcile: Effect.fn(function* ({ id, news, output }) {
267 const env = yield* GcpEnvironment.current;
268 const organization = yield* resolveOrganization(
269 news.organization,
270 output?.organization,
271 );
272 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
273 const logScopeId = yield* toPhysicalId(
274 id,
275 news.logScopeId,
276 output?.logScopeId,
277 "s",
278 );
279 const name = resourceName(organization, location, logScopeId);
280 const ownership = yield* createInternalLabels(id);
281 const desiredDescription = encodeDescription(ownership, news.description);
282 const desiredResources = news.resourceNames ??
283 output?.resourceNames ?? [`projects/${env.project}`];
284
285 let current = yield* getByName(output?.name ?? name);
286
287 if (current === undefined) {
288 const created = yield* logging
289 .createOrganizationsLocationsLogScopes({
290 parent: `${organization}/locations/${location}`,
291 logScopeId,
292 body: {
293 resourceNames: [...desiredResources],
294 description: desiredDescription,
295 },
296 })
297 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
298 current = created ?? undefined;
299 }
300
301 if (current === undefined) {
302 return yield* new OrganizationLogScopeNotResolved({ name });
303 }
304
305 const descriptionChanged =
306 (current.description ?? "") !== desiredDescription;
307 const resourcesChanged =
308 news.resourceNames !== undefined &&
309 !jsonEqual(sorted(current.resourceNames), sorted(news.resourceNames));
310 const updateMask = [
311 descriptionChanged ? "description" : undefined,
312 resourcesChanged ? "resourceNames" : undefined,
313 ].filter((field): field is string => field !== undefined);
314
315 if (updateMask.length > 0) {
316 current = yield* logging.patchOrganizationsLocationsLogScopes({
317 name: current.name ?? name,
318 updateMask: updateMask.join(","),
319 body: {
320 description: desiredDescription,
321 resourceNames: [...desiredResources],
322 },
323 });
324 }
325
326 return toAttrs(current, organization, env.project, location);
327 }),
328
329 delete: Effect.fn(function* ({ output }) {
330 yield* logging
331 .deleteOrganizationsLocationsLogScopes({ name: output.name })
332 .pipe(Effect.catchTag("NotFound", () => Effect.void));
333 }),
334 });