1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as EC2 from "@distilled.cloud/aws/ec2";
5import * as efs from "@distilled.cloud/aws/efs";
6import { describe, expect } from "alchemy-test";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import { fileURLToPath } from "node:url";
12import { getDefaultVpc } from "../DefaultVpc.ts";
13
14// Flagship EFS e2e: a VPC-attached Lambda mounts an EFS access point at
15// /mnt/test, writes a file over HTTP, reads it back, and a config redeploy
16// (fresh sandboxes) proves the data outlives the execution environment.
17//
18// Uses the default VPC and its DEFAULT security group on purpose: the default
19// group's self-referencing ingress rule allows the Lambda ENI to reach the
20// mount target's NFS port, and because we never delete the default group we
21// avoid waiting out the ~20-minute Hyperplane-ENI release that blocks
22// deleting a security group that was attached to a Lambda.
23
24const handlerPath = fileURLToPath(new URL("./efs-handler.ts", import.meta.url));
25
26const testOptions = { providers: AWS.providers() };
27const { test, beforeAll, afterAll } = Test.make(testOptions);
28const sharedStack = Core.scratchStack(testOptions, "EFSLambdaMount");
29
30let subnetId: string;
31let securityGroupId: string;
32let baseUrl: string;
33let fileSystemId: string;
34
35// Resolve the default VPC's subnet and default security group once. Runs
36// inside a deploy effect (the `beforeAll` hook itself has no AWS context).
37const resolveNetwork = Effect.gen(function* () {
38 if (subnetId !== undefined) return;
39 const vpc = yield* getDefaultVpc;
40 const subnets = yield* EC2.describeSubnets({
41 Filters: [
42 { Name: "vpc-id", Values: [vpc.vpcId] },
43 { Name: "default-for-az", Values: ["true"] },
44 ],
45 });
46 subnetId = subnets.Subnets![0]!.SubnetId!;
47 const groups = yield* EC2.describeSecurityGroups({
48 Filters: [
49 { Name: "vpc-id", Values: [vpc.vpcId] },
50 { Name: "group-name", Values: ["default"] },
51 ],
52 });
53 securityGroupId = groups.SecurityGroups![0]!.GroupId!;
54});
55
56const infra = (marker: string) =>
57 Effect.gen(function* () {
58 yield* resolveNetwork;
59 const files = yield* AWS.EFS.FileSystem("MountFiles", {
60 throughputMode: "elastic",
61 });
62 const target = yield* AWS.EFS.MountTarget("MountTarget", {
63 fileSystemId: files.fileSystemId,
64 subnetId,
65 });
66 const accessPoint = yield* AWS.EFS.AccessPoint("MountAccess", {
67 fileSystemId: files.fileSystemId,
68 posixUser: { uid: 1000, gid: 1000 },
69 rootDirectory: {
70 path: "/lambda",
71 creationInfo: { ownerUid: 1000, ownerGid: 1000, permissions: "750" },
72 },
73 });
74 const fn = yield* AWS.Lambda.Function("EfsFn", {
75 main: handlerPath,
76 handler: "handler",
77 isExternal: true,
78 functionUrl: true,
79 memorySize: 256,
80 timeout: Duration.seconds(30),
81 vpc: { subnetIds: [subnetId], securityGroupIds: [securityGroupId] },
82 fileSystemConfigs: [
83 // pass the AccessPoint resource itself — the provider resolves its ARN
84 { accessPoint, localMountPath: "/mnt/test" },
85 ],
86 env: {
87 // Depend on the mount target so the function is only created once
88 // the NFS endpoint is available (Lambda rejects a file-system config
89 // whose mount targets are still creating).
90 EFS_MOUNT_TARGET: target.mountTargetId,
91 DEPLOY_MARKER: marker,
92 },
93 });
94 return { files, target, accessPoint, fn };
95 });
96
97// VPC-attached Lambda cold starts ride on ENI/mount provisioning: budget a
98// generous bounded retry for the FIRST request (3s × 60 ≈ 180s), then fail
99// fast on later ones.
100const getJsonWithRetry = (url: string, times: number) =>
101 HttpClient.get(url).pipe(
102 Effect.flatMap((response) =>
103 response.status === 200
104 ? response.json
105 : response.text.pipe(
106 Effect.flatMap((body) =>
107 Effect.fail(
108 new Error(`${url} returned ${response.status}: ${body}`),
109 ),
110 ),
111 ),
112 ),
113 Effect.retry({
114 schedule: Schedule.max([
115 Schedule.fixed("3 seconds"),
116 Schedule.recurs(times),
117 ]),
118 }),
119 );
120
121// Gated behind AWS_TEST_SLOW: the suite's wall clock is ~6–8 minutes end to
122// end — the beforeAll deploy alone is ~250s (file system + mount-target ENI
123// provisioning + VPC-attached Lambda with EFS mount validation) and the
124// afterAll teardown waits out the mount-target/ENI release (~2–4 min). That
125// is genuinely slow platform provisioning, not a failure mode; the suite was
126// verified green in wave 1C (commit e77b9fd83). Run with AWS_TEST_SLOW=1.
127describe.skipIf(!process.env.AWS_TEST_SLOW).sequential(
128 "EFS Lambda mount",
129 {
130 tags: [
131 "provider:aws",
132 "provider:aws:ec2",
133 "provider:aws:efs",
134 "provider:aws:lambda",
135 "live",
136 ],
137 },
138 () => {
139 beforeAll(
140 Effect.gen(function* () {
141 yield* sharedStack.destroy();
142
143 const deployed = yield* sharedStack.deploy(infra("first"));
144 expect(deployed.fn.functionUrl).toBeTruthy();
145 baseUrl = deployed.fn.functionUrl!.replace(/\/+$/, "");
146 fileSystemId = deployed.files.fileSystemId;
147 }),
148 // The one slow deploy of the suite: file system (~15s) + mount target
149 // (~90s, ENI provisioning) + VPC-attached Lambda (ENI + EFS mount
150 // validation) — observed ~250s end to end.
151 { timeout: 300_000 },
152 );
153
154 afterAll(sharedStack.destroy(), { timeout: 240_000 });
155
156 test.provider(
157 "writes and reads a file through the mount",
158 () =>
159 Effect.gen(function* () {
160 // first request: rides through URL propagation + VPC/EFS cold start
161 const mounted = (yield* getJsonWithRetry(`${baseUrl}/mount`, 60)) as {
162 mounted: boolean;
163 };
164 expect(mounted.mounted).toBe(true);
165
166 const written = (yield* getJsonWithRetry(
167 `${baseUrl}/write?content=hello-from-efs`,
168 5,
169 )) as { written: string };
170 expect(written.written).toBe("hello-from-efs");
171
172 const read = (yield* getJsonWithRetry(`${baseUrl}/read`, 5)) as {
173 content: string;
174 marker: string;
175 };
176 expect(read.content).toBe("hello-from-efs");
177 expect(read.marker).toBe("first");
178 }),
179 { timeout: 180_000 },
180 );
181
182 test.provider(
183 "file persists across a redeploy (fresh sandboxes)",
184 () =>
185 Effect.gen(function* () {
186 // config-only redeploy: new env marker forces an update, which spins
187 // up fresh execution environments — the EFS file must survive.
188 const redeployed = yield* sharedStack.deploy(infra("second"));
189 expect(redeployed.files.fileSystemId).toBe(fileSystemId);
190
191 // A drained-but-alive sandbox from the first deploy may briefly
192 // answer with the old marker — poll until a fresh (marker=second)
193 // environment serves the read.
194 const read = yield* getJsonWithRetry(`${baseUrl}/read`, 10).pipe(
195 Effect.map((r) => r as { content: string; marker: string }),
196 Effect.repeat({
197 schedule: Schedule.fixed("3 seconds"),
198 until: (r) => r.marker === "second",
199 times: 30,
200 }),
201 );
202 expect(read.content).toBe("hello-from-efs");
203 expect(read.marker).toBe("second");
204 }),
205 { timeout: 180_000 },
206 );
207 },
208);