1import * as AWS from "@/AWS";
2import { AutoScalingGroup, LaunchTemplate } from "@/AWS/AutoScaling";
3import { amazonLinux2023, Subnet, Vpc } from "@/AWS/EC2";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as autoscaling from "@distilled.cloud/aws/auto-scaling";
7import * as ec2 from "@distilled.cloud/aws/ec2";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import { getAutoScalingTestSubnetId, getTestAmiId } from "./TestNetwork.ts";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Out-of-band proof that an Auto Scaling Group is fully deleted after the
16// trailing stack.destroy(): describeAutoScalingGroups with a name filter
17// returns an empty list once deletion completes (bounded poll — the provider's
18// delete already waits, this is a cheap final confirmation).
19const assertGroupGone = (name: string) =>
20 autoscaling
21 .describeAutoScalingGroups({ AutoScalingGroupNames: [name] } as any)
22 .pipe(
23 Effect.map((r) => (r.AutoScalingGroups ?? []).length),
24 Effect.repeat({
25 until: (count) => count === 0,
26 schedule: Schedule.spaced("3 seconds"),
27 times: 10,
28 }),
29 Effect.map((count) => expect(count).toBe(0)),
30 );
31
32const launchTemplateName = "alchemy-test-asg-lt-oob";
33
34// The launch template is created out-of-band via distilled `ec2` rather than the
35// `AWS.AutoScaling.LaunchTemplate` resource: that resource's read-before-create
36// path currently dies on an untyped `ec2` NotFound (`describeLaunchTemplates`
37// does not declare `InvalidLaunchTemplateName.NotFoundException`), which is a gap
38// in a different provider. Creating the template directly isolates this test to
39// the ASG `list()` operation under test.
40const cleanupLaunchTemplate = ec2
41 .deleteLaunchTemplate({ LaunchTemplateName: launchTemplateName } as any)
42 .pipe(Effect.catch(() => Effect.void));
43
44// `list()` enumerates every Auto Scaling Group in the account/region via the
45// paginated `autoscaling.describeAutoScalingGroups` op. Deploy a real ASG (sized
46// to zero so no EC2 instances launch), resolve the provider from context via the
47// typed `findProvider`, call `list()`, and assert the deployed group appears in
48// the exhaustively paginated result.
49test.provider(
50 "list enumerates the deployed auto scaling group",
51 (stack) =>
52 Effect.gen(function* () {
53 yield* stack.destroy();
54
55 // The launch template is created out-of-band with the raw SDK, so the
56 // AMI id must be a plain string — `amazonLinux2023()` returns an
57 // Output, which only resolves inside `stack.deploy`.
58 const imageId = yield* getTestAmiId;
59
60 yield* cleanupLaunchTemplate;
61 yield* ec2.createLaunchTemplate({
62 LaunchTemplateName: launchTemplateName,
63 LaunchTemplateData: { ImageId: imageId, InstanceType: "t3.micro" },
64 } as any);
65
66 const group = yield* stack.deploy(
67 Effect.gen(function* () {
68 const vpc = yield* Vpc("TestVpc", { cidrBlock: "10.0.0.0/16" });
69 const subnet = yield* Subnet("TestSubnet", {
70 vpcId: vpc.vpcId,
71 cidrBlock: "10.0.1.0/24",
72 });
73 return yield* AutoScalingGroup("ListAutoScalingGroup", {
74 autoScalingGroupName: "alchemy-test-asg-list",
75 launchTemplate: { launchTemplateName },
76 subnetIds: [subnet.subnetId],
77 minSize: 0,
78 maxSize: 0,
79 desiredCapacity: 0,
80 });
81 }),
82 );
83
84 const provider = yield* Provider.findProvider(AutoScalingGroup);
85 const all = yield* provider.list();
86
87 expect(
88 all.some((g) => g.autoScalingGroupName === group.autoScalingGroupName),
89 ).toBe(true);
90
91 yield* stack.destroy();
92 yield* assertGroupGone("alchemy-test-asg-list");
93 }).pipe(Effect.ensuring(cleanupLaunchTemplate)),
94 {
95 tags: [
96 "provider:aws",
97 "provider:aws:autoscaling",
98 "provider:aws:ec2",
99 "live",
100 ],
101 timeout: 240_000,
102 },
103);
104
105// Whole-resource `launchTemplate: template` spelling. The engine resolves the
106// resource reference to its bare Attributes record before it reaches the
107// provider — no resource `Type` marker survives — so `toLaunchTemplateSpec`
108// must narrow on the attributes shape. Pre-fix it fell through to the
109// reference branch, which sent BOTH LaunchTemplateId and LaunchTemplateName
110// to `createAutoScalingGroup` (AWS rejects a spec carrying both) with the
111// version defaulted to `$Default` instead of the template's resolved default
112// version.
113//
114// The fleet is placed into a subnet of the account's default VPC (resolved
115// out-of-band) rather than a throwaway `Vpc` resource: a failed run loses its
116// in-memory scratch state, and orphaned VPCs quickly exhaust the 5-per-region
117// quota.
118const wholeAsgName = "alchemy-test-asg-whole-lt";
119const cleanupWholeAsg = autoscaling
120 .deleteAutoScalingGroup({
121 AutoScalingGroupName: wholeAsgName,
122 ForceDelete: true,
123 } as any)
124 .pipe(Effect.catch(() => Effect.void));
125
126test.provider(
127 "wires a whole LaunchTemplate resource by id and pinned default version",
128 (stack) =>
129 Effect.gen(function* () {
130 yield* cleanupWholeAsg;
131 yield* stack.destroy();
132
133 // Launch templates do not validate the AMI at creation time; fall back
134 // to a syntactically valid id if the lookup returns nothing.
135 const imageId = amazonLinux2023();
136
137 const subnetId = yield* getAutoScalingTestSubnetId;
138
139 const deployed = yield* stack.deploy(
140 Effect.gen(function* () {
141 const template = yield* LaunchTemplate("WholeLtTemplate", {
142 imageId,
143 instanceType: "t3.micro",
144 });
145 const group = yield* AutoScalingGroup("WholeLtGroup", {
146 autoScalingGroupName: wholeAsgName,
147 // Whole resource — resolves to bare Attributes at deploy time.
148 launchTemplate: template,
149 subnetIds: [subnetId],
150 minSize: 0,
151 maxSize: 0,
152 desiredCapacity: 0,
153 // Duration.Input props — whole seconds on the wire.
154 defaultCooldown: "45 seconds",
155 healthCheckGracePeriod: "2 minutes",
156 });
157 return {
158 templateId: template.launchTemplateId.as<string>(),
159 templateDefaultVersion: template.defaultVersionNumber.as<number>(),
160 group,
161 };
162 }),
163 );
164
165 // The group is wired to the template by ID with the version pinned to
166 // the template's resolved default version (not "$Default").
167 expect(deployed.group.launchTemplateId).toEqual(deployed.templateId);
168 expect(deployed.group.launchTemplateVersion).toEqual(
169 String(deployed.templateDefaultVersion),
170 );
171
172 // Duration.Input props round-trip as whole seconds in the attributes.
173 expect(deployed.group.defaultCooldown).toEqual(45);
174 expect(deployed.group.healthCheckGracePeriod).toEqual(120);
175
176 // Out-of-band: the live group carries the id-only spec and the
177 // Duration-derived second counts on the wire.
178 const described = yield* autoscaling.describeAutoScalingGroups({
179 AutoScalingGroupNames: [wholeAsgName],
180 } as any);
181 const live = described.AutoScalingGroups?.[0];
182 expect(live?.LaunchTemplate?.LaunchTemplateId).toEqual(
183 deployed.templateId,
184 );
185 expect(live?.DefaultCooldown).toEqual(45);
186 expect(live?.HealthCheckGracePeriod).toEqual(120);
187
188 yield* stack.destroy();
189 yield* assertGroupGone(wholeAsgName);
190 }).pipe(Effect.ensuring(cleanupWholeAsg)),
191 {
192 tags: [
193 "provider:aws",
194 "provider:aws:autoscaling",
195 "provider:aws:ec2",
196 "live",
197 ],
198 timeout: 240_000,
199 },
200);