| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import * as apprunner from "@distilled.cloud/aws/apprunner"; |
| 4 | import * as ecr from "@distilled.cloud/aws/ecr"; |
| 5 | import * as iam from "@distilled.cloud/aws/iam"; |
| 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 TestService from "./fixtures/service.ts"; |
| 11 | import { |
| 12 | awaitLogGroups, |
| 13 | logGroupNamesFor, |
| 14 | observeLogGroups, |
| 15 | } from "./logGroups.ts"; |
| 16 | |
| 17 | const { test } = Test.make({ providers: AWS.providers() }); |
| 18 | |
| 19 | // Full end-to-end for the Effect-native `AppRunner.Service` form: bundle the |
| 20 | // inline Effect program, build + push the container image to the managed ECR |
| 21 | // repository (Docker build, linux/amd64), provision the instance/access IAM |
| 22 | // roles, and deploy the App Runner service — then prove over HTTPS that (a) |
| 23 | // the `{ fetch }` handler is served and (b) the `ServerHost.run` background |
| 24 | // loop is actually executing inside the deployed container (`/ticks` climbs). |
| 25 | // |
| 26 | // It is heavy (Docker build + ECR push + 3-5 min App Runner provisioning, |
| 27 | // bills while running), so it is gated behind AWS_TEST_SLOW=1 and always |
| 28 | // destroys what it created. |
| 29 | test.provider.skipIf(!process.env.AWS_TEST_SLOW)( |
| 30 | "deploys an Effect-native App Runner service and serves HTTP", |
| 31 | (stack) => |
| 32 | Effect.gen(function* () { |
| 33 | // Clean slate in case a previous run died mid-flight. |
| 34 | yield* stack.destroy(); |
| 35 | |
| 36 | const service = yield* stack.deploy( |
| 37 | Effect.gen(function* () { |
| 38 | return yield* TestService; |
| 39 | }), |
| 40 | ); |
| 41 | |
| 42 | expect(service.serviceName).toBe("alchemy-test-apprunner-e2e"); |
| 43 | expect(service.status).toBe("RUNNING"); |
| 44 | expect(service.serviceUrl).toBeTruthy(); |
| 45 | // Effect-native attributes: managed repo, roles, and image. |
| 46 | expect(service.repositoryUri).toBeTruthy(); |
| 47 | expect(service.imageUri).toBe( |
| 48 | `${service.repositoryUri}:${service.codeHash}`, |
| 49 | ); |
| 50 | expect(service.instanceRoleArn).toContain(":role/"); |
| 51 | expect(service.accessRoleArn).toContain(":role/"); |
| 52 | |
| 53 | // Out-of-band verification via distilled: private ECR source wired to |
| 54 | // the managed access role and instance role. |
| 55 | const described = yield* apprunner.describeService({ |
| 56 | ServiceArn: service.serviceArn, |
| 57 | }); |
| 58 | expect( |
| 59 | described.Service.SourceConfiguration.ImageRepository |
| 60 | ?.ImageRepositoryType, |
| 61 | ).toBe("ECR"); |
| 62 | expect( |
| 63 | described.Service.SourceConfiguration.AuthenticationConfiguration |
| 64 | ?.AccessRoleArn, |
| 65 | ).toBe(service.accessRoleArn); |
| 66 | expect(described.Service.InstanceConfiguration.InstanceRoleArn).toBe( |
| 67 | service.instanceRoleArn, |
| 68 | ); |
| 69 | |
| 70 | // The deployed program serves HTTP (ride out DNS/edge propagation). |
| 71 | const health = yield* HttpClient.get( |
| 72 | `https://${service.serviceUrl}/health`, |
| 73 | ).pipe( |
| 74 | Effect.flatMap((res) => |
| 75 | res.status === 200 |
| 76 | ? Effect.succeed(res) |
| 77 | : Effect.fail(new Error(`/health returned ${res.status}`)), |
| 78 | ), |
| 79 | Effect.retry({ |
| 80 | schedule: Schedule.max([ |
| 81 | Schedule.fixed("3 seconds"), |
| 82 | Schedule.recurs(20), |
| 83 | ]), |
| 84 | }), |
| 85 | ); |
| 86 | expect(health.status).toBe(200); |
| 87 | |
| 88 | // The `host.run` background loop is executing: /ticks climbs. |
| 89 | const readTicks = HttpClient.get( |
| 90 | `https://${service.serviceUrl}/ticks`, |
| 91 | ).pipe( |
| 92 | Effect.flatMap((res) => res.json), |
| 93 | Effect.map((json) => (json as { ticks: number }).ticks), |
| 94 | ); |
| 95 | const first = yield* readTicks; |
| 96 | const later = yield* readTicks.pipe( |
| 97 | Effect.flatMap((ticks) => |
| 98 | ticks > first |
| 99 | ? Effect.succeed(ticks) |
| 100 | : Effect.fail(new Error(`ticks not climbing (still ${ticks})`)), |
| 101 | ), |
| 102 | Effect.retry({ |
| 103 | schedule: Schedule.max([ |
| 104 | Schedule.fixed("2 seconds"), |
| 105 | Schedule.recurs(15), |
| 106 | ]), |
| 107 | }), |
| 108 | ); |
| 109 | expect(later).toBeGreaterThan(first); |
| 110 | |
| 111 | // App Runner auto-creates these two log groups for the service; they |
| 112 | // survive `deleteService`, so the provider reaps them explicitly. |
| 113 | // Asserting they exist FIRST keeps the post-destroy check below from |
| 114 | // passing vacuously on a wrong name. |
| 115 | const logGroupNames = logGroupNamesFor( |
| 116 | service.serviceName, |
| 117 | service.serviceId, |
| 118 | ); |
| 119 | expect(yield* awaitLogGroups(logGroupNames)).toEqual([true, true]); |
| 120 | |
| 121 | // Destroy immediately — App Runner services bill while running — and |
| 122 | // verify zero leftovers: service, managed repository, both roles, and |
| 123 | // both log groups. |
| 124 | const { repositoryName, instanceRoleName, accessRoleName, serviceArn } = |
| 125 | service; |
| 126 | yield* stack.destroy(); |
| 127 | |
| 128 | const serviceAfter = yield* apprunner |
| 129 | .describeService({ ServiceArn: serviceArn }) |
| 130 | .pipe( |
| 131 | Effect.map((r) => (r.Service.Status ?? "UNKNOWN").toUpperCase()), |
| 132 | Effect.catchTag("ResourceNotFoundException", () => |
| 133 | Effect.succeed("GONE" as const), |
| 134 | ), |
| 135 | ); |
| 136 | expect(["GONE", "DELETED"]).toContain(serviceAfter); |
| 137 | |
| 138 | const repoError = yield* Effect.flip( |
| 139 | ecr.describeRepositories({ repositoryNames: [repositoryName!] }), |
| 140 | ); |
| 141 | expect(repoError._tag).toBe("RepositoryNotFoundException"); |
| 142 | |
| 143 | for (const roleName of [instanceRoleName!, accessRoleName!]) { |
| 144 | const roleError = yield* Effect.flip( |
| 145 | iam.getRole({ RoleName: roleName }), |
| 146 | ); |
| 147 | expect(roleError._tag).toBe("NoSuchEntityException"); |
| 148 | } |
| 149 | |
| 150 | expect(yield* observeLogGroups(logGroupNames)).toEqual([false, false]); |
| 151 | }), |
| 152 | // Docker build + push (~2-4 min) + create (~3-5 min) + delete (~2-3 min). |
| 153 | { |
| 154 | tags: [ |
| 155 | "provider:aws", |
| 156 | "provider:aws:apprunner", |
| 157 | "provider:aws:ecr", |
| 158 | "provider:aws:iam", |
| 159 | "live", |
| 160 | ], |
| 161 | timeout: 1_200_000, |
| 162 | }, |
| 163 | ); |