1import * as cloudcontrolspartner from "@distilled.cloud/gcp/cloudcontrolspartner_v1";
2import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
3import * as Data from "effect/Data";
4import * as Config from "effect/Config";
5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";
7import * as Stream from "effect/Stream";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 alchemyLabelKeys,
12 createInternalLabels,
13 hasAlchemyLabels,
14} from "../Labels.ts";
15
16export const MAX_DISPLAY_NAME_LENGTH = 128;
17export const LIST_LOCATIONS = ["global", "-"] as const;
18
19export class OrganizationNotResolved extends Data.TaggedError(
20 "GCP.CloudControlsPartner.OrganizationNotResolved",
21)<{
22 project: string;
23}> {}
24
25export class CustomerIdRequired extends Data.TaggedError(
26 "GCP.CloudControlsPartner.CustomerIdRequired",
27)<{
28 id: string;
29}> {}
30
31export const lastSegment = (value: string) => {
32 const trimmed = value.replace(/\/+$/, "");
33 const parts = trimmed.split("/");
34 return parts[parts.length - 1] || trimmed;
35};
36
37export const normalizeName = (value: string) => value.replace(/\/+$/, "");
38
39export const organizationParent = (value: string) =>
40 value.startsWith("organizations/")
41 ? `organizations/${lastSegment(value.split("/locations/")[0] ?? value)}`
42 : `organizations/${lastSegment(value)}`;
43
44export const organizationIdOf = (value: string) =>
45 lastSegment(organizationParent(value));
46
47export const normalizeLocation = (location: string) =>
48 lastSegment(location).toLowerCase();
49
50export const locationParent = (organization: string, location: string) =>
51 `${organizationParent(organization)}/locations/${normalizeLocation(location)}`;
52
53export const customerName = (
54 organization: string,
55 location: string,
56 customerId: string,
57) => `${locationParent(organization, location)}/customers/${customerId}`;
58
59export const parseName = (name: string) => {
60 const parts = name.split("/").filter((part) => part.length > 0);
61 const orgsAt = parts.lastIndexOf("organizations");
62 const locationsAt = parts.lastIndexOf("locations");
63 const customersAt = parts.lastIndexOf("customers");
64 return {
65 organization:
66 orgsAt >= 0 && parts[orgsAt + 1]
67 ? `organizations/${parts[orgsAt + 1]}`
68 : "",
69 location:
70 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
71 customerId:
72 customersAt >= 0 && parts[customersAt + 1]
73 ? parts[customersAt + 1]!
74 : lastSegment(name),
75 parent:
76 customersAt > 0
77 ? parts.slice(0, customersAt).join("/")
78 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
79 };
80};
81
82export const toCustomerId = (value: string | undefined) => {
83 if (value === undefined) return undefined;
84 const trimmed = normalizeName(value);
85 if (trimmed.length === 0) return undefined;
86 if (trimmed.includes("/customers/")) return lastSegment(trimmed);
87 return lastSegment(trimmed);
88};
89
90export const sameText = (left: string | undefined, right: string | undefined) =>
91 (left ?? "") === (right ?? "");
92
93export const replaceOn = (
94 previous: string | undefined,
95 next: string | undefined,
96) =>
97 previous !== undefined && next !== undefined && previous !== next
98 ? ({ action: "replace" as const, deleteFirst: false } as const)
99 : undefined;
100
101export const toDisplayName = (
102 id: string,
103 requested: string | undefined,
104 existing: string | undefined,
105) =>
106 Effect.gen(function* () {
107 if (requested !== undefined && requested.length > 0) return requested;
108 if (existing !== undefined && existing.length > 0) return existing;
109 return yield* createPhysicalName({
110 id,
111 maxLength: 40,
112 lowercase: true,
113 });
114 });
115
116export const toCustomerResourceId = (
117 id: string,
118 requested: string | undefined,
119 existing: string | undefined,
120) =>
121 Effect.gen(function* () {
122 const explicit = toCustomerId(requested);
123 if (explicit !== undefined) return explicit;
124 const previous = toCustomerId(existing);
125 if (previous !== undefined) return previous;
126 return yield* new CustomerIdRequired({ id });
127 });
128
129const markerOf = (stack: string, stage: string, id: string) =>
130 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
131
132const fitMarker = (labels: Record<string, string>, maxLength: number) => {
133 let stack = labels[alchemyLabelKeys.stack] ?? "x";
134 let stage = labels[alchemyLabelKeys.stage] ?? "x";
135 let id = labels[alchemyLabelKeys.id] ?? "x";
136 let marker = markerOf(stack, stage, id);
137 while (
138 marker.length > maxLength &&
139 (stack.length > 1 || stage.length > 1 || id.length > 1)
140 ) {
141 if (stack.length >= stage.length && stack.length >= id.length) {
142 stack = stack.slice(0, -1);
143 } else if (stage.length >= id.length) {
144 stage = stage.slice(0, -1);
145 } else {
146 id = id.slice(0, -1);
147 }
148 marker = markerOf(stack, stage, id);
149 }
150 return marker.slice(0, maxLength);
151};
152
153export const encodeDisplayName = (
154 labels: Record<string, string>,
155 text: string | undefined,
156 maxLength = MAX_DISPLAY_NAME_LENGTH,
157): string => {
158 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
159 if (!trimmed) return fitMarker(labels, maxLength);
160 const reserved = Math.min(trimmed.length + 1, Math.max(0, maxLength - 24));
161 const marker = fitMarker(labels, Math.max(24, maxLength - reserved));
162 return `${marker} ${trimmed}`.slice(0, maxLength);
163};
164
165export const parseOwnership = (
166 text: string | undefined,
167): {
168 labels: Record<string, string>;
169 text: string | undefined;
170} => {
171 if (!text?.startsWith("[alchemy ")) {
172 return { labels: {}, text };
173 }
174 const end = text.indexOf("]");
175 if (end < 0) return { labels: {}, text };
176 const labels: Record<string, string> = {};
177 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
178 const eq = part.indexOf("=");
179 if (eq > 0) {
180 labels[part.slice(0, eq)] = part.slice(eq + 1);
181 }
182 }
183 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
184 return { labels, text: rest.length > 0 ? rest : undefined };
185};
186
187export const hasOwnershipMarker = (text: string | undefined) =>
188 Object.keys(parseOwnership(text).labels).some((key) =>
189 key.startsWith("alchemy-"),
190 );
191
192const prefixMatch = (expected: string, observed: string) =>
193 expected === observed ||
194 expected.startsWith(observed) ||
195 observed.startsWith(expected);
196
197export const ownedByAlchemy = (id: string, text: string | undefined) =>
198 Effect.gen(function* () {
199 const expected = yield* createInternalLabels(id);
200 const { labels } = parseOwnership(text);
201 if (!hasOwnershipMarker(text)) return false;
202 const exact = yield* hasAlchemyLabels(id, labels);
203 if (exact) return true;
204 return (
205 prefixMatch(
206 expected[alchemyLabelKeys.stack] ?? "",
207 labels[alchemyLabelKeys.stack] ?? "",
208 ) &&
209 prefixMatch(
210 expected[alchemyLabelKeys.stage] ?? "",
211 labels[alchemyLabelKeys.stage] ?? "",
212 ) &&
213 prefixMatch(
214 expected[alchemyLabelKeys.id] ?? "",
215 labels[alchemyLabelKeys.id] ?? "",
216 )
217 );
218 });
219
220export const ownershipLabels = (id: string) => createInternalLabels(id);
221
222const ancestryParent = (name: string) =>
223 name.startsWith("projects/")
224 ? resourcemanager.getProjects({ name }).pipe(
225 Effect.map((resource) => resource.parent),
226 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
227 )
228 : name.startsWith("folders/")
229 ? resourcemanager.getFolders({ name }).pipe(
230 Effect.map((folder) => folder.parent),
231 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
232 )
233 : Effect.succeed(undefined);
234
235/** Organization from the ConfigProvider (env by default), if configured. */
236const configuredOrganization = () =>
237 Effect.gen(function* () {
238 for (const key of [
239 "GOOGLE_CLOUDCONTROLSPARTNER_ORGANIZATION",
240 "GOOGLE_ORGANIZATION_ID",
241 ]) {
242 const raw = Option.getOrUndefined(
243 yield* Config.option(Config.String(key)),
244 )?.trim();
245 if (raw) return organizationParent(raw);
246 }
247 return undefined;
248 });
249
250export const tryResolveOrganization = () =>
251 Effect.gen(function* () {
252 const configured = yield* configuredOrganization();
253 if (configured !== undefined) return configured;
254 const env = yield* GcpEnvironment.current;
255 let current: string | undefined = `projects/${env.project}`;
256 for (let i = 0; i < 8; i++) {
257 if (current === undefined) return undefined;
258 if (current.startsWith("organizations/")) return current;
259 current = yield* ancestryParent(current);
260 }
261 return undefined;
262 });
263
264export const resolveOrganization = (
265 explicit: string | undefined,
266 existing: string | undefined,
267) =>
268 Effect.gen(function* () {
269 if (explicit !== undefined && explicit.length > 0) {
270 return organizationParent(explicit);
271 }
272 if (existing !== undefined && existing.length > 0) {
273 return organizationParent(existing);
274 }
275 const resolved = yield* tryResolveOrganization();
276 if (resolved === undefined) {
277 const env = yield* GcpEnvironment.current;
278 return yield* new OrganizationNotResolved({ project: env.project });
279 }
280 return resolved;
281 });
282
283export const listLocationParents = (organization: string, region: string) =>
284 [...new Set([region, ...LIST_LOCATIONS])].map((location) =>
285 locationParent(organization, location),
286 );
287
288const emptyCustomers = () =>
289 Effect.succeed([] as cloudcontrolspartner.Customer[]);
290
291export const listCustomers = (parent: string) =>
292 parent.length === 0
293 ? emptyCustomers()
294 : cloudcontrolspartner.listOrganizationsLocationsCustomers
295 .pages({ parent, pageSize: 100 })
296 .pipe(
297 Stream.flatMap((page) => Stream.fromIterable(page.customers ?? [])),
298 Stream.runCollect,
299 Effect.map((chunk) => Array.from(chunk)),
300 Effect.catchTag("NotFound", () => emptyCustomers()),
301 );
302
303export const getCustomer = (name: string) =>
304 name.length === 0
305 ? Effect.succeed(undefined)
306 : cloudcontrolspartner
307 .getOrganizationsLocationsCustomers({ name })
308 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
309
310export const findOwnedCustomer = (id: string, parent: string) =>
311 Effect.gen(function* () {
312 const customers = yield* listCustomers(parent);
313 for (const customer of customers) {
314 if (yield* ownedByAlchemy(id, customer.displayName)) {
315 return customer;
316 }
317 }
318 return undefined;
319 });