1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as apprunner from "@distilled.cloud/aws/apprunner";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientRequest from "effect/http/HttpClientRequest";
9import AppRunnerTestFunctionLive, {
10 AppRunnerTestFunction,
11} from "./fixtures/handler";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15/**
16 * POST a fixture route and decode its JSON body.
17 *
18 * Each binding is granted by its OWN inline policy on the Lambda role, and
19 * those propagate independently — a call can be rejected for seconds after
20 * a sibling binding's call already works, so every route retries. On a
21 * non-200 the fixture's body (the rendered cause) goes into the failure
22 * message; decoding it blind used to surface as a TypeError on a missing
23 * field, which said nothing about what App Runner refused.
24 */
25const postJson = <T>(url: string) =>
26 HttpClient.execute(HttpClientRequest.post(url)).pipe(
27 Effect.flatMap((response) =>
28 response.text.pipe(
29 Effect.flatMap((body) =>
30 response.status === 200
31 ? Effect.try({
32 try: () => JSON.parse(body) as T,
33 catch: () =>
34 new Error(`POST ${url} returned unparseable body: ${body}`),
35 })
36 : Effect.fail(
37 new Error(`POST ${url} returned ${response.status}: ${body}`),
38 ),
39 ),
40 ),
41 ),
42 Effect.retry({
43 schedule: Schedule.max([
44 Schedule.exponential("1 second"),
45 Schedule.recurs(8),
46 ]),
47 }),
48 );
49
50/** Poll the service status out-of-band until it settles to `expected`. */
51const waitForStatus = (serviceArn: string, expected: string) =>
52 apprunner.describeService({ ServiceArn: serviceArn }).pipe(
53 Effect.flatMap((r) =>
54 (r.Service.Status ?? "").toUpperCase() === expected
55 ? Effect.void
56 : Effect.fail(
57 new Error(
58 `service is ${r.Service.Status}, waiting for ${expected}`,
59 ),
60 ),
61 ),
62 Effect.retry({
63 schedule: Schedule.max([
64 Schedule.fixed("10 seconds"),
65 Schedule.recurs(36),
66 ]),
67 }),
68 );
69
70// The fixture deploys a real App Runner service (3-5 min provisioning,
71// bills while running) plus a Lambda wired to it through the four runtime
72// bindings, so the whole flow is gated behind AWS_TEST_SLOW=1 and always
73// destroys what it created.
74test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
75 "a Lambda manages an App Runner service through the runtime bindings",
76 (stack) =>
77 Effect.gen(function* () {
78 // Clean slate in case a previous run died mid-flight.
79 yield* stack.destroy();
80
81 const { functionUrl } = yield* stack.deploy(
82 Effect.gen(function* () {
83 return yield* AppRunnerTestFunction;
84 }).pipe(Effect.provide(AppRunnerTestFunctionLive)),
85 );
86 expect(functionUrl).toBeTruthy();
87 const baseUrl = functionUrl!.replace(/\/+$/, "");
88
89 // Readiness probe — fresh function URLs take seconds to serve 200s,
90 // and the fresh IAM role's apprunner permissions propagate eventually.
91 yield* HttpClient.get(`${baseUrl}/ping`).pipe(
92 Effect.flatMap((response) =>
93 response.status === 200
94 ? Effect.succeed(response)
95 : Effect.fail(new Error(`Function not ready: ${response.status}`)),
96 ),
97 Effect.retry({
98 schedule: Schedule.max([
99 Schedule.fixed("2 seconds"),
100 Schedule.recurs(60),
101 ]),
102 }),
103 );
104
105 // ListOperations: the CREATE_SERVICE operation is on the record.
106 const operations = yield* HttpClient.get(`${baseUrl}/operations`).pipe(
107 Effect.flatMap((response) => response.json),
108 Effect.map(
109 (json) =>
110 json as {
111 operations: { id?: string; type?: string; status?: string }[];
112 },
113 ),
114 // The Lambda role's apprunner:ListOperations grant propagates
115 // eventually — a 500 body parses as not-matching and retries.
116 Effect.filterOrFail(
117 (json) => Array.isArray(json.operations),
118 () => new Error("operations not yet listable"),
119 ),
120 Effect.retry({
121 schedule: Schedule.max([
122 Schedule.exponential("1 second"),
123 Schedule.recurs(8),
124 ]),
125 }),
126 );
127 const createOp = operations.operations.find(
128 (op) => op.type === "CREATE_SERVICE",
129 );
130 expect(createOp).toBeDefined();
131 expect(createOp?.status).toBe("SUCCEEDED");
132
133 // DescribeCustomDomains: no domain is associated, so the list is
134 // empty but the DNSTarget (the service's default endpoint) resolves —
135 // proving the binding's wiring and IAM grant.
136 const customDomains = yield* HttpClient.get(
137 `${baseUrl}/custom-domains`,
138 ).pipe(
139 Effect.flatMap((response) => response.json),
140 Effect.map(
141 (json) =>
142 json as {
143 dnsTarget?: string;
144 customDomains: { domainName?: string; status?: string }[];
145 },
146 ),
147 Effect.filterOrFail(
148 (json) => Array.isArray(json.customDomains),
149 () => new Error("custom domains not yet describable"),
150 ),
151 Effect.retry({
152 schedule: Schedule.max([
153 Schedule.exponential("1 second"),
154 Schedule.recurs(8),
155 ]),
156 }),
157 );
158 expect(customDomains.customDomains).toEqual([]);
159 expect(customDomains.dnsTarget).toContain("awsapprunner.com");
160
161 // PauseService: the service settles to PAUSED.
162 const paused = yield* postJson<{
163 serviceArn: string;
164 operationId?: string;
165 }>(`${baseUrl}/pause`);
166 expect(paused.serviceArn).toContain(
167 ":service/alchemy-test-apprunner-bind/",
168 );
169 yield* waitForStatus(paused.serviceArn, "PAUSED");
170
171 // ResumeService: the service settles back to RUNNING.
172 const resumed = yield* postJson<{
173 serviceArn: string;
174 operationId?: string;
175 }>(`${baseUrl}/resume`);
176 expect(resumed.serviceArn).toBe(paused.serviceArn);
177 yield* waitForStatus(paused.serviceArn, "RUNNING");
178
179 // StartDeployment: returns an operation id; the deployment then shows
180 // up in ListOperations. (The provider's delete waits for the service
181 // to settle, so the in-flight deployment doesn't block destroy.)
182 const deployed = yield* postJson<{ operationId?: string }>(
183 `${baseUrl}/deploy`,
184 );
185 expect(deployed.operationId).toBeTruthy();
186
187 const afterDeploy = yield* HttpClient.get(`${baseUrl}/operations`).pipe(
188 Effect.flatMap((response) => response.json),
189 Effect.map(
190 (json) => json as { operations: { id?: string; type?: string }[] },
191 ),
192 );
193 expect(
194 afterDeploy.operations.some((op) => op.id === deployed.operationId),
195 ).toBe(true);
196
197 // Destroy immediately — App Runner services bill while running.
198 yield* stack.destroy();
199 const after = yield* apprunner
200 .describeService({ ServiceArn: paused.serviceArn })
201 .pipe(
202 Effect.map((r) => (r.Service.Status ?? "UNKNOWN").toUpperCase()),
203 Effect.catchTag("ResourceNotFoundException", () =>
204 Effect.succeed("GONE" as const),
205 ),
206 );
207 expect(["GONE", "DELETED"]).toContain(after);
208 }),
209 // create (~3-5 min) + pause (~1-2 min) + resume (~1-2 min) + delete
210 // (~2-3 min), one sequential test.
211 {
212 tags: [
213 "provider:aws",
214 "provider:aws:apprunner",
215 "provider:aws:lambda",
216 "live",
217 ],
218 timeout: 1_200_000,
219 },
220);