1import * as Lambda from "@/AWS/Lambda";
2import * as SSM from "@/AWS/SSM";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import path from "pathe";
12
13const main = path.resolve(import.meta.dirname, "handler.ts");
14
15const plain = (
16 value: string | Redacted.Redacted<string> | undefined,
17): string | undefined =>
18 value === undefined
19 ? undefined
20 : typeof value === "string"
21 ? value
22 : Redacted.value(value);
23
24export class SSMTestFunction extends Lambda.Function<Lambda.Function>()(
25 "SSMTestFunction",
26) {}
27
28export default SSMTestFunction.make(
29 {
30 main,
31 functionUrl: true,
32 timeout: Duration.seconds(30),
33 },
34 Effect.gen(function* () {
35 const stringParameter = yield* SSM.Parameter("TestStringParameter", {
36 value: "plain-config-value",
37 tags: { Environment: "test" },
38 });
39 const secureParameter = yield* SSM.Parameter("TestSecureParameter", {
40 type: "SecureString",
41 value: Redacted.make("bound-secret-value"),
42 });
43 // A parameter the write-plane bindings mutate at runtime (put, label,
44 // unlabel, history). Runtime writes drift the value; the next deploy
45 // converges it back to "v1".
46 const mutableParameter = yield* SSM.Parameter("TestMutableParameter", {
47 value: "v1",
48 });
49 // A hierarchical subtree for GetParametersByPath: the root parameter is
50 // the bound subtree marker, the child lives under the root's name.
51 const pathRoot = yield* SSM.Parameter("TestPathRoot", {
52 name: "/alchemy-test/ssm-bindings/root",
53 value: "root-value",
54 });
55 yield* SSM.Parameter("TestPathChild", {
56 name: "/alchemy-test/ssm-bindings/root/child",
57 value: "child-value",
58 });
59
60 // Event source: subscribe the host to Parameter Store change events. The
61 // deploy proves the EventBridge rule + invoke permission wiring.
62 yield* SSM.consumeParameterEvents(
63 {
64 kinds: ["change"],
65 names: [
66 "/alchemy-test/ssm-bindings/root",
67 "/alchemy-test/ssm-bindings/root/child",
68 ],
69 },
70 (events) =>
71 Stream.runForEach(events, (event) =>
72 Effect.log(
73 `parameter ${event.detail.operation}: ${event.detail.name}`,
74 ),
75 ),
76 );
77
78 const getStringParameter = yield* SSM.GetParameter(stringParameter);
79 const getSecureParameter = yield* SSM.GetParameter(secureParameter);
80 const getParameters = yield* SSM.GetParameters(
81 stringParameter,
82 secureParameter,
83 );
84 const putParameter = yield* SSM.PutParameter(mutableParameter);
85 const getHistory = yield* SSM.GetParameterHistory(mutableParameter);
86 const labelVersion = yield* SSM.LabelParameterVersion(mutableParameter);
87 const unlabelVersion = yield* SSM.UnlabelParameterVersion(mutableParameter);
88 const getByPath = yield* SSM.GetParametersByPath(pathRoot);
89
90 return {
91 fetch: Effect.gen(function* () {
92 const request = yield* HttpServerRequest;
93 const url = new URL(request.originalUrl);
94 const pathname = url.pathname;
95
96 if (request.method === "GET" && pathname === "/get-string") {
97 const result = yield* getStringParameter();
98 return yield* HttpServerResponse.json({
99 name: result.Parameter?.Name,
100 type: result.Parameter?.Type,
101 value: plain(result.Parameter?.Value),
102 });
103 }
104
105 if (request.method === "GET" && pathname === "/get-secure") {
106 const result = yield* getSecureParameter({ WithDecryption: true });
107 return yield* HttpServerResponse.json({
108 name: result.Parameter?.Name,
109 type: result.Parameter?.Type,
110 value: plain(result.Parameter?.Value),
111 });
112 }
113
114 if (request.method === "GET" && pathname === "/get-many") {
115 const result = yield* getParameters({ WithDecryption: true });
116 return yield* HttpServerResponse.json({
117 parameters: (result.Parameters ?? []).map((parameter) => ({
118 name: parameter.Name,
119 type: parameter.Type,
120 value: plain(parameter.Value),
121 })),
122 invalidParameters: result.InvalidParameters ?? [],
123 });
124 }
125
126 // Write a new version of the mutable parameter, then read it back.
127 if (request.method === "POST" && pathname === "/put") {
128 const put = yield* putParameter({
129 Value: "v2-runtime",
130 Overwrite: true,
131 });
132 const history = yield* getHistory();
133 return yield* HttpServerResponse.json({
134 version: put.Version,
135 latest: plain(history.Parameters?.at(-1)?.Value),
136 });
137 }
138
139 // The mutable parameter's full change history.
140 if (request.method === "GET" && pathname === "/history") {
141 const result = yield* getHistory();
142 return yield* HttpServerResponse.json({
143 count: (result.Parameters ?? []).length,
144 values: (result.Parameters ?? []).map((version) =>
145 plain(version.Value),
146 ),
147 });
148 }
149
150 // Label the latest version, then unlabel the same version. The label
151 // attach is eventually consistent, so retry the unlabel until the
152 // label is visible (RemovedLabels non-empty), bounded at 8 attempts.
153 if (request.method === "POST" && pathname === "/label-cycle") {
154 const labeled = yield* labelVersion({ Labels: ["current"] });
155 const unlabeled = yield* unlabelVersion({
156 ParameterVersion: labeled.ParameterVersion!,
157 Labels: ["current"],
158 }).pipe(
159 Effect.repeat({
160 until: (result): boolean =>
161 (result.RemovedLabels ?? []).length > 0,
162 schedule: Schedule.spaced("1 second"),
163 times: 8,
164 }),
165 );
166 return yield* HttpServerResponse.json({
167 labeledVersion: labeled.ParameterVersion,
168 invalidLabels: labeled.InvalidLabels ?? [],
169 removedLabels: unlabeled.RemovedLabels ?? [],
170 });
171 }
172
173 // Read the subtree under the bound path-root parameter's name.
174 if (request.method === "GET" && pathname === "/by-path") {
175 const result = yield* getByPath({ Recursive: true });
176 return yield* HttpServerResponse.json({
177 parameters: (result.Parameters ?? []).map((parameter) => ({
178 name: parameter.Name,
179 value: plain(parameter.Value),
180 })),
181 });
182 }
183
184 return yield* HttpServerResponse.json(
185 { error: "Not found", method: request.method, pathname },
186 { status: 404 },
187 );
188 }).pipe(Effect.orDie),
189 };
190 }).pipe(
191 Effect.provide(
192 Layer.mergeAll(
193 Lambda.EventSource,
194 SSM.GetParameterHttp,
195 SSM.GetParametersHttp,
196 SSM.GetParameterHistoryHttp,
197 SSM.GetParametersByPathHttp,
198 SSM.PutParameterHttp,
199 SSM.LabelParameterVersionHttp,
200 SSM.UnlabelParameterVersionHttp,
201 ),
202 ),
203 ),
204);