1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as apprunner from "@distilled.cloud/aws/apprunner";
4import * as ecr from "@distilled.cloud/aws/ecr";
5import * as iam from "@distilled.cloud/aws/iam";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import TestService from "./fixtures/service.ts";
11import {
12 awaitLogGroups,
13 logGroupNamesFor,
14 observeLogGroups,
15} from "./logGroups.ts";
16
17const { 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.
29test.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);