1import * as AWS from "@/AWS";
2import {
3 AutoScalingGroup,
4 LaunchTemplate,
5 LifecycleHook,
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 type * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14import { getAutoScalingTestSubnetId } from "./TestNetwork.ts";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18// Deterministic, account-unique names so leftover cleanup from an interrupted
19// run works. The fleet is sized to zero (min/max/desired 0) so no EC2 instances
20// launch — the hook is created and observed against a stable, instance-free ASG.
21const asgName = "alchemy-test-lifecycle-hook-asg";
22const hookName = "alchemy-test-lifecycle-hook";
23
24const cleanupAsg = autoscaling
25 .deleteAutoScalingGroup({ AutoScalingGroupName: asgName, ForceDelete: true })
26 .pipe(Effect.catch(() => Effect.void));
27
28// `describeLifecycleHooks` for a deleted Auto Scaling Group raises AWS wire code
29// `ValidationError` ("AutoScalingGroup ... not found"), typed as
30// `AutoScalingGroupNotFound` via the auto-scaling distilled patch. Treat it as
31// "hook gone" for the out-of-band assertions after teardown.
32const describeHook = autoscaling
33 .describeLifecycleHooks({
34 AutoScalingGroupName: asgName,
35 LifecycleHookNames: [hookName],
36 } as any)
37 .pipe(
38 Effect.map((r) => r.LifecycleHooks?.[0]),
39 Effect.catchTag("AutoScalingGroupNotFound", () =>
40 Effect.succeed(undefined),
41 ),
42 );
43
44test.provider(
45 "creates, updates, and deletes a lifecycle hook on an ASG",
46 (stack) =>
47 Effect.gen(function* () {
48 yield* cleanupAsg;
49 yield* stack.destroy();
50
51 // Launch templates do not validate the AMI at creation time; fall back to
52 // a syntactically valid id if the lookup returns nothing.
53 const imageId = amazonLinux2023();
54
55 // Place the fleet into a subnet of the account's default VPC (resolved
56 // out-of-band) rather than a throwaway Vpc resource — a failed run loses
57 // its scratch state and orphaned VPCs exhaust the 5-per-region quota.
58 const subnetId = yield* getAutoScalingTestSubnetId;
59
60 const deployHook = (heartbeatTimeout: Duration.Input) =>
61 stack.deploy(
62 Effect.gen(function* () {
63 const template = yield* LaunchTemplate("HookTemplate", {
64 imageId,
65 instanceType: "t3.micro",
66 });
67 const group = yield* AutoScalingGroup("HookGroup", {
68 autoScalingGroupName: asgName,
69 launchTemplate: template,
70 subnetIds: [subnetId],
71 minSize: 0,
72 maxSize: 0,
73 desiredCapacity: 0,
74 });
75 return yield* LifecycleHook("Hook", {
76 lifecycleHookName: hookName,
77 autoScalingGroup: group,
78 lifecycleTransition: "TERMINATING",
79 heartbeatTimeout,
80 defaultResult: "CONTINUE",
81 });
82 }),
83 );
84
85 // Create.
86 const created = yield* deployHook("300 seconds");
87 expect(created.lifecycleHookName).toEqual(hookName);
88 expect(created.autoScalingGroupName).toEqual(asgName);
89 expect(created.lifecycleTransition).toEqual(
90 "autoscaling:EC2_INSTANCE_TERMINATING",
91 );
92 expect(created.heartbeatTimeout).toEqual(300);
93 expect(created.defaultResult).toEqual("CONTINUE");
94
95 // Out-of-band proof.
96 const liveCreated = yield* describeHook;
97 expect(liveCreated?.LifecycleHookName).toEqual(hookName);
98 expect(liveCreated?.HeartbeatTimeout).toEqual(300);
99 expect(liveCreated?.DefaultResult).toEqual("CONTINUE");
100
101 // Update the heartbeat timeout in place (same name → no replacement).
102 const updated = yield* deployHook("120 seconds");
103 expect(updated.lifecycleHookName).toEqual(hookName);
104 expect(updated.heartbeatTimeout).toEqual(120);
105
106 const liveUpdated = yield* describeHook;
107 expect(liveUpdated?.HeartbeatTimeout).toEqual(120);
108
109 // Delete via stack teardown; assert the hook is gone out-of-band.
110 yield* stack.destroy();
111 const gone = yield* describeHook.pipe(
112 Effect.repeat({
113 until: (hook) => hook === undefined,
114 schedule: Schedule.spaced("2 seconds"),
115 times: 8,
116 }),
117 );
118 expect(gone).toBeUndefined();
119
120 // The ASG itself is deleted by the stack teardown too — prove the suite
121 // left nothing behind.
122 const groupsLeft = yield* autoscaling
123 .describeAutoScalingGroups({ AutoScalingGroupNames: [asgName] } as any)
124 .pipe(
125 Effect.map((r) => (r.AutoScalingGroups ?? []).length),
126 Effect.repeat({
127 until: (count) => count === 0,
128 schedule: Schedule.spaced("3 seconds"),
129 times: 10,
130 }),
131 );
132 expect(groupsLeft).toBe(0);
133 }).pipe(Effect.ensuring(cleanupAsg)),
134 {
135 tags: [
136 "provider:aws",
137 "provider:aws:autoscaling",
138 "provider:aws:ec2",
139 "live",
140 ],
141 timeout: 240_000,
142 },
143);