1import * as AWS from "@/AWS";
2import { Parameter } from "@/AWS/SSM";
3import * as Test from "@/Test/Alchemy";
4import * as ssm from "@distilled.cloud/aws/ssm";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13const plain = (
14 value: string | Redacted.Redacted<string> | undefined,
15): string | undefined =>
16 value === undefined
17 ? undefined
18 : typeof value === "string"
19 ? value
20 : Redacted.value(value);
21
22class ParameterStillExists extends Data.TaggedError("ParameterStillExists")<{
23 readonly name: string;
24}> {}
25
26const assertParameterDeleted = (name: string) =>
27 ssm.getParameter({ Name: name }).pipe(
28 Effect.flatMap(() => Effect.fail(new ParameterStillExists({ name }))),
29 Effect.catchTag("ParameterNotFound", () => Effect.void),
30 Effect.retry({
31 while: (e) => e._tag === "ParameterStillExists",
32 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
33 }),
34 );
35
36test.provider(
37 "create, update value, update tags, delete String parameter",
38 (stack) =>
39 Effect.gen(function* () {
40 // reconcile away any prior partial/crashed deployment
41 yield* stack.destroy();
42
43 const parameter = yield* stack.deploy(
44 Effect.gen(function* () {
45 return yield* Parameter("ConfigParam", {
46 value: "v1",
47 description: "test config value",
48 tags: { Environment: "test" },
49 });
50 }),
51 );
52
53 expect(parameter.parameterName).toBeDefined();
54 expect(parameter.parameterArn).toContain(":parameter/");
55 expect(parameter.type).toBe("String");
56 expect(parameter.version).toBe(1);
57 expect(parameter.keyArn).toBeUndefined();
58
59 // out-of-band verification via distilled
60 const created = yield* ssm.getParameter({
61 Name: parameter.parameterName,
62 });
63 expect(plain(created.Parameter?.Value)).toBe("v1");
64 expect(created.Parameter?.Type).toBe("String");
65
66 // update the value (new version) and add a tag
67 const updated = yield* stack.deploy(
68 Effect.gen(function* () {
69 return yield* Parameter("ConfigParam", {
70 value: "v2",
71 description: "test config value",
72 tags: { Environment: "test", Extra: "1" },
73 });
74 }),
75 );
76 expect(updated.parameterName).toBe(parameter.parameterName);
77 expect(updated.version).toBeGreaterThan(parameter.version);
78
79 const afterUpdate = yield* ssm.getParameter({
80 Name: parameter.parameterName,
81 });
82 expect(plain(afterUpdate.Parameter?.Value)).toBe("v2");
83
84 const tags = yield* ssm.listTagsForResource({
85 ResourceType: "Parameter",
86 ResourceId: parameter.parameterName,
87 });
88 const tagRecord = Object.fromEntries(
89 (tags.TagList ?? []).map((t) => [t.Key, t.Value]),
90 );
91 expect(tagRecord.Environment).toBe("test");
92 expect(tagRecord.Extra).toBe("1");
93 expect(tagRecord["alchemy::id"]).toBe("ConfigParam");
94
95 // no-op deploy converges without bumping the version
96 const noop = yield* stack.deploy(
97 Effect.gen(function* () {
98 return yield* Parameter("ConfigParam", {
99 value: "v2",
100 description: "test config value",
101 tags: { Environment: "test", Extra: "1" },
102 });
103 }),
104 );
105 expect(noop.version).toBe(updated.version);
106
107 // remove a tag
108 yield* stack.deploy(
109 Effect.gen(function* () {
110 return yield* Parameter("ConfigParam", {
111 value: "v2",
112 description: "test config value",
113 tags: { Environment: "test" },
114 });
115 }),
116 );
117 const afterTagRemoval = yield* ssm.listTagsForResource({
118 ResourceType: "Parameter",
119 ResourceId: parameter.parameterName,
120 });
121 const remaining = Object.fromEntries(
122 (afterTagRemoval.TagList ?? []).map((t) => [t.Key, t.Value]),
123 );
124 expect(remaining.Extra).toBeUndefined();
125 expect(remaining.Environment).toBe("test");
126
127 yield* stack.destroy();
128 yield* assertParameterDeleted(parameter.parameterName);
129 }),
130 { tags: ["provider:aws", "provider:aws:ssm", "live"], timeout: 120_000 },
131);
132
133test.provider(
134 "SecureString parameter lifecycle",
135 (stack) =>
136 Effect.gen(function* () {
137 // reconcile away any prior partial/crashed deployment
138 yield* stack.destroy();
139
140 const parameter = yield* stack.deploy(
141 Effect.gen(function* () {
142 return yield* Parameter("SecureParam", {
143 type: "SecureString",
144 value: Redacted.make("super-secret-v1"),
145 });
146 }),
147 );
148
149 expect(parameter.type).toBe("SecureString");
150 expect(parameter.keyArn).toContain(":key/");
151
152 const decrypted = yield* ssm.getParameter({
153 Name: parameter.parameterName,
154 WithDecryption: true,
155 });
156 expect(plain(decrypted.Parameter?.Value)).toBe("super-secret-v1");
157 expect(decrypted.Parameter?.Type).toBe("SecureString");
158
159 // rotate the value
160 const rotated = yield* stack.deploy(
161 Effect.gen(function* () {
162 return yield* Parameter("SecureParam", {
163 type: "SecureString",
164 value: Redacted.make("super-secret-v2"),
165 });
166 }),
167 );
168 expect(rotated.version).toBeGreaterThan(parameter.version);
169 const afterRotate = yield* ssm.getParameter({
170 Name: parameter.parameterName,
171 WithDecryption: true,
172 });
173 expect(plain(afterRotate.Parameter?.Value)).toBe("super-secret-v2");
174
175 yield* stack.destroy();
176 yield* assertParameterDeleted(parameter.parameterName);
177 }),
178 { tags: ["provider:aws", "provider:aws:ssm", "live"], timeout: 120_000 },
179);
180
181test.provider(
182 "type change String -> SecureString updates in place",
183 (stack) =>
184 Effect.gen(function* () {
185 // reconcile away any prior partial/crashed deployment
186 yield* stack.destroy();
187
188 const parameter = yield* stack.deploy(
189 Effect.gen(function* () {
190 return yield* Parameter("TypeChangeParam", {
191 value: "plain-value",
192 });
193 }),
194 );
195 expect(parameter.type).toBe("String");
196
197 const upgraded = yield* stack.deploy(
198 Effect.gen(function* () {
199 return yield* Parameter("TypeChangeParam", {
200 type: "SecureString",
201 value: Redacted.make("now-encrypted"),
202 });
203 }),
204 );
205
206 // same physical parameter — updated in place via Overwrite, not replaced
207 expect(upgraded.parameterName).toBe(parameter.parameterName);
208 expect(upgraded.type).toBe("SecureString");
209 expect(upgraded.keyArn).toContain(":key/");
210
211 const observed = yield* ssm.getParameter({
212 Name: parameter.parameterName,
213 WithDecryption: true,
214 });
215 expect(observed.Parameter?.Type).toBe("SecureString");
216 expect(plain(observed.Parameter?.Value)).toBe("now-encrypted");
217
218 yield* stack.destroy();
219 yield* assertParameterDeleted(parameter.parameterName);
220 }),
221 { tags: ["provider:aws", "provider:aws:ssm", "live"], timeout: 120_000 },
222);
223
224test.provider(
225 "custom hierarchical name and replacement on rename",
226 (stack) =>
227 Effect.gen(function* () {
228 // reconcile away any prior partial/crashed deployment
229 yield* stack.destroy();
230
231 const first = yield* stack.deploy(
232 Effect.gen(function* () {
233 return yield* Parameter("PathParam", {
234 name: "/alchemy-test/ssm/path-param-a",
235 value: "a",
236 });
237 }),
238 );
239 expect(first.parameterName).toBe("/alchemy-test/ssm/path-param-a");
240 expect(first.parameterArn).toContain(
241 ":parameter/alchemy-test/ssm/path-param-a",
242 );
243
244 // renaming triggers a replacement: new physical parameter, old one gone
245 const second = yield* stack.deploy(
246 Effect.gen(function* () {
247 return yield* Parameter("PathParam", {
248 name: "/alchemy-test/ssm/path-param-b",
249 value: "b",
250 });
251 }),
252 );
253 expect(second.parameterName).toBe("/alchemy-test/ssm/path-param-b");
254
255 const observed = yield* ssm.getParameter({
256 Name: "/alchemy-test/ssm/path-param-b",
257 });
258 expect(plain(observed.Parameter?.Value)).toBe("b");
259 yield* assertParameterDeleted("/alchemy-test/ssm/path-param-a");
260
261 yield* stack.destroy();
262 yield* assertParameterDeleted("/alchemy-test/ssm/path-param-b");
263 }),
264 { tags: ["provider:aws", "provider:aws:ssm", "live"], timeout: 120_000 },
265);