1import * as AWS from "@/AWS";
2import {
3 AutoScalingGroup,
4 LaunchTemplate,
5 ScheduledAction,
6} from "@/AWS/AutoScaling";
7import { amazonLinux2023 } from "@/AWS/EC2";
8import * as Test from "@/Test/Alchemy";
9import * as autoscaling from "@distilled.cloud/aws/auto-scaling";
10import { expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13import { getAutoScalingTestSubnetId } from "./TestNetwork.ts";
14
15const { test } = Test.make({ providers: AWS.providers() });
16
17// Deterministic, account-unique names so leftover cleanup from an interrupted
18// run works. The action uses a cron `recurrence` (no StartTime) so nothing is
19// scheduled in the past and no instances launch (min/max/desired 0).
20const asgName = "alchemy-test-scheduled-action-asg";
21const actionName = "alchemy-test-scheduled-action";
22
23const cleanupAsg = autoscaling
24 .deleteAutoScalingGroup({ AutoScalingGroupName: asgName, ForceDelete: true })
25 .pipe(Effect.catch(() => Effect.void));
26
27// `describeScheduledActions` for a deleted Auto Scaling Group raises AWS wire
28// code `ValidationError` ("AutoScalingGroup ... not found"), typed as
29// `AutoScalingGroupNotFound` via the auto-scaling distilled patch. Treat it as
30// "action gone" for the out-of-band assertions after teardown.
31const describeAction = autoscaling
32 .describeScheduledActions({
33 AutoScalingGroupName: asgName,
34 ScheduledActionNames: [actionName],
35 } as any)
36 .pipe(
37 Effect.map((r) => r.ScheduledUpdateGroupActions?.[0]),
38 Effect.catchTag("AutoScalingGroupNotFound", () =>
39 Effect.succeed(undefined),
40 ),
41 );
42
43test.provider(
44 "creates, updates, and deletes a scheduled action on an ASG",
45 (stack) =>
46 Effect.gen(function* () {
47 yield* cleanupAsg;
48 yield* stack.destroy();
49
50 const imageId = amazonLinux2023();
51
52 const subnetId = yield* getAutoScalingTestSubnetId;
53
54 const deployAction = (recurrence: string, desiredCapacity: number) =>
55 stack.deploy(
56 Effect.gen(function* () {
57 const template = yield* LaunchTemplate("ScheduleTemplate", {
58 imageId,
59 instanceType: "t3.micro",
60 });
61 const group = yield* AutoScalingGroup("ScheduleGroup", {
62 autoScalingGroupName: asgName,
63 launchTemplate: template,
64 subnetIds: [subnetId],
65 minSize: 0,
66 maxSize: 0,
67 desiredCapacity: 0,
68 });
69 return yield* ScheduledAction("Action", {
70 scheduledActionName: actionName,
71 autoScalingGroup: group.autoScalingGroupName,
72 recurrence,
73 timeZone: "America/New_York",
74 minSize: 0,
75 maxSize: 3,
76 desiredCapacity,
77 });
78 }),
79 );
80
81 // Create — daily 09:00 recurrence.
82 const created = yield* deployAction("0 9 * * *", 1);
83 expect(created.scheduledActionName).toEqual(actionName);
84 expect(created.autoScalingGroupName).toEqual(asgName);
85 expect(created.recurrence).toEqual("0 9 * * *");
86 expect(created.timeZone).toEqual("America/New_York");
87 expect(created.minSize).toEqual(0);
88 expect(created.maxSize).toEqual(3);
89 expect(created.desiredCapacity).toEqual(1);
90 expect(created.scheduledActionARN).toMatch(/^arn:aws:autoscaling:/);
91
92 const liveCreated = yield* describeAction;
93 expect(liveCreated?.ScheduledActionName).toEqual(actionName);
94 expect(liveCreated?.Recurrence).toEqual("0 9 * * *");
95 expect(liveCreated?.DesiredCapacity).toEqual(1);
96
97 // Update the recurrence + desired capacity in place (same name → no
98 // replacement).
99 const updated = yield* deployAction("0 18 * * *", 2);
100 expect(updated.scheduledActionName).toEqual(actionName);
101 expect(updated.recurrence).toEqual("0 18 * * *");
102 expect(updated.desiredCapacity).toEqual(2);
103
104 const liveUpdated = yield* describeAction;
105 expect(liveUpdated?.Recurrence).toEqual("0 18 * * *");
106 expect(liveUpdated?.DesiredCapacity).toEqual(2);
107
108 // Delete via stack teardown; assert the action is gone out-of-band.
109 yield* stack.destroy();
110 const gone = yield* describeAction.pipe(
111 Effect.repeat({
112 until: (action) => action === undefined,
113 schedule: Schedule.spaced("2 seconds"),
114 times: 8,
115 }),
116 );
117 expect(gone).toBeUndefined();
118
119 // The ASG itself is deleted by the stack teardown too — prove the suite
120 // left nothing behind.
121 const groupsLeft = yield* autoscaling
122 .describeAutoScalingGroups({ AutoScalingGroupNames: [asgName] } as any)
123 .pipe(
124 Effect.map((r) => (r.AutoScalingGroups ?? []).length),
125 Effect.repeat({
126 until: (count) => count === 0,
127 schedule: Schedule.spaced("3 seconds"),
128 times: 10,
129 }),
130 );
131 expect(groupsLeft).toBe(0);
132 }).pipe(Effect.ensuring(cleanupAsg)),
133 {
134 tags: [
135 "provider:aws",
136 "provider:aws:autoscaling",
137 "provider:aws:ec2",
138 "live",
139 ],
140 timeout: 240_000,
141 },
142);