1import * as AWS from "@/AWS";
2import { ExperimentTemplate } from "@/AWS/FIS";
3import { Role } from "@/AWS/IAM/Role.ts";
4import * as Test from "@/Test/Alchemy";
5import * as fis from "@distilled.cloud/aws/fis";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13const findTemplate = (id: string) =>
14 fis.getExperimentTemplate({ id }).pipe(
15 Effect.map((r) => r.experimentTemplate),
16 Effect.catchTag("ResourceNotFoundException", () =>
17 Effect.succeed(undefined),
18 ),
19 );
20
21class TemplateStillExists extends Data.TaggedError("TemplateStillExists")<{
22 readonly id: string;
23}> {}
24
25const assertTemplateDeleted = (id: string) =>
26 findTemplate(id).pipe(
27 Effect.flatMap((template) =>
28 template === undefined
29 ? Effect.void
30 : Effect.fail(new TemplateStillExists({ id })),
31 ),
32 Effect.retry({
33 while: (e) => e._tag === "TemplateStillExists",
34 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
35 }),
36 );
37
38const fisRole = Effect.gen(function* () {
39 return yield* Role("FisExperimentRole", {
40 assumeRolePolicyDocument: {
41 Version: "2012-10-17",
42 Statement: [
43 {
44 Effect: "Allow",
45 Principal: { Service: "fis.amazonaws.com" },
46 Action: ["sts:AssumeRole"],
47 },
48 ],
49 },
50 inlinePolicies: {
51 "fis-ec2": {
52 Version: "2012-10-17",
53 Statement: [
54 {
55 Effect: "Allow",
56 Action: [
57 "ec2:StopInstances",
58 "ec2:StartInstances",
59 "ec2:DescribeInstances",
60 ],
61 Resource: "*",
62 },
63 ],
64 },
65 },
66 });
67});
68
69test.provider(
70 "create, update definition + tags, delete experiment template",
71 (stack) =>
72 Effect.gen(function* () {
73 yield* stack.destroy();
74
75 // Definition only — no experiment is ever started, so nothing is
76 // disrupted. The stop-instances action targets instances by a tag no
77 // resource in this account carries.
78 const template = yield* stack.deploy(
79 Effect.gen(function* () {
80 const role = yield* fisRole;
81 return yield* ExperimentTemplate("StopInstancesTemplate", {
82 description: "alchemy fis test",
83 roleArn: role.roleArn,
84 targets: {
85 Instances: {
86 resourceType: "aws:ec2:instance",
87 resourceTags: { "alchemy:fis-test": "true" },
88 selectionMode: "COUNT(1)",
89 },
90 },
91 actions: {
92 StopInstances: {
93 actionId: "aws:ec2:stop-instances",
94 parameters: { startInstancesAfterDuration: "PT2M" },
95 targets: { Instances: "Instances" },
96 },
97 },
98 stopConditions: [{ source: "none" }],
99 tags: { Environment: "test" },
100 });
101 }),
102 );
103
104 expect(template.id).toMatch(/^EXT/);
105 expect(template.arn).toContain(":experiment-template/");
106 expect(template.roleArn).toContain(":role/");
107
108 // out-of-band verification via distilled — the template exists with
109 // the exact definition we declared.
110 const created = yield* findTemplate(template.id);
111 expect(created).toBeDefined();
112 expect(created?.description).toBe("alchemy fis test");
113 expect(created?.roleArn).toBe(template.roleArn);
114 expect(created?.stopConditions).toEqual([{ source: "none" }]);
115 expect(created?.targets?.Instances?.resourceType).toBe(
116 "aws:ec2:instance",
117 );
118 expect(created?.targets?.Instances?.selectionMode).toBe("COUNT(1)");
119 expect(created?.targets?.Instances?.resourceTags).toEqual({
120 "alchemy:fis-test": "true",
121 });
122 expect(created?.actions?.StopInstances?.actionId).toBe(
123 "aws:ec2:stop-instances",
124 );
125 expect(created?.actions?.StopInstances?.parameters).toEqual({
126 startInstancesAfterDuration: "PT2M",
127 });
128 expect(created?.tags?.Environment).toBe("test");
129 expect(created?.tags?.["alchemy::id"]).toBe("StopInstancesTemplate");
130
131 // update — new description, wider selection, an extra sequenced wait
132 // action, and changed tags. Same physical template (stable id/arn).
133 const updated = yield* stack.deploy(
134 Effect.gen(function* () {
135 const role = yield* fisRole;
136 return yield* ExperimentTemplate("StopInstancesTemplate", {
137 description: "alchemy fis test updated",
138 roleArn: role.roleArn,
139 targets: {
140 Instances: {
141 resourceType: "aws:ec2:instance",
142 resourceTags: { "alchemy:fis-test": "true" },
143 selectionMode: "ALL",
144 },
145 },
146 actions: {
147 StopInstances: {
148 actionId: "aws:ec2:stop-instances",
149 parameters: { startInstancesAfterDuration: "PT2M" },
150 targets: { Instances: "Instances" },
151 },
152 Wait: {
153 actionId: "aws:fis:wait",
154 parameters: { duration: "PT1M" },
155 startAfter: ["StopInstances"],
156 },
157 },
158 stopConditions: [{ source: "none" }],
159 tags: { Environment: "test", Team: "chaos" },
160 });
161 }),
162 );
163 expect(updated.id).toBe(template.id);
164 expect(updated.arn).toBe(template.arn);
165
166 const afterUpdate = yield* findTemplate(template.id);
167 expect(afterUpdate?.description).toBe("alchemy fis test updated");
168 expect(afterUpdate?.targets?.Instances?.selectionMode).toBe("ALL");
169 expect(afterUpdate?.actions?.Wait?.actionId).toBe("aws:fis:wait");
170 expect(afterUpdate?.actions?.Wait?.startAfter).toEqual(["StopInstances"]);
171 expect(afterUpdate?.tags?.Team).toBe("chaos");
172
173 // removing the user tag converges (internal tags survive)
174 yield* stack.deploy(
175 Effect.gen(function* () {
176 const role = yield* fisRole;
177 return yield* ExperimentTemplate("StopInstancesTemplate", {
178 description: "alchemy fis test updated",
179 roleArn: role.roleArn,
180 targets: {
181 Instances: {
182 resourceType: "aws:ec2:instance",
183 resourceTags: { "alchemy:fis-test": "true" },
184 selectionMode: "ALL",
185 },
186 },
187 actions: {
188 StopInstances: {
189 actionId: "aws:ec2:stop-instances",
190 parameters: { startInstancesAfterDuration: "PT2M" },
191 targets: { Instances: "Instances" },
192 },
193 Wait: {
194 actionId: "aws:fis:wait",
195 parameters: { duration: "PT1M" },
196 startAfter: ["StopInstances"],
197 },
198 },
199 stopConditions: [{ source: "none" }],
200 tags: { Environment: "test" },
201 });
202 }),
203 );
204 const afterTagRemoval = yield* findTemplate(template.id);
205 expect(afterTagRemoval?.tags?.Team).toBeUndefined();
206 expect(afterTagRemoval?.tags?.Environment).toBe("test");
207 expect(afterTagRemoval?.tags?.["alchemy::id"]).toBe(
208 "StopInstancesTemplate",
209 );
210
211 yield* stack.destroy();
212 yield* assertTemplateDeleted(template.id);
213 }),
214 {
215 tags: ["provider:aws", "provider:aws:fis", "provider:aws:iam", "live"],
216 timeout: 120_000,
217 },
218);
219
220test.provider(
221 "changing accountTargeting replaces the template",
222 (stack) =>
223 Effect.gen(function* () {
224 yield* stack.destroy();
225
226 // A target-less wait action keeps the template valid under both
227 // single-account and multi-account targeting.
228 const waitTemplate = (
229 accountTargeting?: "single-account" | "multi-account",
230 ) =>
231 Effect.gen(function* () {
232 const role = yield* fisRole;
233 return yield* ExperimentTemplate("WaitTemplate", {
234 roleArn: role.roleArn,
235 actions: {
236 Wait: {
237 actionId: "aws:fis:wait",
238 parameters: { duration: "PT1M" },
239 },
240 },
241 experimentOptions: accountTargeting
242 ? { accountTargeting }
243 : undefined,
244 });
245 });
246
247 const first = yield* stack.deploy(waitTemplate());
248 expect(first.id).toMatch(/^EXT/);
249 // the description defaults to the logical id
250 const observedFirst = yield* findTemplate(first.id);
251 expect(observedFirst?.description).toBe("WaitTemplate");
252 expect(observedFirst?.experimentOptions?.accountTargeting).toBe(
253 "single-account",
254 );
255
256 // accountTargeting is create-only → replacement: new physical
257 // template, old one deleted.
258 const second = yield* stack.deploy(waitTemplate("multi-account"));
259 expect(second.id).not.toBe(first.id);
260
261 const observedSecond = yield* findTemplate(second.id);
262 expect(observedSecond?.experimentOptions?.accountTargeting).toBe(
263 "multi-account",
264 );
265 yield* assertTemplateDeleted(first.id);
266
267 yield* stack.destroy();
268 yield* assertTemplateDeleted(second.id);
269 }),
270 {
271 tags: ["provider:aws", "provider:aws:fis", "provider:aws:iam", "live"],
272 timeout: 120_000,
273 },
274);