| 1 | import * as kms from "@distilled.cloud/aws/kms"; |
| 2 | import * as ssm from "@distilled.cloud/aws/ssm"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Redacted from "effect/Redacted"; |
| 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 9 | import { isResolved } from "../../Diff.ts"; |
| 10 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 11 | import * as Provider from "../../Provider.ts"; |
| 12 | import { Resource } from "../../Resource.ts"; |
| 13 | import { |
| 14 | createInternalTags, |
| 15 | createTagsList, |
| 16 | diffTags, |
| 17 | hasAlchemyTags, |
| 18 | } from "../../Tags.ts"; |
| 19 | import { AWSEnvironment } from "../Environment.ts"; |
| 20 | import 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 | */ |
| 29 | export 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 | */ |
| 37 | export type ParameterTier = "Standard" | "Advanced" | "Intelligent-Tiering"; |
| 38 | |
| 39 | export 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 | |
| 89 | export 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 | */ |
| 192 | export const Parameter = Resource<Parameter>("AWS.SSM.Parameter"); |
| 193 | |
| 194 | /** Normalize a plain or redacted parameter value to its plain string. */ |
| 195 | const 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 | |
| 204 | const 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 | |
| 216 | const parameterArnOf = (region: string, accountId: string, name: string) => |
| 217 | `arn:aws:ssm:${region}:${accountId}:parameter${name.startsWith("/") ? "" : "/"}${name}`; |
| 218 | |
| 219 | export 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 | ); |