1import * as AWS from "@/AWS";
2import { Stack as CfnStack } from "@/AWS/CloudFormation";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as CloudFormation from "@distilled.cloud/aws/cloudformation";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19class StackStillExists extends Data.TaggedError("StackStillExists") {}
20
21/** Assert the stack is gone (reads as StackNotFound by name). */
22const assertDeleted = Effect.fn(function* (stackName: string) {
23 yield* CloudFormation.describeStacks({ StackName: stackName }).pipe(
24 Effect.flatMap(() => Effect.fail(new StackStillExists())),
25 Effect.retry({
26 while: (e) => e._tag === "StackStillExists",
27 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(20)]),
28 }),
29 Effect.catchTag("StackNotFound", () => Effect.void),
30 );
31});
32
33// A tiny, free template: a single SSM String parameter whose value is a
34// template parameter, plus an output echoing the generated parameter name.
35const template = JSON.stringify({
36 Parameters: { Value: { Type: "String" } },
37 Resources: {
38 Param: {
39 Type: "AWS::SSM::Parameter",
40 Properties: { Type: "String", Value: { Ref: "Value" } },
41 },
42 },
43 Outputs: { ParamName: { Value: { Ref: "Param" } } },
44});
45
46const stackDef = (value: string, tags?: Record<string, string>) =>
47 Effect.gen(function* () {
48 const stack = yield* CfnStack("CfnTestStack", {
49 templateBody: template,
50 parameters: { Value: value },
51 tags,
52 });
53 return { stack };
54 });
55
56test.provider(
57 "create, no-op, update, delete a CloudFormation stack",
58 (stack) =>
59 Effect.gen(function* () {
60 yield* stack.destroy();
61
62 // Create.
63 const { stack: created } = yield* stack.deploy(stackDef("v1"));
64 expect(created.stackStatus).toBe("CREATE_COMPLETE");
65 expect(created.stackId).toMatch(/^arn:aws:cloudformation:/);
66 expect(created.outputs.ParamName).toBeDefined();
67
68 // Verify out-of-band.
69 const described = yield* CloudFormation.describeStacks({
70 StackName: created.stackId,
71 });
72 const live = described.Stacks?.[0];
73 expect(live?.StackStatus).toBe("CREATE_COMPLETE");
74 expect(
75 live?.Tags?.some(
76 (t) => t.Key === "alchemy::id" && t.Value === "CfnTestStack",
77 ),
78 ).toBe(true);
79
80 // No-op update (same template + params) — must not error.
81 const { stack: noop } = yield* stack.deploy(stackDef("v1"));
82 expect(noop.stackStatus).toBe("CREATE_COMPLETE");
83 expect(noop.stackId).toBe(created.stackId);
84
85 // Update the template parameter — stack id is stable, status becomes
86 // UPDATE_COMPLETE.
87 const { stack: updated } = yield* stack.deploy(
88 stackDef("v2", { env: "prod" }),
89 );
90 expect(updated.stackId).toBe(created.stackId);
91 expect(updated.stackStatus).toBe("UPDATE_COMPLETE");
92
93 const reDescribed = yield* CloudFormation.describeStacks({
94 StackName: created.stackId,
95 });
96 expect(reDescribed.Stacks?.[0]?.StackStatus).toBe("UPDATE_COMPLETE");
97 expect(
98 reDescribed.Stacks?.[0]?.Tags?.some(
99 (t) => t.Key === "env" && t.Value === "prod",
100 ),
101 ).toBe(true);
102
103 // Delete + wait gone.
104 yield* stack.destroy();
105 yield* assertDeleted(created.stackName);
106 }).pipe(logLevel),
107 {
108 tags: ["provider:aws", "provider:aws:cloudformation", "live"],
109 timeout: 300_000,
110 },
111);
112
113test.provider(
114 "list enumerates the deployed stack",
115 (stack) =>
116 Effect.gen(function* () {
117 yield* stack.destroy();
118
119 const { stack: created } = yield* stack.deploy(stackDef("v1"));
120
121 const provider = yield* Provider.findProvider(CfnStack);
122 const all = yield* provider.list();
123 expect(all.some((x) => x.stackId === created.stackId)).toBe(true);
124
125 yield* stack.destroy();
126 yield* assertDeleted(created.stackName);
127 }).pipe(logLevel),
128 {
129 tags: ["provider:aws", "provider:aws:cloudformation", "live"],
130 timeout: 300_000,
131 },
132);