1import * as billingbudgets from "@distilled.cloud/gcp/billingbudgets_v1";
2import * as cloudbilling from "@distilled.cloud/gcp/cloudbilling_v1";
3import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Stream from "effect/Stream";
7import { GcpEnvironment } from "../Environment.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9
10export const MAX_DISPLAY_NAME_LENGTH = 60;
11
12export class BillingAccountNotResolved extends Data.TaggedError(
13 "GCP.BillingBudgets.BillingAccountNotResolved",
14)<{
15 project: string;
16}> {}
17
18export const lastSegment = (value: string) => {
19 const trimmed = value.replace(/\/+$/, "");
20 const parts = trimmed.split("/");
21 return parts[parts.length - 1] || trimmed;
22};
23
24export const billingAccountIdOf = (value: string) => lastSegment(value);
25
26export const billingAccountParent = (billingAccountId: string) =>
27 billingAccountId.startsWith("billingAccounts/")
28 ? billingAccountId
29 : `billingAccounts/${billingAccountId}`;
30
31export const budgetNameOf = (billingAccountId: string, budgetId: string) =>
32 `${billingAccountParent(billingAccountId)}/budgets/${budgetId}`;
33
34export const parseBudgetName = (
35 name: string,
36 fallbackAccount = "",
37): { billingAccountId: string; budgetId: string } => {
38 const parts = name.split("/").filter((part) => part.length > 0);
39 const accountsAt = parts.indexOf("billingAccounts");
40 const budgetsAt = parts.indexOf("budgets");
41 return {
42 billingAccountId:
43 accountsAt >= 0 && parts[accountsAt + 1]
44 ? parts[accountsAt + 1]!
45 : fallbackAccount,
46 budgetId:
47 budgetsAt >= 0 && parts[budgetsAt + 1]
48 ? parts[budgetsAt + 1]!
49 : lastSegment(name),
50 };
51};
52
53/** The display name Alchemy generates for a logical id when none is given. */
54export const generatedDisplayName = (id: string) =>
55 createPhysicalName({
56 id,
57 maxLength: 20,
58 lowercase: true,
59 });
60
61export const lookupProjectBillingAccountId = (project: string) =>
62 cloudbilling.getBillingInfoProjects({ name: `projects/${project}` }).pipe(
63 Effect.map((info) =>
64 info.billingAccountName
65 ? billingAccountIdOf(info.billingAccountName)
66 : undefined,
67 ),
68 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
69 );
70
71export const resolveBillingAccountId = (
72 explicit: string | undefined,
73 existing: string | undefined,
74) =>
75 Effect.gen(function* () {
76 if (explicit !== undefined && explicit.length > 0) {
77 return billingAccountIdOf(explicit);
78 }
79 if (existing !== undefined && existing.length > 0) {
80 return billingAccountIdOf(existing);
81 }
82 const env = yield* GcpEnvironment.current;
83 const resolved = yield* lookupProjectBillingAccountId(env.project);
84 if (resolved === undefined) {
85 return yield* new BillingAccountNotResolved({ project: env.project });
86 }
87 return resolved;
88 });
89
90export const jsonEqual = (left: unknown, right: unknown) =>
91 JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
92
93const canonicalize = (value: unknown): unknown => {
94 if (Array.isArray(value)) return value.map(canonicalize);
95 if (value !== null && typeof value === "object") {
96 return Object.fromEntries(
97 Object.entries(value as Record<string, unknown>)
98 .filter(([, item]) => item !== undefined)
99 .sort(([left], [right]) => left.localeCompare(right))
100 .map(([key, item]) => [key, canonicalize(item)]),
101 );
102 }
103 return value;
104};
105
106export const sortedStrings = (values: readonly string[] | undefined) =>
107 [...(values ?? [])].slice().sort();
108
109export const toProjectNumberRef = (value: string) => {
110 const id = lastSegment(value);
111 if (/^\d+$/.test(id)) return Effect.succeed(`projects/${id}`);
112 return resourcemanager.getProjects({ name: `projects/${id}` }).pipe(
113 Effect.map((project) => project.name ?? `projects/${id}`),
114 Effect.catchTag("NotFound", () => Effect.succeed(`projects/${id}`)),
115 );
116};
117
118export const resolveProjectRefs = (projects: readonly string[] | undefined) =>
119 projects === undefined
120 ? Effect.succeed(undefined as string[] | undefined)
121 : Effect.forEach(projects, toProjectNumberRef);
122
123export const filterChanged = (
124 observed: Record<string, unknown> | undefined,
125 desired: Record<string, unknown> | undefined,
126) => {
127 if (desired === undefined) return false;
128 const left = observed ?? {};
129 for (const [key, value] of Object.entries(desired)) {
130 if (value === undefined) continue;
131 if (!jsonEqual(left[key], value)) return true;
132 }
133 return false;
134};
135
136export const compact = <T extends Record<string, unknown>>(value: T): T =>
137 Object.fromEntries(
138 Object.entries(value).filter(([, item]) => item !== undefined),
139 ) as T;
140
141export const projectScope = (project: string) =>
142 project.startsWith("projects/") ? project : `projects/${project}`;
143
144export const getBudget = (name: string) =>
145 name.length === 0
146 ? Effect.succeed(undefined)
147 : billingbudgets.getBillingAccountsBudgets({ name }).pipe(
148 // Missing budgets return 403 "The caller does not have permission"
149 // rather than 404 (typed as BudgetNotFound).
150 Effect.catchTag(["NotFound", "BudgetNotFound"], () =>
151 Effect.succeed(undefined),
152 ),
153 );
154
155export const listBudgets = (parent: string, scope?: string) =>
156 parent.length === 0
157 ? Effect.succeed([] as billingbudgets.GoogleCloudBillingBudgetsV1Budget[])
158 : billingbudgets.listBillingAccountsBudgets
159 .pages({
160 parent,
161 pageSize: 100,
162 scope,
163 })
164 .pipe(
165 Stream.take(10),
166 Stream.flatMap((page) => Stream.fromIterable(page.budgets ?? [])),
167 Stream.runCollect,
168 Effect.map((chunk) => Array.from(chunk)),
169 Effect.catchTag("NotFound", () =>
170 Effect.succeed(
171 [] as billingbudgets.GoogleCloudBillingBudgetsV1Budget[],
172 ),
173 ),
174 );
175
176export const findBudgetByDisplayName = (parent: string, displayName: string) =>
177 Effect.gen(function* () {
178 const env = yield* GcpEnvironment.current;
179 const budgets = yield* listBudgets(parent, projectScope(env.project));
180 return budgets.find((budget) => budget.displayName === displayName);
181 });