1import * as AWS from "@/AWS";
2import * as Test from "./VpcTest.ts";
3import { expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as HttpClient from "effect/http/HttpClient";
7import TestUbuntuInstance from "./fixtures/ubuntu-instance.ts";
8import { assertInstanceTerminated } from "./Gone.ts";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12// Ubuntu 24.04 end-to-end for the hosted bootstrap (issues #1027 + #1028):
13// Ubuntu AMIs ship without `unzip`, `dnf`, or `yum`, so a served HTTP response
14// proves the bootstrap's `apt-get` branch installed `unzip`, the AWS CLI
15// install and S3 bundle sync succeeded, and the systemd unit's
16// `bun --no-install` start ran the self-contained bundle.
17//
18// Heavy (instance boot + apt-get + AWS CLI install + bun install + S3 sync),
19// so skipped under `FAST=1`.
20test.provider.skipIf(!!process.env.FAST)(
21 "deploys a hosted Ubuntu 24.04 instance that serves HTTP",
22 (stack) =>
23 Effect.gen(function* () {
24 yield* stack.destroy();
25
26 const { instanceId, publicIpAddress } = yield* stack.deploy(
27 Effect.gen(function* () {
28 const instance = yield* TestUbuntuInstance;
29 return {
30 instanceId: instance.instanceId,
31 publicIpAddress: instance.publicIpAddress,
32 };
33 }),
34 );
35
36 expect(publicIpAddress).toBeTruthy();
37 const base = `http://${publicIpAddress}:3000`;
38
39 // Poll until the instance boots, apt-get installs unzip, the AWS CLI
40 // installs, bun installs, the bundle syncs from S3, and the systemd
41 // unit serves 200 on :3000. Connection errors before the server binds
42 // are normalised to "not ready" so the poll keeps going.
43 const served = yield* HttpClient.get(`${base}/health`).pipe(
44 Effect.map((res) => res.status === 200),
45 Effect.catch(() => Effect.succeed(false)),
46 Effect.repeat({
47 schedule: Schedule.spaced("8 seconds"),
48 until: (ok) => ok,
49 times: 75,
50 }),
51 );
52 expect(served).toBe(true);
53
54 const getJson = (path: string) =>
55 HttpClient.get(`${base}${path}`).pipe(
56 Effect.flatMap((res) =>
57 res.status === 200
58 ? res.json
59 : Effect.fail(
60 new Error(`${path} temporarily returned ${res.status}`),
61 ),
62 ),
63 Effect.retry({ schedule: Schedule.spaced("1 second"), times: 10 }),
64 );
65
66 const body = yield* getJson("/health");
67 expect(body).toEqual({ ok: true });
68
69 // Prove the ServerHost.run background loop is executing on the instance.
70 const readTicks = getJson("/ticks").pipe(
71 Effect.map((value) => (value as { ticks: number }).ticks),
72 );
73 const first = yield* readTicks;
74 yield* Effect.sleep("3 seconds");
75 const second = yield* readTicks;
76 expect(second).toBeGreaterThan(first);
77
78 yield* stack.destroy();
79
80 // Zero-orphan proof: the (billed) instance reached a terminal state.
81 yield* assertInstanceTerminated(instanceId);
82 }),
83 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 1_200_000 },
84);