1import * as AWS from "@/AWS";
2import { StateMachine } from "@/AWS/StepFunctions";
3import * as Test from "@/Test/Alchemy";
4import * as iam from "@distilled.cloud/aws/iam";
5import * as sfn from "@distilled.cloud/aws/sfn";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const plain = (value: string | Redacted.Redacted<string>): string =>
15 typeof value === "string" ? value : Redacted.value(value);
16
17class StateMachineStillExists extends Data.TaggedError(
18 "StateMachineStillExists",
19)<{ readonly stateMachineArn: string }> {}
20
21// Deletion is asynchronous and slow — a machine stays visible in DELETING
22// for minutes after deleteStateMachine returns. Deletion having been
23// initiated (status DELETING) or completed (typed StateMachineDoesNotExist)
24// both count as deleted; only an ACTIVE machine means destroy failed.
25const assertStateMachineDeleted = (stateMachineArn: string) =>
26 sfn.describeStateMachine({ stateMachineArn }).pipe(
27 Effect.flatMap((machine) =>
28 machine.status === "DELETING"
29 ? Effect.void
30 : Effect.fail(new StateMachineStillExists({ stateMachineArn })),
31 ),
32 Effect.catchTag("StateMachineDoesNotExist", () => Effect.void),
33 Effect.retry({
34 while: (e) => e._tag === "StateMachineStillExists",
35 schedule: Schedule.max([
36 Schedule.fixed("2 seconds"),
37 Schedule.recurs(10),
38 ]),
39 }),
40 );
41
42class RoleStillExists extends Data.TaggedError("RoleStillExists")<{
43 readonly roleName: string;
44}> {}
45
46const assertRoleDeleted = (roleName: string) =>
47 iam.getRole({ RoleName: roleName }).pipe(
48 Effect.flatMap(() => Effect.fail(new RoleStillExists({ roleName }))),
49 Effect.catchTag("NoSuchEntityException", () => Effect.void),
50 Effect.retry({
51 while: (e) => e._tag === "RoleStillExists",
52 schedule: Schedule.max([
53 Schedule.fixed("2 seconds"),
54 Schedule.recurs(10),
55 ]),
56 }),
57 );
58
59const passDefinition = (comment: string) => ({
60 Comment: comment,
61 StartAt: "Done",
62 States: {
63 Done: { Type: "Pass", Result: { ok: true }, End: true },
64 },
65});
66
67test.provider(
68 "create, update definition, update tags, destroy STANDARD state machine",
69 (stack) =>
70 Effect.gen(function* () {
71 const machine = yield* stack.deploy(
72 Effect.gen(function* () {
73 return yield* StateMachine("Workflow", {
74 definition: passDefinition("v1"),
75 tags: { Environment: "test" },
76 });
77 }),
78 );
79
80 expect(machine.stateMachineArn).toContain(":stateMachine:");
81 expect(machine.type).toBe("STANDARD");
82 expect(machine.roleArn).toContain(":role/");
83 expect(machine.roleName).toBeDefined();
84
85 // out-of-band verification via distilled
86 const created = yield* sfn.describeStateMachine({
87 stateMachineArn: machine.stateMachineArn,
88 });
89 expect(created.type).toBe("STANDARD");
90 expect(created.roleArn).toBe(machine.roleArn);
91 expect(JSON.parse(plain(created.definition)).Comment).toBe("v1");
92
93 const tags = yield* sfn.listTagsForResource({
94 resourceArn: machine.stateMachineArn,
95 });
96 const tagRecord = Object.fromEntries(
97 (tags.tags ?? []).map((t) => [t.key, t.value]),
98 );
99 expect(tagRecord.Environment).toBe("test");
100 expect(tagRecord["alchemy::id"]).toBe("Workflow");
101
102 // update the definition and add a tag — same physical machine
103 const updated = yield* stack.deploy(
104 Effect.gen(function* () {
105 return yield* StateMachine("Workflow", {
106 definition: passDefinition("v2"),
107 tags: { Environment: "test", Extra: "1" },
108 });
109 }),
110 );
111 expect(updated.stateMachineArn).toBe(machine.stateMachineArn);
112
113 const afterUpdate = yield* sfn.describeStateMachine({
114 stateMachineArn: machine.stateMachineArn,
115 });
116 expect(JSON.parse(plain(afterUpdate.definition)).Comment).toBe("v2");
117 const revisionAfterUpdate = afterUpdate.revisionId;
118
119 const afterUpdateTags = yield* sfn.listTagsForResource({
120 resourceArn: machine.stateMachineArn,
121 });
122 expect(
123 Object.fromEntries(
124 (afterUpdateTags.tags ?? []).map((t) => [t.key, t.value]),
125 ).Extra,
126 ).toBe("1");
127
128 // no-op deploy converges without creating a new revision
129 yield* stack.deploy(
130 Effect.gen(function* () {
131 return yield* StateMachine("Workflow", {
132 definition: passDefinition("v2"),
133 tags: { Environment: "test", Extra: "1" },
134 });
135 }),
136 );
137 const afterNoop = yield* sfn.describeStateMachine({
138 stateMachineArn: machine.stateMachineArn,
139 });
140 expect(afterNoop.revisionId).toBe(revisionAfterUpdate);
141
142 // remove a tag
143 yield* stack.deploy(
144 Effect.gen(function* () {
145 return yield* StateMachine("Workflow", {
146 definition: passDefinition("v2"),
147 tags: { Environment: "test" },
148 });
149 }),
150 );
151 const afterTagRemoval = yield* sfn.listTagsForResource({
152 resourceArn: machine.stateMachineArn,
153 });
154 const remaining = Object.fromEntries(
155 (afterTagRemoval.tags ?? []).map((t) => [t.key, t.value]),
156 );
157 expect(remaining.Extra).toBeUndefined();
158 expect(remaining.Environment).toBe("test");
159
160 yield* stack.destroy();
161 yield* assertStateMachineDeleted(machine.stateMachineArn);
162 yield* assertRoleDeleted(machine.roleName!);
163 }),
164 {
165 tags: [
166 "provider:aws",
167 "provider:aws:iam",
168 "provider:aws:stepfunctions",
169 "live",
170 ],
171 timeout: 120_000,
172 },
173);
174
175test.provider(
176 "EXPRESS workflow with tracing; type change triggers replacement",
177 (stack) =>
178 Effect.gen(function* () {
179 const express = yield* stack.deploy(
180 Effect.gen(function* () {
181 return yield* StateMachine("TypedWorkflow", {
182 type: "EXPRESS",
183 tracingEnabled: true,
184 definition: passDefinition("express"),
185 });
186 }),
187 );
188 expect(express.type).toBe("EXPRESS");
189
190 const observed = yield* sfn.describeStateMachine({
191 stateMachineArn: express.stateMachineArn,
192 });
193 expect(observed.type).toBe("EXPRESS");
194 expect(observed.tracingConfiguration?.enabled).toBe(true);
195
196 // changing the type replaces the machine (new instance id => new name)
197 const standard = yield* stack.deploy(
198 Effect.gen(function* () {
199 return yield* StateMachine("TypedWorkflow", {
200 type: "STANDARD",
201 definition: passDefinition("standard"),
202 });
203 }),
204 );
205 expect(standard.type).toBe("STANDARD");
206 expect(standard.stateMachineArn).not.toBe(express.stateMachineArn);
207
208 const observedStandard = yield* sfn.describeStateMachine({
209 stateMachineArn: standard.stateMachineArn,
210 });
211 expect(observedStandard.type).toBe("STANDARD");
212 yield* assertStateMachineDeleted(express.stateMachineArn);
213
214 yield* stack.destroy();
215 yield* assertStateMachineDeleted(standard.stateMachineArn);
216 }),
217 {
218 tags: ["provider:aws", "provider:aws:stepfunctions", "live"],
219 timeout: 120_000,
220 },
221);
222
223test.provider(
224 "string definition with substitutions",
225 (stack) =>
226 Effect.gen(function* () {
227 const machine = yield* stack.deploy(
228 Effect.gen(function* () {
229 return yield* StateMachine("TemplatedWorkflow", {
230 definition: JSON.stringify({
231 Comment: "${comment}",
232 StartAt: "Done",
233 States: { Done: { Type: "Pass", End: true } },
234 }),
235 definitionSubstitutions: { comment: "substituted-value" },
236 });
237 }),
238 );
239
240 const observed = yield* sfn.describeStateMachine({
241 stateMachineArn: machine.stateMachineArn,
242 });
243 expect(JSON.parse(plain(observed.definition)).Comment).toBe(
244 "substituted-value",
245 );
246
247 yield* stack.destroy();
248 yield* assertStateMachineDeleted(machine.stateMachineArn);
249 }),
250 {
251 tags: ["provider:aws", "provider:aws:stepfunctions", "live"],
252 timeout: 120_000,
253 },
254);