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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 ParameterNotResolved,
19 deleteVersions,
20 getParameter,
21 lastSegment,
22 listAlchemyParameters,
23 normalizeFormat,
24 normalizeLocation,
25 parameterResourceName,
26 parseParameterName,
27 sameText,
28 toPhysicalId,
29 userLabels,
30 waitUntilParameterGone,
31} from "./internal.ts";
32
33export type ParameterFormat =
34 | parametermanager.ParameterFormatEnum
35 | (string & {});
36
37export type ParameterProps = {
38 /**
39 * Parameter id (the `{parameter}` segment of
40 * `projects/{project}/locations/{location}/parameters/{parameter}`).
41 * If omitted, a unique name is generated from the stack, stage, and
42 * logical id. Must be 1-63 characters, start with a letter, and match
43 * `[a-zA-Z][a-zA-Z0-9_-]*`. Immutable — changing it replaces the
44 * parameter.
45 */
46 parameterId?: string;
47 /**
48 * Parameter Manager location (`global`, `us-central1`, …). Immutable —
49 * changing it replaces the parameter. `GLOBAL` is accepted and
50 * normalized to `global`. Regional locations require Parameter Manager
51 * regional access on the project.
52 * @default "global"
53 */
54 location?: string;
55 /**
56 * Payload format. Immutable — changing it replaces the parameter. The
57 * API defaults unspecified values to `UNFORMATTED`. `JSON` and `YAML`
58 * enable secret-reference rendering on versions.
59 * @default "UNFORMATTED"
60 */
61 format?: ParameterFormat;
62 /**
63 * Cloud KMS CryptoKey used to encrypt versions
64 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
65 * Must live in the same location as the parameter. Empty or omitted
66 * uses Google-managed encryption. Mutable.
67 */
68 kmsKey?: string;
69 /**
70 * User labels. Alchemy ownership labels are merged in automatically.
71 */
72 labels?: Record<string, string>;
73};
74
75export type Parameter = Resource<
76 "GCP.ParameterManager.Parameter",
77 ParameterProps,
78 {
79 /** Full resource name `projects/{project}/locations/{location}/parameters/{parameter}`. */
80 name: string;
81 /** Parameter id (last path segment). */
82 parameterId: string;
83 /** Project id. */
84 project: string;
85 /** Location id (`us-central1`, `global`, …). */
86 location: string;
87 /** Payload format (`UNFORMATTED`, `YAML`, `JSON`). */
88 format: string;
89 /** CMEK resource name, if set. */
90 kmsKey: string | undefined;
91 /** User labels (Alchemy ownership labels stripped). */
92 labels: Record<string, string>;
93 /** IAM policy member for the user-assigned name. */
94 iamPolicyNamePrincipal: string | undefined;
95 /** IAM policy member for the system-assigned uid. */
96 iamPolicyUidPrincipal: string | undefined;
97 /** RFC3339 creation timestamp. */
98 createTime: string | undefined;
99 /** RFC3339 last-update timestamp. */
100 updateTime: string | undefined;
101 },
102 never,
103 Providers
104>;
105
106/**
107 * A Parameter Manager parameter — metadata and encryption for versioned
108 * payloads. Version data lives on {@link ParametersVersion}.
109 *
110 * Changing `parameterId`, `location`, or `format` replaces the parameter.
111 * Labels and `kmsKey` update in place.
112 *
113 * ### Creating a Parameter
114 * **Example:** Generated name
115 * ```typescript
116 * const parameter = yield* GCP.ParameterManager.Parameter("AppConfig", {});
117 * ```
118 *
119 * **Example:** Explicit id, format, and labels
120 * ```typescript
121 * const parameter = yield* GCP.ParameterManager.Parameter("AppConfig", {
122 * parameterId: "order-config",
123 * format: "JSON",
124 * labels: { env: "prod" },
125 * });
126 * ```
127 *
128 * ### Parameter Versions
129 * **Example:** Store a JSON payload
130 * ```typescript
131 * const parameter = yield* GCP.ParameterManager.Parameter("AppConfig", {
132 * format: "JSON",
133 * });
134 * const version = yield* GCP.ParameterManager.ParametersVersion("V1", {
135 * parameter: parameter.name,
136 * data: JSON.stringify({ host: "api.example.com" }),
137 * });
138 * ```
139 *
140 * @resource
141 * @category ParameterManager
142 */
143export const Parameter = Resource<Parameter>("GCP.ParameterManager.Parameter");
144
145export { ParameterNotResolved };
146
147const toAttrs = (parameter: parametermanager.Parameter, project: string) => {
148 const name = parameter.name ?? "";
149 const parsed = parseParameterName(name);
150 return {
151 name,
152 parameterId: parsed.parameterId,
153 project: parsed.project || project,
154 location: parsed.location,
155 format: normalizeFormat(parameter.format),
156 kmsKey: parameter.kmsKey,
157 labels: userLabels(parameter.labels),
158 iamPolicyNamePrincipal: parameter.policyMember?.iamPolicyNamePrincipal,
159 iamPolicyUidPrincipal: parameter.policyMember?.iamPolicyUidPrincipal,
160 createTime: parameter.createTime,
161 updateTime: parameter.updateTime,
162 };
163};
164
165export const ParameterProvider = () =>
166 Provider.succeed(Parameter, {
167 stables: [
168 "name",
169 "parameterId",
170 "project",
171 "location",
172 "format",
173 "createTime",
174 ],
175
176 diff: Effect.fn(function* ({ news, olds, output }) {
177 if (!isResolved(news)) return undefined;
178 const previousId = olds?.parameterId ?? output?.parameterId;
179 const nextId = news.parameterId ?? previousId;
180 const previousLocation = normalizeLocation(
181 olds?.location ?? output?.location,
182 );
183 const nextLocation = normalizeLocation(
184 news.location ?? olds?.location ?? output?.location,
185 );
186 const previousFormat = normalizeFormat(olds?.format ?? output?.format);
187 const nextFormat = normalizeFormat(
188 news.format ?? olds?.format ?? output?.format,
189 );
190 const idChanged =
191 previousId !== undefined &&
192 nextId !== undefined &&
193 previousId !== nextId;
194 const locationChanged = previousLocation !== nextLocation;
195 const formatChanged = previousFormat !== nextFormat;
196 if (!idChanged && !locationChanged && !formatChanged) return undefined;
197 return {
198 action: "replace" as const,
199 deleteFirst:
200 !idChanged &&
201 !locationChanged &&
202 formatChanged &&
203 previousId !== undefined &&
204 nextId === previousId,
205 };
206 }),
207
208 read: Effect.fn(function* ({ id, olds, output }) {
209 const env = yield* GcpEnvironment.current;
210 const parameterId = yield* toPhysicalId(
211 id,
212 olds?.parameterId,
213 output?.parameterId,
214 "parameter",
215 );
216 const location = normalizeLocation(olds?.location ?? output?.location);
217 const name =
218 output?.name ??
219 parameterResourceName(env.project, location, parameterId);
220 const existing = yield* getParameter(name);
221 if (existing === undefined) return undefined;
222 const attrs = toAttrs(existing, env.project);
223 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
224 ? attrs
225 : Unowned(attrs);
226 }),
227
228 list: () =>
229 Effect.gen(function* () {
230 const env = yield* GcpEnvironment.current;
231 const parameters = yield* listAlchemyParameters(env.project);
232 return parameters.map((parameter) => toAttrs(parameter, env.project));
233 }),
234
235 reconcile: Effect.fn(function* ({ id, news, output }) {
236 const env = yield* GcpEnvironment.current;
237 const parameterId = yield* toPhysicalId(
238 id,
239 news.parameterId,
240 output?.parameterId,
241 "parameter",
242 );
243 const location = normalizeLocation(news.location ?? output?.location);
244 const format = normalizeFormat(news.format ?? output?.format);
245 const name = parameterResourceName(env.project, location, parameterId);
246 const desiredLabels = {
247 ...toLabels(news.labels),
248 ...(yield* createInternalLabels(id)),
249 };
250 const desiredKmsKey = news.kmsKey ?? "";
251
252 let current = yield* getParameter(name);
253
254 if (current !== undefined && normalizeFormat(current.format) !== format) {
255 yield* deleteVersions(current.name ?? name);
256 yield* parametermanager
257 .deleteProjectsLocationsParameters({ name: current.name ?? name })
258 .pipe(Effect.catchTag("NotFound", () => Effect.void));
259 yield* waitUntilParameterGone(name);
260 current = undefined;
261 }
262
263 if (current === undefined) {
264 const created = yield* parametermanager
265 .createProjectsLocationsParameters({
266 parent: `projects/${env.project}/locations/${location}`,
267 parameterId,
268 body: {
269 labels: desiredLabels,
270 format,
271 kmsKey: news.kmsKey,
272 },
273 })
274 .pipe(Effect.catchTag("Conflict", () => getParameter(name)));
275 current = (yield* getParameter(name)) ?? created ?? undefined;
276 }
277
278 if (current === undefined) {
279 return yield* new ParameterNotResolved({ name });
280 }
281
282 const observedLabels = tagRecord(current.labels);
283 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
284 const labelsChanged = upsert.length > 0 || removed.length > 0;
285 const kmsChanged = !sameText(current.kmsKey, desiredKmsKey);
286
287 if (labelsChanged || kmsChanged) {
288 yield* parametermanager.patchProjectsLocationsParameters({
289 name,
290 updateMask: [
291 labelsChanged ? "labels" : undefined,
292 kmsChanged ? "kmsKey" : undefined,
293 ]
294 .filter((field): field is string => field !== undefined)
295 .join(","),
296 body: {
297 name,
298 labels: desiredLabels,
299 kmsKey: news.kmsKey,
300 },
301 });
302 current = (yield* getParameter(name)) ?? current;
303 }
304
305 return toAttrs(current, env.project);
306 }),
307
308 delete: Effect.fn(function* ({ output }) {
309 const name = output.name;
310 yield* deleteVersions(name);
311 yield* parametermanager.deleteProjectsLocationsParameters({ name }).pipe(
312 Effect.retry({
313 while: (error) => error._tag === "Conflict",
314 times: 8,
315 schedule: Schedule.spaced("1 second"),
316 }),
317 Effect.catchTag(["NotFound", "BadRequest"], () => Effect.void),
318 );
319 yield* waitUntilParameterGone(name);
320 }),
321 });
322
323export const parameterIdOf = (name: string) => lastSegment(name);