1import * as cloudbilling from "@distilled.cloud/gcp/cloudbilling_v1";
2import * as logging from "@distilled.cloud/gcp/logging_v2";
3import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import {
9 alchemyLabelKeys,
10 createInternalLabels,
11 hasAlchemyLabels,
12} from "../Labels.ts";
13
14export const MAX_NAME_LENGTH = 100;
15export const DEFAULT_LOCATION = "global";
16export const DEFAULT_RETENTION_DAYS = 30;
17export const DEFAULT_BUCKET_ID = "_Default";
18
19export const lastSegment = (value: string) => {
20 const trimmed = value.replace(/\/+$/, "");
21 const parts = trimmed.split("/");
22 return parts[parts.length - 1] || trimmed;
23};
24
25export const encodeDescription = (
26 labels: Record<string, string>,
27 description: string | undefined,
28): string => {
29 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
30 return description ? `${marker}\n${description}` : marker;
31};
32
33export const parseDescription = (
34 description: string | undefined,
35): {
36 labels: Record<string, string>;
37 description: string | undefined;
38} => {
39 if (!description?.startsWith("[alchemy ")) {
40 return { labels: {}, description };
41 }
42 const end = description.indexOf("]");
43 if (end < 0) return { labels: {}, description };
44 const labels: Record<string, string> = {};
45 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
46 const eq = part.indexOf("=");
47 if (eq > 0) {
48 labels[part.slice(0, eq)] = part.slice(eq + 1);
49 }
50 }
51 const rest = description.slice(end + 1).replace(/^\n/, "");
52 return { labels, description: rest.length > 0 ? rest : undefined };
53};
54
55export const hasOwnershipMarker = (description: string | undefined) =>
56 Object.keys(parseDescription(description).labels).some((key) =>
57 key.startsWith("alchemy-"),
58 );
59
60export const toPhysicalId = (
61 id: string,
62 explicit: string | undefined,
63 existing: string | undefined,
64 prefix: string,
65 options?: { delimiter?: string; underscores?: boolean },
66) =>
67 Effect.gen(function* () {
68 if (explicit !== undefined) return explicit;
69 if (existing !== undefined) return existing;
70 const generated = yield* createPhysicalName({
71 id,
72 maxLength: MAX_NAME_LENGTH,
73 lowercase: true,
74 delimiter: options?.delimiter,
75 });
76 let next =
77 options?.underscores === true
78 ? generated.replace(/[^a-z0-9_]/g, "_").replace(/_+/g, "_")
79 : generated;
80 if (!/^[a-z0-9]/.test(next)) {
81 next = `${prefix}${next}`;
82 }
83 return next.slice(0, MAX_NAME_LENGTH);
84 });
85
86export const toLinkId = (
87 id: string,
88 explicit: string | undefined,
89 existing: string | undefined,
90) =>
91 Effect.gen(function* () {
92 if (explicit !== undefined) return explicit;
93 if (existing !== undefined) return existing;
94 const generated = yield* createPhysicalName({
95 id,
96 maxLength: MAX_NAME_LENGTH,
97 lowercase: true,
98 });
99 let next = generated.replace(/[^a-z0-9_]/g, "_").replace(/_+/g, "_");
100 if (!/^[a-z]/.test(next)) next = `l${next}`;
101 return next.replace(/_+$/g, "").slice(0, MAX_NAME_LENGTH) || "link";
102 });
103
104export const scopeParent = (project: string, folderId: string | undefined) =>
105 folderId !== undefined && folderId.length > 0
106 ? `folders/${lastSegment(folderId)}`
107 : `projects/${project}`;
108
109export const locationParent = (parent: string, location: string) =>
110 `${parent}/locations/${location}`;
111
112export const jsonEqual = (left: unknown, right: unknown) =>
113 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
114
115export const sorted = (values: readonly string[] | undefined) =>
116 [...(values ?? [])].slice().sort();
117
118export const createOwnership = (id: string) => createInternalLabels(id);
119
120export const ownedBy = (id: string, labels: Record<string, string>) =>
121 hasAlchemyLabels(id, labels);
122
123export type ParsedLoggingName = {
124 parent: string;
125 location: string | undefined;
126 bucketId: string | undefined;
127 viewId: string | undefined;
128 linkId: string | undefined;
129 logScopeId: string | undefined;
130 savedQueryId: string | undefined;
131 sinkId: string | undefined;
132};
133
134export const parseLoggingName = (name: string): ParsedLoggingName => {
135 const parts = name.split("/").filter((part) => part.length > 0);
136 const get = (key: string) => {
137 const index = parts.indexOf(key);
138 return index >= 0 ? parts[index + 1] : undefined;
139 };
140 const folder = get("folders");
141 const organization = get("organizations");
142 const billingAccount = get("billingAccounts");
143 const project = get("projects");
144 const parent =
145 folder !== undefined
146 ? `folders/${folder}`
147 : organization !== undefined
148 ? `organizations/${organization}`
149 : billingAccount !== undefined
150 ? `billingAccounts/${billingAccount}`
151 : project !== undefined
152 ? `projects/${project}`
153 : parts.slice(0, 2).join("/");
154 return {
155 parent,
156 location: get("locations"),
157 bucketId: get("buckets"),
158 viewId: get("views"),
159 linkId: get("links"),
160 logScopeId: get("logScopes"),
161 savedQueryId: get("savedQueries"),
162 sinkId: get("sinks"),
163 };
164};
165
166export const isDeletedBucket = (
167 bucket: logging.LogBucket | undefined,
168): bucket is undefined =>
169 bucket === undefined || bucket.lifecycleState === "DELETE_REQUESTED";
170
171export const isPendingBucket = (state: string | undefined) =>
172 state === "CREATING" || state === "UPDATING";
173
174export const canonRestricted = (fields: readonly string[] | undefined) =>
175 [...(fields ?? [])].slice().sort();
176
177export type LogBucketIndexConfig = {
178 fieldPath: string;
179 type: "INDEX_TYPE_STRING" | "INDEX_TYPE_INTEGER";
180};
181
182export const canonIndexConfigs = (
183 configs:
184 | readonly logging.IndexConfig[]
185 | readonly LogBucketIndexConfig[]
186 | undefined,
187): LogBucketIndexConfig[] =>
188 [...(configs ?? [])]
189 .flatMap((config) =>
190 config.fieldPath
191 ? [
192 {
193 fieldPath: config.fieldPath,
194 type: (config.type ??
195 "INDEX_TYPE_STRING") as LogBucketIndexConfig["type"],
196 },
197 ]
198 : [],
199 )
200 .sort((left, right) => left.fieldPath.localeCompare(right.fieldPath));
201
202export type SinkExclusion = {
203 name: string;
204 filter: string;
205 description?: string;
206 disabled?: boolean;
207};
208
209export const exclusionsOf = (
210 list: readonly logging.LogExclusion[] | readonly SinkExclusion[] | undefined,
211): SinkExclusion[] =>
212 (list ?? [])
213 .map((exclusion) => ({
214 name: exclusion.name ?? "",
215 filter: exclusion.filter ?? "",
216 description: exclusion.description,
217 disabled: exclusion.disabled === true,
218 }))
219 .sort((left, right) => left.name.localeCompare(right.name));
220
221export const sameExclusions = (
222 left: readonly logging.LogExclusion[] | readonly SinkExclusion[] | undefined,
223 right: readonly logging.LogExclusion[] | readonly SinkExclusion[] | undefined,
224) => jsonEqual(exclusionsOf(left), exclusionsOf(right));
225
226export const toExclusionsBody = (
227 list: readonly SinkExclusion[] | undefined,
228): logging.LogExclusion[] | undefined => {
229 if (list === undefined) return undefined;
230 return list.map((exclusion) => ({
231 name: exclusion.name,
232 filter: exclusion.filter,
233 description: exclusion.description,
234 disabled: exclusion.disabled === true ? true : undefined,
235 }));
236};
237
238export class BillingAccountNotResolved extends Data.TaggedError(
239 "GCP.Logging.BillingAccountNotResolved",
240)<{
241 project: string;
242}> {}
243
244export class FolderNotResolved extends Data.TaggedError(
245 "GCP.Logging.FolderNotResolved",
246)<{
247 project: string;
248}> {}
249
250export class OrganizationNotResolved extends Data.TaggedError(
251 "GCP.Logging.OrganizationNotResolved",
252)<{
253 project: string;
254}> {}
255
256export const billingAccountIdOf = (value: string) => lastSegment(value);
257
258export const billingAccountParent = (billingAccountId: string) =>
259 billingAccountId.startsWith("billingAccounts/")
260 ? billingAccountId
261 : `billingAccounts/${billingAccountId}`;
262
263export const folderParent = (folderId: string) =>
264 folderId.startsWith("folders/") ? folderId : `folders/${folderId}`;
265
266export const organizationIdOf = (value: string) => lastSegment(value);
267
268export const organizationParent = (value: string) =>
269 value.startsWith("organizations/")
270 ? value
271 : `organizations/${lastSegment(value)}`;
272
273export const lookupProjectBillingAccountId = (project: string) =>
274 cloudbilling.getBillingInfoProjects({ name: `projects/${project}` }).pipe(
275 Effect.map((info) =>
276 info.billingAccountName
277 ? billingAccountIdOf(info.billingAccountName)
278 : undefined,
279 ),
280 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
281 );
282
283export const resolveBillingAccountId = (
284 explicit: string | undefined,
285 existing: string | undefined,
286) =>
287 Effect.gen(function* () {
288 if (explicit !== undefined) return billingAccountIdOf(explicit);
289 if (existing !== undefined) return billingAccountIdOf(existing);
290 const env = yield* GcpEnvironment.current;
291 const resolved = yield* lookupProjectBillingAccountId(env.project);
292 if (resolved === undefined) {
293 return yield* new BillingAccountNotResolved({ project: env.project });
294 }
295 return resolved;
296 });
297
298export const lookupProjectFolderId = (project: string) =>
299 resourcemanager.getProjects({ name: `projects/${project}` }).pipe(
300 Effect.map((resource) => {
301 const parent = resource.parent ?? "";
302 return parent.startsWith("folders/") ? lastSegment(parent) : undefined;
303 }),
304 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
305 );
306
307export const resolveFolderId = (
308 explicit: string | undefined,
309 existing: string | undefined,
310) =>
311 Effect.gen(function* () {
312 if (explicit !== undefined) return lastSegment(explicit);
313 if (existing !== undefined) return lastSegment(existing);
314 const env = yield* GcpEnvironment.current;
315 const resolved = yield* lookupProjectFolderId(env.project);
316 if (resolved === undefined) {
317 return yield* new FolderNotResolved({ project: env.project });
318 }
319 return resolved;
320 });
321
322const parentOf = (name: string) =>
323 name.startsWith("projects/")
324 ? resourcemanager.getProjects({ name }).pipe(
325 Effect.map((resource) => resource.parent),
326 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
327 )
328 : name.startsWith("folders/")
329 ? resourcemanager.getFolders({ name }).pipe(
330 Effect.map((folder) => folder.parent),
331 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
332 )
333 : Effect.succeed(undefined);
334
335export const tryResolveOrganization = () =>
336 Effect.gen(function* () {
337 const env = yield* GcpEnvironment.current;
338 let current: string | undefined = `projects/${env.project}`;
339 for (let i = 0; i < 8; i++) {
340 if (current === undefined) return undefined;
341 if (current.startsWith("organizations/")) return current;
342 current = yield* parentOf(current);
343 }
344 return undefined;
345 });
346
347export const resolveOrganization = (
348 explicit: string | undefined,
349 existing: string | undefined,
350) =>
351 Effect.gen(function* () {
352 if (explicit !== undefined) return organizationParent(explicit);
353 if (existing !== undefined) return organizationParent(existing);
354 const resolved = yield* tryResolveOrganization();
355 if (resolved === undefined) {
356 const env = yield* GcpEnvironment.current;
357 return yield* new OrganizationNotResolved({ project: env.project });
358 }
359 return resolved;
360 });