1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import { GcpEnvironment } from "../Environment.ts";
7import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
8import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
9import {
10 encodeDescription,
11 hasOwnershipMarker,
12 lastSegment,
13 orgIdOf,
14 orgParent,
15 parseDescription,
16} from "./ownership.ts";
17
18export {
19 encodeDescription,
20 hasOwnershipMarker,
21 lastSegment,
22 orgIdOf,
23 orgParent,
24 parseDescription,
25};
26
27export const orgNameOf = (organization: string) =>
28 organization.startsWith("organizations/")
29 ? organization
30 : `organizations/${organization}`;
31
32export const orgName = orgNameOf;
33
34export const defaultOrgName = (project: string, organization?: string) =>
35 orgNameOf(organization ?? project);
36
37export const createOwnership = (id: string) => createInternalLabels(id);
38
39export const ownedBy = (id: string, labels: Record<string, string>) =>
40 hasAlchemyLabels(id, labels);
41
42export const sameJson = (left: unknown, right: unknown) =>
43 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
44
45export const jsonEqual = sameJson;
46
47export const sameStringList = (
48 left: readonly string[] | undefined,
49 right: readonly string[] | undefined,
50) =>
51 JSON.stringify([...(left ?? [])].sort()) ===
52 JSON.stringify([...(right ?? [])].sort());
53
54export const sortedStrings = (values: readonly string[] | undefined) =>
55 [...(values ?? [])].sort();
56
57export const sameRecord = (
58 left: Record<string, string> | undefined,
59 right: Record<string, string> | undefined,
60) => sameJson(left ?? {}, right ?? {});
61
62export const toPhysicalId = (
63 id: string,
64 explicit: string | undefined,
65 existing: string | undefined,
66 maxLength = 63,
67) =>
68 Effect.gen(function* () {
69 if (explicit !== undefined) return explicit;
70 if (existing !== undefined) return existing;
71 let generated = yield* createPhysicalName({
72 id,
73 maxLength,
74 lowercase: true,
75 });
76 if (!/^[a-z]/.test(generated)) {
77 generated = `a${generated}`.slice(0, maxLength);
78 }
79 return generated.replace(/-+$/g, "") || "resource";
80 });
81
82export const letterPrefixedId = (
83 id: string,
84 explicit: string | undefined,
85 existing: string | undefined,
86 maxLength = 63,
87) => toPhysicalId(id, explicit, existing, maxLength);
88
89export const dcCollectorId = (
90 id: string,
91 explicit: string | undefined,
92 existing: string | undefined,
93) =>
94 Effect.gen(function* () {
95 if (explicit !== undefined) return explicit;
96 if (existing !== undefined) return existing;
97 const generated = yield* createPhysicalName({
98 id,
99 maxLength: 28,
100 lowercase: true,
101 });
102 const cleaned = generated.replace(/[^a-z0-9]/g, "_");
103 return cleaned.startsWith("dc_") ? cleaned : `dc_${cleaned}`;
104 });
105
106export const collectPages = <Page, Item, E, R>(
107 stream: Stream.Stream<Page, E, R>,
108 pick: (page: Page) => readonly Item[] | undefined,
109) =>
110 stream.pipe(
111 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
112 Stream.runCollect,
113 Effect.map((chunk) => Array.from(chunk)),
114 );
115
116export const listOrgNames = () =>
117 Effect.gen(function* () {
118 const env = yield* GcpEnvironment.current;
119 const page = yield* apigee
120 .listOrganizations({ parent: "organizations" })
121 .pipe(
122 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
123 Effect.succeed({
124 organizations:
125 [] as apigee.GoogleCloudApigeeV1OrganizationProjectMappingList,
126 }),
127 ),
128 );
129 const mappings = (page.organizations ?? []).filter(
130 (mapping) =>
131 mapping.projectId === env.project ||
132 (mapping.projectIds ?? []).includes(env.project),
133 );
134 const ids =
135 mappings.length > 0
136 ? mappings.map((mapping) => mapping.organization ?? env.project)
137 : [env.project];
138 return [...new Set(ids.map(orgNameOf))];
139 });
140
141export const resolveOrgId = (project: string) =>
142 Effect.gen(function* () {
143 const names = yield* listOrgNames();
144 return orgIdOf(names[0] ?? project);
145 });
146
147export const childName = (parent: string, collection: string, id: string) =>
148 `${parent}/${collection}/${lastSegment(id)}`;
149
150const isAttributesObject = (
151 value:
152 | readonly apigee.GoogleCloudApigeeV1Attribute[]
153 | apigee.GoogleCloudApigeeV1Attributes,
154): value is apigee.GoogleCloudApigeeV1Attributes => !Array.isArray(value);
155
156export const attributesToRecord = (
157 attributes:
158 | readonly apigee.GoogleCloudApigeeV1Attribute[]
159 | apigee.GoogleCloudApigeeV1Attributes
160 | undefined,
161): Record<string, string> => {
162 const list =
163 attributes === undefined
164 ? []
165 : isAttributesObject(attributes)
166 ? (attributes.attribute ?? [])
167 : attributes;
168 const record: Record<string, string> = {};
169 for (const item of list) {
170 if (item.name !== undefined) record[item.name] = item.value ?? "";
171 }
172 return record;
173};
174
175export const recordToAttributes = (
176 record: Record<string, string>,
177): apigee.GoogleCloudApigeeV1Attribute[] =>
178 Object.entries(record).map(([name, value]) => ({ name, value }));
179
180export const userAttributes = (record: Record<string, string> | undefined) =>
181 Object.fromEntries(
182 Object.entries(record ?? {}).filter(([key]) => !key.startsWith("alchemy-")),
183 );
184
185export const desiredAttributes = (
186 user: Record<string, string> | undefined,
187 ownership: Record<string, string>,
188) => ({ ...(user ?? {}), ...ownership });
189
190export const propertiesToRecord = (
191 properties: apigee.GoogleCloudApigeeV1Properties | undefined,
192): Record<string, string> => {
193 const record: Record<string, string> = {};
194 for (const item of properties?.property ?? []) {
195 if (item.name !== undefined) record[item.name] = item.value ?? "";
196 }
197 return record;
198};
199
200export const recordToProperties = (
201 record: Record<string, string>,
202): apigee.GoogleCloudApigeeV1Properties => ({
203 property: Object.entries(record).map(([name, value]) => ({ name, value })),
204});
205
206export const userProperties = (
207 properties: apigee.GoogleCloudApigeeV1Properties | undefined,
208) => userAttributes(propertiesToRecord(properties));
209
210/**
211 * Wait for an Apigee long-running operation through the shared GCP waiter.
212 * `notFoundOk` / `alreadyExistsOk` accept an operation that finished with
213 * `NOT_FOUND` (5) / `ALREADY_EXISTS` (6).
214 */
215export const waitForOperation = (
216 operation: apigee.GoogleLongrunningOperation,
217 options?: {
218 notFoundOk?: boolean;
219 alreadyExistsOk?: boolean;
220 budget?: Duration.Input;
221 },
222) => {
223 let latest = operation;
224 return waitForGcpOperation(
225 operation,
226 (name) =>
227 apigee.getOrganizationsOperations({ name }).pipe(
228 Effect.tap((current) =>
229 Effect.sync(() => {
230 latest = current;
231 }),
232 ),
233 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], (error) =>
234 options?.notFoundOk === true
235 ? Effect.succeed<apigee.GoogleLongrunningOperation>({
236 name,
237 done: true,
238 })
239 : Effect.fail(error),
240 ),
241 ),
242 { budget: options?.budget ?? "10 minutes" },
243 ).pipe(
244 Effect.map(() => latest),
245 Effect.catchIf(
246 (error) =>
247 error._tag === "GCP.OperationFailed" &&
248 ((options?.notFoundOk === true && error.code === 5) ||
249 (options?.alreadyExistsOk === true && error.code === 6)),
250 () => Effect.succeed(latest),
251 ),
252 );
253};
254
255export const stringField = (
256 value: Record<string, unknown> | undefined,
257 key: string,
258): string | undefined => {
259 const raw = value?.[key];
260 return typeof raw === "string" ? raw : undefined;
261};