1import * as AWS from "@/AWS";
2import { AutoScalingConfiguration, Service } from "@/AWS/AppRunner";
3import * as Test from "@/Test/Alchemy";
4import * as apprunner from "@distilled.cloud/aws/apprunner";
5import * as sts from "@distilled.cloud/aws/sts";
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 {
11 awaitLogGroups,
12 deleteLogGroups,
13 logGroupNamesFor,
14 observeLogGroups,
15} from "./logGroups.ts";
16
17const { 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.
21test.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.
38const 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
60const 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.
91test.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.
182test.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);