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 * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import { waitForDeleteOperation } from "./operations.ts";
13import {
14 createOwnership,
15 encodeDescription,
16 hasOwnershipMarker,
17 lastSegment,
18 ownedBy,
19 parseDescription,
20 parseLoggingName,
21 toLinkId,
22} from "./internal.ts";
23
24export type LocationsBucketsLinkProps = {
25 /**
26 * Full resource name of the parent analytics-enabled log bucket
27 * (`{parent}/locations/{location}/buckets/{bucket}`). A bucket may
28 * contain only one link. Immutable — changing it replaces the link
29 * (delete first).
30 */
31 bucketName: string;
32 /**
33 * Link id (also the BigQuery dataset id). If omitted, a unique name is
34 * generated. Up to 100 characters: letters, digits, and underscores.
35 * Immutable — changing it replaces the link (delete first).
36 */
37 linkId?: string;
38 /**
39 * Human-readable description. Links have no labels field, so Alchemy
40 * ownership is stored in a `[alchemy …]` prefix and stripped from
41 * attributes. Changing the description replaces the link.
42 */
43 description?: string;
44};
45
46export type LocationsBucketsLink = Resource<
47 "GCP.Logging.LocationsBucketsLink",
48 LocationsBucketsLinkProps,
49 {
50 /** Full resource name `{bucket}/links/{linkId}`. */
51 name: string;
52 /** Link id (last path segment). */
53 linkId: string;
54 /** Parent bucket resource name. */
55 bucketName: string;
56 /** User description with the Alchemy ownership prefix stripped. */
57 description: string | undefined;
58 /** BigQuery dataset resource name, if created. */
59 bigqueryDatasetId: string | undefined;
60 /** Link lifecycle (`ACTIVE`, `CREATING`, …). */
61 lifecycleState: string | undefined;
62 /** RFC3339 creation timestamp. */
63 createTime: string | undefined;
64 },
65 never,
66 Providers
67>;
68
69/**
70 * A BigQuery linked dataset for an analytics-enabled log bucket, managed
71 * through the generic locations APIs.
72 *
73 * Create is asynchronous and also provisions a BigQuery dataset whose id
74 * matches `linkId`. A bucket may currently contain only one link. Links
75 * have no labels field, so Alchemy stamps ownership into the description
76 * for `list` / nuke. There is no update API — changing `linkId`,
77 * `bucketName`, or `description` replaces the link (delete first).
78 *
79 * ### Creating a Locations Bucket Link
80 * **Example:** Link an analytics bucket to BigQuery
81 * ```typescript
82 * const bucket = yield* GCP.Logging.LocationsBucket("Analytics", {
83 * analyticsEnabled: true,
84 * });
85 * const link = yield* GCP.Logging.LocationsBucketsLink("Bq", {
86 * bucketName: bucket.name,
87 * description: "log analytics dataset",
88 * });
89 * ```
90 *
91 * @resource
92 * @category Logging
93 */
94export const LocationsBucketsLink = Resource<LocationsBucketsLink>(
95 "GCP.Logging.LocationsBucketsLink",
96);
97
98export class LocationsBucketsLinkNotResolved extends Data.TaggedError(
99 "GCP.Logging.LocationsBucketsLinkNotResolved",
100)<{
101 name: string;
102}> {}
103
104export class LocationsBucketsLinkFailed extends Data.TaggedError(
105 "GCP.Logging.LocationsBucketsLinkFailed",
106)<{
107 name: string;
108 state: string | undefined;
109}> {}
110
111const resourceName = (bucketName: string, linkId: string) =>
112 `${bucketName}/links/${linkId}`;
113
114const isDeleted = (link: logging.Link | undefined): link is undefined =>
115 link === undefined || link.lifecycleState === "DELETE_REQUESTED";
116
117const isPending = (state: string | undefined) =>
118 state === "CREATING" || state === "UPDATING";
119
120const toAttrs = (link: logging.Link, bucketName: string) => {
121 const parsed = parseLoggingName(link.name ?? "");
122 const linkId = parsed.linkId ?? lastSegment(link.name ?? "");
123 const description = parseDescription(link.description);
124 return {
125 name: link.name ?? resourceName(bucketName, linkId),
126 linkId,
127 bucketName,
128 description: description.description,
129 bigqueryDatasetId: link.bigqueryDataset?.datasetId,
130 lifecycleState: link.lifecycleState,
131 createTime: link.createTime,
132 };
133};
134
135const getByName = (name: string) =>
136 logging
137 .getLocationsBucketsLinks({ name })
138 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
139
140const waitUntilActive = (name: string) =>
141 Effect.gen(function* () {
142 const link = yield* getByName(name);
143 if (link === undefined || isDeleted(link)) {
144 return yield* new LocationsBucketsLinkNotResolved({ name });
145 }
146 if (link.lifecycleState === "FAILED") {
147 return yield* new LocationsBucketsLinkFailed({
148 name,
149 state: link.lifecycleState,
150 });
151 }
152 if (isPending(link.lifecycleState)) {
153 return yield* new LocationsBucketsLinkNotResolved({ name });
154 }
155 return link;
156 }).pipe(
157 Effect.retry({
158 while: (error) =>
159 error._tag === "GCP.Logging.LocationsBucketsLinkNotResolved",
160 times: 10,
161 schedule: Schedule.spaced("5 seconds"),
162 }),
163 );
164
165export const LocationsBucketsLinkProvider = () =>
166 Provider.succeed(LocationsBucketsLink, {
167 stables: ["name", "linkId", "bucketName", "createTime"],
168
169 diff: Effect.fn(function* ({ news, olds, output }) {
170 if (!isResolved(news)) return undefined;
171 const previousId = olds?.linkId ?? output?.linkId;
172 const idChanged =
173 previousId !== undefined &&
174 news.linkId !== undefined &&
175 news.linkId !== previousId;
176 const previousBucket = olds?.bucketName ?? output?.bucketName;
177 const bucketChanged =
178 previousBucket !== undefined && news.bucketName !== previousBucket;
179 const previousDescription = olds?.description ?? output?.description;
180 const descriptionChanged =
181 news.description !== undefined &&
182 previousDescription !== undefined &&
183 news.description !== previousDescription;
184 if (!idChanged && !bucketChanged && !descriptionChanged) {
185 return undefined;
186 }
187 return { action: "replace" as const, deleteFirst: true };
188 }),
189
190 read: Effect.fn(function* ({ id, olds, output }) {
191 const linkId = yield* toLinkId(id, olds?.linkId, output?.linkId);
192 const bucketName = olds?.bucketName ?? output?.bucketName;
193 if (bucketName === undefined) return undefined;
194 const name = output?.name ?? resourceName(bucketName, linkId);
195 const existing = yield* getByName(name);
196 if (isDeleted(existing)) return undefined;
197 const attrs = toAttrs(existing, bucketName);
198 const { labels } = parseDescription(existing.description);
199 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
200 }),
201
202 list: () =>
203 Effect.gen(function* () {
204 const env = yield* GcpEnvironment.current;
205 const buckets = yield* logging.listLocationsBuckets
206 .pages({
207 parent: `projects/${env.project}/locations/-`,
208 pageSize: 1000,
209 })
210 .pipe(
211 Stream.flatMap((page) => Stream.fromIterable(page.buckets ?? [])),
212 Stream.filter((bucket) => bucket.analyticsEnabled === true),
213 Stream.runCollect,
214 Effect.map((chunk) => Array.from(chunk)),
215 );
216 const links: ReturnType<typeof toAttrs>[] = [];
217 for (const bucket of buckets) {
218 if (bucket.name === undefined) continue;
219 const listed = yield* logging.listLocationsBucketsLinks
220 .pages({
221 parent: bucket.name,
222 pageSize: 1000,
223 })
224 .pipe(
225 Stream.flatMap((page) => Stream.fromIterable(page.links ?? [])),
226 Stream.filter(
227 (link) =>
228 !isDeleted(link) && hasOwnershipMarker(link.description),
229 ),
230 Stream.map((link) => toAttrs(link, bucket.name ?? "")),
231 Stream.runCollect,
232 Effect.map((chunk) => Array.from(chunk)),
233 Effect.catchTag("NotFound", () =>
234 Effect.succeed([] as ReturnType<typeof toAttrs>[]),
235 ),
236 );
237 links.push(...listed);
238 }
239 return links;
240 }),
241
242 reconcile: Effect.fn(function* ({ id, news, output }) {
243 const linkId = yield* toLinkId(id, news.linkId, output?.linkId);
244 const name = resourceName(news.bucketName, linkId);
245 const ownership = yield* createOwnership(id);
246 const desiredDescription = encodeDescription(ownership, news.description);
247
248 let current = yield* getByName(output?.name ?? name);
249
250 if (
251 current !== undefined &&
252 current.lifecycleState === "DELETE_REQUESTED"
253 ) {
254 current = undefined;
255 }
256
257 if (current === undefined) {
258 yield* logging
259 .createLocationsBucketsLinks({
260 parent: news.bucketName,
261 linkId,
262 body: { description: desiredDescription },
263 })
264 .pipe(
265 Effect.catchTag("Conflict", () =>
266 Effect.succeed<logging.Operation>({ done: true }),
267 ),
268 Effect.asVoid,
269 );
270 current = yield* waitUntilActive(name);
271 }
272
273 if (isDeleted(current)) {
274 return yield* new LocationsBucketsLinkNotResolved({ name });
275 }
276
277 if (isPending(current.lifecycleState)) {
278 current = yield* waitUntilActive(current.name ?? name);
279 }
280
281 return toAttrs(current, news.bucketName);
282 }),
283
284 delete: Effect.fn(function* ({ output }) {
285 if (output.lifecycleState === "DELETE_REQUESTED") return;
286 yield* logging.deleteLocationsBucketsLinks({ name: output.name }).pipe(
287 Effect.flatMap((operation) => waitForDeleteOperation(operation)),
288 Effect.catchTag(["NotFound", "BadRequest"], () => Effect.void),
289 );
290 yield* getByName(output.name).pipe(
291 Effect.flatMap((link) =>
292 isDeleted(link)
293 ? Effect.void
294 : Effect.fail(
295 new LocationsBucketsLinkNotResolved({ name: output.name }),
296 ),
297 ),
298 Effect.retry({
299 while: (error) =>
300 error._tag === "GCP.Logging.LocationsBucketsLinkNotResolved",
301 times: 10,
302 schedule: Schedule.spaced("3 seconds"),
303 }),
304 );
305 }),
306 });