1import * as CloudControl from "@/AWS/CloudControl";
2import * as Lambda from "@/AWS/Lambda";
3import type * as cloudcontrol from "@distilled.cloud/aws/cloudcontrol";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8import * as Schedule from "effect/Schedule";
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 SSM_PARAMETER = "AWS::SSM::Parameter";
16
17// Deterministic parameter names unique to this suite (distinct from
18// Resource.test.ts's parameter).
19export const FIXTURE_PARAM = "/alchemy-test/cloudcontrol/bindings/fixture";
20export const RUNTIME_PARAM = "/alchemy-test/cloudcontrol/bindings/runtime";
21
22const plain = (
23 value: string | Redacted.Redacted<string> | undefined,
24): string | undefined =>
25 value === undefined || typeof value === "string"
26 ? value
27 : Redacted.value(value);
28
29const readValue = (
30 description: cloudcontrol.ResourceDescription | undefined,
31): string | undefined => {
32 const raw = plain(description?.Properties);
33 if (raw === undefined) return undefined;
34 return (JSON.parse(raw) as { Value?: string }).Value;
35};
36
37export class CloudControlTestFunction extends Lambda.Function<Lambda.Function>()(
38 "CloudControlTestFunction",
39) {}
40
41export default CloudControlTestFunction.make(
42 {
43 main,
44 functionUrl: true,
45 // The runtime lifecycle routes poll async Cloud Control operations.
46 timeout: Duration.seconds(120),
47 },
48 Effect.gen(function* () {
49 // A Cloud Control-managed resource deployed with the stack, read back by
50 // the GetResource / ListResources routes.
51 yield* CloudControl.Resource("BindingsParam", {
52 typeName: SSM_PARAMETER,
53 desiredState: {
54 Name: FIXTURE_PARAM,
55 Type: "String",
56 Value: "fixture",
57 },
58 });
59
60 // Cloud Control invokes the AWS::SSM::Parameter handlers with the
61 // caller's credentials, so the host also needs the handlers' underlying
62 // SSM permissions (see the type schema's `handlers` section).
63 const ssmHandlerPolicy = [
64 {
65 Effect: "Allow" as const,
66 Action: [
67 "ssm:PutParameter",
68 "ssm:DeleteParameter",
69 "ssm:GetParameter",
70 "ssm:GetParameters",
71 "ssm:DescribeParameters",
72 "ssm:AddTagsToResource",
73 "ssm:RemoveTagsFromResource",
74 "ssm:ListTagsForResource",
75 "ssm:LabelParameterVersion",
76 "ssm:UnlabelParameterVersion",
77 ],
78 Resource: ["*"],
79 },
80 ];
81
82 const getResource = yield* CloudControl.GetResource({
83 handlerPolicyStatements: ssmHandlerPolicy,
84 });
85 const listResources = yield* CloudControl.ListResources({
86 handlerPolicyStatements: ssmHandlerPolicy,
87 });
88 const createResource = yield* CloudControl.CreateResource({
89 handlerPolicyStatements: ssmHandlerPolicy,
90 });
91 const updateResource = yield* CloudControl.UpdateResource({
92 handlerPolicyStatements: ssmHandlerPolicy,
93 });
94 const deleteResource = yield* CloudControl.DeleteResource({
95 handlerPolicyStatements: ssmHandlerPolicy,
96 });
97 const getResourceRequestStatus =
98 yield* CloudControl.GetResourceRequestStatus();
99 const listResourceRequests = yield* CloudControl.ListResourceRequests();
100 const cancelResourceRequest = yield* CloudControl.CancelResourceRequest();
101
102 const bound = {
103 getResource,
104 listResources,
105 createResource,
106 updateResource,
107 deleteResource,
108 getResourceRequestStatus,
109 listResourceRequests,
110 cancelResourceRequest,
111 };
112
113 /** Poll a request token until the operation settles (bounded ~80s). */
114 const waitSettled = Effect.fn(function* (requestToken: string) {
115 return yield* getResourceRequestStatus({
116 RequestToken: requestToken,
117 }).pipe(
118 Effect.repeat({
119 schedule: Schedule.spaced("2 seconds"),
120 until: (r): boolean => {
121 const status = r.ProgressEvent?.OperationStatus;
122 return (
123 status !== "PENDING" &&
124 status !== "IN_PROGRESS" &&
125 status !== "CANCEL_IN_PROGRESS"
126 );
127 },
128 times: 40,
129 }),
130 );
131 });
132
133 /**
134 * Best-effort delete of the runtime parameter so the create route is
135 * idempotent across retried invocations.
136 */
137 const cleanupRuntimeParam = deleteResource({
138 TypeName: SSM_PARAMETER,
139 Identifier: RUNTIME_PARAM,
140 }).pipe(
141 Effect.flatMap((r) =>
142 r.ProgressEvent?.RequestToken !== undefined
143 ? waitSettled(r.ProgressEvent.RequestToken)
144 : Effect.succeed(undefined),
145 ),
146 Effect.catchTag("ResourceNotFoundException", () =>
147 Effect.succeed(undefined),
148 ),
149 );
150
151 return {
152 fetch: Effect.gen(function* () {
153 const request = yield* HttpServerRequest;
154 const url = new URL(request.originalUrl);
155 const pathname = url.pathname;
156
157 if (request.method === "GET" && pathname === "/bindings") {
158 return yield* HttpServerResponse.json({
159 bound: Object.keys(bound),
160 });
161 }
162
163 if (request.method === "GET" && pathname === "/fixture") {
164 const result = yield* getResource({
165 TypeName: SSM_PARAMETER,
166 Identifier: FIXTURE_PARAM,
167 });
168 return yield* HttpServerResponse.json({
169 identifier: result.ResourceDescription?.Identifier ?? null,
170 value: readValue(result.ResourceDescription) ?? null,
171 });
172 }
173
174 if (request.method === "GET" && pathname === "/list") {
175 // Bounded page walk looking for the fixture parameter.
176 let nextToken: string | undefined;
177 let found = false;
178 let count = 0;
179 for (let page = 0; page < 5 && !found; page++) {
180 const result: cloudcontrol.ListResourcesOutput =
181 yield* listResources({
182 TypeName: SSM_PARAMETER,
183 MaxResults: 100,
184 NextToken: nextToken,
185 });
186 const descriptions = result.ResourceDescriptions ?? [];
187 count += descriptions.length;
188 found = descriptions.some(
189 (description) => description.Identifier === FIXTURE_PARAM,
190 );
191 nextToken = result.NextToken;
192 if (nextToken === undefined) break;
193 }
194 return yield* HttpServerResponse.json({ count, found });
195 }
196
197 if (request.method === "GET" && pathname === "/requests") {
198 const result = yield* listResourceRequests({ MaxResults: 20 });
199 return yield* HttpServerResponse.json({
200 count: (result.ResourceRequestStatusSummaries ?? []).length,
201 });
202 }
203
204 if (request.method === "GET" && pathname === "/status-not-found") {
205 // A token that never existed proves the grant end-to-end via the
206 // typed error (an IAM gap would surface AccessDeniedException).
207 const result = yield* getResourceRequestStatus({
208 RequestToken: "00000000-0000-0000-0000-000000000000",
209 }).pipe(
210 Effect.map(() => ({ found: true })),
211 Effect.catchTag("RequestTokenNotFoundException", () =>
212 Effect.succeed({ found: false }),
213 ),
214 );
215 return yield* HttpServerResponse.json(result);
216 }
217
218 if (request.method === "POST" && pathname === "/cancel-not-found") {
219 const tag = yield* cancelResourceRequest({
220 RequestToken: "00000000-0000-0000-0000-000000000000",
221 }).pipe(
222 Effect.map(() => "Cancelled"),
223 Effect.catchTag(
224 [
225 "RequestTokenNotFoundException",
226 "ConcurrentModificationException",
227 ],
228 (e) => Effect.succeed(e._tag),
229 ),
230 );
231 return yield* HttpServerResponse.json({ tag });
232 }
233
234 if (request.method === "POST" && pathname === "/runtime-create") {
235 const body = (yield* request.json) as unknown as { value: string };
236 yield* cleanupRuntimeParam;
237 const created = yield* createResource({
238 TypeName: SSM_PARAMETER,
239 DesiredState: JSON.stringify({
240 Name: RUNTIME_PARAM,
241 Type: "String",
242 Value: body.value,
243 }),
244 });
245 const settled = yield* waitSettled(
246 created.ProgressEvent!.RequestToken!,
247 );
248 const result = yield* getResource({
249 TypeName: SSM_PARAMETER,
250 Identifier: RUNTIME_PARAM,
251 });
252 return yield* HttpServerResponse.json({
253 status: settled.ProgressEvent?.OperationStatus ?? null,
254 value: readValue(result.ResourceDescription) ?? null,
255 });
256 }
257
258 if (request.method === "POST" && pathname === "/runtime-update") {
259 const body = (yield* request.json) as unknown as { value: string };
260 const updated = yield* updateResource({
261 TypeName: SSM_PARAMETER,
262 Identifier: RUNTIME_PARAM,
263 PatchDocument: JSON.stringify([
264 { op: "replace", path: "/Value", value: body.value },
265 ]),
266 });
267 const settled = yield* waitSettled(
268 updated.ProgressEvent!.RequestToken!,
269 );
270 const result = yield* getResource({
271 TypeName: SSM_PARAMETER,
272 Identifier: RUNTIME_PARAM,
273 });
274 return yield* HttpServerResponse.json({
275 status: settled.ProgressEvent?.OperationStatus ?? null,
276 value: readValue(result.ResourceDescription) ?? null,
277 });
278 }
279
280 if (request.method === "POST" && pathname === "/runtime-delete") {
281 const deleted = yield* deleteResource({
282 TypeName: SSM_PARAMETER,
283 Identifier: RUNTIME_PARAM,
284 });
285 const settled = yield* waitSettled(
286 deleted.ProgressEvent!.RequestToken!,
287 );
288 // Bounded read-until-gone to absorb eventual consistency.
289 const gone = yield* getResource({
290 TypeName: SSM_PARAMETER,
291 Identifier: RUNTIME_PARAM,
292 }).pipe(
293 Effect.map(() => false),
294 Effect.catchTag("ResourceNotFoundException", () =>
295 Effect.succeed(true),
296 ),
297 Effect.repeat({
298 schedule: Schedule.spaced("1 second"),
299 until: (isGone): boolean => isGone,
300 times: 5,
301 }),
302 );
303 return yield* HttpServerResponse.json({
304 status: settled.ProgressEvent?.OperationStatus ?? null,
305 gone,
306 });
307 }
308
309 return yield* HttpServerResponse.json(
310 { error: "Not found", method: request.method, pathname },
311 { status: 404 },
312 );
313 }).pipe(Effect.orDie),
314 };
315 }).pipe(
316 Effect.provide(
317 Layer.mergeAll(
318 CloudControl.GetResourceHttp,
319 CloudControl.ListResourcesHttp,
320 CloudControl.CreateResourceHttp,
321 CloudControl.UpdateResourceHttp,
322 CloudControl.DeleteResourceHttp,
323 CloudControl.GetResourceRequestStatusHttp,
324 CloudControl.ListResourceRequestsHttp,
325 CloudControl.CancelResourceRequestHttp,
326 ),
327 ),
328 ),
329);