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