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