| 1 | import * as AWS from "@/AWS"; |
| 2 | import type { VpcId } from "@/AWS/EC2"; |
| 3 | import * as Core from "@/Test/Core"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as EC2 from "@distilled.cloud/aws/ec2"; |
| 6 | import * as efs from "@distilled.cloud/aws/efs"; |
| 7 | import { describe, expect } from "alchemy-test"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | import { getDefaultVpc } from "../DefaultVpc.ts"; |
| 11 | |
| 12 | const testOptions = { providers: AWS.providers() }; |
| 13 | const { test, beforeAll, afterAll } = Test.make(testOptions); |
| 14 | const 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 | |
| 21 | let vpcId: VpcId; |
| 22 | let subnetId: string; |
| 23 | let defaultSecurityGroupId: string; |
| 24 | let extraSecurityGroupId: string; |
| 25 | let fileSystemId: string; |
| 26 | let 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). |
| 31 | const 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 | |
| 51 | const 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 | |
| 69 | describe.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. |
| 139 | test.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 | ); |