1import * as acm from "@distilled.cloud/gcp/accesscontextmanager_v1";
2import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import {
10 alchemyLabelKeys,
11 createInternalLabels,
12 hasAlchemyLabels,
13} from "../Labels.ts";
14import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
15
16export const MAX_TITLE_LENGTH = 256;
17export const MAX_DESCRIPTION_LENGTH = 1024;
18export const MAX_RESOURCE_ID_LENGTH = 50;
19
20export class ResourceNotResolved extends Data.TaggedError(
21 "GCP.AccessContextManager.NotResolved",
22)<{
23 name: string;
24}> {}
25
26export class ResourceStillExists extends Data.TaggedError(
27 "GCP.AccessContextManager.StillExists",
28)<{
29 name: string;
30}> {}
31
32export class OrganizationRequired extends Data.TaggedError(
33 "GCP.AccessContextManager.OrganizationRequired",
34)<{
35 project: string;
36}> {}
37
38export const lastSegment = (value: string) => {
39 const trimmed = value.replace(/\/+$/, "");
40 const parts = trimmed.split("/");
41 return parts[parts.length - 1] || trimmed;
42};
43
44export const organizationParent = (value: string) =>
45 value.startsWith("organizations/")
46 ? value
47 : `organizations/${lastSegment(value)}`;
48
49export const policyNameOf = (policy: string) =>
50 policy.startsWith("accessPolicies/")
51 ? policy
52 : `accessPolicies/${lastSegment(policy)}`;
53
54export const policyIdOf = (policy: string) => lastSegment(policyNameOf(policy));
55
56export const resourceNameOf = (
57 policy: string,
58 collection: string,
59 id: string,
60) => `${policyNameOf(policy)}/${collection}/${id}`;
61
62export const parseName = (name: string, collection: string) => {
63 const parts = name.split("/").filter((part) => part.length > 0);
64 const collectionAt = parts.lastIndexOf(collection);
65 const policiesAt = parts.lastIndexOf("accessPolicies");
66 const orgsAt = parts.lastIndexOf("organizations");
67 return {
68 name,
69 policyId:
70 policiesAt >= 0 && parts[policiesAt + 1] ? parts[policiesAt + 1]! : "",
71 organization: orgsAt >= 0 && parts[orgsAt + 1] ? parts[orgsAt + 1]! : "",
72 id:
73 collectionAt >= 0 && parts[collectionAt + 1]
74 ? parts[collectionAt + 1]!
75 : lastSegment(name),
76 parent:
77 collectionAt > 0
78 ? parts.slice(0, collectionAt).join("/")
79 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
80 };
81};
82
83export const toAcmId = (
84 id: string,
85 requested: string | undefined,
86 existing: string | undefined,
87 maxLength = MAX_RESOURCE_ID_LENGTH,
88) =>
89 Effect.gen(function* () {
90 if (requested !== undefined) return requested;
91 if (existing !== undefined) return existing;
92 const generated = yield* createPhysicalName({
93 id,
94 maxLength,
95 lowercase: true,
96 delimiter: "_",
97 });
98 let next = generated.replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_");
99 if (!/^[a-zA-Z]/.test(next)) {
100 next = `a${next}`;
101 }
102 next = next.slice(0, maxLength).replace(/_+$/, "");
103 return next.length > 0 ? next : "a";
104 });
105
106const parentOf = (name: string) =>
107 name.startsWith("projects/")
108 ? resourcemanager.getProjects({ name }).pipe(
109 Effect.map((resource) => resource.parent),
110 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
111 )
112 : name.startsWith("folders/")
113 ? resourcemanager.getFolders({ name }).pipe(
114 Effect.map((folder) => folder.parent),
115 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
116 )
117 : Effect.succeed(undefined);
118
119export const tryResolveOrganization = () =>
120 Effect.gen(function* () {
121 const env = yield* GcpEnvironment.current;
122 let current: string | undefined = `projects/${env.project}`;
123 for (let i = 0; i < 8; i++) {
124 if (current === undefined) return undefined;
125 if (current.startsWith("organizations/")) return current;
126 current = yield* parentOf(current);
127 }
128 return undefined;
129 });
130
131export const resolveOrganization = (
132 explicit: string | undefined,
133 existing: string | undefined,
134) =>
135 Effect.gen(function* () {
136 if (explicit !== undefined) return organizationParent(explicit);
137 if (existing !== undefined) return organizationParent(existing);
138 const resolved = yield* tryResolveOrganization();
139 if (resolved === undefined) {
140 const env = yield* GcpEnvironment.current;
141 return yield* new OrganizationRequired({ project: env.project });
142 }
143 return resolved;
144 });
145
146export const projectNumberOf = (project: string) =>
147 resourcemanager.getProjects({ name: `projects/${project}` }).pipe(
148 Effect.map((resource) => lastSegment(resource.name ?? "")),
149 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
150 );
151
152export const normalizeScope = (scope: string, projectNumber?: string) => {
153 if (
154 scope.startsWith("projects/") ||
155 scope.startsWith("folders/") ||
156 scope.startsWith("organizations/")
157 ) {
158 return scope;
159 }
160 if (/^\d+$/.test(scope)) return `projects/${scope}`;
161 if (projectNumber !== undefined && projectNumber.length > 0) {
162 return `projects/${projectNumber}`;
163 }
164 return `projects/${scope}`;
165};
166
167export const normalizeScopes = (
168 scopes: readonly string[] | undefined,
169 projectNumber?: string,
170) => (scopes ?? []).map((scope) => normalizeScope(scope, projectNumber));
171
172const markerOf = (stack: string, stage: string, id: string) =>
173 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
174
175const fitMarker = (labels: Record<string, string>, maxLength: number) => {
176 let stack = labels[alchemyLabelKeys.stack] ?? "x";
177 let stage = labels[alchemyLabelKeys.stage] ?? "x";
178 let id = labels[alchemyLabelKeys.id] ?? "x";
179 let marker = markerOf(stack, stage, id);
180 while (
181 marker.length > maxLength &&
182 (stack.length > 1 || stage.length > 1 || id.length > 1)
183 ) {
184 if (stack.length >= stage.length && stack.length >= id.length) {
185 stack = stack.slice(0, -1);
186 } else if (stage.length >= id.length) {
187 stage = stage.slice(0, -1);
188 } else {
189 id = id.slice(0, -1);
190 }
191 marker = markerOf(stack, stage, id);
192 }
193 return marker.slice(0, maxLength);
194};
195
196export const encodeOwnership = (
197 labels: Record<string, string>,
198 text: string | undefined,
199 maxLength = MAX_DESCRIPTION_LENGTH,
200): string => {
201 const marker = fitMarker(labels, maxLength);
202 const trimmed = text?.trim();
203 if (!trimmed) return marker.slice(0, maxLength);
204 return `${marker}\n${trimmed}`.slice(0, maxLength);
205};
206
207export const encodeOwnershipLine = (
208 labels: Record<string, string>,
209 text: string | undefined,
210 maxLength = MAX_TITLE_LENGTH,
211): string => {
212 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
213 const reserved =
214 trimmed && trimmed.length > 0
215 ? Math.min(trimmed.length + 1, Math.max(0, maxLength - 24))
216 : 0;
217 const marker = fitMarker(labels, Math.max(24, maxLength - reserved));
218 if (!trimmed) return marker.slice(0, maxLength);
219 return `${marker} ${trimmed}`.slice(0, maxLength);
220};
221
222export const parseOwnership = (
223 text: string | undefined,
224): {
225 labels: Record<string, string>;
226 text: string | undefined;
227} => {
228 if (!text?.startsWith("[alchemy ")) {
229 return { labels: {}, text };
230 }
231 const end = text.indexOf("]");
232 if (end < 0) return { labels: {}, text };
233 const labels: Record<string, string> = {};
234 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
235 const eq = part.indexOf("=");
236 if (eq > 0) {
237 labels[part.slice(0, eq)] = part.slice(eq + 1);
238 }
239 }
240 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
241 return { labels, text: rest.length > 0 ? rest : undefined };
242};
243
244export const hasOwnershipMarker = (text: string | undefined) =>
245 Object.keys(parseOwnership(text).labels).some((key) =>
246 key.startsWith("alchemy-"),
247 );
248
249const prefixMatch = (expected: string, observed: string) =>
250 expected === observed ||
251 expected.startsWith(observed) ||
252 observed.startsWith(expected);
253
254export const ownedByAlchemy = (id: string, text: string | undefined) =>
255 Effect.gen(function* () {
256 const expected = yield* createInternalLabels(id);
257 const { labels } = parseOwnership(text);
258 if (!hasOwnershipMarker(text)) return false;
259 const exact = yield* hasAlchemyLabels(id, labels);
260 if (exact) return true;
261 return (
262 prefixMatch(
263 expected[alchemyLabelKeys.stack] ?? "",
264 labels[alchemyLabelKeys.stack] ?? "",
265 ) &&
266 prefixMatch(
267 expected[alchemyLabelKeys.stage] ?? "",
268 labels[alchemyLabelKeys.stage] ?? "",
269 ) &&
270 prefixMatch(
271 expected[alchemyLabelKeys.id] ?? "",
272 labels[alchemyLabelKeys.id] ?? "",
273 )
274 );
275 });
276
277export const jsonEqual = (left: unknown, right: unknown) =>
278 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
279
280export const sortedStrings = (values: readonly string[] | undefined) =>
281 [...(values ?? [])].slice().sort();
282
283export const sameStringList = (
284 left: readonly string[] | undefined,
285 right: readonly string[] | undefined,
286) =>
287 JSON.stringify(sortedStrings(left)) === JSON.stringify(sortedStrings(right));
288
289export const collectPages = <Page, A, E, R>(
290 pages: Stream.Stream<Page, E, R>,
291 items: (page: Page) => readonly A[] | undefined,
292) =>
293 pages.pipe(
294 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
295 Stream.runCollect,
296 Effect.map((chunk) => Array.from(chunk)),
297 );
298
299export const listAccessPolicies = (organization: string) =>
300 collectPages(
301 acm.listAccessPolicies.pages({
302 parent: organizationParent(organization),
303 pageSize: 100,
304 }),
305 (page) => page.accessPolicies,
306 ).pipe(
307 Effect.catchTag("NotFound", () => Effect.succeed([] as acm.AccessPolicy[])),
308 );
309
310export const listOwnedPolicies = () =>
311 Effect.gen(function* () {
312 const organization = yield* tryResolveOrganization();
313 if (organization === undefined) return [] as acm.AccessPolicy[];
314 const policies = yield* listAccessPolicies(organization);
315 return policies.filter((policy) => hasOwnershipMarker(policy.title));
316 });
317
318export const resourceNameFromOperation = (
319 operation: acm.Operation,
320 prefix?: string,
321): string | undefined => {
322 const name = operation.response?.name;
323 if (typeof name !== "string" || name.length === 0) return undefined;
324 if (prefix !== undefined && !name.startsWith(prefix)) return undefined;
325 return name;
326};
327
328/**
329 * Wait for an Access Context Manager operation (a few minutes at most) and
330 * return the finished operation so callers can read its `response`.
331 */
332export const waitForOperation = (operation: acm.Operation) =>
333 Effect.gen(function* () {
334 yield* waitForGcpOperation(
335 operation,
336 (name) => acm.getOperations({ name }),
337 { budget: "10 minutes" },
338 );
339 if (operation.done === true || !operation.name) return operation;
340 return yield* acm.getOperations({ name: operation.name });
341 });
342
343export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
344 get: Effect.Effect<A | undefined, E, R>,
345 name: string,
346) =>
347 get.pipe(
348 Effect.flatMap((value) =>
349 value
350 ? Effect.succeed(value)
351 : Effect.fail(new ResourceNotResolved({ name })),
352 ),
353 Effect.retry({
354 while: (error) => error._tag === "GCP.AccessContextManager.NotResolved",
355 times: 8,
356 schedule: Schedule.spaced("1 second"),
357 }),
358 );
359
360export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
361 get: Effect.Effect<A | undefined, E, R>,
362 name: string,
363) =>
364 get.pipe(
365 Effect.flatMap((value) =>
366 value === undefined
367 ? Effect.void
368 : Effect.fail(new ResourceStillExists({ name })),
369 ),
370 Effect.retry({
371 while: (error) => error._tag === "GCP.AccessContextManager.StillExists",
372 times: 10,
373 schedule: Schedule.spaced("2 seconds"),
374 }),
375 );
376
377export const replaceOnIdentity = (changed: boolean) =>
378 changed
379 ? ({ action: "replace" as const, deleteFirst: false } as const)
380 : undefined;