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 createOwnership,
13 encodeDescription,
14 exclusionsOf,
15 hasOwnershipMarker,
16 lastSegment,
17 ownedBy,
18 parseDescription,
19 parseLoggingName,
20 sameExclusions,
21 scopeParent,
22 toExclusionsBody,
23 toPhysicalId,
24 type SinkExclusion,
25} from "./internal.ts";
26
27export type FolderSinkExclusion = SinkExclusion;
28
29export type FolderSinkBigQueryOptions = {
30 /**
31 * Use BigQuery partitioned tables instead of dated tables
32 * (`syslog_20170523`). Only applies when `destination` is a BigQuery
33 * dataset.
34 * @default false
35 */
36 usePartitionedTables?: boolean;
37};
38
39export type FolderSinkProps = {
40 /**
41 * Folder id (`folders/{folder}` or the numeric id). When omitted, the
42 * stack project is used. Immutable — changing it replaces the sink.
43 */
44 folderId?: string;
45 /**
46 * Sink id (the `{sink}` segment of `{parent}/sinks/{sink}`). If omitted,
47 * a unique name is generated from the stack, stage, and logical id.
48 * Limited to 100 characters: letters, digits, underscores, hyphens,
49 * periods; first character must be alphanumeric. Immutable — changing
50 * it replaces the sink.
51 */
52 sinkId?: string;
53 /**
54 * Export destination. One of:
55 * `storage.googleapis.com/[GCS_BUCKET]`,
56 * `bigquery.googleapis.com/projects/[PROJECT_ID]/datasets/[DATASET]`,
57 * `pubsub.googleapis.com/projects/[PROJECT_ID]/topics/[TOPIC_ID]`,
58 * `logging.googleapis.com/projects/[PROJECT_ID]`,
59 * `logging.googleapis.com/projects/[PROJECT_ID]/locations/[LOCATION_ID]/buckets/[BUCKET_ID]`.
60 */
61 destination: string;
62 /**
63 * Advanced logs filter. Only matching entries in the sink's parent are
64 * exported. Empty or omitted exports every entry.
65 */
66 filter?: string;
67 /**
68 * Human-readable description (max 8000 characters). Logging sinks have
69 * no labels field, so Alchemy ownership is stored in a `[alchemy …]`
70 * prefix and stripped from attributes.
71 */
72 description?: string;
73 /**
74 * When true, the sink exists but does not export any log entries.
75 * @default false
76 */
77 disabled?: boolean;
78 /**
79 * Exclusion filters. An entry matching `filter` and any exclusion is
80 * not exported.
81 */
82 exclusions?: FolderSinkExclusion[];
83 /**
84 * BigQuery-specific export options. Ignored unless `destination` is a
85 * BigQuery dataset.
86 */
87 bigqueryOptions?: FolderSinkBigQueryOptions;
88 /**
89 * When true, Cloud Logging assigns a unique writer service account as
90 * `writerIdentity`. Folder sinks always receive a writer identity.
91 */
92 uniqueWriterIdentity?: boolean;
93 /**
94 * Caller-provided writer service account
95 * (`serviceAccount:some@email`). Only valid when routing to a log
96 * bucket in a different project.
97 */
98 customWriterIdentity?: string;
99 /**
100 * When true, logs from child projects, folders, and billing accounts
101 * are also eligible for export. Folder/org sinks only.
102 * @default false
103 */
104 includeChildren?: boolean;
105 /**
106 * When true (requires `includeChildren`), matching logs are intercepted
107 * and omitted from non-`_Required` child sinks. Folder/org sinks only.
108 * @default false
109 */
110 interceptChildren?: boolean;
111};
112
113export type FolderSink = Resource<
114 "GCP.Logging.FolderSink",
115 FolderSinkProps,
116 {
117 /** Full resource name `{parent}/sinks/{sink}`. */
118 name: string;
119 /** Sink id (last path segment). */
120 sinkId: string;
121 /** Parent resource (`folders/{folder}` or `projects/{project}`). */
122 parent: string;
123 /** Folder id when the parent is a folder. */
124 folderId: string | undefined;
125 /** Export destination. */
126 destination: string;
127 /** Advanced logs filter, if set. */
128 filter: string | undefined;
129 /** User description with the Alchemy ownership prefix stripped. */
130 description: string | undefined;
131 /** Whether the sink is disabled. */
132 disabled: boolean;
133 /** Exclusion filters. */
134 exclusions: FolderSinkExclusion[];
135 /** BigQuery export options, if set. */
136 bigqueryOptions: FolderSinkBigQueryOptions | undefined;
137 /** Whether child resources are included (org/folder sinks). */
138 includeChildren: boolean;
139 /** Whether matching child logs are intercepted (org/folder sinks). */
140 interceptChildren: boolean;
141 /**
142 * IAM identity Cloud Logging uses to write to `destination`. Empty
143 * for same-project log-bucket destinations.
144 */
145 writerIdentity: string | undefined;
146 /** RFC3339 creation timestamp. */
147 createTime: string | undefined;
148 /** RFC3339 last-update timestamp. */
149 updateTime: string | undefined;
150 },
151 never,
152 Providers
153>;
154
155/**
156 * A Cloud Logging sink owned by a folder (or the stack project).
157 *
158 * Logging sinks have no labels field, so Alchemy stamps ownership into
159 * the description for `list` / nuke. `folderId` and `sinkId` are identity
160 * — changing either replaces the sink. Filter, destination, description,
161 * disabled flag, exclusions, and BigQuery options update in place.
162 *
163 * ### Creating a Folder Sink
164 * **Example:** Route errors to the `_Default` log bucket
165 * ```typescript
166 * const sink = yield* GCP.Logging.FolderSink("Errors", {
167 * destination:
168 * "logging.googleapis.com/projects/my-project/locations/global/buckets/_Default",
169 * filter: "severity>=ERROR",
170 * });
171 * ```
172 *
173 * **Example:** Folder-owned sink including children
174 * ```typescript
175 * const sink = yield* GCP.Logging.FolderSink("Errors", {
176 * folderId: "123456789",
177 * destination:
178 * "logging.googleapis.com/projects/my-project/locations/global/buckets/_Default",
179 * filter: "severity>=ERROR",
180 * includeChildren: true,
181 * uniqueWriterIdentity: true,
182 * });
183 * ```
184 *
185 * ### Updating a Folder Sink
186 * **Example:** Change the filter
187 * ```typescript
188 * const sink = yield* GCP.Logging.FolderSink("Errors", {
189 * destination:
190 * "logging.googleapis.com/projects/my-project/locations/global/buckets/_Default",
191 * filter: "severity>=WARNING",
192 * description: "warnings and errors",
193 * });
194 * ```
195 *
196 * @resource
197 * @category Logging
198 */
199export const FolderSink = Resource<FolderSink>("GCP.Logging.FolderSink");
200
201export class FolderSinkNotResolved extends Data.TaggedError(
202 "GCP.Logging.FolderSinkNotResolved",
203)<{
204 name: string;
205}> {}
206
207const resourceName = (parent: string, sinkId: string) =>
208 `${parent}/sinks/${sinkId}`;
209
210const folderIdOf = (parent: string) =>
211 parent.startsWith("folders/") ? lastSegment(parent) : undefined;
212
213const sinkIdOf = (sink: logging.LogSink) => {
214 const raw = sink.name ?? sink.resourceName ?? "";
215 const parsed = parseLoggingName(raw);
216 return parsed.sinkId ?? (raw.includes("/") ? lastSegment(raw) : raw);
217};
218
219const toAttrs = (sink: logging.LogSink, parent: string) => {
220 const sinkId = sinkIdOf(sink);
221 const parsed = parseDescription(sink.description);
222 const name =
223 sink.resourceName ?? (sinkId ? resourceName(parent, sinkId) : "");
224 const resolvedParent = parseLoggingName(name).parent || parent;
225 const bq = sink.bigqueryOptions;
226 return {
227 name,
228 sinkId,
229 parent: resolvedParent,
230 folderId: folderIdOf(resolvedParent),
231 destination: sink.destination ?? "",
232 filter: sink.filter,
233 description: parsed.description,
234 disabled: sink.disabled === true,
235 exclusions: exclusionsOf(sink.exclusions),
236 bigqueryOptions:
237 bq?.usePartitionedTables !== undefined
238 ? { usePartitionedTables: bq.usePartitionedTables }
239 : undefined,
240 includeChildren: sink.includeChildren === true,
241 interceptChildren: sink.interceptChildren === true,
242 writerIdentity: sink.writerIdentity,
243 createTime: sink.createTime,
244 updateTime: sink.updateTime,
245 };
246};
247
248const getByName = (name: string) =>
249 logging
250 .getFoldersSinks({ sinkName: name })
251 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
252
253const toCreateBody = (
254 sinkId: string,
255 props: FolderSinkProps,
256 description: string,
257): logging.LogSink => ({
258 name: sinkId,
259 destination: props.destination,
260 filter: props.filter,
261 description,
262 disabled: props.disabled === true ? true : undefined,
263 exclusions: toExclusionsBody(props.exclusions),
264 bigqueryOptions: props.bigqueryOptions,
265 includeChildren: props.includeChildren === true ? true : undefined,
266 interceptChildren: props.interceptChildren === true ? true : undefined,
267});
268
269export const FolderSinkProvider = () =>
270 Provider.succeed(FolderSink, {
271 stables: ["name", "sinkId", "parent", "folderId", "createTime"],
272
273 diff: Effect.fn(function* ({ news, olds, output }) {
274 if (!isResolved(news)) return undefined;
275 const previous = olds?.sinkId ?? output?.sinkId;
276 const idChanged =
277 previous !== undefined &&
278 news.sinkId !== undefined &&
279 news.sinkId !== previous;
280 const previousFolder = olds?.folderId ?? output?.folderId;
281 const folderChanged =
282 news.folderId !== undefined && news.folderId !== previousFolder;
283 if (!idChanged && !folderChanged) return undefined;
284 return { action: "replace" as const, deleteFirst: false };
285 }),
286
287 read: Effect.fn(function* ({ id, olds, output }) {
288 const env = yield* GcpEnvironment.current;
289 const parent = scopeParent(
290 env.project,
291 olds?.folderId ?? output?.folderId,
292 );
293 const sinkId = yield* toPhysicalId(id, olds?.sinkId, output?.sinkId, "s");
294 const name = output?.name ?? resourceName(parent, sinkId);
295 const existing = yield* getByName(name);
296 if (existing === undefined) return undefined;
297 const attrs = toAttrs(existing, parent);
298 const { labels } = parseDescription(existing.description);
299 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
300 }),
301
302 list: () =>
303 Effect.gen(function* () {
304 const env = yield* GcpEnvironment.current;
305 return yield* logging.listFoldersSinks
306 .pages({
307 parent: `projects/${env.project}`,
308 pageSize: 1000,
309 })
310 .pipe(
311 Stream.flatMap((page) => Stream.fromIterable(page.sinks ?? [])),
312 Stream.filter((sink) => hasOwnershipMarker(sink.description)),
313 Stream.map((sink) => toAttrs(sink, `projects/${env.project}`)),
314 Stream.runCollect,
315 Effect.map((chunk) => Array.from(chunk)),
316 );
317 }),
318
319 reconcile: Effect.fn(function* ({ id, news, output }) {
320 const env = yield* GcpEnvironment.current;
321 const parent = scopeParent(
322 env.project,
323 news.folderId ?? output?.folderId,
324 );
325 const sinkId = yield* toPhysicalId(id, news.sinkId, output?.sinkId, "s");
326 const name = resourceName(parent, sinkId);
327 const ownership = yield* createOwnership(id);
328 const desiredDescription = encodeDescription(ownership, news.description);
329 const uniqueWriterIdentity =
330 news.uniqueWriterIdentity ??
331 (news.folderId !== undefined ? true : undefined);
332
333 let current = yield* getByName(output?.name ?? name);
334
335 if (current === undefined) {
336 const created = yield* logging
337 .createFoldersSinks({
338 parent,
339 uniqueWriterIdentity,
340 customWriterIdentity: news.customWriterIdentity,
341 body: toCreateBody(sinkId, news, desiredDescription),
342 })
343 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
344 current = created ?? undefined;
345 }
346
347 if (current === undefined) {
348 return yield* new FolderSinkNotResolved({ name });
349 }
350
351 const desiredDisabled = news.disabled === true;
352 const desiredIncludeChildren = news.includeChildren === true;
353 const desiredInterceptChildren = news.interceptChildren === true;
354 const desiredBq = news.bigqueryOptions?.usePartitionedTables === true;
355 const observedBq = current.bigqueryOptions?.usePartitionedTables === true;
356
357 const destinationChanged =
358 (current.destination ?? "") !== news.destination;
359 const filterChanged = (current.filter ?? "") !== (news.filter ?? "");
360 const descriptionChanged =
361 (current.description ?? "") !== desiredDescription;
362 const disabledChanged = (current.disabled === true) !== desiredDisabled;
363 const exclusionsChanged = !sameExclusions(
364 current.exclusions,
365 news.exclusions,
366 );
367 const bqChanged =
368 news.bigqueryOptions !== undefined && desiredBq !== observedBq;
369 const includeChildrenChanged =
370 news.includeChildren !== undefined &&
371 (current.includeChildren === true) !== desiredIncludeChildren;
372 const interceptChildrenChanged =
373 news.interceptChildren !== undefined &&
374 (current.interceptChildren === true) !== desiredInterceptChildren;
375
376 const updateMask = [
377 destinationChanged ? "destination" : undefined,
378 filterChanged ? "filter" : undefined,
379 descriptionChanged ? "description" : undefined,
380 disabledChanged ? "disabled" : undefined,
381 exclusionsChanged ? "exclusions" : undefined,
382 bqChanged ? "bigqueryOptions.usePartitionedTables" : undefined,
383 includeChildrenChanged ? "includeChildren" : undefined,
384 interceptChildrenChanged ? "interceptChildren" : undefined,
385 ].filter((field): field is string => field !== undefined);
386
387 if (updateMask.length > 0) {
388 const body: logging.LogSink = {
389 name: sinkId,
390 destination: news.destination,
391 filter: news.filter ?? "",
392 description: desiredDescription,
393 disabled: desiredDisabled,
394 };
395 if (exclusionsChanged) {
396 body.exclusions = toExclusionsBody(news.exclusions) ?? [];
397 }
398 if (bqChanged) {
399 body.bigqueryOptions = {
400 usePartitionedTables: desiredBq,
401 };
402 }
403 if (includeChildrenChanged) {
404 body.includeChildren = desiredIncludeChildren;
405 }
406 if (interceptChildrenChanged) {
407 body.interceptChildren = desiredInterceptChildren;
408 }
409 current = yield* logging.patchFoldersSinks({
410 sinkName: current.resourceName ?? name,
411 uniqueWriterIdentity,
412 customWriterIdentity: news.customWriterIdentity,
413 updateMask: updateMask.join(","),
414 body,
415 });
416 }
417
418 return toAttrs(current, parent);
419 }),
420
421 delete: Effect.fn(function* ({ output }) {
422 yield* logging
423 .deleteFoldersSinks({ sinkName: output.name })
424 .pipe(Effect.catchTag("NotFound", () => Effect.void));
425 }),
426 });