1import * as CloudFormation from "@/AWS/CloudFormation";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Result from "effect/Result";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13/** Deterministic, account-unique export name for the fixture stack. */
14export const FIXTURE_EXPORT_NAME = "AlchemyCfnBindingsExport";
15
16// A tiny, free template: one SSM String parameter, one exported output.
17const template = JSON.stringify({
18 Resources: {
19 Param: {
20 Type: "AWS::SSM::Parameter",
21 Properties: { Type: "String", Value: "cfn-bindings-fixture" },
22 },
23 },
24 Outputs: {
25 ParamName: {
26 Value: { Ref: "Param" },
27 Export: { Name: FIXTURE_EXPORT_NAME },
28 },
29 },
30});
31
32/**
33 * Probe helper: run the bound operation and report either the success
34 * projection or the typed error tag, so the test can assert that operations
35 * rejected by CloudFormation fail with a TYPED tag (never an untyped
36 * catch-all).
37 */
38const tagOr = <A, E extends { _tag: string }, R>(
39 effect: Effect.Effect<A, E, R>,
40 onSuccess: (value: A) => Record<string, unknown>,
41) =>
42 Effect.result(effect).pipe(
43 Effect.map((result) =>
44 Result.isSuccess(result)
45 ? onSuccess(result.success)
46 : { errorTag: result.failure._tag },
47 ),
48 );
49
50export class CfnTestFunction extends Lambda.Function<Lambda.Function>()(
51 "CfnTestFunction",
52) {}
53
54export default CfnTestFunction.make(
55 {
56 main,
57 functionUrl: true,
58 // Above the 3s AWS default: describe/list calls plus distilled's bounded
59 // retries must complete within the invocation.
60 timeout: Duration.seconds(30),
61 },
62 Effect.gen(function* () {
63 const stack = yield* CloudFormation.Stack("BindingsStack", {
64 templateBody: template,
65 });
66
67 const describeStacks = yield* CloudFormation.DescribeStacks(stack);
68 const describeStackEvents =
69 yield* CloudFormation.DescribeStackEvents(stack);
70 const describeStackResources =
71 yield* CloudFormation.DescribeStackResources(stack);
72 const listStackResources = yield* CloudFormation.ListStackResources(stack);
73 const getTemplate = yield* CloudFormation.GetTemplate(stack);
74 const detectStackDrift = yield* CloudFormation.DetectStackDrift(stack);
75 const describeStackResourceDrifts =
76 yield* CloudFormation.DescribeStackResourceDrifts(stack);
77 const signalResource = yield* CloudFormation.SignalResource(stack);
78 const listExports = yield* CloudFormation.ListExports();
79 const listImports = yield* CloudFormation.ListImports();
80 const describeStackDriftDetectionStatus =
81 yield* CloudFormation.DescribeStackDriftDetectionStatus();
82 const validateTemplate = yield* CloudFormation.ValidateTemplate();
83
84 return {
85 fetch: Effect.gen(function* () {
86 const request = yield* HttpServerRequest;
87 const url = new URL(request.originalUrl);
88 const pathname = url.pathname;
89
90 if (request.method === "GET" && pathname === "/describe") {
91 const result = yield* describeStacks();
92 const live = result.Stacks?.[0];
93 return yield* HttpServerResponse.json({
94 stackId: live?.StackId,
95 status: live?.StackStatus,
96 outputs: Object.fromEntries(
97 (live?.Outputs ?? []).map((o) => [o.OutputKey, o.OutputValue]),
98 ),
99 });
100 }
101
102 if (request.method === "GET" && pathname === "/events") {
103 const result = yield* describeStackEvents();
104 return yield* HttpServerResponse.json({
105 count: (result.StackEvents ?? []).length,
106 statuses: (result.StackEvents ?? [])
107 .slice(0, 5)
108 .map((e) => e.ResourceStatus),
109 });
110 }
111
112 if (request.method === "GET" && pathname === "/resources") {
113 const result = yield* describeStackResources({
114 LogicalResourceId: "Param",
115 });
116 const resource = result.StackResources?.[0];
117 return yield* HttpServerResponse.json({
118 logicalId: resource?.LogicalResourceId,
119 physicalId: resource?.PhysicalResourceId,
120 type: resource?.ResourceType,
121 });
122 }
123
124 if (request.method === "GET" && pathname === "/list-resources") {
125 const result = yield* listStackResources();
126 return yield* HttpServerResponse.json({
127 types: (result.StackResourceSummaries ?? []).map(
128 (r) => r.ResourceType,
129 ),
130 });
131 }
132
133 if (request.method === "GET" && pathname === "/template") {
134 const result = yield* getTemplate();
135 return yield* HttpServerResponse.json({
136 template: result.TemplateBody,
137 });
138 }
139
140 if (request.method === "POST" && pathname === "/drift") {
141 const result = yield* detectStackDrift();
142 return yield* HttpServerResponse.json({
143 detectionId: result.StackDriftDetectionId,
144 });
145 }
146
147 if (request.method === "GET" && pathname === "/drift-status") {
148 const detectionId = url.searchParams.get("id") ?? undefined;
149 const result = yield* describeStackDriftDetectionStatus({
150 StackDriftDetectionId: detectionId,
151 });
152 return yield* HttpServerResponse.json({
153 detectionStatus: result.DetectionStatus,
154 stackDriftStatus: result.StackDriftStatus,
155 });
156 }
157
158 if (request.method === "GET" && pathname === "/resource-drifts") {
159 const result = yield* describeStackResourceDrifts();
160 return yield* HttpServerResponse.json({
161 drifts: (result.StackResourceDrifts ?? []).map((d) => ({
162 logicalId: d.LogicalResourceId,
163 status: d.StackResourceDriftStatus,
164 })),
165 });
166 }
167
168 if (request.method === "GET" && pathname === "/exports") {
169 const result = yield* listExports();
170 return yield* HttpServerResponse.json({
171 names: (result.Exports ?? []).map((e) => e.Name),
172 });
173 }
174
175 if (request.method === "GET" && pathname === "/imports") {
176 const name = url.searchParams.get("name") ?? FIXTURE_EXPORT_NAME;
177 // Nothing imports the fixture export — CloudFormation rejects the
178 // call with a typed ValidationError, proving IAM + typed errors.
179 return yield* HttpServerResponse.json(
180 yield* tagOr(listImports({ ExportName: name }), (result) => ({
181 imports: result.Imports ?? [],
182 })),
183 );
184 }
185
186 if (request.method === "POST" && pathname === "/validate") {
187 const body = (yield* request.json) as unknown as {
188 template?: string;
189 };
190 return yield* HttpServerResponse.json(
191 yield* tagOr(
192 validateTemplate({
193 TemplateBody: body.template ?? template,
194 }),
195 (result) => ({
196 parameters: (result.Parameters ?? []).map(
197 (p) => p.ParameterKey,
198 ),
199 capabilities: result.Capabilities ?? [],
200 }),
201 ),
202 );
203 }
204
205 if (request.method === "POST" && pathname === "/signal") {
206 // CloudFormation accepts (and ignores) signals for resources that
207 // are not waiting on a CreationPolicy — success proves the IAM +
208 // binding wiring; a rejection would surface as a typed tag.
209 return yield* HttpServerResponse.json(
210 yield* tagOr(
211 signalResource({
212 LogicalResourceId: "Param",
213 UniqueId: "cfn-bindings-test",
214 Status: "SUCCESS",
215 }),
216 () => ({ ok: true }),
217 ),
218 );
219 }
220
221 return yield* HttpServerResponse.json(
222 { error: "Not found", method: request.method, pathname },
223 { status: 404 },
224 );
225 }).pipe(Effect.orDie),
226 };
227 }).pipe(
228 Effect.provide(
229 Layer.mergeAll(
230 CloudFormation.DescribeStacksHttp,
231 CloudFormation.DescribeStackEventsHttp,
232 CloudFormation.DescribeStackResourcesHttp,
233 CloudFormation.ListStackResourcesHttp,
234 CloudFormation.GetTemplateHttp,
235 CloudFormation.DetectStackDriftHttp,
236 CloudFormation.DescribeStackResourceDriftsHttp,
237 CloudFormation.SignalResourceHttp,
238 CloudFormation.ListExportsHttp,
239 CloudFormation.ListImportsHttp,
240 CloudFormation.DescribeStackDriftDetectionStatusHttp,
241 CloudFormation.ValidateTemplateHttp,
242 ),
243 ),
244 ),
245);