1import * as FIS from "@/AWS/FIS";
2import { Role } from "@/AWS/IAM/Role.ts";
3import * as Lambda from "@/AWS/Lambda";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Stream from "effect/Stream";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14export class FisTestFunction extends Lambda.Function<Lambda.Function>()(
15 "FisTestFunction",
16) {}
17
18export default FisTestFunction.make(
19 {
20 main,
21 functionUrl: true,
22 timeout: Duration.seconds(30),
23 },
24 Effect.gen(function* () {
25 // The experiment template the template-scoped bindings are bound to. A
26 // single target-less aws:fis:wait action, so starting an experiment
27 // disrupts nothing — it just idles until it completes or is stopped.
28 const role = yield* Role("FisBindingsRole", {
29 assumeRolePolicyDocument: {
30 Version: "2012-10-17",
31 Statement: [
32 {
33 Effect: "Allow",
34 Principal: { Service: "fis.amazonaws.com" },
35 Action: ["sts:AssumeRole"],
36 },
37 ],
38 },
39 });
40 const template = yield* FIS.ExperimentTemplate("BindingsWaitTemplate", {
41 description: "alchemy fis bindings fixture template",
42 roleArn: role.roleArn,
43 actions: {
44 Wait: {
45 actionId: "aws:fis:wait",
46 parameters: { duration: "PT3M" },
47 },
48 },
49 });
50
51 // Event source: subscribe the host to experiment state changes. The
52 // deploy proves the EventBridge rule + invoke permission wiring.
53 yield* FIS.consumeExperimentEvents({}, (events) =>
54 Stream.runForEach(events, (event) =>
55 Effect.log(
56 `fis experiment ${event.detail["experiment-id"]} -> ` +
57 `${event.detail["new-state"]?.status}`,
58 ),
59 ),
60 );
61
62 const startExperiment = yield* FIS.StartExperiment(template);
63 const getExperimentTemplate = yield* FIS.GetExperimentTemplate(template);
64 const getExperiment = yield* FIS.GetExperiment();
65 const stopExperiment = yield* FIS.StopExperiment();
66 const listExperiments = yield* FIS.ListExperiments();
67 const listExperimentResolvedTargets =
68 yield* FIS.ListExperimentResolvedTargets();
69 const listExperimentTemplates = yield* FIS.ListExperimentTemplates();
70 const getAction = yield* FIS.GetAction();
71 const listActions = yield* FIS.ListActions();
72 const getTargetResourceType = yield* FIS.GetTargetResourceType();
73 const listTargetResourceTypes = yield* FIS.ListTargetResourceTypes();
74 const getSafetyLever = yield* FIS.GetSafetyLever();
75 const updateSafetyLeverState = yield* FIS.UpdateSafetyLeverState();
76 const getExperimentTargetAccountConfiguration =
77 yield* FIS.GetExperimentTargetAccountConfiguration();
78 const listExperimentTargetAccountConfigurations =
79 yield* FIS.ListExperimentTargetAccountConfigurations();
80
81 const bound = {
82 startExperiment,
83 getExperimentTemplate,
84 getExperiment,
85 stopExperiment,
86 listExperiments,
87 listExperimentResolvedTargets,
88 listExperimentTemplates,
89 getAction,
90 listActions,
91 getTargetResourceType,
92 listTargetResourceTypes,
93 getSafetyLever,
94 // Engaging the lever halts every experiment in the account, so the
95 // suite only proves registration/IAM wiring and never calls it.
96 updateSafetyLeverState,
97 // Multi-account reads; registration-only on a single-account fixture.
98 getExperimentTargetAccountConfiguration,
99 listExperimentTargetAccountConfigurations,
100 };
101
102 return {
103 fetch: Effect.gen(function* () {
104 const request = yield* HttpServerRequest;
105 const url = new URL(request.originalUrl);
106 const pathname = url.pathname;
107 const id = url.searchParams.get("id") ?? "";
108
109 if (request.method === "GET" && pathname === "/bindings") {
110 return yield* HttpServerResponse.json({
111 bound: Object.keys(bound),
112 });
113 }
114
115 // Template-scoped read: the template id is injected by the binding.
116 if (request.method === "GET" && pathname === "/template") {
117 const { experimentTemplate } = yield* getExperimentTemplate();
118 return yield* HttpServerResponse.json({
119 id: experimentTemplate?.id,
120 actions: Object.keys(experimentTemplate?.actions ?? {}),
121 });
122 }
123
124 if (request.method === "GET" && pathname === "/templates") {
125 const { experimentTemplates } = yield* listExperimentTemplates();
126 return yield* HttpServerResponse.json({
127 ids: (experimentTemplates ?? []).map((t) => t.id),
128 });
129 }
130
131 // Start a (harmless, wait-only) experiment from the bound template.
132 if (request.method === "POST" && pathname === "/experiments") {
133 const { experiment } = yield* startExperiment({
134 tags: { "alchemy-test": "fis-bindings" },
135 });
136 return yield* HttpServerResponse.json({
137 id: experiment?.id,
138 status: experiment?.state?.status,
139 });
140 }
141
142 if (request.method === "GET" && pathname === "/experiment") {
143 const { experiment } = yield* getExperiment({ id });
144 return yield* HttpServerResponse.json({
145 id: experiment?.id,
146 templateId: experiment?.experimentTemplateId,
147 status: experiment?.state?.status,
148 });
149 }
150
151 if (request.method === "GET" && pathname === "/experiments") {
152 const templateId = url.searchParams.get("templateId") ?? undefined;
153 const { experiments } = yield* listExperiments({
154 experimentTemplateId: templateId,
155 });
156 return yield* HttpServerResponse.json({
157 ids: (experiments ?? []).map((e) => e.id),
158 });
159 }
160
161 if (request.method === "POST" && pathname === "/stop") {
162 const { experiment } = yield* stopExperiment({ id });
163 return yield* HttpServerResponse.json({
164 status: experiment?.state?.status,
165 });
166 }
167
168 if (request.method === "GET" && pathname === "/resolved-targets") {
169 const { resolvedTargets } = yield* listExperimentResolvedTargets({
170 experimentId: id,
171 });
172 return yield* HttpServerResponse.json({
173 count: (resolvedTargets ?? []).length,
174 });
175 }
176
177 // Catalog reads.
178 if (request.method === "GET" && pathname === "/action") {
179 const { action } = yield* getAction({ id });
180 return yield* HttpServerResponse.json({ id: action?.id });
181 }
182
183 if (request.method === "GET" && pathname === "/actions") {
184 const { actions } = yield* listActions();
185 return yield* HttpServerResponse.json({
186 ids: (actions ?? []).map((a) => a.id),
187 });
188 }
189
190 if (request.method === "GET" && pathname === "/target-resource-type") {
191 const { targetResourceType } = yield* getTargetResourceType({
192 resourceType: url.searchParams.get("type") ?? "",
193 });
194 return yield* HttpServerResponse.json({
195 resourceType: targetResourceType?.resourceType,
196 });
197 }
198
199 if (request.method === "GET" && pathname === "/target-resource-types") {
200 const { targetResourceTypes } = yield* listTargetResourceTypes();
201 return yield* HttpServerResponse.json({
202 resourceTypes: (targetResourceTypes ?? []).map(
203 (t) => t.resourceType,
204 ),
205 });
206 }
207
208 // The account's safety lever has the well-known id `default`.
209 if (request.method === "GET" && pathname === "/safety-lever") {
210 const { safetyLever } = yield* getSafetyLever({ id: "default" });
211 return yield* HttpServerResponse.json({
212 id: safetyLever?.id,
213 status: safetyLever?.state?.status,
214 });
215 }
216
217 return yield* HttpServerResponse.json(
218 { error: "Not found", method: request.method, pathname },
219 { status: 404 },
220 );
221 }).pipe(Effect.orDie),
222 };
223 }).pipe(
224 Effect.provide(
225 Layer.mergeAll(
226 Lambda.EventSource,
227 FIS.StartExperimentHttp,
228 FIS.GetExperimentTemplateHttp,
229 FIS.GetExperimentHttp,
230 FIS.StopExperimentHttp,
231 FIS.ListExperimentsHttp,
232 FIS.ListExperimentResolvedTargetsHttp,
233 FIS.ListExperimentTemplatesHttp,
234 FIS.GetActionHttp,
235 FIS.ListActionsHttp,
236 FIS.GetTargetResourceTypeHttp,
237 FIS.ListTargetResourceTypesHttp,
238 FIS.GetSafetyLeverHttp,
239 FIS.UpdateSafetyLeverStateHttp,
240 FIS.GetExperimentTargetAccountConfigurationHttp,
241 FIS.ListExperimentTargetAccountConfigurationsHttp,
242 ),
243 ),
244 ),
245);