1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Effect from "effect/Effect";
3import { createPhysicalName } from "../../PhysicalName.ts";
4import { GcpEnvironment } from "../Environment.ts";
5import {
6 alchemyLabelKeys,
7 createInternalLabels,
8 hasAlchemyLabels,
9} from "../Labels.ts";
10
11export const lastSegment = (value: string) => {
12 const trimmed = value.replace(/\/+$/, "");
13 const parts = trimmed.split("/");
14 return parts[parts.length - 1] || trimmed;
15};
16
17export const segmentAfter = (value: string, key: string) => {
18 const parts = value.split("/").filter((part) => part.length > 0);
19 const index = parts.lastIndexOf(key);
20 return index >= 0 && parts[index + 1] ? parts[index + 1]! : undefined;
21};
22
23export const stripRevision = (name: string) => {
24 const parts = name.split("/").filter((part) => part.length > 0);
25 const index = parts.lastIndexOf("revisions");
26 return index >= 0 ? parts.slice(0, index).join("/") : name;
27};
28
29export const organizationIdOf = (
30 value: string | undefined,
31 project: string,
32) => {
33 if (value === undefined || value.length === 0) return project;
34 return segmentAfter(value, "organizations") ?? lastSegment(value);
35};
36
37export const environmentIdOf = (value: string) =>
38 segmentAfter(value, "environments") ?? lastSegment(value);
39
40export const organizationNameOf = (organizationId: string) =>
41 `organizations/${organizationId}`;
42
43export const environmentNameOf = (
44 organizationId: string,
45 environmentId: string,
46) => `organizations/${organizationId}/environments/${environmentId}`;
47
48export const parseOrgEnv = (name: string) => ({
49 organizationId: segmentAfter(name, "organizations") ?? "",
50 environmentId: segmentAfter(name, "environments") ?? "",
51});
52
53export const missingToUndefined = <A, E extends { readonly _tag: string }, R>(
54 effect: Effect.Effect<A, E, R>,
55) =>
56 effect.pipe(
57 Effect.catchIf(
58 (
59 error,
60 ): error is E & {
61 readonly _tag: "NotFound" | "ApigeeResourceNotFound";
62 } => error._tag === "NotFound" || error._tag === "ApigeeResourceNotFound",
63 () => Effect.succeed(undefined as A | undefined),
64 ),
65 );
66
67export const emptyOnMissing = <A, E extends { readonly _tag: string }, R>(
68 effect: Effect.Effect<A, E, R>,
69 empty: A,
70) =>
71 effect.pipe(
72 Effect.catchIf(
73 (
74 error,
75 ): error is E & {
76 readonly _tag: "NotFound" | "ApigeeResourceNotFound";
77 } => error._tag === "NotFound" || error._tag === "ApigeeResourceNotFound",
78 () => Effect.succeed(empty),
79 ),
80 );
81
82export const deployedConfig = (parent: string) =>
83 missingToUndefined(
84 apigee.getDeployedConfigOrganizationsEnvironments({
85 name: `${parent}/deployedConfig`,
86 }),
87 );
88
89export const namesFromConfig = (
90 items: readonly { name?: string }[] | undefined,
91): string[] =>
92 (items ?? [])
93 .map((item) => item.name)
94 .filter(
95 (name): name is string => typeof name === "string" && name.length > 0,
96 )
97 .map((name) => lastSegment(stripRevision(name)));
98
99export const toResourceId = (
100 id: string,
101 explicit: string | undefined,
102 existing: string | undefined,
103 options?: { maxLength?: number; rfc1035?: boolean },
104) =>
105 Effect.gen(function* () {
106 if (explicit !== undefined) return explicit;
107 if (existing !== undefined) return existing;
108 const maxLength = options?.maxLength ?? 63;
109 const generated = yield* createPhysicalName({
110 id,
111 maxLength,
112 lowercase: true,
113 });
114 if (options?.rfc1035 !== true) return generated;
115 let next = generated
116 .toLowerCase()
117 .replace(/[^a-z0-9-]/g, "-")
118 .replace(/-+/g, "-")
119 .replace(/^-+/, "")
120 .replace(/-+$/, "");
121 if (!/^[a-z]/.test(next)) next = `a${next}`;
122 next = next.slice(0, maxLength).replace(/-+$/, "");
123 return next.length > 0 ? next : "resource";
124 });
125
126export const encodeDescription = (
127 labels: Record<string, string>,
128 description: string | undefined,
129): string => {
130 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
131 return description ? `${marker}\n${description}` : marker;
132};
133
134export const parseDescription = (
135 description: string | undefined,
136): {
137 labels: Record<string, string>;
138 description: string | undefined;
139} => {
140 if (!description?.startsWith("[alchemy ")) {
141 return { labels: {}, description };
142 }
143 const end = description.indexOf("]");
144 if (end < 0) return { labels: {}, description };
145 const labels: Record<string, string> = {};
146 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
147 const eq = part.indexOf("=");
148 if (eq > 0) {
149 labels[part.slice(0, eq)] = part.slice(eq + 1);
150 }
151 }
152 const rest = description.slice(end + 1).replace(/^\n/, "");
153 return { labels, description: rest.length > 0 ? rest : undefined };
154};
155
156export const hasOwnershipMarker = (description: string | undefined) =>
157 Object.keys(parseDescription(description).labels).some((key) =>
158 key.startsWith("alchemy-"),
159 );
160
161export const ownedById = (id: string, labels: Record<string, string>) =>
162 hasAlchemyLabels(id, labels);
163
164export const sameText = (left: string | undefined, right: string | undefined) =>
165 (left ?? "") === (right ?? "");
166
167export const sameList = (
168 left: readonly string[] | undefined,
169 right: readonly string[] | undefined,
170) =>
171 [...(left ?? [])].sort().join("\0") === [...(right ?? [])].sort().join("\0");
172
173export type ProjectEnvironment = {
174 organizationId: string;
175 environmentId: string;
176 parent: string;
177};
178
179export const listProjectEnvironments = Effect.fn(function* () {
180 const env = yield* GcpEnvironment.current;
181 const page = yield* emptyOnMissing(
182 apigee.listOrganizations({ parent: "organizations" }),
183 {
184 organizations:
185 [] as apigee.GoogleCloudApigeeV1OrganizationProjectMappingList,
186 },
187 );
188 const mappings = (page.organizations ?? []).filter(
189 (mapping) =>
190 mapping.projectId === env.project ||
191 (mapping.projectIds ?? []).includes(env.project),
192 );
193 const orgs =
194 mappings.length > 0
195 ? mappings
196 : [{ organization: env.project, projectId: env.project }];
197 const found: ProjectEnvironment[] = [];
198 for (const mapping of orgs) {
199 const organizationId = mapping.organization ?? env.project;
200 const organization = yield* missingToUndefined(
201 apigee.getOrganizations({
202 name: organizationNameOf(organizationId),
203 }),
204 );
205 const environments = organization?.environments ?? [];
206 for (const environmentId of environments) {
207 if (!environmentId) continue;
208 found.push({
209 organizationId,
210 environmentId,
211 parent: environmentNameOf(organizationId, environmentId),
212 });
213 }
214 }
215 return found;
216});
217
218export const listNameArray = (
219 values: readonly string[] | undefined,
220): string[] =>
221 (values ?? []).filter(
222 (value) => typeof value === "string" && value.length > 0,
223 );
224
225export const namesFromListPayload = (payload: unknown): string[] => {
226 if (Array.isArray(payload)) {
227 return payload.flatMap((item) =>
228 typeof item === "string"
229 ? [item]
230 : item && typeof item === "object" && "name" in item
231 ? typeof (item as { name?: unknown }).name === "string"
232 ? [(item as { name: string }).name]
233 : []
234 : [],
235 );
236 }
237 if (payload && typeof payload === "object") {
238 for (const key of [
239 "keystores",
240 "aliases",
241 "keyvaluemaps",
242 "references",
243 "targetServers",
244 "targetservers",
245 "values",
246 ] as const) {
247 const value = (payload as Record<string, unknown>)[key];
248 if (Array.isArray(value)) return namesFromListPayload(value);
249 }
250 }
251 return [];
252};