1import * as parametermanager from "@distilled.cloud/gcp/parametermanager_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import { DeleteNotConfirmed } from "../Errors.ts";
9import { ALCHEMY_LABEL_PREFIX, stripInternalLabels } from "../Labels.ts";
10
11export const DEFAULT_LOCATION = "global";
12export const DEFAULT_FORMAT: parametermanager.ParameterFormatEnum =
13 "UNFORMATTED";
14export const MAX_NAME_LENGTH = 63;
15
16export class ParameterNotResolved extends Data.TaggedError(
17 "GCP.ParameterManager.ParameterNotResolved",
18)<{
19 name: string;
20}> {}
21
22export class ParameterVersionNotResolved extends Data.TaggedError(
23 "GCP.ParameterManager.ParameterVersionNotResolved",
24)<{
25 name: string;
26}> {}
27
28export class ParameterVersionPayloadRequired extends Data.TaggedError(
29 "GCP.ParameterManager.ParameterVersionPayloadRequired",
30)<{
31 name: string;
32}> {}
33
34export const lastSegment = (value: string) => {
35 const trimmed = value.replace(/\/+$/, "");
36 const parts = trimmed.split("/");
37 return parts[parts.length - 1] || trimmed;
38};
39
40export const normalizeLocation = (location: string | undefined) =>
41 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
42
43export const normalizeFormat = (
44 format: string | undefined,
45): parametermanager.ParameterFormatEnum => {
46 const value = (format ?? DEFAULT_FORMAT).toUpperCase();
47 if (value === "YAML" || value === "JSON" || value === "UNFORMATTED") {
48 return value;
49 }
50 return DEFAULT_FORMAT;
51};
52
53export const rfc1035 = (name: string, fallback: string): string => {
54 let next = name
55 .toLowerCase()
56 .replace(/[^a-z0-9-]/g, "-")
57 .replace(/-+/g, "-")
58 .replace(/^-+/, "")
59 .replace(/-+$/, "");
60 if (!/^[a-z]/.test(next)) {
61 next = `p${next}`;
62 }
63 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
64 return next.length > 0 ? next : fallback;
65};
66
67export const toPhysicalId = (
68 id: string,
69 explicit: string | undefined,
70 existing: string | undefined,
71 fallback: string,
72) =>
73 Effect.gen(function* () {
74 if (explicit !== undefined) return explicit;
75 if (existing !== undefined) return existing;
76 return rfc1035(
77 yield* createPhysicalName({
78 id,
79 maxLength: MAX_NAME_LENGTH,
80 lowercase: true,
81 }),
82 fallback,
83 );
84 });
85
86export const sameText = (left: string | undefined, right: string | undefined) =>
87 (left ?? "") === (right ?? "");
88
89export const userLabels = (
90 labels: Record<string, string | undefined> | null | undefined,
91): Record<string, string> => stripInternalLabels(tagRecord(labels));
92
93export const hasAlchemyLabelMap = (
94 labels: Record<string, string | undefined> | null | undefined,
95) =>
96 Object.keys(labels ?? {}).some((key) => key.startsWith(ALCHEMY_LABEL_PREFIX));
97
98export const parseParameterName = (name: string) => {
99 const parts = name.split("/").filter((part) => part.length > 0);
100 const parametersAt = parts.lastIndexOf("parameters");
101 const locationsAt = parts.lastIndexOf("locations");
102 const projectsAt = parts.lastIndexOf("projects");
103 return {
104 project:
105 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
106 location:
107 locationsAt >= 0 && parts[locationsAt + 1]
108 ? parts[locationsAt + 1]!
109 : DEFAULT_LOCATION,
110 parameterId:
111 parametersAt >= 0 && parts[parametersAt + 1]
112 ? parts[parametersAt + 1]!
113 : lastSegment(name),
114 parent: parametersAt > 0 ? parts.slice(0, parametersAt).join("/") : "",
115 };
116};
117
118export const parseVersionName = (name: string) => {
119 const parts = name.split("/").filter((part) => part.length > 0);
120 const versionsAt = parts.lastIndexOf("versions");
121 const parsed = parseParameterName(
122 versionsAt > 0 ? parts.slice(0, versionsAt).join("/") : name,
123 );
124 return {
125 ...parsed,
126 parameter:
127 versionsAt > 0 ? parts.slice(0, versionsAt).join("/") : parsed.parent,
128 parameterVersionId:
129 versionsAt >= 0 && parts[versionsAt + 1]
130 ? parts[versionsAt + 1]!
131 : lastSegment(name),
132 };
133};
134
135export const parameterResourceName = (
136 project: string,
137 location: string,
138 parameterId: string,
139) => `projects/${project}/locations/${location}/parameters/${parameterId}`;
140
141export const expandParameter = (
142 parameter: string,
143 project: string,
144 location: string,
145) => {
146 const next = parameter.replace(/\/+$/, "");
147 if (next.includes("/parameters/")) return next;
148 const id = lastSegment(next);
149 if (id.length === 0) {
150 return parameterResourceName(project, location, "parameter");
151 }
152 if (next.startsWith("projects/")) {
153 return `${next}/parameters/${id}`;
154 }
155 return parameterResourceName(project, location, id);
156};
157
158export const versionResourceName = (parameter: string, versionId: string) =>
159 `${parameter}/versions/${versionId}`;
160
161export const locationFromParameter = (
162 parameter: string | undefined,
163 fallback: string,
164) => {
165 if (parameter === undefined || parameter.length === 0) return fallback;
166 if (!parameter.includes("/locations/")) return fallback;
167 return parseParameterName(parameter).location || fallback;
168};
169
170export const encodeUtf8Base64 = (value: string) =>
171 Effect.sync(() => Buffer.from(value, "utf8").toString("base64"));
172
173export const desiredPayloadData = (props: {
174 data?: string;
175 payload?: { data?: string };
176}) =>
177 Effect.gen(function* () {
178 if (props.payload?.data !== undefined) return props.payload.data;
179 if (props.data !== undefined) return yield* encodeUtf8Base64(props.data);
180 return undefined;
181 });
182
183export const samePayload = (
184 left: string | undefined,
185 right: string | undefined,
186) => {
187 const a = left ?? "";
188 const b = right ?? "";
189 if (a === b) return true;
190 if (a.length === 0 || b.length === 0) return false;
191 try {
192 return Buffer.from(a, "base64").compare(Buffer.from(b, "base64")) === 0;
193 } catch {
194 return false;
195 }
196};
197
198export const getParameter = (name: string) =>
199 parametermanager
200 .getProjectsLocationsParameters({ name })
201 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
202
203export const getVersion = (name: string) =>
204 parametermanager
205 .getProjectsLocationsParametersVersions({ name, view: "FULL" })
206 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
207
208export const waitUntilParameterGone = (name: string) =>
209 getParameter(name).pipe(
210 Effect.repeat({
211 schedule: Schedule.spaced("1 second"),
212 until: (parameter): boolean => parameter === undefined,
213 times: 30,
214 }),
215 Effect.flatMap((parameter): Effect.Effect<void, DeleteNotConfirmed> =>
216 parameter === undefined
217 ? Effect.void
218 : Effect.fail(new DeleteNotConfirmed({ resource: name })),
219 ),
220 );
221
222export const waitUntilVersionGone = (name: string) =>
223 getVersion(name).pipe(
224 Effect.repeat({
225 schedule: Schedule.spaced("1 second"),
226 until: (version): boolean => version === undefined,
227 times: 30,
228 }),
229 Effect.flatMap((version): Effect.Effect<void, DeleteNotConfirmed> =>
230 version === undefined
231 ? Effect.void
232 : Effect.fail(new DeleteNotConfirmed({ resource: name })),
233 ),
234 );
235
236const emptyParameters = () =>
237 Effect.succeed([] as parametermanager.Parameter[]);
238
239const collectParameterPages = (parent: string) =>
240 parametermanager.listProjectsLocationsParameters
241 .pages({
242 parent,
243 pageSize: 1000,
244 })
245 .pipe(
246 Stream.runCollect,
247 Effect.map((pages) =>
248 Array.from(pages).flatMap((page) => page.parameters ?? []),
249 ),
250 Effect.catchTag("NotFound", () => emptyParameters()),
251 );
252
253export const listAlchemyParameters = (project: string) =>
254 Effect.gen(function* () {
255 const aggregated = yield* collectParameterPages(
256 `projects/${project}/locations/-`,
257 );
258 const parameters =
259 aggregated.length > 0
260 ? aggregated
261 : yield* collectParameterPages(
262 `projects/${project}/locations/${DEFAULT_LOCATION}`,
263 );
264 return parameters.filter((parameter) =>
265 hasAlchemyLabelMap(parameter.labels),
266 );
267 });
268
269const emptyVersions = () =>
270 Effect.succeed([] as parametermanager.ParameterVersion[]);
271
272export const listVersions = (parent: string) =>
273 parent.length === 0
274 ? emptyVersions()
275 : parametermanager.listProjectsLocationsParametersVersions
276 .pages({
277 parent,
278 pageSize: 1000,
279 })
280 .pipe(
281 Stream.runCollect,
282 Effect.map((pages) =>
283 Array.from(pages).flatMap((page) => page.parameterVersions ?? []),
284 ),
285 // A missing parameter has no versions.
286 Effect.catchTag("NotFound", () => emptyVersions()),
287 );
288
289export const deleteVersions = (parent: string) =>
290 Effect.gen(function* () {
291 const versions = yield* listVersions(parent);
292 yield* Effect.forEach(
293 versions,
294 (version) =>
295 version.name
296 ? parametermanager
297 .deleteProjectsLocationsParametersVersions({
298 name: version.name,
299 })
300 .pipe(Effect.catchTag("NotFound", () => Effect.void))
301 : Effect.void,
302 { concurrency: 4 },
303 );
304 });