1import * as parametermanager from "@distilled.cloud/gcp/parametermanager_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 DEFAULT_LOCATION,
12 ParameterNotResolved,
13 ParameterVersionNotResolved,
14 ParameterVersionPayloadRequired,
15 desiredPayloadData,
16 expandParameter,
17 getParameter,
18 getVersion,
19 hasAlchemyLabelMap,
20 lastSegment,
21 listAlchemyParameters,
22 listVersions,
23 locationFromParameter,
24 normalizeLocation,
25 parseVersionName,
26 samePayload,
27 toPhysicalId,
28 versionResourceName,
29 waitUntilVersionGone,
30} from "./internal.ts";
31
32export type ParametersVersionProps = {
33 /**
34 * Parent parameter. Full name
35 * `projects/{project}/locations/{location}/parameters/{parameter}`
36 * or the parameter id (combined with `location`). Immutable —
37 * changing it replaces the version.
38 */
39 parameter: string;
40 /**
41 * Parameter Manager location used when `parameter` is a bare id.
42 * Immutable — changing it replaces the version. `GLOBAL` is accepted
43 * and normalized to `global`.
44 * @default "global"
45 */
46 location?: string;
47 /**
48 * Version id (the `{version}` segment of
49 * `.../parameters/{parameter}/versions/{version}`). If omitted, a
50 * unique name is generated from the stack, stage, and logical id.
51 * Must be 1-63 characters, start with a letter, and match
52 * `[a-zA-Z][a-zA-Z0-9_-]*`. Immutable — changing it replaces the
53 * version.
54 */
55 parameterVersionId?: string;
56 /**
57 * UTF-8 payload. Alchemy base64-encodes this for the API. Ignored
58 * when `payload.data` is set. Immutable — changing it replaces the
59 * version.
60 */
61 data?: string;
62 /**
63 * Payload as stored by the API. `data` is standard base64. Takes
64 * precedence over `data`. Immutable — changing it replaces the
65 * version.
66 */
67 payload?: {
68 data?: string;
69 };
70 /**
71 * When true, the version is metadata-only: get always returns the
72 * BASIC view and render fails. Mutable.
73 * @default false
74 */
75 disabled?: boolean;
76};
77
78export type ParametersVersion = Resource<
79 "GCP.ParameterManager.ParametersVersion",
80 ParametersVersionProps,
81 {
82 /** Full resource name `projects/{project}/locations/{location}/parameters/{parameter}/versions/{version}`. */
83 name: string;
84 /** Version id (last path segment). */
85 parameterVersionId: string;
86 /** Parent parameter resource name. */
87 parameter: string;
88 /** Parent parameter id. */
89 parameterId: string;
90 /** Project id. */
91 project: string;
92 /** Location id (`us-central1`, `global`, …). */
93 location: string;
94 /** Standard-base64 payload, when the FULL view returned it. */
95 payloadData: string | undefined;
96 /** Whether the version is disabled. */
97 disabled: boolean;
98 /** CMEK key version used to encrypt this payload, if the parent uses CMEK. */
99 kmsKeyVersion: string | undefined;
100 /** RFC3339 creation timestamp. */
101 createTime: string | undefined;
102 /** RFC3339 last-update timestamp. */
103 updateTime: string | undefined;
104 },
105 never,
106 Providers
107>;
108
109/**
110 * A Parameter Manager parameter version — an immutable payload attached
111 * to a {@link Parameter}. Versions have no labels field; `list` / nuke
112 * find versions whose parent parameter carries Alchemy labels.
113 *
114 * Changing `parameterVersionId`, `parameter`, `location`, or the payload
115 * replaces the version. `disabled` updates in place.
116 *
117 * ### Creating a ParametersVersion
118 * **Example:** UTF-8 payload
119 * ```typescript
120 * const parameter = yield* GCP.ParameterManager.Parameter("AppConfig", {});
121 * const version = yield* GCP.ParameterManager.ParametersVersion("V1", {
122 * parameter: parameter.name,
123 * data: "host=api.example.com",
124 * });
125 * ```
126 *
127 * **Example:** JSON payload on a JSON parameter
128 * ```typescript
129 * const parameter = yield* GCP.ParameterManager.Parameter("AppConfig", {
130 * format: "JSON",
131 * });
132 * const version = yield* GCP.ParameterManager.ParametersVersion("V1", {
133 * parameter: parameter.name,
134 * data: JSON.stringify({ host: "api.example.com" }),
135 * });
136 * ```
137 *
138 * ### Disabling a Version
139 * **Example:** Keep metadata, hide payload
140 * ```typescript
141 * const version = yield* GCP.ParameterManager.ParametersVersion("V1", {
142 * parameter: parameter.name,
143 * parameterVersionId: "v1",
144 * data: "host=api.example.com",
145 * disabled: true,
146 * });
147 * ```
148 *
149 * @resource
150 * @category ParameterManager
151 */
152export const ParametersVersion = Resource<ParametersVersion>(
153 "GCP.ParameterManager.ParametersVersion",
154);
155
156export { ParameterVersionNotResolved, ParameterVersionPayloadRequired };
157
158const toAttrs = (
159 version: parametermanager.ParameterVersion,
160 project: string,
161) => {
162 const name = version.name ?? "";
163 const parsed = parseVersionName(name);
164 return {
165 name,
166 parameterVersionId: parsed.parameterVersionId,
167 parameter: parsed.parameter,
168 parameterId: lastSegment(parsed.parameter),
169 project: parsed.project || project,
170 location: parsed.location,
171 payloadData: version.payload?.data,
172 disabled: version.disabled === true,
173 kmsKeyVersion: version.kmsKeyVersion,
174 createTime: version.createTime,
175 updateTime: version.updateTime,
176 };
177};
178
179const createBody = (
180 payloadData: string,
181 disabled: boolean,
182): parametermanager.ParameterVersion => ({
183 payload: { data: payloadData },
184 disabled,
185});
186
187export const ParametersVersionProvider = () =>
188 Provider.succeed(ParametersVersion, {
189 stables: [
190 "name",
191 "parameterVersionId",
192 "parameter",
193 "parameterId",
194 "project",
195 "location",
196 "createTime",
197 ],
198
199 diff: Effect.fn(function* ({ news, olds, output }) {
200 if (!isResolved(news)) return undefined;
201 const previousId = olds?.parameterVersionId ?? output?.parameterVersionId;
202 const nextId = news.parameterVersionId ?? previousId;
203 const previousParameter = lastSegment(
204 olds?.parameter ?? output?.parameter ?? "",
205 );
206 const nextParameter = lastSegment(news.parameter);
207 const previousLocation = normalizeLocation(
208 olds?.location ??
209 output?.location ??
210 locationFromParameter(
211 olds?.parameter ?? output?.parameter,
212 DEFAULT_LOCATION,
213 ),
214 );
215 const nextLocation = normalizeLocation(
216 news.location ??
217 locationFromParameter(news.parameter, previousLocation),
218 );
219 const previousPayload =
220 olds?.payload?.data ??
221 (olds?.data !== undefined
222 ? yield* desiredPayloadData(olds)
223 : output?.payloadData);
224 const nextPayload = yield* desiredPayloadData(news);
225 const idChanged =
226 previousId !== undefined &&
227 nextId !== undefined &&
228 previousId !== nextId;
229 const parentChanged =
230 previousParameter.length > 0 && previousParameter !== nextParameter;
231 const locationChanged = previousLocation !== nextLocation;
232 const payloadChanged =
233 nextPayload !== undefined &&
234 previousPayload !== undefined &&
235 !samePayload(previousPayload, nextPayload);
236 if (!idChanged && !parentChanged && !locationChanged && !payloadChanged) {
237 return undefined;
238 }
239 return {
240 action: "replace" as const,
241 deleteFirst:
242 !idChanged &&
243 !parentChanged &&
244 !locationChanged &&
245 payloadChanged &&
246 previousId !== undefined &&
247 nextId === previousId,
248 };
249 }),
250
251 read: Effect.fn(function* ({ id, olds, output }) {
252 const env = yield* GcpEnvironment.current;
253 const location = normalizeLocation(
254 olds?.location ??
255 output?.location ??
256 locationFromParameter(
257 olds?.parameter ?? output?.parameter,
258 DEFAULT_LOCATION,
259 ),
260 );
261 const parameter = expandParameter(
262 olds?.parameter ?? output?.parameter ?? "",
263 env.project,
264 location,
265 );
266 const parameterVersionId = yield* toPhysicalId(
267 id,
268 olds?.parameterVersionId,
269 output?.parameterVersionId,
270 "version",
271 );
272 const name =
273 output?.name ?? versionResourceName(parameter, parameterVersionId);
274 const existing = yield* getVersion(name);
275 if (existing === undefined) return undefined;
276 const attrs = toAttrs(existing, env.project);
277 const parent = yield* getParameter(attrs.parameter);
278 return hasAlchemyLabelMap(parent?.labels) ? attrs : Unowned(attrs);
279 }),
280
281 list: () =>
282 Effect.gen(function* () {
283 const env = yield* GcpEnvironment.current;
284 const parameters = yield* listAlchemyParameters(env.project);
285 const versions = yield* Effect.forEach(
286 parameters,
287 (parameter) => listVersions(parameter.name ?? ""),
288 { concurrency: 4 },
289 );
290 return versions.flat().map((version) => toAttrs(version, env.project));
291 }),
292
293 reconcile: Effect.fn(function* ({ id, news, output }) {
294 const env = yield* GcpEnvironment.current;
295 const location = normalizeLocation(
296 news.location ??
297 output?.location ??
298 locationFromParameter(news.parameter, DEFAULT_LOCATION),
299 );
300 const parameter = expandParameter(news.parameter, env.project, location);
301 const parameterVersionId = yield* toPhysicalId(
302 id,
303 news.parameterVersionId,
304 output?.parameterVersionId,
305 "version",
306 );
307 const name = versionResourceName(parameter, parameterVersionId);
308 const desiredDisabled = news.disabled === true;
309 const desiredPayload = yield* desiredPayloadData(news);
310
311 yield* getParameter(parameter).pipe(
312 Effect.flatMap((parent) =>
313 parent === undefined
314 ? Effect.fail(new ParameterNotResolved({ name: parameter }))
315 : Effect.succeed(parent),
316 ),
317 Effect.retry({
318 while: (error) =>
319 error._tag === "GCP.ParameterManager.ParameterNotResolved",
320 times: 8,
321 schedule: Schedule.spaced("1 second"),
322 }),
323 );
324
325 let current = yield* getVersion(name);
326
327 if (
328 current !== undefined &&
329 desiredPayload !== undefined &&
330 !samePayload(current.payload?.data, desiredPayload)
331 ) {
332 yield* parametermanager
333 .deleteProjectsLocationsParametersVersions({
334 name: current.name ?? name,
335 })
336 .pipe(Effect.catchTag("NotFound", () => Effect.void));
337 yield* waitUntilVersionGone(name);
338 current = undefined;
339 }
340
341 if (current === undefined) {
342 if (desiredPayload === undefined) {
343 return yield* new ParameterVersionPayloadRequired({ name });
344 }
345 const created = yield* parametermanager
346 .createProjectsLocationsParametersVersions({
347 parent: parameter,
348 parameterVersionId,
349 body: createBody(desiredPayload, desiredDisabled),
350 })
351 .pipe(
352 Effect.retry({
353 // The parent parameter may not be readable yet.
354 while: (error): boolean => error._tag === "NotFound",
355 times: 8,
356 schedule: Schedule.spaced("1 second"),
357 }),
358 Effect.catchTag("Conflict", () => getVersion(name)),
359 );
360 current = (yield* getVersion(name)) ?? created ?? undefined;
361 }
362
363 if (current === undefined) {
364 return yield* new ParameterVersionNotResolved({ name });
365 }
366
367 if ((current.disabled === true) !== desiredDisabled) {
368 yield* parametermanager.patchProjectsLocationsParametersVersions({
369 name,
370 updateMask: "disabled",
371 body: {
372 name,
373 disabled: desiredDisabled,
374 },
375 });
376 current = (yield* getVersion(name)) ?? current;
377 }
378
379 return toAttrs(current, env.project);
380 }),
381
382 delete: Effect.fn(function* ({ output }) {
383 yield* parametermanager
384 .deleteProjectsLocationsParametersVersions({ name: output.name })
385 .pipe(
386 Effect.retry({
387 while: (error) => error._tag === "Conflict",
388 times: 8,
389 schedule: Schedule.spaced("1 second"),
390 }),
391 Effect.catchTag(["NotFound", "BadRequest"], () => Effect.void),
392 );
393 yield* waitUntilVersionGone(output.name);
394 }),
395 });