| 1 | import * as Lambda from "@/AWS/Lambda"; |
| 2 | import * as SSM from "@/AWS/SSM"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | import * as Stream from "effect/Stream"; |
| 9 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 10 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 11 | import path from "pathe"; |
| 12 | |
| 13 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 14 | |
| 15 | const 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 | |
| 24 | export class SSMTestFunction extends Lambda.Function<Lambda.Function>()( |
| 25 | "SSMTestFunction", |
| 26 | ) {} |
| 27 | |
| 28 | export 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 | ); |