1import * as logging from "@distilled.cloud/gcp/logging_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 listProjectBuckets,
16 waitForDeleteOperation,
17 waitForOperation,
18} from "./operations.ts";
19import {
20 encodeDescription,
21 hasOwnershipMarker,
22 lastSegment,
23 parseDescription,
24} from "./ownership.ts";
25
26const MAX_NAME_LENGTH = 100;
27const DEFAULT_LOCATION = "global";
28
29export type BucketsLinkProps = {
30 /**
31 * Parent analytics-enabled log bucket. Full name
32 * `projects/{project}/locations/{location}/buckets/{bucket}` or the
33 * bucket id (combined with `location`). A bucket may have at most one
34 * link. Immutable — changing it replaces the link (delete-first).
35 */
36 bucket: string;
37 /**
38 * Link id (the `{link}` segment of
39 * `.../buckets/{bucket}/links/{link}`). Also used as the BigQuery
40 * dataset id, so it may contain only letters, digits, and underscores
41 * and must start with a letter. If omitted, a unique name is generated.
42 * Immutable — changing it replaces the link.
43 */
44 linkId?: string;
45 /**
46 * Bucket location used when `bucket` is a bare id. Immutable — changing
47 * it replaces the link.
48 * @default "global"
49 */
50 location?: string;
51 /**
52 * Human-readable description (max 8000 characters). Links have no
53 * labels field and no update API, so Alchemy stamps ownership into the
54 * description at create and cannot change it in place.
55 */
56 description?: string;
57};
58
59export type BucketsLink = Resource<
60 "GCP.Logging.BucketsLink",
61 BucketsLinkProps,
62 {
63 /** Full resource name `projects/{project}/locations/{location}/buckets/{bucket}/links/{linkId}`. */
64 name: string;
65 /** Link id (last path segment). */
66 linkId: string;
67 /** Parent log bucket resource name. */
68 bucket: string;
69 /** Bucket id. */
70 bucketId: string;
71 /** Project id. */
72 project: string;
73 /** Location of the parent bucket. */
74 location: string;
75 /** User description with the Alchemy ownership prefix stripped. */
76 description: string | undefined;
77 /** BigQuery dataset id created for this link. */
78 datasetId: string | undefined;
79 /** Link lifecycle (`ACTIVE`, `DELETE_REQUESTED`, …). */
80 lifecycleState: string | undefined;
81 /** RFC3339 creation timestamp. */
82 createTime: string | undefined;
83 },
84 never,
85 Providers
86>;
87
88/**
89 * A Cloud Logging link that creates a BigQuery dataset over an
90 * analytics-enabled log bucket.
91 *
92 * A bucket may have only one link. Create and delete are long-running
93 * operations; the matching BigQuery dataset is created and deleted with
94 * the link. Links have no update API and no labels field — Alchemy
95 * stamps ownership into the description at create. `linkId` and the
96 * parent bucket are identity; replacement is delete-first because a
97 * bucket cannot hold two links.
98 *
99 * ### Creating a Link
100 * **Example:** Link an analytics bucket to BigQuery
101 * ```typescript
102 * const bucket = yield* GCP.Logging.LogBucket("AppLogs", {
103 * analyticsEnabled: true,
104 * description: "analytics logs",
105 * });
106 * const link = yield* GCP.Logging.BucketsLink("Analytics", {
107 * bucket: bucket.name,
108 * description: "bigquery analytics",
109 * });
110 * ```
111 *
112 * **Example:** Named link
113 * ```typescript
114 * const link = yield* GCP.Logging.BucketsLink("Analytics", {
115 * bucket: bucket.name,
116 * linkId: "app_logs",
117 * description: "bigquery analytics",
118 * });
119 * ```
120 *
121 * @resource
122 * @category Logging
123 */
124export const BucketsLink = Resource<BucketsLink>("GCP.Logging.BucketsLink");
125
126export class BucketsLinkNotResolved extends Data.TaggedError(
127 "GCP.Logging.BucketsLinkNotResolved",
128)<{
129 name: string;
130}> {}
131
132export class BucketsLinkFailed extends Data.TaggedError(
133 "GCP.Logging.BucketsLinkFailed",
134)<{
135 name: string;
136 state: string | undefined;
137}> {}
138
139export class BucketsLinkStillExists extends Data.TaggedError(
140 "GCP.Logging.BucketsLinkStillExists",
141)<{
142 name: string;
143}> {}
144
145const parseLinkName = (name: string) => {
146 const match = name.match(
147 /^projects\/([^/]+)\/locations\/([^/]+)\/buckets\/([^/]+)\/links\/([^/]+)$/,
148 );
149 if (!match) return undefined;
150 return {
151 project: match[1]!,
152 location: match[2]!,
153 bucketId: match[3]!,
154 linkId: match[4]!,
155 };
156};
157
158const parseBucket = (bucket: string, project: string, location: string) => {
159 const match = bucket.match(
160 /^(projects\/([^/]+)\/locations\/([^/]+)\/buckets\/([^/]+))$/,
161 );
162 if (match) {
163 return {
164 name: match[1]!,
165 project: match[2]!,
166 location: match[3]!,
167 bucketId: match[4]!,
168 };
169 }
170 return {
171 name: `projects/${project}/locations/${location}/buckets/${bucket}`,
172 project,
173 location,
174 bucketId: lastSegment(bucket),
175 };
176};
177
178const resourceName = (
179 project: string,
180 location: string,
181 bucketId: string,
182 linkId: string,
183) =>
184 `projects/${project}/locations/${location}/buckets/${bucketId}/links/${linkId}`;
185
186const linkIdOf = (link: logging.Link, fallback?: string) => {
187 const parsed = parseLinkName(link.name ?? "");
188 return parsed?.linkId ?? fallback ?? lastSegment(link.name ?? "");
189};
190
191const toId = (id: string, linkId: string | undefined, existing?: string) =>
192 Effect.gen(function* () {
193 if (linkId !== undefined) return linkId;
194 if (existing !== undefined) return existing;
195 const generated = yield* createPhysicalName({
196 id,
197 maxLength: MAX_NAME_LENGTH,
198 lowercase: true,
199 delimiter: "_",
200 });
201 const cleaned = generated.replace(/-/g, "_").replace(/[^a-z0-9_]/g, "_");
202 return /^[a-z]/.test(cleaned)
203 ? cleaned
204 : `l${cleaned}`.slice(0, MAX_NAME_LENGTH);
205 });
206
207const isDeleted = (link: logging.Link | undefined): link is undefined =>
208 link === undefined || link.lifecycleState === "DELETE_REQUESTED";
209
210const isPending = (state: string | undefined) =>
211 state === "CREATING" || state === "UPDATING";
212
213const toAttrs = (
214 link: logging.Link,
215 project: string,
216 location: string,
217 bucketId: string,
218) => {
219 const linkId = linkIdOf(link);
220 const parsed = parseDescription(link.description);
221 const parsedName = parseLinkName(link.name ?? "");
222 const resolvedLocation = parsedName?.location ?? location;
223 const resolvedBucketId = parsedName?.bucketId ?? bucketId;
224 const resolvedProject = parsedName?.project ?? project;
225 return {
226 name:
227 link.name ??
228 (linkId
229 ? resourceName(
230 resolvedProject,
231 resolvedLocation,
232 resolvedBucketId,
233 linkId,
234 )
235 : ""),
236 linkId,
237 bucket: `projects/${resolvedProject}/locations/${resolvedLocation}/buckets/${resolvedBucketId}`,
238 bucketId: resolvedBucketId,
239 project: resolvedProject,
240 location: resolvedLocation,
241 description: parsed.description,
242 datasetId: link.bigqueryDataset?.datasetId,
243 lifecycleState: link.lifecycleState,
244 createTime: link.createTime,
245 };
246};
247
248const isValidLinkName = (name: string) =>
249 /^projects\/[^/]+\/locations\/[^/]+\/buckets\/[^/]+\/links\/[^/]+$/.test(
250 name,
251 );
252
253const getByName = (name: string) =>
254 !isValidLinkName(name)
255 ? Effect.succeed(undefined)
256 : logging
257 .getProjectsLocationsBucketsLinks({ name })
258 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
259
260const waitUntilActive = (name: string) =>
261 Effect.gen(function* () {
262 const link = yield* getByName(name);
263 if (isDeleted(link)) {
264 return yield* new BucketsLinkNotResolved({ name });
265 }
266 if (link.lifecycleState === "FAILED") {
267 return yield* new BucketsLinkFailed({
268 name,
269 state: link.lifecycleState,
270 });
271 }
272 if (isPending(link.lifecycleState)) {
273 return yield* new BucketsLinkNotResolved({ name });
274 }
275 return link;
276 }).pipe(
277 Effect.retry({
278 while: (error) => error._tag === "GCP.Logging.BucketsLinkNotResolved",
279 times: 10,
280 schedule: Schedule.spaced("3 seconds"),
281 }),
282 Effect.catchTag("GCP.Logging.BucketsLinkNotResolved", () =>
283 Effect.fail(
284 new BucketsLinkFailed({
285 name,
286 state: "PENDING",
287 }),
288 ),
289 ),
290 );
291
292const waitUntilDeleted = (name: string) =>
293 getByName(name).pipe(
294 Effect.flatMap((link) =>
295 isDeleted(link)
296 ? Effect.void
297 : Effect.fail(new BucketsLinkStillExists({ name })),
298 ),
299 Effect.retry({
300 while: (error) => error._tag === "GCP.Logging.BucketsLinkStillExists",
301 times: 10,
302 schedule: Schedule.spaced("3 seconds"),
303 }),
304 Effect.catchTag("GCP.Logging.BucketsLinkStillExists", () => Effect.void),
305 );
306
307export const BucketsLinkProvider = () =>
308 Provider.succeed(BucketsLink, {
309 nuke: {
310 dependsOn: ["GCP.Logging.LogBucket"],
311 },
312 stables: [
313 "name",
314 "linkId",
315 "bucket",
316 "bucketId",
317 "project",
318 "location",
319 "createTime",
320 ],
321
322 diff: Effect.fn(function* ({ news, olds, output }) {
323 if (!isResolved(news)) return undefined;
324 const previousId = olds?.linkId ?? output?.linkId;
325 const idChanged =
326 previousId !== undefined &&
327 news.linkId !== undefined &&
328 news.linkId !== previousId;
329 const previousBucket = olds?.bucket ?? output?.bucket;
330 const bucketChanged =
331 previousBucket !== undefined &&
332 news.bucket.includes("/buckets/") &&
333 news.bucket !== previousBucket;
334 if (!idChanged && !bucketChanged) return undefined;
335 return { action: "replace" as const, deleteFirst: true };
336 }),
337
338 read: Effect.fn(function* ({ id, olds, output }) {
339 const env = yield* GcpEnvironment.current;
340 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
341 const parent = parseBucket(
342 olds?.bucket ?? output?.bucket ?? "",
343 env.project,
344 location,
345 );
346 const linkId = yield* toId(id, olds?.linkId, output?.linkId);
347 const name =
348 output?.name ??
349 resourceName(parent.project, parent.location, parent.bucketId, linkId);
350 const existing = yield* getByName(name);
351 if (isDeleted(existing)) return undefined;
352 const attrs = toAttrs(
353 existing,
354 parent.project,
355 parent.location,
356 parent.bucketId,
357 );
358 const { labels } = parseDescription(existing.description);
359 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
360 }),
361
362 list: () =>
363 Effect.gen(function* () {
364 const env = yield* GcpEnvironment.current;
365 const buckets = yield* listProjectBuckets();
366 const pages = yield* Effect.forEach(
367 buckets,
368 (bucket) =>
369 logging.listProjectsLocationsBucketsLinks
370 .pages({
371 parent: bucket.name!,
372 pageSize: 1000,
373 })
374 .pipe(
375 Stream.flatMap((page) => Stream.fromIterable(page.links ?? [])),
376 Stream.filter(
377 (link) =>
378 !isDeleted(link) && hasOwnershipMarker(link.description),
379 ),
380 Stream.map((link) => {
381 const parsed = parseLinkName(link.name ?? "");
382 return toAttrs(
383 link,
384 parsed?.project ?? env.project,
385 parsed?.location ?? DEFAULT_LOCATION,
386 parsed?.bucketId ?? lastSegment(bucket.name ?? ""),
387 );
388 }),
389 Stream.runCollect,
390 Effect.map((chunk) => Array.from(chunk)),
391 Effect.catchTag("NotFound", () =>
392 Effect.succeed([] as ReturnType<typeof toAttrs>[]),
393 ),
394 ),
395 { concurrency: 4 },
396 );
397 return pages.flat();
398 }),
399
400 reconcile: Effect.fn(function* ({ id, news, output }) {
401 const env = yield* GcpEnvironment.current;
402 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
403 const parent = parseBucket(news.bucket, env.project, location);
404 if (parent.bucketId.length === 0) {
405 return yield* new BucketsLinkNotResolved({
406 name: parent.name,
407 });
408 }
409 const linkId = yield* toId(id, news.linkId, output?.linkId);
410 const name = resourceName(
411 parent.project,
412 parent.location,
413 parent.bucketId,
414 linkId,
415 );
416 const ownership = yield* createInternalLabels(id);
417 const desiredDescription = encodeDescription(ownership, news.description);
418
419 let current = yield* getByName(output?.name ?? name);
420
421 if (
422 current !== undefined &&
423 current.lifecycleState === "DELETE_REQUESTED"
424 ) {
425 yield* waitUntilDeleted(current.name ?? name);
426 current = undefined;
427 }
428
429 if (current === undefined) {
430 const created = yield* logging
431 .createProjectsLocationsBucketsLinks({
432 parent: parent.name,
433 linkId,
434 body: { description: desiredDescription },
435 })
436 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
437 if (created !== undefined) {
438 yield* waitForOperation(created);
439 }
440 current = yield* waitUntilActive(name);
441 }
442
443 if (isDeleted(current)) {
444 return yield* new BucketsLinkNotResolved({ name });
445 }
446
447 return toAttrs(current, parent.project, parent.location, parent.bucketId);
448 }),
449
450 delete: Effect.fn(function* ({ output }) {
451 if (output.lifecycleState === "DELETE_REQUESTED") return;
452 const names = isValidLinkName(output.name)
453 ? [output.name]
454 : output.linkId
455 ? yield* listProjectBuckets().pipe(
456 Effect.map((buckets) =>
457 buckets.flatMap((bucket) =>
458 bucket.name ? [`${bucket.name}/links/${output.linkId}`] : [],
459 ),
460 ),
461 )
462 : [];
463 yield* Effect.forEach(
464 names,
465 (name) =>
466 logging.deleteProjectsLocationsBucketsLinks({ name }).pipe(
467 Effect.flatMap((operation) => waitForDeleteOperation(operation)),
468 Effect.catchTag(["NotFound", "BadRequest"], () => Effect.void),
469 Effect.flatMap(() => waitUntilDeleted(name)),
470 ),
471 { concurrency: 4 },
472 );
473 }),
474 });