1import * as observability from "@distilled.cloud/gcp/observability_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
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 linkResourceName,
16 listLinksAt,
17 listProjectDatasets,
18 parseDescription,
19 parseLinkName,
20 resolveDatasetParent,
21 toLinkId,
22} from "./internal.ts";
23import { waitForOperation } from "./operations.ts";
24
25export type BucketsDatasetsLinkProps = {
26 /**
27 * Parent observability dataset. Full name
28 * `projects/{project}/locations/{location}/buckets/{bucket}/datasets/{dataset}`
29 * or the dataset id (combined with `bucket` and `location`). A dataset
30 * may have at most one link. Immutable — changing it replaces the
31 * link (delete-first).
32 */
33 dataset: string;
34 /**
35 * Parent bucket used when `dataset` is a bare id. Full name or the
36 * bucket id. Defaults to `_Trace`. Immutable — changing it replaces
37 * the link.
38 */
39 bucket?: string;
40 /**
41 * Bucket location used when `dataset` is a bare id. Immutable —
42 * changing it replaces the link.
43 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
44 */
45 location?: string;
46 /**
47 * Link id (the `{link}` segment of
48 * `.../datasets/{dataset}/links/{link}`). Also used as the linked
49 * BigQuery dataset id, so it may contain only letters, digits, and
50 * underscores and must start with a letter. If omitted, a unique name
51 * is generated. Immutable — changing it replaces the link.
52 */
53 linkId?: string;
54 /**
55 * User-friendly display name.
56 */
57 displayName?: string;
58 /**
59 * Human-readable description (max 8000 characters). Links have no
60 * labels field, so Alchemy stamps ownership into the description for
61 * `list` / nuke.
62 */
63 description?: string;
64};
65
66export type BucketsDatasetsLink = Resource<
67 "GCP.Observability.BucketsDatasetsLink",
68 BucketsDatasetsLinkProps,
69 {
70 /** Full resource name `projects/{project}/locations/{location}/buckets/{bucket}/datasets/{dataset}/links/{linkId}`. */
71 name: string;
72 /** Link id (last path segment). */
73 linkId: string;
74 /** Parent dataset resource name. */
75 dataset: string;
76 /** Dataset id. */
77 datasetId: string;
78 /** Parent bucket resource name. */
79 bucket: string;
80 /** Bucket id. */
81 bucketId: string;
82 /** Project id. */
83 project: string;
84 /** Location of the parent bucket. */
85 location: string;
86 /** Display name. */
87 displayName: string | undefined;
88 /** User description with the Alchemy ownership prefix stripped. */
89 description: string | undefined;
90 /** RFC3339 creation timestamp. */
91 createTime: string | undefined;
92 },
93 never,
94 Providers
95>;
96
97/**
98 * A Cloud Observability dataset link that creates a linked BigQuery
99 * dataset over an observability analytics dataset (typically
100 * `_Trace/Spans`).
101 *
102 * A dataset may have only one link. Create, update, and delete are
103 * long-running operations. Links have no labels field — Alchemy stamps
104 * ownership into the description. `linkId` and the parent dataset are
105 * identity; replacement is delete-first because a dataset cannot hold
106 * two links.
107 *
108 * ### Creating a Link
109 * **Example:** Link the default trace dataset to BigQuery
110 * ```typescript
111 * const link = yield* GCP.Observability.BucketsDatasetsLink("Analytics", {
112 * dataset:
113 * "projects/my-project/locations/us-central1/buckets/_Trace/datasets/Spans",
114 * description: "bigquery analytics",
115 * });
116 * ```
117 *
118 * **Example:** Named link from bucket and dataset ids
119 * ```typescript
120 * const link = yield* GCP.Observability.BucketsDatasetsLink("Analytics", {
121 * bucket: "_Trace",
122 * dataset: "Spans",
123 * linkId: "trace_spans",
124 * displayName: "Trace spans",
125 * });
126 * ```
127 *
128 * ### Updating a Link
129 * Change props on the same logical id; the engine keeps the physical id.
130 *
131 * **Example:** Change the description
132 * ```typescript
133 * const link = yield* GCP.Observability.BucketsDatasetsLink("Analytics", {
134 * dataset:
135 * "projects/my-project/locations/us-central1/buckets/_Trace/datasets/Spans",
136 * description: "updated analytics",
137 * });
138 * ```
139 *
140 * @resource
141 * @category Observability
142 */
143export const BucketsDatasetsLink = Resource<BucketsDatasetsLink>(
144 "GCP.Observability.BucketsDatasetsLink",
145);
146
147export class BucketsDatasetsLinkNotResolved extends Data.TaggedError(
148 "GCP.Observability.BucketsDatasetsLinkNotResolved",
149)<{
150 name: string;
151}> {}
152
153export class BucketsDatasetsLinkStillExists extends Data.TaggedError(
154 "GCP.Observability.BucketsDatasetsLinkStillExists",
155)<{
156 name: string;
157}> {}
158
159const isValidLinkName = (name: string) => parseLinkName(name) !== undefined;
160
161const toAttrs = (
162 link: observability.Link,
163 project: string,
164 location: string,
165 bucketId: string,
166 datasetId: string,
167) => {
168 const parsedName = parseLinkName(link.name ?? "");
169 const parsed = parseDescription(link.description);
170 const linkId = parsedName?.linkId ?? (link.name ?? "").split("/").pop() ?? "";
171 const resolvedProject = parsedName?.project ?? project;
172 const resolvedLocation = parsedName?.location ?? location;
173 const resolvedBucketId = parsedName?.bucketId ?? bucketId;
174 const resolvedDatasetId = parsedName?.datasetId ?? datasetId;
175 return {
176 name:
177 link.name ??
178 (linkId
179 ? linkResourceName(
180 resolvedProject,
181 resolvedLocation,
182 resolvedBucketId,
183 resolvedDatasetId,
184 linkId,
185 )
186 : ""),
187 linkId,
188 dataset: `projects/${resolvedProject}/locations/${resolvedLocation}/buckets/${resolvedBucketId}/datasets/${resolvedDatasetId}`,
189 datasetId: resolvedDatasetId,
190 bucket: `projects/${resolvedProject}/locations/${resolvedLocation}/buckets/${resolvedBucketId}`,
191 bucketId: resolvedBucketId,
192 project: resolvedProject,
193 location: resolvedLocation,
194 displayName: link.displayName,
195 description: parsed.description,
196 createTime: link.createTime,
197 };
198};
199
200const getByName = (name: string) =>
201 !isValidLinkName(name)
202 ? Effect.succeed(undefined)
203 : observability
204 .getProjectsLocationsBucketsDatasetsLinks({ name })
205 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
206
207const waitUntilPresent = (name: string) =>
208 getByName(name).pipe(
209 Effect.flatMap((link) =>
210 link
211 ? Effect.succeed(link)
212 : Effect.fail(new BucketsDatasetsLinkNotResolved({ name })),
213 ),
214 Effect.retry({
215 while: (error) =>
216 error._tag === "GCP.Observability.BucketsDatasetsLinkNotResolved",
217 times: 10,
218 schedule: Schedule.spaced("3 seconds"),
219 }),
220 );
221
222const waitUntilDeleted = (name: string) =>
223 getByName(name).pipe(
224 Effect.flatMap((link) =>
225 link === undefined
226 ? Effect.void
227 : Effect.fail(new BucketsDatasetsLinkStillExists({ name })),
228 ),
229 Effect.retry({
230 while: (error) =>
231 error._tag === "GCP.Observability.BucketsDatasetsLinkStillExists",
232 times: 10,
233 schedule: Schedule.spaced("3 seconds"),
234 }),
235 Effect.catchTag(
236 "GCP.Observability.BucketsDatasetsLinkStillExists",
237 () => Effect.void,
238 ),
239 );
240
241export const BucketsDatasetsLinkProvider = () =>
242 Provider.succeed(BucketsDatasetsLink, {
243 stables: [
244 "name",
245 "linkId",
246 "dataset",
247 "datasetId",
248 "bucket",
249 "bucketId",
250 "project",
251 "location",
252 "createTime",
253 ],
254
255 diff: Effect.fn(function* ({ news, olds, output }) {
256 if (!isResolved(news)) return undefined;
257 const previousId = olds?.linkId ?? output?.linkId;
258 const idChanged =
259 previousId !== undefined &&
260 news.linkId !== undefined &&
261 news.linkId !== previousId;
262 const previousDataset = olds?.dataset ?? output?.dataset;
263 const datasetChanged =
264 previousDataset !== undefined &&
265 news.dataset.includes("/datasets/") &&
266 news.dataset !== previousDataset;
267 const previousBucket = olds?.bucket ?? output?.bucket;
268 const bucketChanged =
269 previousBucket !== undefined &&
270 news.bucket !== undefined &&
271 news.bucket.includes("/buckets/") &&
272 news.bucket !== previousBucket;
273 if (!idChanged && !datasetChanged && !bucketChanged) return undefined;
274 return { action: "replace" as const, deleteFirst: true };
275 }),
276
277 read: Effect.fn(function* ({ id, olds, output }) {
278 const env = yield* GcpEnvironment.current;
279 const location = olds?.location ?? output?.location ?? env.region;
280 const parent = resolveDatasetParent(
281 olds?.dataset ?? output?.dataset ?? "",
282 olds?.bucket ?? output?.bucket,
283 location,
284 env.project,
285 );
286 const linkId = yield* toLinkId(id, olds?.linkId, output?.linkId);
287 const name =
288 output?.name ??
289 linkResourceName(
290 parent.project,
291 parent.location,
292 parent.bucketId,
293 parent.datasetId,
294 linkId,
295 );
296 const existing = yield* getByName(name);
297 if (existing === undefined) return undefined;
298 const attrs = toAttrs(
299 existing,
300 parent.project,
301 parent.location,
302 parent.bucketId,
303 parent.datasetId,
304 );
305 const { labels } = parseDescription(existing.description);
306 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
307 }),
308
309 list: () =>
310 Effect.gen(function* () {
311 const env = yield* GcpEnvironment.current;
312 const datasets = yield* listProjectDatasets();
313 const pages = yield* Effect.forEach(
314 datasets,
315 (dataset) =>
316 listLinksAt(dataset.name!).pipe(
317 Effect.map((links) =>
318 links
319 .filter((link) => hasOwnershipMarker(link.description))
320 .map((link) => {
321 const parsed = parseLinkName(link.name ?? "");
322 return toAttrs(
323 link,
324 parsed?.project ?? env.project,
325 parsed?.location ?? env.region,
326 parsed?.bucketId ?? "",
327 parsed?.datasetId ?? "",
328 );
329 }),
330 ),
331 ),
332 { concurrency: 4 },
333 );
334 return pages.flat();
335 }),
336
337 reconcile: Effect.fn(function* ({ id, news, output }) {
338 const env = yield* GcpEnvironment.current;
339 const location = news.location ?? output?.location ?? env.region;
340 const parent = resolveDatasetParent(
341 news.dataset,
342 news.bucket,
343 location,
344 env.project,
345 );
346 const linkId = yield* toLinkId(id, news.linkId, output?.linkId);
347 const name = linkResourceName(
348 parent.project,
349 parent.location,
350 parent.bucketId,
351 parent.datasetId,
352 linkId,
353 );
354 const ownership = yield* createInternalLabels(id);
355 const desiredDescription = encodeDescription(ownership, news.description);
356
357 let current = yield* getByName(output?.name ?? name);
358
359 if (current === undefined) {
360 const created = yield* observability
361 .createProjectsLocationsBucketsDatasetsLinks({
362 parent: parent.name,
363 linkId,
364 body: {
365 displayName: news.displayName,
366 description: desiredDescription,
367 },
368 })
369 .pipe(
370 Effect.catchTag("Conflict", () =>
371 Effect.succeed<observability.Operation>({ done: true }),
372 ),
373 );
374 yield* waitForOperation(created).pipe(
375 Effect.catchTag("NotFound", () => Effect.void),
376 );
377 current = yield* waitUntilPresent(name);
378 }
379
380 if (current === undefined) {
381 return yield* new BucketsDatasetsLinkNotResolved({ name });
382 }
383
384 const descriptionChanged =
385 (current.description ?? "") !== desiredDescription;
386 const displayNameChanged =
387 (current.displayName ?? "") !== (news.displayName ?? "");
388 const updateMask = [
389 descriptionChanged ? "description" : undefined,
390 displayNameChanged ? "displayName" : undefined,
391 ].filter((field): field is string => field !== undefined);
392
393 if (updateMask.length > 0) {
394 const patched =
395 yield* observability.patchProjectsLocationsBucketsDatasetsLinks({
396 name: current.name ?? name,
397 updateMask: updateMask.join(","),
398 body: {
399 displayName: news.displayName,
400 description: desiredDescription,
401 },
402 });
403 yield* waitForOperation(patched).pipe(
404 Effect.catchTag("NotFound", () => Effect.void),
405 );
406 current = (yield* getByName(current.name ?? name)) ?? current;
407 }
408
409 return toAttrs(
410 current,
411 parent.project,
412 parent.location,
413 parent.bucketId,
414 parent.datasetId,
415 );
416 }),
417
418 delete: Effect.fn(function* ({ output }) {
419 const names = isValidLinkName(output.name)
420 ? [output.name]
421 : output.linkId
422 ? yield* listProjectDatasets().pipe(
423 Effect.map((datasets) =>
424 datasets.flatMap((dataset) =>
425 dataset.name
426 ? [`${dataset.name}/links/${output.linkId}`]
427 : [],
428 ),
429 ),
430 )
431 : [];
432 yield* Effect.forEach(
433 names,
434 (name) =>
435 observability
436 .deleteProjectsLocationsBucketsDatasetsLinks({ name })
437 .pipe(
438 Effect.flatMap((operation) =>
439 waitForOperation(operation, { notFoundOk: true }),
440 ),
441 Effect.catchTag(["NotFound", "BadRequest"], () => Effect.void),
442 Effect.flatMap(() => waitUntilDeleted(name)),
443 ),
444 { concurrency: 1 },
445 );
446 }),
447 });