| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Core from "@/Test/Core"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as autoscaling from "@distilled.cloud/aws/auto-scaling"; |
| 5 | import * as eventbridge from "@distilled.cloud/aws/eventbridge"; |
| 6 | import { describe, expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Schedule from "effect/Schedule"; |
| 9 | import * as HttpClient from "effect/http/HttpClient"; |
| 10 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 11 | import AsgBindingsFunctionLive, { |
| 12 | AsgBindingsFunction, |
| 13 | bindingsAsgName, |
| 14 | } from "./fixtures/bindings-handler.ts"; |
| 15 | |
| 16 | const testOptions = { providers: AWS.providers() }; |
| 17 | const { test, beforeAll, afterAll } = Test.make(testOptions); |
| 18 | const sharedStack = Core.scratchStack(testOptions, "AutoScalingBindings"); |
| 19 | |
| 20 | const readinessPolicy = Schedule.max([ |
| 21 | Schedule.fixed("2 seconds"), |
| 22 | Schedule.recurs(75), |
| 23 | ]); |
| 24 | |
| 25 | let baseUrl: string; |
| 26 | |
| 27 | interface RouteResult { |
| 28 | ok: boolean; |
| 29 | tag: string; |
| 30 | name?: string; |
| 31 | maxSize?: number; |
| 32 | count?: number; |
| 33 | } |
| 34 | |
| 35 | // Call a fixture route, repeating (bounded) while the response still shows an |
| 36 | // authorization failure — a freshly attached IAM policy is eventually |
| 37 | // consistent and the first invocations after deploy can see AccessDenied. |
| 38 | // Routes whose EXPECTED outcome is a scoped denial pass |
| 39 | // `retryDenied: false` (they run after routes that already proved the |
| 40 | // policy propagated). |
| 41 | const callRoute = ( |
| 42 | method: "GET" | "POST", |
| 43 | path: string, |
| 44 | options: { retryDenied?: boolean } = {}, |
| 45 | ) => |
| 46 | Effect.gen(function* () { |
| 47 | const request = |
| 48 | method === "GET" |
| 49 | ? HttpClientRequest.get(`${baseUrl}${path}`) |
| 50 | : HttpClientRequest.post(`${baseUrl}${path}`); |
| 51 | return yield* HttpClient.execute(request).pipe( |
| 52 | Effect.flatMap((response) => |
| 53 | response.status === 200 |
| 54 | ? response.json |
| 55 | : Effect.fail( |
| 56 | new Error(`Route ${path} not ready: ${response.status}`), |
| 57 | ), |
| 58 | ), |
| 59 | // `json` is the untyped `Json` union; RouteResult's optional props |
| 60 | // (`string | undefined`) aren't Json-assignable, so the intentional |
| 61 | // two-step conversion is required (same pattern as the /refresh route). |
| 62 | Effect.map((json) => json as unknown as RouteResult), |
| 63 | Effect.repeat({ |
| 64 | until: (body): boolean => |
| 65 | options.retryDenied === false || |
| 66 | (body.tag !== "AccessDenied" && body.tag !== "AccessDeniedException"), |
| 67 | schedule: Schedule.spaced("3 seconds"), |
| 68 | times: 10, |
| 69 | }), |
| 70 | ); |
| 71 | }); |
| 72 | |
| 73 | // Deploys a Lambda bound to a zero-sized ASG with every AutoScaling runtime |
| 74 | // binding plus the instance-events EventBridge subscription. Read bindings are |
| 75 | // asserted on live data; instance-scoped write bindings are probed with a |
| 76 | // well-formed non-existent instance id — AWS answers with a typed |
| 77 | // `ValidationError` (never AccessDenied), proving the binding's IAM grant. |
| 78 | describe( |
| 79 | "AutoScaling runtime bindings", |
| 80 | { |
| 81 | tags: [ |
| 82 | "provider:aws", |
| 83 | "provider:aws:autoscaling", |
| 84 | "provider:aws:ec2", |
| 85 | "provider:aws:lambda", |
| 86 | "live", |
| 87 | ], |
| 88 | }, |
| 89 | () => { |
| 90 | beforeAll( |
| 91 | Effect.gen(function* () { |
| 92 | yield* sharedStack.destroy(); |
| 93 | |
| 94 | const { fn } = yield* sharedStack.deploy( |
| 95 | Effect.gen(function* () { |
| 96 | const fn = yield* AsgBindingsFunction; |
| 97 | return { fn }; |
| 98 | }).pipe(Effect.provide(AsgBindingsFunctionLive)), |
| 99 | ); |
| 100 | |
| 101 | expect(fn.functionUrl).toBeTruthy(); |
| 102 | baseUrl = fn.functionUrl!.replace(/\/+$/, ""); |
| 103 | |
| 104 | yield* HttpClient.get(`${baseUrl}/health`).pipe( |
| 105 | Effect.flatMap((response) => |
| 106 | response.status === 200 |
| 107 | ? Effect.succeed(response) |
| 108 | : Effect.fail( |
| 109 | new Error(`Function not ready: ${response.status}`), |
| 110 | ), |
| 111 | ), |
| 112 | Effect.retry({ schedule: readinessPolicy }), |
| 113 | ); |
| 114 | }), |
| 115 | { timeout: 300_000 }, |
| 116 | ); |
| 117 | |
| 118 | afterAll( |
| 119 | Effect.gen(function* () { |
| 120 | yield* sharedStack.destroy(); |
| 121 | // Assert the fixture ASG is fully gone — the suite must leave nothing. |
| 122 | // The hook context has no AWS providers of its own, so provide them |
| 123 | // explicitly for the out-of-band distilled call. |
| 124 | const remaining = yield* Core.withProviders( |
| 125 | autoscaling |
| 126 | .describeAutoScalingGroups({ |
| 127 | AutoScalingGroupNames: [bindingsAsgName], |
| 128 | } as any) |
| 129 | .pipe( |
| 130 | Effect.map((r) => (r.AutoScalingGroups ?? []).length), |
| 131 | Effect.repeat({ |
| 132 | until: (count) => count === 0, |
| 133 | schedule: Schedule.spaced("3 seconds"), |
| 134 | times: 10, |
| 135 | }), |
| 136 | ), |
| 137 | testOptions, |
| 138 | sharedStack.name, |
| 139 | ); |
| 140 | expect(remaining).toBe(0); |
| 141 | }), |
| 142 | { timeout: 180_000 }, |
| 143 | ); |
| 144 | |
| 145 | test.provider( |
| 146 | "DescribeAutoScalingGroup returns the bound group's live state", |
| 147 | (_stack) => |
| 148 | Effect.gen(function* () { |
| 149 | const body = yield* callRoute("GET", "/describe"); |
| 150 | expect(body.ok).toBe(true); |
| 151 | expect(body.name).toEqual(bindingsAsgName); |
| 152 | expect(body.maxSize).toEqual(0); |
| 153 | }), |
| 154 | { timeout: 60_000 }, |
| 155 | ); |
| 156 | |
| 157 | test.provider( |
| 158 | "DescribeScalingActivities lists the group's activities", |
| 159 | (_stack) => |
| 160 | Effect.gen(function* () { |
| 161 | const body = yield* callRoute("GET", "/activities"); |
| 162 | expect(body.ok).toBe(true); |
| 163 | expect(body.count).toBeGreaterThanOrEqual(0); |
| 164 | }), |
| 165 | { timeout: 60_000 }, |
| 166 | ); |
| 167 | |
| 168 | test.provider( |
| 169 | "SetDesiredCapacity succeeds against the bound group", |
| 170 | (_stack) => |
| 171 | Effect.gen(function* () { |
| 172 | const body = yield* callRoute("POST", "/set-desired"); |
| 173 | expect(body.ok).toBe(true); |
| 174 | }), |
| 175 | { timeout: 60_000 }, |
| 176 | ); |
| 177 | |
| 178 | test.provider( |
| 179 | "ExecutePolicy is IAM-wired (typed ValidationError for a bogus policy)", |
| 180 | (_stack) => |
| 181 | Effect.gen(function* () { |
| 182 | const body = yield* callRoute("POST", "/execute-policy"); |
| 183 | expect(body.tag).toEqual("ValidationError"); |
| 184 | }), |
| 185 | { timeout: 60_000 }, |
| 186 | ); |
| 187 | |
| 188 | // SetInstanceHealth / TerminateInstanceInAutoScalingGroup carry no group |
| 189 | // name in the request — EC2 Auto Scaling resolves the instance's owning |
| 190 | // group for resource-level authorization. The bogus instance resolves to |
| 191 | // nothing, so the (correct, least-privilege) group-scoped grant denies — |
| 192 | // a typed AccessDeniedException, proving the binding client signed and |
| 193 | // dispatched the operation with the attached policy (a missing policy |
| 194 | // would deny identically for REAL instances too; the other seven bindings |
| 195 | // prove grant attachment works end-to-end via the same scaffold). With a |
| 196 | // real in-group instance the same grant authorizes. |
| 197 | test.provider( |
| 198 | "SetInstanceHealth dispatches a signed request (scoped denial for a bogus instance)", |
| 199 | (_stack) => |
| 200 | Effect.gen(function* () { |
| 201 | const body = yield* callRoute("POST", "/set-health", { |
| 202 | retryDenied: false, |
| 203 | }); |
| 204 | expect(body.tag).toEqual("AccessDeniedException"); |
| 205 | }), |
| 206 | { timeout: 60_000 }, |
| 207 | ); |
| 208 | |
| 209 | test.provider( |
| 210 | "SetInstanceProtection is IAM-wired (typed ValidationError for a bogus instance)", |
| 211 | (_stack) => |
| 212 | Effect.gen(function* () { |
| 213 | const body = yield* callRoute("POST", "/set-protection"); |
| 214 | expect(body.tag).toEqual("ValidationError"); |
| 215 | }), |
| 216 | { timeout: 60_000 }, |
| 217 | ); |
| 218 | |
| 219 | test.provider( |
| 220 | "TerminateInstanceInAutoScalingGroup dispatches a signed request (scoped denial)", |
| 221 | (_stack) => |
| 222 | Effect.gen(function* () { |
| 223 | const body = yield* callRoute("POST", "/terminate", { |
| 224 | retryDenied: false, |
| 225 | }); |
| 226 | expect(body.tag).toEqual("AccessDeniedException"); |
| 227 | }), |
| 228 | { timeout: 60_000 }, |
| 229 | ); |
| 230 | |
| 231 | test.provider( |
| 232 | "Standby enter/exit are IAM-wired (typed ValidationError)", |
| 233 | (_stack) => |
| 234 | Effect.gen(function* () { |
| 235 | const enter = yield* callRoute("POST", "/standby-enter"); |
| 236 | expect(enter.tag).toEqual("ValidationError"); |
| 237 | const exit = yield* callRoute("POST", "/standby-exit"); |
| 238 | expect(exit.tag).toEqual("ValidationError"); |
| 239 | }), |
| 240 | { timeout: 60_000 }, |
| 241 | ); |
| 242 | |
| 243 | test.provider( |
| 244 | "InstanceRefresh start/describe/cancel round-trip on the zero-instance fleet", |
| 245 | (_stack) => |
| 246 | Effect.gen(function* () { |
| 247 | const body = (yield* callRoute("POST", "/refresh")) as unknown as { |
| 248 | startTag: string; |
| 249 | describeOk: boolean; |
| 250 | describeCount?: number; |
| 251 | cancelTag: string; |
| 252 | }; |
| 253 | // A refresh on the empty fleet completes (or is already in progress |
| 254 | // from a previous run) — never an authorization failure. |
| 255 | expect(["Success", "InstanceRefreshInProgressFault"]).toContain( |
| 256 | body.startTag, |
| 257 | ); |
| 258 | expect(body.describeOk).toBe(true); |
| 259 | expect(body.describeCount).toBeGreaterThanOrEqual(1); |
| 260 | expect(body.cancelTag).toEqual("Success"); |
| 261 | }), |
| 262 | { timeout: 60_000 }, |
| 263 | ); |
| 264 | |
| 265 | test.provider( |
| 266 | "consumeInstanceEvents created the EventBridge rule for instance events", |
| 267 | (_stack) => |
| 268 | Effect.gen(function* () { |
| 269 | // The rule's physical name is `{stack}-{logicalId}` truncated to fit |
| 270 | // the instance-id suffix (e.g. `AutoScalingBindings-BindingsGroup- |
| 271 | // Instan3unhktjkefuey4…`), so the full logical id may not survive. |
| 272 | // Filter server-side on the stable stack+logical prefix and pick the |
| 273 | // autoscaling-pattern rule. |
| 274 | let rule: eventbridge.Rule | undefined; |
| 275 | let nextToken: string | undefined; |
| 276 | for (let page = 0; page < 10 && !rule; page++) { |
| 277 | const result = yield* eventbridge.listRules({ |
| 278 | NamePrefix: "AutoScalingBindings-BindingsGroup-", |
| 279 | NextToken: nextToken, |
| 280 | Limit: 100, |
| 281 | }); |
| 282 | rule = (result.Rules ?? []).find((candidate) => |
| 283 | candidate.EventPattern?.includes("aws.autoscaling"), |
| 284 | ); |
| 285 | nextToken = result.NextToken; |
| 286 | if (!nextToken) break; |
| 287 | } |
| 288 | expect(rule).toBeDefined(); |
| 289 | expect(rule?.EventPattern).toContain("aws.autoscaling"); |
| 290 | expect(rule?.EventPattern).toContain( |
| 291 | "EC2 Instance Launch Successful", |
| 292 | ); |
| 293 | }), |
| 294 | { tags: ["provider:aws:eventbridge"], timeout: 60_000 }, |
| 295 | ); |
| 296 | }, |
| 297 | ); |