1import * as AWS from "@/AWS";
2import * as Test from "./VpcTest.ts";
3import { expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import * as Redacted from "effect/Redacted";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import TestInstance, { keyPair } from "./fixtures/instance.ts";
9import { assertInstanceTerminated } from "./Gone.ts";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// Full end-to-end: bundle the hosted program, launch a real EC2 instance into a
14// public subnet, and prove over HTTP (directly against the instance's public
15// IP) that (a) the `{ fetch }` handler is served by the instance's Bun HTTP
16// server and (b) the `ServerHost.run` background loop is executing on the
17// instance (`/ticks` keeps climbing).
18//
19// Heavy (instance boot + bun install + S3 sync + systemd), so skipped under
20// `FAST=1`.
21test.provider.skipIf(!!process.env.FAST)(
22 "deploys a real EC2 instance that serves HTTP and runs a background loop",
23 (stack) =>
24 Effect.gen(function* () {
25 yield* stack.destroy();
26
27 const { instanceId, publicIpAddress, privateKey } = yield* stack.deploy(
28 Effect.gen(function* () {
29 const instance = yield* TestInstance;
30 // Resolve the same key-pair resource the instance uses and return its
31 // private key from the stack (resolved to a `Redacted` value).
32 const key = yield* keyPair;
33 return {
34 instanceId: instance.instanceId,
35 publicIpAddress: instance.publicIpAddress,
36 privateKey: key.privateKey,
37 };
38 }),
39 );
40
41 expect(publicIpAddress).toBeTruthy();
42 // Unredact only to prove the returned value is usable key material. Do
43 // not log the ephemeral private key into the test artifact.
44 const pem = privateKey ? Redacted.value(privateKey) : undefined;
45 expect(pem).toContain("PRIVATE KEY");
46 const base = `http://${publicIpAddress}:3000`;
47
48 // Poll until the instance boots, installs bun, syncs the bundle from S3,
49 // and the systemd unit serves 200 on :3000. Connection errors before the
50 // server binds are normalised to "not ready" so the poll keeps going
51 // (a bare `Effect.retry` does not retry the transport-level failure).
52 const served = yield* HttpClient.get(`${base}/health`).pipe(
53 Effect.map((res) => res.status === 200),
54 Effect.catch(() => Effect.succeed(false)),
55 Effect.repeat({
56 schedule: Schedule.spaced("8 seconds"),
57 until: (ok) => ok,
58 times: 75,
59 }),
60 );
61 expect(served).toBe(true);
62
63 const getJson = (path: string) =>
64 HttpClient.get(`${base}${path}`).pipe(
65 Effect.flatMap((res) =>
66 res.status === 200
67 ? res.json
68 : Effect.fail(
69 new Error(`${path} temporarily returned ${res.status}`),
70 ),
71 ),
72 // cloud-init/systemd can briefly restart the hosted process just
73 // after the first successful health probe, especially while a full
74 // network lane is saturating EC2. Bound the post-readiness probes as
75 // well as the initial health poll.
76 Effect.retry({ schedule: Schedule.spaced("1 second"), times: 10 }),
77 );
78
79 const body = yield* getJson("/health");
80 expect(body).toEqual({ ok: true });
81
82 // Prove the ServerHost.run background loop is executing on the instance:
83 // the tick counter climbs between two reads.
84 const readTicks = getJson("/ticks").pipe(
85 Effect.map((value) => (value as { ticks: number }).ticks),
86 );
87 const first = yield* readTicks;
88 yield* Effect.sleep("3 seconds");
89 const second = yield* readTicks;
90 expect(second).toBeGreaterThan(first);
91
92 yield* stack.destroy();
93
94 // Zero-orphan proof: the (billed) instance reached a terminal state.
95 yield* assertInstanceTerminated(instanceId);
96 }),
97 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 1_200_000 },
98);