1/**
2 * Hosted `AWS.EC2.Instance` under `alchemy dev`: the dualized EC2 providers
3 * deploy the whole fleet (VPC network, security group, instance, and the
4 * hosted program's S3 bundle) into the floci emulator, where the instance
5 * runs as a REAL docker container that boots the Alchemy userData — syncs
6 * the bundle from emulated S3, installs the runtime, and serves the hosted
7 * `{ fetch }` program.
8 *
9 * Proof structure:
10 * - attrs carry the emulator's `.localhost.floci.io` public address — the
11 * live cloud can never mint it;
12 * - e2e over HTTP: floci publishes the security-group app port behind a
13 * host-routing mux, so `http://<host>:3000/marker` serves THIS build's
14 * marker from inside the emulated instance;
15 * - bindings through compute: a floci-hosted Lambda bound to the instance
16 * (`DescribeInstance` / `DescribeInstanceStatus`) answers over its
17 * function URL — emulated Lambda → EC2 control plane → emulated
18 * instance, no real cloud anywhere;
19 * - destroy terminates the instance (verified out-of-band via distilled).
20 *
21 * Requires Docker (floci runs as a container); skipped when unavailable.
22 */
23import * as AWS from "@/AWS";
24import * as Endpoint from "@/AWS/Endpoint.ts";
25import * as Region from "@/AWS/Region.ts";
26import * as Test from "@/Test/Alchemy";
27import { Credentials } from "@distilled.cloud/aws/Credentials";
28import type { RegionName } from "@distilled.cloud/aws/Region";
29import * as ec2 from "@distilled.cloud/aws/ec2";
30import { expect } from "alchemy-test";
31import * as Effect from "effect/Effect";
32import * as Layer from "effect/Layer";
33import * as Redacted from "effect/Redacted";
34import * as Schedule from "effect/Schedule";
35import * as HttpClient from "effect/http/HttpClient";
36import DevInstance, { MARKER } from "./fixtures/dev-instance.ts";
37import DevProbeFunctionLive, {
38 Ec2DevProbeFunction,
39} from "./fixtures/dev-instance-fn.ts";
40
41const { test } = Test.make({ providers: AWS.providers(), dev: true });
42
43/** Floci-scoped context for the raw distilled calls the test makes itself. */
44const flociContext = Layer.mergeAll(
45 Endpoint.of("http://localhost:4566"),
46 Region.of("us-east-1"),
47 Layer.succeed(
48 Credentials,
49 Effect.succeed({
50 accessKeyId: Redacted.make("test"),
51 secretAccessKey: Redacted.make("test"),
52 sessionToken: undefined,
53 region: "us-east-1" as RegionName,
54 }),
55 ),
56);
57
58// TODO: floci does not settle security group rules yet
59// (SecurityGroupRulesNotSettled); needs a fix in the emulator.
60test.provider.todo(
61 "dev runs a hosted EC2 instance as a container serving HTTP, with Lambda EC2 bindings against it",
62 (stack) =>
63 Effect.gen(function* () {
64 yield* stack.destroy();
65
66 const outputs = yield* stack.deploy(
67 Effect.gen(function* () {
68 const instance = yield* DevInstance;
69 const fn = yield* Ec2DevProbeFunction;
70 return {
71 instanceId: instance.instanceId,
72 publicDnsName: instance.publicDnsName,
73 functionUrl: fn.functionUrl,
74 };
75 }).pipe(Effect.provide(DevProbeFunctionLive)),
76 );
77
78 expect(outputs.instanceId).toMatch(/^i-/);
79 expect(outputs.functionUrl).toBeDefined();
80 // Emulator identity: floci addresses instances under its own
81 // host-routed localhost domain.
82 const host =
83 outputs.publicDnsName?.endsWith(".localhost.floci.io") === true
84 ? outputs.publicDnsName
85 : `${outputs.instanceId}.localhost.floci.io`;
86 const base = `http://${host}:3000`;
87
88 // The instance container boots the Alchemy userData: bundle sync from
89 // emulated S3, runtime install, then the Bun HTTP server on :3000.
90 const served = yield* HttpClient.get(`${base}/health`).pipe(
91 Effect.map((res) => res.status === 200),
92 Effect.catch(() => Effect.succeed(false)),
93 Effect.repeat({
94 schedule: Schedule.spaced("3 seconds"),
95 until: (ok): boolean => ok,
96 times: 60,
97 }),
98 );
99 expect(served).toBe(true);
100
101 // The program served is THIS build.
102 const marker = yield* HttpClient.get(`${base}/marker`).pipe(
103 Effect.flatMap((res) => res.text),
104 Effect.retry({ schedule: Schedule.spaced("1 second"), times: 10 }),
105 );
106 expect(marker).toBe(MARKER);
107
108 // Bindings through compute: the floci-hosted Lambda observes the
109 // emulated instance via its DescribeInstance binding.
110 const describeUrl = `${outputs.functionUrl}describe`;
111 const described = yield* HttpClient.get(describeUrl).pipe(
112 Effect.flatMap((res) =>
113 res.status === 200
114 ? res.json
115 : Effect.fail(new Error(`/describe returned ${res.status}`)),
116 ),
117 Effect.map(
118 (json) =>
119 json as {
120 ok: boolean;
121 state?: string;
122 instanceId?: string;
123 },
124 ),
125 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 20 }),
126 );
127 expect(described.ok).toBe(true);
128 expect(described.instanceId).toBe(outputs.instanceId);
129 expect(described.state).toBe("running");
130
131 // Reboot resilience: the provider's in-place update path ends in
132 // RebootInstances, and floci's guest services must come back after
133 // the container restart (the systemd shim's processes die with the
134 // container; the emulator re-runs the boot sequence). Without this
135 // the box goes dark after every hosted-program update.
136 yield* ec2
137 .rebootInstances({ InstanceIds: [outputs.instanceId] })
138 .pipe(Effect.provide(flociContext));
139 const backAfterReboot = yield* HttpClient.get(`${base}/marker`).pipe(
140 Effect.flatMap((res) => res.text),
141 Effect.map((text) => text.includes(MARKER)),
142 Effect.catch(() => Effect.succeed(false)),
143 Effect.repeat({
144 schedule: Schedule.spaced("2 seconds"),
145 until: (ok): boolean => ok,
146 times: 60,
147 }),
148 );
149 expect(backAfterReboot).toBe(true);
150
151 const status = yield* HttpClient.get(`${outputs.functionUrl}status`).pipe(
152 Effect.flatMap((res) =>
153 res.status === 200
154 ? res.json
155 : Effect.fail(new Error(`/status returned ${res.status}`)),
156 ),
157 Effect.map((json) => json as { ok: boolean; count?: number }),
158 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 10 }),
159 );
160 expect(status.ok).toBe(true);
161 expect(status.count).toBeGreaterThan(0);
162
163 // Destroy: the emulated instance reaches a terminal state (or is
164 // fully forgotten by the emulator).
165 yield* stack.destroy();
166 const gone = yield* ec2
167 .describeInstances({ InstanceIds: [outputs.instanceId] })
168 .pipe(
169 Effect.map((res) => {
170 const state = res.Reservations?.[0]?.Instances?.[0]?.State?.Name;
171 return state === undefined || state === "terminated";
172 }),
173 Effect.catchTag("InvalidInstanceID.NotFound", () =>
174 Effect.succeed(true),
175 ),
176 Effect.provide(flociContext),
177 Effect.repeat({
178 schedule: Schedule.spaced("2 seconds"),
179 until: (isGone): boolean => isGone,
180 times: 30,
181 }),
182 );
183 expect(gone).toBe(true);
184 }),
185 {
186 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:lambda", "local"],
187 timeout: 600_000,
188 },
189);