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 encodeDescription,
14 folderParent,
15 hasOwnershipMarker,
16 lastSegment,
17 lookupProjectFolderId,
18 parseDescription,
19 resolveFolderId,
20 toPhysicalId,
21} from "./internal.ts";
22
23export type FolderExclusionProps = {
24 /**
25 * Exclusion id (the `{exclusion}` segment of
26 * `folders/{folder}/exclusions/{exclusion}`). If omitted, a unique name
27 * is generated from the stack, stage, and logical id. Limited to 100
28 * characters: letters, digits, underscores, hyphens, periods; first
29 * character must be alphanumeric. Immutable — changing it replaces the
30 * exclusion.
31 */
32 exclusionId?: string;
33 /**
34 * Folder id (`123456789` or `folders/{id}`). If omitted, Alchemy uses
35 * the parent folder of the current project. Immutable — changing it
36 * replaces the exclusion.
37 */
38 folderId?: string;
39 /**
40 * Advanced logs filter matching entries to exclude from the `_Default`
41 * sink. Required.
42 */
43 filter: string;
44 /**
45 * Human-readable description. Logging exclusions have no labels field,
46 * so Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
47 * from attributes.
48 */
49 description?: string;
50 /**
51 * When true, the exclusion exists but does not exclude any entries.
52 * @default false
53 */
54 disabled?: boolean;
55};
56
57export type FolderExclusion = Resource<
58 "GCP.Logging.FolderExclusion",
59 FolderExclusionProps,
60 {
61 /** Full resource name `folders/{folder}/exclusions/{exclusionId}`. */
62 name: string;
63 /** Exclusion id (last path segment). */
64 exclusionId: string;
65 /** Folder id. */
66 folderId: string;
67 /** Advanced logs filter. */
68 filter: string;
69 /** User description with the Alchemy ownership prefix stripped. */
70 description: string | undefined;
71 /** Whether the exclusion is disabled. */
72 disabled: boolean;
73 /** RFC3339 creation timestamp. */
74 createTime: string | undefined;
75 /** RFC3339 last-update timestamp. */
76 updateTime: string | undefined;
77 },
78 never,
79 Providers
80>;
81
82/**
83 * A Cloud Logging exclusion on a folder `_Default` sink.
84 *
85 * Exclusions drop matching log entries before they are stored in the
86 * `_Default` bucket (they do not apply to `_Required`). Folder exclusions
87 * do not apply to child projects. Logging exclusions have no labels field,
88 * so Alchemy stamps ownership into the description for `list` / nuke.
89 * Name is identity — changing `exclusionId` or `folderId` replaces the
90 * exclusion.
91 *
92 * ### Creating a Folder Exclusion
93 * **Example:** Generated name, drop debug logs
94 * ```typescript
95 * const exclusion = yield* GCP.Logging.FolderExclusion("DropDebug", {
96 * filter: "severity=DEBUG",
97 * description: "drop debug entries",
98 * });
99 * ```
100 *
101 * **Example:** Named exclusion on an explicit folder
102 * ```typescript
103 * const exclusion = yield* GCP.Logging.FolderExclusion("DropDebug", {
104 * folderId: "123456789",
105 * exclusionId: "drop-debug",
106 * filter: "severity=DEBUG",
107 * });
108 * ```
109 *
110 * ### Updating a Folder Exclusion
111 * **Example:** Change the filter and disable
112 * ```typescript
113 * const exclusion = yield* GCP.Logging.FolderExclusion("DropDebug", {
114 * filter: "severity<ERROR",
115 * description: "drop non-errors",
116 * disabled: true,
117 * });
118 * ```
119 *
120 * @resource
121 * @category Logging
122 */
123export const FolderExclusion = Resource<FolderExclusion>(
124 "GCP.Logging.FolderExclusion",
125);
126
127export class FolderExclusionNotResolved extends Data.TaggedError(
128 "GCP.Logging.FolderExclusionNotResolved",
129)<{
130 name: string;
131}> {}
132
133const resourceName = (folderId: string, exclusionId: string) =>
134 `${folderParent(folderId)}/exclusions/${exclusionId}`;
135
136const exclusionIdOf = (exclusion: logging.LogExclusion) => {
137 const raw = exclusion.name ?? "";
138 return raw.includes("/") ? lastSegment(raw) : raw;
139};
140
141const folderIdOfName = (name: string, fallback: string) => {
142 const match = name.match(/^folders\/([^/]+)\//);
143 return match?.[1] ?? fallback;
144};
145
146const toAttrs = (exclusion: logging.LogExclusion, folderId: string) => {
147 const exclusionId = exclusionIdOf(exclusion);
148 const parsed = parseDescription(exclusion.description);
149 const folder = folderIdOfName(exclusion.name ?? "", folderId);
150 return {
151 name:
152 exclusion.name?.includes("/") === true
153 ? exclusion.name
154 : resourceName(folder, exclusionId),
155 exclusionId,
156 folderId: folder,
157 filter: exclusion.filter ?? "",
158 description: parsed.description,
159 disabled: exclusion.disabled === true,
160 createTime: exclusion.createTime,
161 updateTime: exclusion.updateTime,
162 };
163};
164
165const getByName = (name: string) =>
166 logging
167 .getFoldersExclusions({ name })
168 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
169
170export const FolderExclusionProvider = () =>
171 Provider.succeed(FolderExclusion, {
172 stables: ["name", "exclusionId", "folderId", "createTime"],
173
174 diff: Effect.fn(function* ({ news, olds, output }) {
175 if (!isResolved(news)) return undefined;
176 const previousId = olds?.exclusionId ?? output?.exclusionId;
177 const idChanged =
178 previousId !== undefined &&
179 news.exclusionId !== undefined &&
180 news.exclusionId !== previousId;
181 const previousFolder = olds?.folderId ?? output?.folderId;
182 const folderChanged =
183 previousFolder !== undefined &&
184 news.folderId !== undefined &&
185 lastSegment(news.folderId) !== lastSegment(previousFolder);
186 if (!idChanged && !folderChanged) return undefined;
187 return { action: "replace" as const, deleteFirst: false };
188 }),
189
190 read: Effect.fn(function* ({ id, olds, output }) {
191 const folderId = yield* resolveFolderId(olds?.folderId, output?.folderId);
192 const exclusionId = yield* toPhysicalId(
193 id,
194 olds?.exclusionId,
195 output?.exclusionId,
196 "e",
197 );
198 const name = output?.name ?? resourceName(folderId, exclusionId);
199 const existing = yield* getByName(name);
200 if (existing === undefined) return undefined;
201 const attrs = toAttrs(existing, folderId);
202 const { labels } = parseDescription(existing.description);
203 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
204 }),
205
206 list: () =>
207 Effect.gen(function* () {
208 const env = yield* GcpEnvironment.current;
209 const folderId = yield* lookupProjectFolderId(env.project);
210 if (folderId === undefined) return [];
211 return yield* logging.listFoldersExclusions
212 .pages({
213 parent: folderParent(folderId),
214 pageSize: 1000,
215 })
216 .pipe(
217 Stream.flatMap((page) =>
218 Stream.fromIterable(page.exclusions ?? []),
219 ),
220 Stream.filter((exclusion) =>
221 hasOwnershipMarker(exclusion.description),
222 ),
223 Stream.map((exclusion) => toAttrs(exclusion, folderId)),
224 Stream.runCollect,
225 Effect.map((chunk) => Array.from(chunk)),
226 Effect.catchTag("NotFound", () =>
227 Effect.succeed([] as FolderExclusion["Attributes"][]),
228 ),
229 );
230 }),
231
232 reconcile: Effect.fn(function* ({ id, news, output }) {
233 const folderId = yield* resolveFolderId(news.folderId, output?.folderId);
234 const exclusionId = yield* toPhysicalId(
235 id,
236 news.exclusionId,
237 output?.exclusionId,
238 "e",
239 );
240 const name = resourceName(folderId, exclusionId);
241 const ownership = yield* createInternalLabels(id);
242 const desiredDescription = encodeDescription(ownership, news.description);
243
244 let current = yield* getByName(output?.name ?? name);
245
246 if (current === undefined) {
247 const created = yield* logging
248 .createFoldersExclusions({
249 parent: folderParent(folderId),
250 body: {
251 name: exclusionId,
252 filter: news.filter,
253 description: desiredDescription,
254 disabled: news.disabled === true ? true : undefined,
255 },
256 })
257 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
258 current = created ?? undefined;
259 }
260
261 if (current === undefined) {
262 return yield* new FolderExclusionNotResolved({ name });
263 }
264
265 const desiredDisabled = news.disabled === true;
266 const filterChanged = (current.filter ?? "") !== news.filter;
267 const descriptionChanged =
268 (current.description ?? "") !== desiredDescription;
269 const disabledChanged = (current.disabled === true) !== desiredDisabled;
270
271 const updateMask = [
272 filterChanged ? "filter" : undefined,
273 descriptionChanged ? "description" : undefined,
274 disabledChanged ? "disabled" : undefined,
275 ].filter((field): field is string => field !== undefined);
276
277 if (updateMask.length > 0) {
278 current = yield* logging.patchFoldersExclusions({
279 name: current.name?.includes("/") ? current.name : name,
280 updateMask: updateMask.join(","),
281 body: {
282 filter: news.filter,
283 description: desiredDescription,
284 disabled: desiredDisabled,
285 },
286 });
287 }
288
289 return toAttrs(current, folderId);
290 }),
291
292 delete: Effect.fn(function* ({ output }) {
293 yield* logging
294 .deleteFoldersExclusions({ name: output.name })
295 .pipe(Effect.catchTag("NotFound", () => Effect.void));
296 }),
297 });