1import * as AWS from "@/AWS";
2import type { VpcId } from "@/AWS/EC2";
3import * as Core from "@/Test/Core";
4import * as Test from "@/Test/Alchemy";
5import * as EC2 from "@distilled.cloud/aws/ec2";
6import * as efs from "@distilled.cloud/aws/efs";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import { getDefaultVpc } from "../DefaultVpc.ts";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "EFSMountTarget");
15
16// Mount-target churn is the slow part of EFS (1–3 minutes each way), so the
17// suite deploys ONE mount target in a `beforeAll`, drives updates against it
18// in tests, and tears it down once in `afterAll`. Multi-AZ coverage is gated
19// behind AWS_TEST_EFS_MULTI_AZ=1 because it doubles the churn.
20
21let vpcId: VpcId;
22let subnetId: string;
23let defaultSecurityGroupId: string;
24let extraSecurityGroupId: string;
25let fileSystemId: string;
26let mountTargetId: string;
27
28// Resolve the default VPC, a default subnet, and the default security group
29// once. Runs inside a deploy/test effect (the `beforeAll` hook itself has no
30// AWS context).
31const resolveNetwork = Effect.gen(function* () {
32 if (subnetId !== undefined) return;
33 const vpc = yield* getDefaultVpc;
34 vpcId = vpc.vpcId;
35 const subnets = yield* EC2.describeSubnets({
36 Filters: [
37 { Name: "vpc-id", Values: [vpc.vpcId] },
38 { Name: "default-for-az", Values: ["true"] },
39 ],
40 });
41 subnetId = subnets.Subnets![0]!.SubnetId!;
42 const groups = yield* EC2.describeSecurityGroups({
43 Filters: [
44 { Name: "vpc-id", Values: [vpc.vpcId] },
45 { Name: "group-name", Values: ["default"] },
46 ],
47 });
48 defaultSecurityGroupId = groups.SecurityGroups![0]!.GroupId!;
49});
50
51const infra = (securityGroups?: () => string[]) =>
52 Effect.gen(function* () {
53 yield* resolveNetwork;
54 const files = yield* AWS.EFS.FileSystem("MtFiles");
55 const extraSg = yield* AWS.EC2.SecurityGroup("MtSg", {
56 vpcId,
57 description: "alchemy EFS mount target test",
58 });
59 const target = yield* AWS.EFS.MountTarget("MtTarget", {
60 fileSystemId: files.fileSystemId,
61 subnetId,
62 ...(securityGroups !== undefined
63 ? { securityGroups: securityGroups() }
64 : {}),
65 });
66 return { files, target, extraSg };
67 });
68
69describe.sequential(
70 "EFS MountTarget",
71 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:efs", "live"] },
72 () => {
73 beforeAll(
74 Effect.gen(function* () {
75 yield* sharedStack.destroy();
76
77 // initial deploy: no explicit security groups → the VPC default group
78 const deployed = yield* sharedStack.deploy(infra());
79 fileSystemId = deployed.files.fileSystemId;
80 mountTargetId = deployed.target.mountTargetId;
81 extraSecurityGroupId = deployed.extraSg.groupId;
82 }),
83 { timeout: 240_000 },
84 );
85
86 afterAll(sharedStack.destroy(), { timeout: 240_000 });
87
88 test.provider(
89 "mount target is available in the subnet with the default security group",
90 () =>
91 Effect.gen(function* () {
92 expect(mountTargetId).toMatch(/^fsmt-/);
93
94 const observed = yield* efs
95 .describeMountTargets({ MountTargetId: mountTargetId })
96 .pipe(Effect.map((r) => r.MountTargets![0]));
97 expect(observed.LifeCycleState).toBe("available");
98 expect(observed.SubnetId).toBe(subnetId);
99 expect(observed.FileSystemId).toBe(fileSystemId);
100 expect(observed.IpAddress).toBeTruthy();
101
102 const groups = yield* efs.describeMountTargetSecurityGroups({
103 MountTargetId: mountTargetId,
104 });
105 expect(groups.SecurityGroups).toEqual([defaultSecurityGroupId]);
106 }),
107 { timeout: 60_000 },
108 );
109
110 test.provider(
111 "security groups are modified in place",
112 () =>
113 Effect.gen(function* () {
114 const updated = yield* sharedStack.deploy(
115 infra(() => [defaultSecurityGroupId, extraSecurityGroupId]),
116 );
117 // same mount target — securityGroups is mutable
118 expect(updated.target.mountTargetId).toBe(mountTargetId);
119
120 const observed = yield* efs
121 .describeMountTargetSecurityGroups({
122 MountTargetId: mountTargetId,
123 })
124 .pipe(Effect.map((r) => [...r.SecurityGroups].sort()));
125 expect(observed).toEqual(
126 [defaultSecurityGroupId, extraSecurityGroupId].sort(),
127 );
128 }),
129 { timeout: 120_000 },
130 );
131 },
132);
133
134// Full teardown verification for the shared fixture is implicit: the mount
135// target's delete waits until it is observed gone (its ENI must release
136// before the security group and file system can delete), so a leaked mount
137// target fails the afterAll destroy. Multi-AZ is gated — it adds another
138// 1–3 minute create/delete cycle per AZ.
139test.provider.skipIf(!process.env.AWS_TEST_EFS_MULTI_AZ)(
140 "creates one mount target per availability zone",
141 (stack) =>
142 Effect.gen(function* () {
143 yield* stack.destroy();
144
145 const vpc = yield* getDefaultVpc;
146 const subnets = yield* EC2.describeSubnets({
147 Filters: [
148 { Name: "vpc-id", Values: [vpc.vpcId] },
149 { Name: "default-for-az", Values: ["true"] },
150 ],
151 });
152 const [subnetA, subnetB] = subnets.Subnets!.map((s) => s.SubnetId!);
153
154 const deployed = yield* stack.deploy(
155 Effect.gen(function* () {
156 const files = yield* AWS.EFS.FileSystem("MultiAzFiles");
157 const targetA = yield* AWS.EFS.MountTarget("MtA", {
158 fileSystemId: files.fileSystemId,
159 subnetId: subnetA,
160 });
161 const targetB = yield* AWS.EFS.MountTarget("MtB", {
162 fileSystemId: files.fileSystemId,
163 subnetId: subnetB,
164 });
165 return { files, targetA, targetB };
166 }),
167 );
168
169 expect(deployed.targetA.availabilityZoneName).not.toBe(
170 deployed.targetB.availabilityZoneName,
171 );
172
173 const observed = yield* efs.describeMountTargets({
174 FileSystemId: deployed.files.fileSystemId,
175 });
176 expect(observed.MountTargets).toHaveLength(2);
177
178 yield* stack.destroy();
179 const gone = yield* efs
180 .describeMountTargets({ FileSystemId: deployed.files.fileSystemId })
181 .pipe(
182 Effect.map((r) => (r.MountTargets ?? []).length === 0),
183 Effect.catchTag("FileSystemNotFound", () => Effect.succeed(true)),
184 Effect.repeat({
185 schedule: Schedule.fixed("5 seconds"),
186 until: (g) => g,
187 times: 36,
188 }),
189 );
190 expect(gone).toBe(true);
191 }).pipe(
192 Effect.tap(() => stack.destroy()),
193 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
194 ),
195 {
196 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:efs", "live"],
197 timeout: 600_000,
198 },
199);