1import * as kms from "@distilled.cloud/aws/kms";
2import * as ssm from "@distilled.cloud/aws/ssm";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Redacted from "effect/Redacted";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { Unowned } from "../../AdoptPolicy.ts";
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import {
14 createInternalTags,
15 createTagsList,
16 diffTags,
17 hasAlchemyTags,
18} from "../../Tags.ts";
19import { AWSEnvironment } from "../Environment.ts";
20import type { Providers } from "../Providers.ts";
21
22/**
23 * The kind of value the parameter stores.
24 *
25 * - `String` — plain text value (the default).
26 * - `StringList` — comma-separated list of values.
27 * - `SecureString` — value encrypted at rest with a KMS key.
28 */
29export type ParameterType = "String" | "StringList" | "SecureString";
30
31/**
32 * The parameter tier. `Standard` parameters are free (up to 10,000 per
33 * account, 4 KB values). `Advanced` parameters cost money but allow larger
34 * values (8 KB), higher counts, and parameter policies. `Intelligent-Tiering`
35 * lets Systems Manager pick the tier per request.
36 */
37export type ParameterTier = "Standard" | "Advanced" | "Intelligent-Tiering";
38
39export interface ParameterProps {
40 /**
41 * The fully qualified name of the parameter (may be a hierarchical path
42 * such as `/my-app/prod/db-url`). If omitted, a deterministic physical
43 * name is generated from the app, stage, and logical ID.
44 */
45 name?: string;
46 /**
47 * The type of the parameter.
48 * @default "String"
49 */
50 type?: ParameterType;
51 /**
52 * The parameter value. Accepts a plain string or a `Redacted` string —
53 * use `Redacted.make(...)` for `SecureString` values so they never leak
54 * into logs.
55 */
56 value: string | Redacted.Redacted<string>;
57 /**
58 * Description of the parameter.
59 */
60 description?: string;
61 /**
62 * The parameter tier. Note: downgrading an `Advanced` parameter back to
63 * `Standard` is not supported by the API and triggers a replacement.
64 * @default "Standard"
65 */
66 tier?: ParameterTier;
67 /**
68 * The KMS key ID, alias (e.g. `alias/my-key`), or ARN used to encrypt a
69 * `SecureString` parameter. Only valid when `type` is `SecureString`.
70 * @default "alias/aws/ssm" (the AWS-managed key)
71 */
72 keyId?: string;
73 /**
74 * A regular expression the parameter value must match, e.g. `^\d+$`.
75 */
76 allowedPattern?: string;
77 /**
78 * The data type for a `String` parameter. Use `aws:ec2:image` to have
79 * Systems Manager validate the value is a valid AMI ID.
80 * @default "text"
81 */
82 dataType?: "text" | "aws:ec2:image" | "aws:ssm:integration";
83 /**
84 * Tags to apply to the parameter. Merged with internal Alchemy tags.
85 */
86 tags?: Record<string, string>;
87}
88
89export interface Parameter extends Resource<
90 "AWS.SSM.Parameter",
91 ParameterProps,
92 {
93 parameterName: string;
94 parameterArn: string;
95 type: ParameterType;
96 version: number;
97 /**
98 * The ARN of the KMS key that encrypts this parameter. Defined only for
99 * `SecureString` parameters (resolved from `keyId`, defaulting to the
100 * AWS-managed `alias/aws/ssm` key). Bindings use it to grant
101 * `kms:Decrypt` for `WithDecryption` reads.
102 */
103 keyArn: string | undefined;
104 },
105 never,
106 Providers
107> {}
108
109/**
110 * An AWS Systems Manager (SSM) Parameter Store parameter.
111 *
112 * `Parameter` owns the lifecycle of a `String`, `StringList`, or
113 * `SecureString` parameter. A parameter name is auto-generated from the app,
114 * stage, and logical ID unless you provide one explicitly. Standard-tier
115 * parameters are free, making them ideal for configuration values, feature
116 * flags, and small secrets.
117 * ### Creating Parameters
118 * **Example:** String Parameter
119 * ```typescript
120 * import * as SSM from "alchemy/AWS/SSM";
121 *
122 * const config = yield* SSM.Parameter("DatabaseUrl", {
123 * value: "postgres://db.example.com:5432/app",
124 * });
125 * ```
126 *
127 * **Example:** StringList Parameter
128 * ```typescript
129 * const subnets = yield* SSM.Parameter("AllowedOrigins", {
130 * type: "StringList",
131 * value: "https://a.example.com,https://b.example.com",
132 * });
133 * ```
134 *
135 * **Example:** Parameter with a Hierarchical Name
136 * ```typescript
137 * const param = yield* SSM.Parameter("DbUrl", {
138 * name: "/my-app/prod/db-url",
139 * value: "postgres://db.example.com:5432/app",
140 * });
141 * ```
142 *
143 * ### SecureString Parameters
144 * **Example:** Encrypted with the AWS-managed key
145 * ```typescript
146 * import * as Redacted from "effect/Redacted";
147 *
148 * const apiKey = yield* SSM.Parameter("ApiKey", {
149 * type: "SecureString",
150 * value: Redacted.make("super-secret-value"),
151 * });
152 * ```
153 *
154 * **Example:** Encrypted with a customer-managed KMS key
155 * ```typescript
156 * const key = yield* KMS.Key("SecretsKey");
157 * const apiKey = yield* SSM.Parameter("ApiKey", {
158 * type: "SecureString",
159 * value: Redacted.make("super-secret-value"),
160 * keyId: key.keyId,
161 * });
162 * ```
163 *
164 * ### Validation
165 * **Example:** Constrain values with an allowed pattern
166 * ```typescript
167 * const port = yield* SSM.Parameter("Port", {
168 * value: "5432",
169 * allowedPattern: "^\\d+$",
170 * });
171 * ```
172 *
173 * ### Reading Parameters at Runtime
174 * Bind read operations in the init phase and use them in runtime handlers.
175 *
176 * **Example:** Read a parameter from a handler
177 * ```typescript
178 * // init
179 * const getParameter = yield* SSM.GetParameter(config);
180 *
181 * return {
182 * fetch: Effect.gen(function* () {
183 * // runtime
184 * const result = yield* getParameter({ WithDecryption: true });
185 * return HttpServerResponse.text(String(result.Parameter?.Value));
186 * }),
187 * };
188 * ```
189 *
190 * @resource
191 */
192export const Parameter = Resource<Parameter>("AWS.SSM.Parameter");
193
194/** Normalize a plain or redacted parameter value to its plain string. */
195const toPlainValue = (
196 value: string | Redacted.Redacted<string> | undefined,
197): string | undefined =>
198 value === undefined
199 ? undefined
200 : typeof value === "string"
201 ? value
202 : Redacted.value(value);
203
204const toTagRecord = (
205 tags: ReadonlyArray<{ Key?: string; Value?: string }> | undefined,
206): Record<string, string> =>
207 Object.fromEntries(
208 (tags ?? [])
209 .filter(
210 (tag): tag is { Key: string; Value: string } =>
211 typeof tag.Key === "string" && typeof tag.Value === "string",
212 )
213 .map((tag) => [tag.Key, tag.Value]),
214 );
215
216const parameterArnOf = (region: string, accountId: string, name: string) =>
217 `arn:aws:ssm:${region}:${accountId}:parameter${name.startsWith("/") ? "" : "/"}${name}`;
218
219export const ParameterProvider = () =>
220 Provider.effect(
221 Parameter,
222 Effect.gen(function* () {
223 const createName = Effect.fn(function* (
224 id: string,
225 props: Pick<ParameterProps, "name">,
226 ) {
227 if (props.name) return props.name;
228 const generated = yield* createPhysicalName({ id, maxLength: 128 });
229 // SSM rejects parameter names prefixed with "aws" or "ssm"
230 // (case-insensitive) with a ValidationException. Stack names like
231 // "SSMBindings" would otherwise produce invalid physical names, so
232 // deterministically prefix those.
233 return /^(aws|ssm)/i.test(generated) ? `p-${generated}` : generated;
234 });
235
236 // DescribeParameters is the only API that surfaces tier, description,
237 // allowedPattern, and the KMS key id. It is eventually consistent, so
238 // treat an absent result as "no metadata yet" rather than "missing".
239 const describeByName = Effect.fn(function* (name: string) {
240 return yield* ssm.describeParameters
241 .pages({
242 ParameterFilters: [
243 { Key: "Name", Option: "Equals", Values: [name] },
244 ],
245 })
246 .pipe(
247 Stream.map((page) => page.Parameters ?? []),
248 Stream.flattenIterable,
249 Stream.runHead,
250 Effect.map(Option.getOrUndefined),
251 );
252 });
253
254 // Resolve a key id/alias/ARN to the key ARN for the `keyArn` attribute.
255 // The AWS-managed `alias/aws/ssm` key is created lazily on first use,
256 // so tolerate NotFound as "not resolvable yet".
257 const resolveKeyArn = Effect.fn(function* (keyId: string | undefined) {
258 return yield* kms.describeKey({ KeyId: keyId ?? "alias/aws/ssm" }).pipe(
259 Effect.map((r) => r.KeyMetadata?.Arn),
260 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
261 );
262 });
263
264 const fetchObservedTags = Effect.fn(function* (name: string) {
265 return yield* ssm
266 .listTagsForResource({ ResourceType: "Parameter", ResourceId: name })
267 .pipe(
268 Effect.map((r) => toTagRecord(r.TagList)),
269 // A just-created (or just-deleted) parameter can transiently
270 // surface InvalidResourceId; treat as "no tags observed".
271 Effect.catchTag("InvalidResourceId", () =>
272 Effect.succeed({} as Record<string, string>),
273 ),
274 );
275 });
276
277 return Parameter.Provider.of({
278 stables: ["parameterName", "parameterArn"],
279
280 list: () =>
281 Effect.gen(function* () {
282 const { accountId, region } = yield* AWSEnvironment.current;
283 const pages = yield* ssm.describeParameters
284 .pages({})
285 .pipe(Stream.runCollect);
286 const metas = Array.from(pages).flatMap(
287 (page) => page.Parameters ?? [],
288 );
289 return yield* Effect.forEach(
290 metas.filter(
291 (m): m is typeof m & { Name: string } => m.Name != null,
292 ),
293 (m) =>
294 Effect.gen(function* () {
295 const type = (m.Type ?? "String") as ParameterType;
296 const keyArn =
297 type === "SecureString"
298 ? yield* resolveKeyArn(m.KeyId).pipe(
299 Effect.catch(() => Effect.succeed(undefined)),
300 )
301 : undefined;
302 return {
303 parameterName: m.Name,
304 parameterArn:
305 m.ARN ?? parameterArnOf(region, accountId, m.Name),
306 type,
307 version: m.Version ?? 1,
308 keyArn,
309 };
310 }),
311 { concurrency: 5 },
312 );
313 }),
314
315 read: Effect.fn(function* ({ id, olds, output }) {
316 const { accountId, region } = yield* AWSEnvironment.current;
317 const name =
318 output?.parameterName ?? (yield* createName(id, olds ?? {}));
319 const found = yield* ssm.getParameter({ Name: name }).pipe(
320 Effect.map((r) => r.Parameter),
321 Effect.catchTag("ParameterNotFound", () =>
322 Effect.succeed(undefined),
323 ),
324 );
325 if (!found) return undefined;
326 const type = (found.Type ?? "String") as ParameterType;
327 const meta = yield* describeByName(name).pipe(
328 Effect.catch(() => Effect.succeed(undefined)),
329 );
330 const keyArn =
331 type === "SecureString"
332 ? yield* resolveKeyArn(meta?.KeyId).pipe(
333 Effect.catch(() => Effect.succeed(undefined)),
334 )
335 : undefined;
336 const attrs = {
337 parameterName: name,
338 parameterArn: found.ARN ?? parameterArnOf(region, accountId, name),
339 type,
340 version: found.Version ?? 1,
341 keyArn,
342 };
343 const tags = yield* fetchObservedTags(name).pipe(
344 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
345 );
346 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
347 }),
348
349 diff: Effect.fn(function* ({ id, news, olds }) {
350 if (!isResolved(news)) return undefined;
351 const oldName = yield* createName(id, olds ?? {});
352 const newName = yield* createName(id, news ?? {});
353 if (oldName !== newName) {
354 return { action: "replace" } as const;
355 }
356 // The API cannot downgrade an Advanced parameter back to Standard —
357 // the only way to converge is delete + recreate.
358 const oldTier = olds?.tier ?? "Standard";
359 const newTier = news?.tier ?? "Standard";
360 if (oldTier === "Advanced" && newTier === "Standard") {
361 return { action: "replace" } as const;
362 }
363 // Type changes (String <-> SecureString etc.) are mutable via
364 // PutParameter Overwrite=true, so fall through to default update.
365 }),
366
367 reconcile: Effect.fn(function* ({ id, news, output, session }) {
368 const { accountId, region } = yield* AWSEnvironment.current;
369 const name = output?.parameterName ?? (yield* createName(id, news));
370 const parameterArn = parameterArnOf(region, accountId, name);
371 const desiredType = news.type ?? "String";
372 const desiredValue = toPlainValue(news.value)!;
373 const internalTags = yield* createInternalTags(id);
374 const desiredTags = { ...news.tags, ...internalTags };
375
376 // 1. OBSERVE — cloud state is authoritative. `output` is only a
377 // cache of the physical name; if the parameter was deleted
378 // out-of-band we fall through to create.
379 const observed = yield* ssm
380 .getParameter({ Name: name, WithDecryption: true })
381 .pipe(
382 Effect.map((r) => r.Parameter),
383 Effect.catchTag("ParameterNotFound", () =>
384 Effect.succeed(undefined),
385 ),
386 );
387
388 let version: number | undefined;
389
390 if (observed === undefined) {
391 // 2. ENSURE — create with tags in one call. PutParameter rejects
392 // Tags together with Overwrite, so if a concurrent reconciler
393 // won the race we fall back to an overwrite put (the tag sync
394 // below converges tags).
395 version = yield* ssm
396 .putParameter({
397 Name: name,
398 Value: news.value,
399 Type: desiredType,
400 Description: news.description,
401 KeyId: news.keyId,
402 AllowedPattern: news.allowedPattern,
403 Tier: news.tier,
404 DataType: news.dataType,
405 Tags: createTagsList(desiredTags),
406 })
407 .pipe(
408 Effect.map((r) => r.Version),
409 Effect.catchTag("ParameterAlreadyExists", () =>
410 ssm
411 .putParameter({
412 Name: name,
413 Value: news.value,
414 Type: desiredType,
415 Description: news.description,
416 KeyId: news.keyId,
417 AllowedPattern: news.allowedPattern,
418 Tier: news.tier,
419 DataType: news.dataType,
420 Overwrite: true,
421 })
422 .pipe(Effect.map((r) => r.Version)),
423 ),
424 );
425 } else {
426 // 3. SYNC — diff observed cloud state against desired and only
427 // call PutParameter when something actually drifted.
428 // DescribeParameters carries the metadata GetParameter omits
429 // (tier, description, allowedPattern, KMS key id); it is
430 // eventually consistent so an absent record just means "can't
431 // prove a no-op" and we overwrite.
432 const meta = yield* describeByName(name).pipe(
433 Effect.catch(() => Effect.succeed(undefined)),
434 );
435 const drift =
436 toPlainValue(observed.Value) !== desiredValue ||
437 (observed.Type ?? "String") !== desiredType ||
438 (news.description !== undefined &&
439 meta?.Description !== news.description) ||
440 (news.allowedPattern !== undefined &&
441 meta?.AllowedPattern !== news.allowedPattern) ||
442 (news.tier !== undefined && meta?.Tier !== news.tier) ||
443 (news.dataType !== undefined &&
444 (meta?.DataType ?? "text") !== news.dataType) ||
445 (desiredType === "SecureString" &&
446 news.keyId !== undefined &&
447 meta?.KeyId !== news.keyId);
448 if (drift) {
449 version = yield* ssm
450 .putParameter({
451 Name: name,
452 Value: news.value,
453 Type: desiredType,
454 Description: news.description,
455 KeyId: news.keyId,
456 AllowedPattern: news.allowedPattern,
457 Tier: news.tier,
458 DataType: news.dataType,
459 Overwrite: true,
460 })
461 .pipe(Effect.map((r) => r.Version));
462 } else {
463 version = observed.Version;
464 }
465 }
466
467 // 3b. SYNC TAGS — diff against OBSERVED cloud tags (never olds or
468 // output) so adoption converges. Internal Alchemy tags always
469 // win over user tags.
470 const observedTags = yield* fetchObservedTags(name);
471 const { upsert, removed } = diffTags(observedTags, desiredTags);
472 if (upsert.length > 0) {
473 yield* ssm
474 .addTagsToResource({
475 ResourceType: "Parameter",
476 ResourceId: name,
477 Tags: upsert,
478 })
479 .pipe(
480 Effect.retry({
481 while: (e) =>
482 e._tag === "TooManyUpdates" ||
483 e._tag === "InvalidResourceId",
484 schedule: Schedule.max([
485 Schedule.fixed(1000),
486 Schedule.recurs(5),
487 ]),
488 }),
489 );
490 }
491 if (removed.length > 0) {
492 yield* ssm
493 .removeTagsFromResource({
494 ResourceType: "Parameter",
495 ResourceId: name,
496 TagKeys: removed,
497 })
498 .pipe(
499 Effect.retry({
500 while: (e) => e._tag === "TooManyUpdates",
501 schedule: Schedule.max([
502 Schedule.fixed(1000),
503 Schedule.recurs(5),
504 ]),
505 }),
506 );
507 }
508
509 // 4. RETURN — resolve the encryption key ARN after the put so a
510 // first SecureString write has already materialized the
511 // AWS-managed alias/aws/ssm key.
512 const keyArn =
513 desiredType === "SecureString"
514 ? yield* resolveKeyArn(news.keyId)
515 : undefined;
516
517 yield* session.note(parameterArn);
518 return {
519 parameterName: name,
520 parameterArn,
521 type: desiredType,
522 version: version ?? 1,
523 keyArn,
524 };
525 }),
526
527 delete: Effect.fn(function* ({ output }) {
528 yield* ssm
529 .deleteParameter({ Name: output.parameterName })
530 .pipe(Effect.catchTag("ParameterNotFound", () => Effect.void));
531 }),
532 });
533 }),
534 );