1import * as AWS from "@/AWS";
2import {
3 AutoScalingGroup,
4 LaunchTemplate,
5 ScalingPolicy,
6} from "@/AWS/AutoScaling";
7import { amazonLinux2023, Subnet, Vpc } from "@/AWS/EC2";
8import * as Provider from "@/Provider";
9import { isResourceState, State, type ResourceState } from "@/State";
10import * as Test from "@/Test/Alchemy";
11import * as autoscaling from "@distilled.cloud/aws/auto-scaling";
12import * as ec2 from "@distilled.cloud/aws/ec2";
13import { expect } from "alchemy-test";
14import * as Effect from "effect/Effect";
15import * as Schedule from "effect/Schedule";
16import { getAutoScalingTestSubnetId, getTestAmiId } from "./TestNetwork.ts";
17
18const { test } = Test.make({ providers: AWS.providers() });
19
20// Out-of-band proof a scaling policy is deleted: describePolicies by name
21// returns an empty list once the policy (or its group) is gone.
22const assertPolicyGone = (policyName: string) =>
23 autoscaling.describePolicies({ PolicyNames: [policyName] }).pipe(
24 Effect.map((r) => (r.ScalingPolicies ?? []).length),
25 Effect.repeat({
26 until: (count) => count === 0,
27 schedule: Schedule.spaced("2 seconds"),
28 times: 8,
29 }),
30 Effect.map((count) => expect(count).toBe(0)),
31 );
32
33// Out-of-band proof an Auto Scaling Group is deleted after the trailing
34// stack.destroy(): the name-filtered describe returns an empty list.
35const assertGroupGone = (name: string) =>
36 autoscaling
37 .describeAutoScalingGroups({ AutoScalingGroupNames: [name] } as any)
38 .pipe(
39 Effect.map((r) => (r.AutoScalingGroups ?? []).length),
40 Effect.repeat({
41 until: (count) => count === 0,
42 schedule: Schedule.spaced("3 seconds"),
43 times: 10,
44 }),
45 Effect.map((count) => expect(count).toBe(0)),
46 );
47
48// `list()` enumerates every scaling policy in the account/region via the
49// paginated `autoscaling.describePolicies` op (no AutoScalingGroupName filter,
50// so it spans every group). Deploy a real ASG (sized to zero so no EC2
51// instances launch) with a target-tracking policy, resolve the provider from
52// context via the typed `findProvider`, call `list()`, and assert the deployed
53// policy appears in the exhaustively paginated result.
54test.provider(
55 "list enumerates the deployed scaling policy",
56 (stack) =>
57 Effect.gen(function* () {
58 yield* stack.destroy();
59
60 // Launch templates do not validate the AMI at creation time; fall back to
61 // a syntactically valid id if the lookup returns nothing.
62 const imageId = amazonLinux2023();
63
64 const policy = yield* stack.deploy(
65 Effect.gen(function* () {
66 const vpc = yield* Vpc("TestVpc", { cidrBlock: "10.0.0.0/16" });
67 const subnet = yield* Subnet("TestSubnet", {
68 vpcId: vpc.vpcId,
69 cidrBlock: "10.0.1.0/24",
70 });
71 const template = yield* LaunchTemplate("ListPolicyTemplate", {
72 launchTemplateName: "alchemy-test-policy-lt-list",
73 imageId,
74 instanceType: "t3.micro",
75 });
76 const group = yield* AutoScalingGroup("ListPolicyGroup", {
77 autoScalingGroupName: "alchemy-test-policy-asg-list",
78 launchTemplate: template,
79 subnetIds: [subnet.subnetId],
80 minSize: 0,
81 maxSize: 0,
82 desiredCapacity: 0,
83 });
84 return yield* ScalingPolicy("ListScalingPolicy", {
85 policyName: "alchemy-test-scaling-policy-list",
86 autoScalingGroup: group,
87 predefinedMetricType: "ASGAverageCPUUtilization",
88 targetValue: 50,
89 });
90 }),
91 );
92
93 const provider = yield* Provider.findProvider(ScalingPolicy);
94 const all = yield* provider.list();
95
96 expect(all.some((p) => p.policyName === policy.policyName)).toBe(true);
97
98 yield* stack.destroy();
99 yield* assertPolicyGone(policy.policyName);
100 yield* assertGroupGone("alchemy-test-policy-asg-list");
101 }),
102 {
103 tags: [
104 "provider:aws",
105 "provider:aws:autoscaling",
106 "provider:aws:ec2",
107 "live",
108 ],
109 timeout: 240_000,
110 },
111);
112
113// Regression test for https://github.com/alchemy-run/alchemy/issues/736.
114//
115// An interrupted first deploy persists the scaling policy as
116// `status: "creating"` with no attributes — and the Output-valued
117// `autoScalingGroup` prop (a whole AutoScalingGroup resource object) does not
118// survive the state round-trip: it deserializes as `undefined`. Plan's
119// recovery branches then hand those junk props back to the provider as
120// `olds`:
121//
122// - `read` must tolerate `olds.autoScalingGroup === undefined` and fall back
123// to an account-wide `describePolicies` lookup by (unique physical) policy
124// name, so a half-created policy that DOES exist is recovered in place
125// (same policyArn, no duplicate).
126// - `diff` must NOT treat "old ASG name unknown vs new ASG name known" as an
127// ASG change: pre-fix it forced `action: "replace"`, which mints a new
128// instanceId and therefore a NEW generated policy name instead of resuming
129// the same create.
130//
131// The launch template is created out-of-band via distilled `ec2` (not the
132// `AWS.AutoScaling.LaunchTemplate` resource) to isolate this test from that
133// provider's unrelated untyped-NotFound read bug. The fleet is placed into a
134// subnet of the account's default VPC (resolved out-of-band) rather than a
135// throwaway `Vpc` resource: a failed run of this test loses its in-memory
136// scratch state, and orphaned VPCs quickly exhaust the 5-per-region quota.
137const recoveryLtName = "alchemy-test-scaling-policy-recovery-lt";
138const recoveryAsgName = "alchemy-test-scaling-policy-recovery-asg";
139const cleanupRecoveryLt = ec2
140 .deleteLaunchTemplate({ LaunchTemplateName: recoveryLtName } as any)
141 .pipe(Effect.catch(() => Effect.void));
142// Leftover-cleanup for interrupted/failed prior runs (the ASG name is
143// deterministic and account-unique; deleting it also deletes its policies).
144const cleanupRecoveryAsg = autoscaling
145 .deleteAutoScalingGroup({
146 AutoScalingGroupName: recoveryAsgName,
147 ForceDelete: true,
148 } as any)
149 .pipe(Effect.catch(() => Effect.void));
150
151test.provider(
152 "recovers a half-created scaling policy whose creating-state lost the Output-valued autoScalingGroup (#736)",
153 (stack) =>
154 Effect.gen(function* () {
155 yield* cleanupRecoveryAsg;
156 yield* cleanupRecoveryLt;
157 yield* stack.destroy();
158
159 // The launch template is created out-of-band with the raw SDK, so the
160 // AMI id must be a plain string — `amazonLinux2023()` returns an
161 // Output, which only resolves inside `stack.deploy`.
162 const imageId = yield* getTestAmiId;
163 yield* ec2.createLaunchTemplate({
164 LaunchTemplateName: recoveryLtName,
165 LaunchTemplateData: { ImageId: imageId, InstanceType: "t3.micro" },
166 } as any);
167
168 const subnetId = yield* getAutoScalingTestSubnetId;
169
170 const deployPolicy = () =>
171 stack.deploy(
172 Effect.gen(function* () {
173 const group = yield* AutoScalingGroup("RecoveryGroup", {
174 autoScalingGroupName: recoveryAsgName,
175 launchTemplate: { launchTemplateName: recoveryLtName },
176 subnetIds: [subnetId],
177 minSize: 0,
178 maxSize: 0,
179 desiredCapacity: 0,
180 });
181 return yield* ScalingPolicy("RecoveryScalingPolicy", {
182 // Whole-resource spelling — the #736 shape (an unresolved
183 // Output persisted at `creating` does not survive the state
184 // round-trip) AND coverage for the resolved-attributes
185 // narrowing: the engine resolves `group` to its bare
186 // Attributes record (no resource `Type` marker), which the
187 // provider must narrow by shape (`autoScalingGroupName`
188 // string field) to extract the group name.
189 // policyName intentionally omitted: the engine-generated
190 // physical name embeds the instanceId, so a wrongful
191 // `replace` (pre-fix diff) is observable as a changed name.
192 autoScalingGroup: group,
193 predefinedMetricType: "ASGAverageCPUUtilization",
194 targetValue: 50,
195 // Duration.Input prop — whole seconds on the wire.
196 estimatedInstanceWarmup: "90 seconds",
197 });
198 }),
199 );
200
201 const created = yield* deployPolicy();
202
203 // Duration.Input round-trips to the wire as whole seconds.
204 const liveCreated = yield* autoscaling.describePolicies({
205 PolicyNames: [created.policyName],
206 });
207 expect(liveCreated.ScalingPolicies?.[0]?.EstimatedInstanceWarmup).toEqual(
208 90,
209 );
210
211 // Rewrite the policy's persisted row into the wedged shape an
212 // interrupted deploy leaves behind: `creating`, no attributes, and the
213 // Output-valued `autoScalingGroup` lost in the round-trip.
214 const wedgeRow = Effect.gen(function* () {
215 const state = yield* yield* State;
216 const stage = stack.stage;
217 const fqns = yield* state.list({ stack: stack.name, stage });
218 const rows = yield* Effect.forEach(fqns, (fqn) =>
219 state
220 .get({ stack: stack.name, stage, fqn })
221 .pipe(Effect.map((row) => ({ fqn, row }))),
222 );
223 const wedged = rows.find(
224 (r): r is { fqn: string; row: ResourceState } =>
225 isResourceState(r.row) &&
226 r.row.resourceType === "AWS.AutoScaling.ScalingPolicy",
227 );
228 if (!wedged) {
229 return yield* Effect.die(
230 new Error(
231 "no AWS.AutoScaling.ScalingPolicy state row found after deploy",
232 ),
233 );
234 }
235 yield* state.set({
236 stack: stack.name,
237 stage,
238 fqn: wedged.fqn,
239 value: {
240 ...wedged.row,
241 status: "creating",
242 attr: undefined,
243 props: {
244 ...wedged.row.props,
245 autoScalingGroup: undefined,
246 },
247 },
248 });
249 });
250
251 // Variant A — the half-created policy still exists in the cloud.
252 // `read` gets junk olds (no ASG name) and must find the policy via the
253 // account-wide name-only lookup: SAME policyArn, no duplicate.
254 yield* wedgeRow;
255 const recoveredInPlace = yield* deployPolicy();
256 expect(recoveredInPlace.policyArn).toEqual(created.policyArn);
257 expect(recoveredInPlace.policyName).toEqual(created.policyName);
258 expect(recoveredInPlace.autoScalingGroupName).toEqual(
259 created.autoScalingGroupName,
260 );
261
262 // Variant B — wedge again AND delete the policy out-of-band so the
263 // recovery `read` misses and `diff` runs with the junk olds. Pre-fix,
264 // diff saw `undefined !== <asg name>` and forced a replacement (new
265 // instanceId => new generated policy name). Post-fix it falls through
266 // to update, resumes the same create, and recreates under the SAME
267 // name.
268 yield* wedgeRow;
269 yield* autoscaling.deletePolicy({
270 AutoScalingGroupName: created.autoScalingGroupName,
271 PolicyName: created.policyName,
272 } as any);
273 const remaining = yield* autoscaling
274 .describePolicies({ PolicyNames: [created.policyName] })
275 .pipe(
276 Effect.map((result) => (result.ScalingPolicies ?? []).length),
277 Effect.repeat({
278 until: (count) => count === 0,
279 schedule: Schedule.spaced("2 seconds"),
280 times: 8,
281 }),
282 );
283 expect(remaining).toBe(0);
284
285 const recreated = yield* deployPolicy();
286 expect(recreated.policyName).toEqual(created.policyName);
287 expect(recreated.autoScalingGroupName).toEqual(
288 created.autoScalingGroupName,
289 );
290
291 // Out-of-band proof the policy is live again.
292 const after = yield* autoscaling.describePolicies({
293 PolicyNames: [created.policyName],
294 });
295 expect(
296 (after.ScalingPolicies ?? []).some(
297 (p) => p.PolicyName === created.policyName,
298 ),
299 ).toBe(true);
300
301 yield* stack.destroy();
302 yield* assertPolicyGone(created.policyName);
303 yield* assertGroupGone(recoveryAsgName);
304 yield* cleanupRecoveryLt;
305 }).pipe(Effect.ensuring(cleanupRecoveryLt)),
306 {
307 tags: [
308 "provider:aws",
309 "provider:aws:autoscaling",
310 "provider:aws:ec2",
311 "live",
312 ],
313 timeout: 240_000,
314 },
315);