1import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { GcpEnvironment } from "../Environment.ts";
6import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
7
8export const FOLDER_DISPLAY_MAX = 30;
9export const PROJECT_ID_MIN = 6;
10export const PROJECT_ID_MAX = 30;
11export const PROJECT_DISPLAY_MIN = 4;
12export const PROJECT_DISPLAY_MAX = 30;
13export const LIEN_REASON_MAX = 200;
14export const LIEN_ORIGIN_MAX = 200;
15export const DEFAULT_LIEN_ORIGIN = "alchemy.effect";
16export const DEFAULT_LIEN_RESTRICTIONS = [
17 "resourcemanager.projects.delete",
18] as const;
19
20export class ParentRequired extends Data.TaggedError(
21 "GCP.ResourceManager.ParentRequired",
22)<{
23 project: string;
24}> {}
25
26export const lastSegment = (value: string) => {
27 const trimmed = value.replace(/\/+$/, "");
28 const parts = trimmed.split("/");
29 return parts[parts.length - 1] || trimmed;
30};
31
32export const organizationParent = (value: string) =>
33 value.startsWith("organizations/")
34 ? value
35 : `organizations/${lastSegment(value)}`;
36
37export const folderParent = (value: string) =>
38 value.startsWith("folders/") ? value : `folders/${lastSegment(value)}`;
39
40export const projectParent = (value: string) =>
41 value.startsWith("projects/") ? value : `projects/${lastSegment(value)}`;
42
43export const normalizeHierarchyParent = (value: string) => {
44 if (
45 value.startsWith("organizations/") ||
46 value.startsWith("folders/") ||
47 value.startsWith("projects/")
48 ) {
49 return value;
50 }
51 if (value.startsWith("orgs/")) {
52 return `organizations/${value.slice("orgs/".length)}`;
53 }
54 return folderParent(value);
55};
56
57export const sameHierarchyParent = (
58 left: string | undefined,
59 right: string | undefined,
60) => {
61 if (left === undefined || right === undefined) return left === right;
62 if (left === right) return true;
63 const leftKind = left.split("/")[0];
64 const rightKind = right.split("/")[0];
65 return leftKind === rightKind && lastSegment(left) === lastSegment(right);
66};
67
68export const isDeleteRequested = (state: string | undefined) =>
69 state === "DELETE_REQUESTED";
70
71export const resourceNameFromOperation = (
72 operation: resourcemanager.Operation,
73 prefix: string,
74): string | undefined => {
75 const name = operation.response?.name;
76 return typeof name === "string" && name.startsWith(prefix) ? name : undefined;
77};
78
79export const projectIdFromOperation = (
80 operation: resourcemanager.Operation,
81): string | undefined => {
82 const projectId = operation.response?.projectId;
83 return typeof projectId === "string" && projectId.length > 0
84 ? projectId
85 : undefined;
86};
87
88const parentOf = (name: string) =>
89 name.startsWith("projects/")
90 ? resourcemanager.getProjects({ name }).pipe(
91 Effect.map((resource) => resource.parent),
92 Effect.catchTag(["NotFound", "Forbidden", "ProjectNotFound"], () =>
93 Effect.succeed(undefined),
94 ),
95 )
96 : name.startsWith("folders/")
97 ? resourcemanager.getFolders({ name }).pipe(
98 Effect.map((folder) => folder.parent),
99 Effect.catchTag(["NotFound", "Forbidden", "FolderNotFound"], () =>
100 Effect.succeed(undefined),
101 ),
102 )
103 : Effect.succeed(undefined);
104
105export const tryResolveParent = () =>
106 Effect.gen(function* () {
107 const env = yield* GcpEnvironment.current;
108 return yield* parentOf(`projects/${env.project}`).pipe(
109 Effect.map((parent) => {
110 if (parent === undefined || parent.length === 0) return undefined;
111 if (
112 parent.startsWith("folders/") ||
113 parent.startsWith("organizations/")
114 ) {
115 return parent;
116 }
117 return undefined;
118 }),
119 );
120 });
121
122export const resolveParent = (
123 explicit: string | undefined,
124 existing: string | undefined,
125) =>
126 Effect.gen(function* () {
127 if (explicit !== undefined && explicit.length > 0) {
128 return normalizeHierarchyParent(explicit);
129 }
130 if (existing !== undefined && existing.length > 0) {
131 return normalizeHierarchyParent(existing);
132 }
133 const resolved = yield* tryResolveParent();
134 if (resolved === undefined) {
135 const env = yield* GcpEnvironment.current;
136 return yield* new ParentRequired({ project: env.project });
137 }
138 return resolved;
139 });
140
141export const projectNumberOf = (project: string) =>
142 resourcemanager.getProjects({ name: `projects/${project}` }).pipe(
143 Effect.map((resource) => {
144 const number = lastSegment(resource.name ?? "");
145 return /^\d+$/.test(number) ? number : project;
146 }),
147 Effect.catchTag("NotFound", () => Effect.succeed(project)),
148 );
149
150export const sortedStrings = (values: readonly string[] | undefined) =>
151 [...(values ?? [])].slice().sort();
152
153export const sameStringList = (
154 left: readonly string[] | undefined,
155 right: readonly string[] | undefined,
156) =>
157 JSON.stringify(sortedStrings(left)) === JSON.stringify(sortedStrings(right));
158
159export const collectPages = <Page, A, E, R>(
160 pages: Stream.Stream<Page, E, R>,
161 items: (page: Page) => readonly A[] | undefined,
162) =>
163 pages.pipe(
164 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
165 Stream.runCollect,
166 Effect.map((chunk) => Array.from(chunk)),
167 );
168
169/**
170 * Wait for a Resource Manager operation; project and folder operations
171 * settle within a minute or two. With `allowAlreadyExists`, ALREADY_EXISTS
172 * (code 6) counts as success (create race); with `notFoundOk`, so does
173 * NOT_FOUND (code 5, delete race) and an operation that is already gone.
174 */
175export const waitForOperation = (
176 operation: resourcemanager.Operation,
177 options?: { notFoundOk?: boolean; allowAlreadyExists?: boolean },
178) =>
179 waitForGcpOperation(
180 operation,
181 (name) => resourcemanager.getOperations({ name }),
182 { budget: "10 minutes" },
183 ).pipe(
184 Effect.catchIf(
185 (error) =>
186 (error._tag === "GCP.OperationFailed" &&
187 ((options?.allowAlreadyExists === true && error.code === 6) ||
188 (options?.notFoundOk === true && error.code === 5))) ||
189 (options?.notFoundOk === true && error._tag === "NotFound"),
190 () => Effect.succeed(operation),
191 ),
192 );
193
194/**
195 * Wait for a create operation, then re-read it for its typed `response`
196 * (the created resource's name / project id).
197 */
198export const waitForCreate = (operation: resourcemanager.Operation) =>
199 waitForOperation(operation, { allowAlreadyExists: true }).pipe(
200 Effect.andThen(() =>
201 operation.name === undefined || operation.name.length === 0
202 ? Effect.succeed(operation)
203 : resourcemanager.getOperations({ name: operation.name }),
204 ),
205 );