| 1 | import * as AWS from "@/AWS"; |
| 2 | import { AutoScalingConfiguration, Service } from "@/AWS/AppRunner"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as apprunner from "@distilled.cloud/aws/apprunner"; |
| 5 | import * as sts from "@distilled.cloud/aws/sts"; |
| 6 | import { 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 { |
| 11 | awaitLogGroups, |
| 12 | deleteLogGroups, |
| 13 | logGroupNamesFor, |
| 14 | observeLogGroups, |
| 15 | } from "./logGroups.ts"; |
| 16 | |
| 17 | const { test } = Test.make({ providers: AWS.providers() }); |
| 18 | |
| 19 | // Ungated typed-error probe: proves the distilled error union carries the |
| 20 | // not-found tag the provider's observe/read/delete paths depend on. |
| 21 | test.provider( |
| 22 | "describeService on a nonexistent ARN fails with ResourceNotFoundException", |
| 23 | () => |
| 24 | Effect.gen(function* () { |
| 25 | const { Account } = yield* sts.getCallerIdentity({}); |
| 26 | const error = yield* Effect.flip( |
| 27 | apprunner.describeService({ |
| 28 | ServiceArn: `arn:aws:apprunner:us-west-2:${Account}:service/alchemy-nonexistent-probe/0000000000000000000000000000000000`, |
| 29 | }), |
| 30 | ); |
| 31 | expect(error._tag).toBe("ResourceNotFoundException"); |
| 32 | }), |
| 33 | { tags: ["provider:aws", "provider:aws:apprunner", "live"] }, |
| 34 | ); |
| 35 | |
| 36 | // The provider's delete already waits until the service is gone, so this |
| 37 | // asserts DELETED-or-gone with a short bounded confirmation retry. |
| 38 | const assertServiceGone = (arn: string) => |
| 39 | Effect.gen(function* () { |
| 40 | const status = yield* apprunner.describeService({ ServiceArn: arn }).pipe( |
| 41 | Effect.map((r) => (r.Service.Status ?? "UNKNOWN").toUpperCase()), |
| 42 | Effect.catchTag("ResourceNotFoundException", () => |
| 43 | Effect.succeed("GONE" as const), |
| 44 | ), |
| 45 | ); |
| 46 | if (status !== "GONE" && status !== "DELETED") { |
| 47 | return yield* Effect.fail( |
| 48 | new Error(`App Runner service still exists (status: ${status})`), |
| 49 | ); |
| 50 | } |
| 51 | }).pipe( |
| 52 | Effect.retry({ |
| 53 | schedule: Schedule.max([ |
| 54 | Schedule.fixed("5 seconds"), |
| 55 | Schedule.recurs(12), |
| 56 | ]), |
| 57 | }), |
| 58 | ); |
| 59 | |
| 60 | const assertConfigGone = (name: string) => |
| 61 | Effect.gen(function* () { |
| 62 | const page = yield* apprunner.listAutoScalingConfigurations({ |
| 63 | AutoScalingConfigurationName: name, |
| 64 | }); |
| 65 | const active = (page.AutoScalingConfigurationSummaryList ?? []).filter( |
| 66 | (s) => |
| 67 | s.AutoScalingConfigurationName === name && |
| 68 | s.Status?.toUpperCase() === "ACTIVE", |
| 69 | ); |
| 70 | if (active.length > 0) { |
| 71 | return yield* Effect.fail( |
| 72 | new Error( |
| 73 | `Auto scaling configuration '${name}' still has ACTIVE revisions`, |
| 74 | ), |
| 75 | ); |
| 76 | } |
| 77 | }).pipe( |
| 78 | Effect.retry({ |
| 79 | schedule: Schedule.max([ |
| 80 | Schedule.fixed("5 seconds"), |
| 81 | Schedule.recurs(12), |
| 82 | ]), |
| 83 | }), |
| 84 | ); |
| 85 | |
| 86 | // App Runner service provisioning takes 3-5+ minutes and the service bills |
| 87 | // while it runs, so the full lifecycle is gated behind AWS_TEST_SLOW=1 and |
| 88 | // always destroys what it created. The source is a public ECR gallery |
| 89 | // image (ECR_PUBLIC), which needs no access role, no local Docker build, |
| 90 | // and no private repository. |
| 91 | test.provider.skipIf(!process.env.AWS_TEST_SLOW)( |
| 92 | "create a public-ECR service with custom scaling, verify it serves, destroy, verify gone", |
| 93 | (stack) => |
| 94 | Effect.gen(function* () { |
| 95 | // Clean slate in case a previous run died mid-flight. |
| 96 | yield* stack.destroy(); |
| 97 | |
| 98 | const { asc, service } = yield* stack.deploy( |
| 99 | Effect.gen(function* () { |
| 100 | const asc = yield* AutoScalingConfiguration("SvcScaling", { |
| 101 | autoScalingConfigurationName: "alchemy-test-svc-asc", |
| 102 | maxConcurrency: 100, |
| 103 | minSize: 1, |
| 104 | maxSize: 1, |
| 105 | }); |
| 106 | const service = yield* Service("HelloService", { |
| 107 | serviceName: "alchemy-test-apprunner-svc", |
| 108 | imageRepository: { |
| 109 | imageIdentifier: |
| 110 | "public.ecr.aws/aws-containers/hello-app-runner:latest", |
| 111 | imageRepositoryType: "ECR_PUBLIC", |
| 112 | port: "8000", |
| 113 | }, |
| 114 | autoDeploymentsEnabled: false, |
| 115 | instanceConfiguration: { cpu: "256", memory: "512" }, |
| 116 | autoScalingConfigurationArn: asc.autoScalingConfigurationArn, |
| 117 | }); |
| 118 | return { asc, service }; |
| 119 | }), |
| 120 | ); |
| 121 | |
| 122 | expect(service.serviceName).toBe("alchemy-test-apprunner-svc"); |
| 123 | expect(service.serviceArn).toContain( |
| 124 | ":service/alchemy-test-apprunner-svc/", |
| 125 | ); |
| 126 | expect(service.status).toBe("RUNNING"); |
| 127 | expect(service.serviceUrl).toBeDefined(); |
| 128 | |
| 129 | // Out-of-band verification via distilled: RUNNING, wired to the |
| 130 | // custom auto scaling configuration, right image + instance size. |
| 131 | const described = yield* apprunner.describeService({ |
| 132 | ServiceArn: service.serviceArn, |
| 133 | }); |
| 134 | expect(described.Service.Status).toBe("RUNNING"); |
| 135 | expect( |
| 136 | described.Service.AutoScalingConfigurationSummary |
| 137 | ?.AutoScalingConfigurationArn, |
| 138 | ).toBe(asc.autoScalingConfigurationArn); |
| 139 | expect( |
| 140 | described.Service.SourceConfiguration.ImageRepository |
| 141 | ?.ImageRepositoryType, |
| 142 | ).toBe("ECR_PUBLIC"); |
| 143 | expect(described.Service.InstanceConfiguration.Cpu).toBe("256"); |
| 144 | expect(described.Service.InstanceConfiguration.Memory).toBe("512"); |
| 145 | |
| 146 | // The public endpoint serves. The URL is live once RUNNING, but ride |
| 147 | // out DNS/edge propagation with a bounded retry. |
| 148 | const response = yield* HttpClient.get( |
| 149 | `https://${service.serviceUrl}`, |
| 150 | ).pipe( |
| 151 | Effect.flatMap((res) => |
| 152 | res.status === 200 |
| 153 | ? Effect.succeed(res) |
| 154 | : Effect.fail(new Error(`service returned ${res.status}`)), |
| 155 | ), |
| 156 | Effect.retry({ |
| 157 | schedule: Schedule.max([ |
| 158 | Schedule.fixed("3 seconds"), |
| 159 | Schedule.recurs(20), |
| 160 | ]), |
| 161 | }), |
| 162 | ); |
| 163 | expect(response.status).toBe(200); |
| 164 | |
| 165 | // Destroy immediately — App Runner services bill while running — |
| 166 | // and verify both the service and its scaling config are gone. |
| 167 | yield* stack.destroy(); |
| 168 | yield* assertServiceGone(service.serviceArn); |
| 169 | yield* assertConfigGone("alchemy-test-svc-asc"); |
| 170 | }), |
| 171 | // create (~3-5 min) + delete (~2-3 min), one sequential test. |
| 172 | { |
| 173 | tags: ["provider:aws", "provider:aws:apprunner", "live"], |
| 174 | timeout: 900_000, |
| 175 | }, |
| 176 | ); |
| 177 | |
| 178 | // The `retainLogGroups` opt-out: destroying the service leaves the two |
| 179 | // auto-created log groups in place. Uses the same public ECR gallery image |
| 180 | // as the lifecycle test above (no access role, no Docker build) since all |
| 181 | // this needs is a real service that has logged something. |
| 182 | test.provider.skipIf(!process.env.AWS_TEST_SLOW)( |
| 183 | "retainLogGroups keeps the auto-created log groups after the service is destroyed", |
| 184 | (stack) => |
| 185 | Effect.gen(function* () { |
| 186 | // Clean slate in case a previous run died mid-flight. |
| 187 | yield* stack.destroy(); |
| 188 | |
| 189 | const service = yield* stack.deploy( |
| 190 | Service("RetainedLogsService", { |
| 191 | serviceName: "alchemy-test-apprunner-retain-logs", |
| 192 | imageRepository: { |
| 193 | imageIdentifier: |
| 194 | "public.ecr.aws/aws-containers/hello-app-runner:latest", |
| 195 | imageRepositoryType: "ECR_PUBLIC", |
| 196 | port: "8000", |
| 197 | }, |
| 198 | autoDeploymentsEnabled: false, |
| 199 | instanceConfiguration: { cpu: "256", memory: "512" }, |
| 200 | retainLogGroups: true, |
| 201 | }), |
| 202 | ); |
| 203 | expect(service.status).toBe("RUNNING"); |
| 204 | |
| 205 | const logGroupNames = logGroupNamesFor( |
| 206 | service.serviceName, |
| 207 | service.serviceId, |
| 208 | ); |
| 209 | expect(yield* awaitLogGroups(logGroupNames)).toEqual([true, true]); |
| 210 | |
| 211 | yield* stack.destroy(); |
| 212 | yield* assertServiceGone(service.serviceArn); |
| 213 | |
| 214 | // The service is gone but its logs are not: the provider honored the |
| 215 | // opt-out instead of reaping them. |
| 216 | expect(yield* observeLogGroups(logGroupNames)).toEqual([true, true]); |
| 217 | |
| 218 | // Nothing owns them now that the state row is gone, so the test that |
| 219 | // asked for them to be retained is what cleans them up — a retained |
| 220 | // pair left here would leak into every later run's census. |
| 221 | yield* deleteLogGroups(logGroupNames); |
| 222 | expect(yield* observeLogGroups(logGroupNames)).toEqual([false, false]); |
| 223 | }), |
| 224 | // create (~3-5 min) + delete (~2-3 min), one sequential test. |
| 225 | { |
| 226 | tags: ["provider:aws", "provider:aws:apprunner", "live"], |
| 227 | timeout: 900_000, |
| 228 | }, |
| 229 | ); |