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 { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 DEFAULT_LOCATION,
15 billingAccountIdOf,
16 billingAccountParent,
17 encodeDescription,
18 hasOwnershipMarker,
19 lastSegment,
20 lookupProjectBillingAccountId,
21 parseDescription,
22 resolveBillingAccountId,
23 toLinkId,
24} from "./internal.ts";
25import { waitForDeleteOperation, waitForOperation } from "./operations.ts";
26
27export type BillingBucketsLinkProps = {
28 /**
29 * Link id (the `{link}` segment of
30 * `billingAccounts/{billingAccount}/locations/{location}/buckets/{bucket}/links/{link}`).
31 * If omitted, a unique name is generated from the stack, stage, and
32 * logical id. Must be alphanumeric plus underscores (BigQuery dataset
33 * naming). Immutable — changing it replaces the link.
34 */
35 linkId?: string;
36 /**
37 * Analytics-enabled bucket id that owns this link. A bucket may have
38 * only one link. Immutable — changing it replaces the link.
39 */
40 bucketId: string;
41 /**
42 * Billing account id (`XXXXXX-XXXXXX-XXXXXX` or
43 * `billingAccounts/{id}`). If omitted, Alchemy uses the billing
44 * account linked to the current project. Immutable — changing it
45 * replaces the link.
46 */
47 billingAccountId?: string;
48 /**
49 * Location of the parent bucket. Immutable — changing it replaces the
50 * link.
51 * @default "global"
52 */
53 location?: string;
54 /**
55 * Human-readable description (max 8000 characters). Links have no
56 * labels or update API, so Alchemy ownership is stored in a
57 * `[alchemy …]` prefix at create time and stripped from attributes.
58 */
59 description?: string;
60};
61
62export type BillingBucketsLink = Resource<
63 "GCP.Logging.BillingBucketsLink",
64 BillingBucketsLinkProps,
65 {
66 /** Full resource name `billingAccounts/{billingAccount}/locations/{location}/buckets/{bucket}/links/{link}`. */
67 name: string;
68 /** Link id (last path segment). */
69 linkId: string;
70 /** Parent bucket id. */
71 bucketId: string;
72 /** Billing account id. */
73 billingAccountId: string;
74 /** Location of the parent bucket. */
75 location: string;
76 /** User description with the Alchemy ownership prefix stripped. */
77 description: string | undefined;
78 /** Linked BigQuery dataset id, if reported. */
79 bigqueryDatasetId: string | undefined;
80 /** Link lifecycle (`ACTIVE`, `CREATING`, …). */
81 lifecycleState: string | undefined;
82 /** RFC3339 creation timestamp. */
83 createTime: string | undefined;
84 },
85 never,
86 Providers
87>;
88
89/**
90 * A BigQuery dataset link for a billing-account log bucket.
91 *
92 * Creating a link asynchronously provisions a BigQuery dataset (and views
93 * for each log view) so Log Analytics queries can run against the bucket.
94 * A bucket may currently contain only one link. Links have no update API
95 * — they are existence-only after create. Alchemy stamps ownership into
96 * the description at create time for `list` / nuke. `linkId`, `bucketId`,
97 * `location`, and `billingAccountId` are identity.
98 *
99 * ### Creating a Billing Bucket Link
100 * **Example:** Link an analytics-enabled bucket
101 * ```typescript
102 * const bucket = yield* GCP.Logging.BillingBucket("AppLogs", {
103 * analyticsEnabled: true,
104 * });
105 * const link = yield* GCP.Logging.BillingBucketsLink("Analytics", {
106 * bucketId: bucket.bucketId,
107 * location: bucket.location,
108 * billingAccountId: bucket.billingAccountId,
109 * description: "log analytics",
110 * });
111 * ```
112 *
113 * **Example:** Named link
114 * ```typescript
115 * const link = yield* GCP.Logging.BillingBucketsLink("Analytics", {
116 * billingAccountId: "AAAAAA-BBBBBB-CCCCCC",
117 * location: "global",
118 * bucketId: "app-logs",
119 * linkId: "app_logs_link",
120 * });
121 * ```
122 *
123 * @resource
124 * @category Logging
125 */
126export const BillingBucketsLink = Resource<BillingBucketsLink>(
127 "GCP.Logging.BillingBucketsLink",
128);
129
130export class BillingBucketsLinkNotResolved extends Data.TaggedError(
131 "GCP.Logging.BillingBucketsLinkNotResolved",
132)<{
133 name: string;
134}> {}
135
136const resourceName = (
137 billingAccountId: string,
138 location: string,
139 bucketId: string,
140 linkId: string,
141) =>
142 `${billingAccountParent(billingAccountId)}/locations/${location}/buckets/${bucketId}/links/${linkId}`;
143
144const parseLinkName = (name: string) => {
145 const match = name.match(
146 /^billingAccounts\/([^/]+)\/locations\/([^/]+)\/buckets\/([^/]+)\/links\/([^/]+)$/,
147 );
148 if (!match) return undefined;
149 return {
150 billingAccountId: match[1]!,
151 location: match[2]!,
152 bucketId: match[3]!,
153 linkId: match[4]!,
154 };
155};
156
157const isDeleted = (link: logging.Link | undefined): link is undefined =>
158 link === undefined || link.lifecycleState === "DELETE_REQUESTED";
159
160const isPending = (state: string | undefined) =>
161 state === "CREATING" || state === "UPDATING";
162
163const toAttrs = (
164 link: logging.Link,
165 billingAccountId: string,
166 location: string,
167 bucketId: string,
168) => {
169 const parsedName = parseLinkName(link.name ?? "");
170 const linkId = parsedName?.linkId ?? lastSegment(link.name ?? "");
171 const parsed = parseDescription(link.description);
172 const account = parsedName?.billingAccountId ?? billingAccountId;
173 const resolvedLocation = parsedName?.location ?? location;
174 const resolvedBucket = parsedName?.bucketId ?? bucketId;
175 return {
176 name:
177 link.name ??
178 (linkId
179 ? resourceName(account, resolvedLocation, resolvedBucket, linkId)
180 : ""),
181 linkId,
182 bucketId: resolvedBucket,
183 billingAccountId: account,
184 location: resolvedLocation,
185 description: parsed.description,
186 bigqueryDatasetId: link.bigqueryDataset?.datasetId,
187 lifecycleState: link.lifecycleState,
188 createTime: link.createTime,
189 };
190};
191
192const getByName = (name: string) =>
193 logging
194 .getBillingAccountsLocationsBucketsLinks({ name })
195 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
196
197const waitUntilActive = (name: string) =>
198 Effect.gen(function* () {
199 const link = yield* getByName(name);
200 if (link === undefined || isDeleted(link)) {
201 return yield* new BillingBucketsLinkNotResolved({ name });
202 }
203 if (link.lifecycleState === "FAILED") {
204 return yield* new BillingBucketsLinkNotResolved({ name });
205 }
206 if (isPending(link.lifecycleState)) {
207 return yield* new BillingBucketsLinkNotResolved({ name });
208 }
209 return link;
210 }).pipe(
211 Effect.retry({
212 while: (error) =>
213 error._tag === "GCP.Logging.BillingBucketsLinkNotResolved",
214 times: 10,
215 schedule: Schedule.spaced("3 seconds"),
216 }),
217 );
218
219const waitUntilDeleted = (name: string) =>
220 getByName(name).pipe(
221 Effect.flatMap((link) =>
222 isDeleted(link)
223 ? Effect.void
224 : Effect.fail(new BillingBucketsLinkNotResolved({ name })),
225 ),
226 Effect.retry({
227 while: (error) =>
228 error._tag === "GCP.Logging.BillingBucketsLinkNotResolved",
229 times: 10,
230 schedule: Schedule.spaced("3 seconds"),
231 }),
232 );
233
234export const BillingBucketsLinkProvider = () =>
235 Provider.succeed(BillingBucketsLink, {
236 stables: [
237 "name",
238 "linkId",
239 "bucketId",
240 "billingAccountId",
241 "location",
242 "createTime",
243 ],
244
245 diff: Effect.fn(function* ({ news, olds, output }) {
246 if (!isResolved(news)) return undefined;
247 const previousId = olds?.linkId ?? output?.linkId;
248 const idChanged =
249 previousId !== undefined &&
250 news.linkId !== undefined &&
251 news.linkId !== previousId;
252 const previousBucket = olds?.bucketId ?? output?.bucketId;
253 const bucketChanged =
254 previousBucket !== undefined && news.bucketId !== previousBucket;
255 const previousLocation = olds?.location ?? output?.location;
256 const locationChanged =
257 previousLocation !== undefined &&
258 news.location !== undefined &&
259 news.location !== previousLocation;
260 const previousAccount =
261 olds?.billingAccountId ?? output?.billingAccountId;
262 const accountChanged =
263 previousAccount !== undefined &&
264 news.billingAccountId !== undefined &&
265 billingAccountIdOf(news.billingAccountId) !==
266 billingAccountIdOf(previousAccount);
267 if (!idChanged && !bucketChanged && !locationChanged && !accountChanged) {
268 return undefined;
269 }
270 return { action: "replace" as const, deleteFirst: true };
271 }),
272
273 read: Effect.fn(function* ({ id, olds, output }) {
274 const billingAccountId = yield* resolveBillingAccountId(
275 olds?.billingAccountId,
276 output?.billingAccountId,
277 );
278 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
279 const bucketId = olds?.bucketId ?? output?.bucketId ?? "";
280 const linkId = yield* toLinkId(id, olds?.linkId, output?.linkId);
281 const name =
282 output?.name ??
283 resourceName(billingAccountId, location, bucketId, linkId);
284 const existing = yield* getByName(name);
285 if (isDeleted(existing)) return undefined;
286 const attrs = toAttrs(existing, billingAccountId, location, bucketId);
287 const { labels } = parseDescription(existing.description);
288 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
289 }),
290
291 list: () =>
292 Effect.gen(function* () {
293 const env = yield* GcpEnvironment.current;
294 const billingAccountId = yield* lookupProjectBillingAccountId(
295 env.project,
296 );
297 if (billingAccountId === undefined) return [];
298 const buckets = yield* logging.listBillingAccountsLocationsBuckets
299 .pages({
300 parent: `${billingAccountParent(billingAccountId)}/locations/-`,
301 pageSize: 1000,
302 })
303 .pipe(
304 Stream.flatMap((page) => Stream.fromIterable(page.buckets ?? [])),
305 Stream.runCollect,
306 Effect.map((chunk) => Array.from(chunk)),
307 Effect.catchTag("NotFound", () =>
308 Effect.succeed([] as logging.LogBucket[]),
309 ),
310 );
311 const listed: BillingBucketsLink["Attributes"][] = [];
312 for (const bucket of buckets) {
313 if (!bucket.name) continue;
314 const links = yield* logging.listBillingAccountsLocationsBucketsLinks
315 .pages({ parent: bucket.name, pageSize: 1000 })
316 .pipe(
317 Stream.flatMap((page) => Stream.fromIterable(page.links ?? [])),
318 Stream.filter(
319 (link) =>
320 !isDeleted(link) && hasOwnershipMarker(link.description),
321 ),
322 Stream.map((link) =>
323 toAttrs(
324 link,
325 billingAccountId,
326 DEFAULT_LOCATION,
327 lastSegment(bucket.name ?? ""),
328 ),
329 ),
330 Stream.runCollect,
331 Effect.map((chunk) => Array.from(chunk)),
332 Effect.catchTag("NotFound", () =>
333 Effect.succeed([] as BillingBucketsLink["Attributes"][]),
334 ),
335 );
336 listed.push(...links);
337 }
338 return listed;
339 }),
340
341 reconcile: Effect.fn(function* ({ id, news, output }) {
342 const billingAccountId = yield* resolveBillingAccountId(
343 news.billingAccountId,
344 output?.billingAccountId,
345 );
346 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
347 const bucketId = news.bucketId;
348 const linkId = yield* toLinkId(id, news.linkId, output?.linkId);
349 const name = resourceName(billingAccountId, location, bucketId, linkId);
350 const parent = `${billingAccountParent(billingAccountId)}/locations/${location}/buckets/${bucketId}`;
351 const ownership = yield* createInternalLabels(id);
352 const desiredDescription = encodeDescription(ownership, news.description);
353
354 let current = yield* getByName(output?.name ?? name);
355
356 if (current === undefined) {
357 const operation = yield* logging
358 .createBillingAccountsLocationsBucketsLinks({
359 parent,
360 linkId,
361 body: { description: desiredDescription },
362 })
363 .pipe(
364 Effect.catchTag("Conflict", () =>
365 Effect.succeed<logging.Operation>({ done: true }),
366 ),
367 );
368 if (operation.done !== true || operation.name) {
369 yield* waitForOperation(operation);
370 }
371 current = yield* waitUntilActive(name);
372 } else if (isPending(current.lifecycleState)) {
373 current = yield* waitUntilActive(current.name ?? name);
374 }
375
376 if (isDeleted(current)) {
377 return yield* new BillingBucketsLinkNotResolved({ name });
378 }
379
380 return toAttrs(current, billingAccountId, location, bucketId);
381 }),
382
383 delete: Effect.fn(function* ({ output }) {
384 if (output.lifecycleState === "DELETE_REQUESTED") return;
385 const operation = yield* logging
386 .deleteBillingAccountsLocationsBucketsLinks({ name: output.name })
387 .pipe(
388 Effect.catchTag("NotFound", () =>
389 Effect.succeed<logging.Operation>({ done: true }),
390 ),
391 );
392 if (operation.done !== true || operation.name) {
393 yield* waitForDeleteOperation(operation);
394 }
395 yield* waitUntilDeleted(output.name);
396 }),
397 });