| 1 | import * as AWS from "@/AWS"; |
| 2 | import { |
| 3 | AutoScalingGroup, |
| 4 | LaunchTemplate, |
| 5 | ScalingPolicy, |
| 6 | } from "@/AWS/AutoScaling"; |
| 7 | import { amazonLinux2023, Subnet, Vpc } from "@/AWS/EC2"; |
| 8 | import * as Provider from "@/Provider"; |
| 9 | import { isResourceState, State, type ResourceState } from "@/State"; |
| 10 | import * as Test from "@/Test/Alchemy"; |
| 11 | import * as autoscaling from "@distilled.cloud/aws/auto-scaling"; |
| 12 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 13 | import { expect } from "alchemy-test"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Schedule from "effect/Schedule"; |
| 16 | import { getAutoScalingTestSubnetId, getTestAmiId } from "./TestNetwork.ts"; |
| 17 | |
| 18 | const { 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. |
| 22 | const 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. |
| 35 | const 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. |
| 54 | test.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. |
| 137 | const recoveryLtName = "alchemy-test-scaling-policy-recovery-lt"; |
| 138 | const recoveryAsgName = "alchemy-test-scaling-policy-recovery-asg"; |
| 139 | const 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). |
| 144 | const cleanupRecoveryAsg = autoscaling |
| 145 | .deleteAutoScalingGroup({ |
| 146 | AutoScalingGroupName: recoveryAsgName, |
| 147 | ForceDelete: true, |
| 148 | } as any) |
| 149 | .pipe(Effect.catch(() => Effect.void)); |
| 150 | |
| 151 | test.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 | ); |