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