fix(efs): place mount targets in their real subnet #14

Merged
Zakaria merged 1 commits from fix/efs-mount-target-subnet into main 2026-10-25 23:43:51 +00:00
4 changed files with 149 additions and 9 deletions
Showing only changes of commit 5dd9a1e346 - Show all commits
@@ -29,6 +29,10 @@ public class EfsException extends AwsException {
return new EfsException(Status.NOT_FOUND, "MountTargetNotFound", "Mount target " + mountTargetId + " does not exist."); return new EfsException(Status.NOT_FOUND, "MountTargetNotFound", "Mount target " + mountTargetId + " does not exist.");
} }
public static EfsException subnetNotFound(String subnetId) {
return new EfsException(Status.BAD_REQUEST, "SubnetNotFound", "The subnet ID '" + subnetId + "' does not exist");
}
public static EfsException mountTargetConflict(String message) { public static EfsException mountTargetConflict(String message) {
return new EfsException(Status.CONFLICT, "MountTargetConflict", message); return new EfsException(Status.CONFLICT, "MountTargetConflict", message);
} }
@@ -2,7 +2,10 @@ package io.github.hectorvent.floci.services.efs;
import io.github.hectorvent.floci.core.storage.StorageBackend; import io.github.hectorvent.floci.core.storage.StorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.Resettable; import io.github.hectorvent.floci.core.common.Resettable;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ec2.model.Subnet;
import io.github.hectorvent.floci.services.efs.model.*; import io.github.hectorvent.floci.services.efs.model.*;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@@ -33,13 +36,16 @@ public class EfsService implements Resettable {
private final ConcurrentHashMap<String, Object> syncLocks = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Object> syncLocks = new ConcurrentHashMap<>();
private final RegionResolver regionResolver; private final RegionResolver regionResolver;
private final Ec2Service ec2Service;
private Object lockFor(String key) { private Object lockFor(String key) {
return syncLocks.computeIfAbsent(key, k -> new Object()); return syncLocks.computeIfAbsent(key, k -> new Object());
} }
@Inject @Inject
public EfsService(StorageFactory storageFactory, RegionResolver regionResolver) { public EfsService(StorageFactory storageFactory, RegionResolver regionResolver, Ec2Service ec2Service) {
this.regionResolver = regionResolver; this.regionResolver = regionResolver;
this.ec2Service = ec2Service;
this.fileSystemStore = storageFactory.create("efs", "efs-filesystems.json", this.fileSystemStore = storageFactory.create("efs", "efs-filesystems.json",
new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, FileSystem>>() {}); new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, FileSystem>>() {});
this.mountTargetStore = storageFactory.create("efs", "efs-mounttargets.json", this.mountTargetStore = storageFactory.create("efs", "efs-mounttargets.json",
@@ -361,14 +367,24 @@ public class EfsService implements Resettable {
public MountTarget createMountTarget(CreateMountTargetRequest request, String region) { public MountTarget createMountTarget(CreateMountTargetRequest request, String region) {
synchronized (lockFor(regionKey(region, request.getFileSystemId()))) { synchronized (lockFor(regionKey(region, request.getFileSystemId()))) {
FileSystem fs = getFileSystem(region, request.getFileSystemId()); FileSystem fs = getFileSystem(region, request.getFileSystemId());
Subnet subnet = requireSubnet(region, request.getSubnetId());
for (MountTarget existing : mountTargetStore.scan(k -> k.startsWith(region + "::"))) { List<MountTarget> regionMountTargets = mountTargetStore.scan(k -> k.startsWith(region + "::"));
for (MountTarget existing : regionMountTargets) {
if (existing.getFileSystemId().equals(request.getFileSystemId())) { if (existing.getFileSystemId().equals(request.getFileSystemId())) {
if (existing.getSubnetId().equals(request.getSubnetId())) { if (existing.getSubnetId().equals(request.getSubnetId())) {
throw EfsException.mountTargetConflict("Mount target already exists in this subnet"); throw EfsException.mountTargetConflict("Mount target already exists in this subnet");
} }
// AWS allows one mount target per Availability Zone, all in one VPC.
if (subnet.getAvailabilityZone().equals(existing.getAvailabilityZoneName())) {
throw EfsException.mountTargetConflict("Mount target already exists in this AZ");
}
if (!subnet.getVpcId().equals(existing.getVpcId())) {
throw EfsException.mountTargetConflict("Mount targets of a file system must be in the same VPC");
} }
} }
}
long inSubnet = regionMountTargets.stream().filter(existing -> request.getSubnetId().equals(existing.getSubnetId())).count();
String mtId = "fsmt-" + UUID.randomUUID().toString().replace("-", "").substring(0, 17); String mtId = "fsmt-" + UUID.randomUUID().toString().replace("-", "").substring(0, 17);
@@ -376,11 +392,11 @@ public class EfsService implements Resettable {
mt.setMountTargetId(mtId); mt.setMountTargetId(mtId);
mt.setFileSystemId(request.getFileSystemId()); mt.setFileSystemId(request.getFileSystemId());
mt.setSubnetId(request.getSubnetId()); mt.setSubnetId(request.getSubnetId());
mt.setIpAddress(request.getIpAddress() != null ? request.getIpAddress() : "10.0.0.10"); mt.setIpAddress(request.getIpAddress() != null ? request.getIpAddress() : hostAddress(subnet.getCidrBlock(), inSubnet));
mt.setLifeCycleState(LifeCycleState.available); mt.setLifeCycleState(LifeCycleState.available);
mt.setVpcId("vpc-12345678"); mt.setVpcId(subnet.getVpcId());
mt.setAvailabilityZoneName(region + "a"); mt.setAvailabilityZoneName(subnet.getAvailabilityZone());
mt.setAvailabilityZoneId(generateAzId(region + "a")); mt.setAvailabilityZoneId(generateAzId(subnet.getAvailabilityZone()));
mt.setNetworkInterfaceId("eni-" + UUID.randomUUID().toString().replace("-", "").substring(0, 17)); mt.setNetworkInterfaceId("eni-" + UUID.randomUUID().toString().replace("-", "").substring(0, 17));
if (request.getSecurityGroups() != null) { if (request.getSecurityGroups() != null) {
mt.setSecurityGroups(new ArrayList<>(request.getSecurityGroups())); mt.setSecurityGroups(new ArrayList<>(request.getSecurityGroups()));
@@ -613,6 +629,32 @@ public class EfsService implements Resettable {
return region + "::" + id; return region + "::" + id;
} }
private Subnet requireSubnet(String region, String subnetId) {
if (subnetId == null || subnetId.isBlank()) {
throw EfsException.badRequest("SubnetId is required.");
}
try {
return ec2Service.requireSubnet(region, subnetId);
} catch (AwsException e) {
throw EfsException.subnetNotFound(subnetId);
}
}
/**
* An address inside the subnet for its n-th mount target, skipping the first four
* addresses AWS reserves in every subnet.
*/
static String hostAddress(String cidrBlock, long index) {
String[] parts = cidrBlock.split("/");
String[] octets = parts[0].split("\\.");
long base = 0;
for (String octet : octets) {
base = (base << 8) | Long.parseLong(octet);
}
long address = base + 4 + index;
return ((address >> 24) & 0xff) + "." + ((address >> 16) & 0xff) + "." + ((address >> 8) & 0xff) + "." + (address & 0xff);
}
private String generateAzId(String azName) { private String generateAzId(String azName) {
if (azName == null || azName.length() < 2) return azName + "-id"; if (azName == null || azName.length() < 2) return azName + "-id";
String region = azName.substring(0, azName.length() - 1); String region = azName.substring(0, azName.length() - 1);
@@ -90,7 +90,7 @@ class EfsIntegrationTest {
.body(""" .body("""
{ {
"FileSystemId": "%s", "FileSystemId": "%s",
"SubnetId": "subnet-12345", "SubnetId": "subnet-default-us-east-1-a",
"SecurityGroups": ["sg-11111"] "SecurityGroups": ["sg-11111"]
} }
""".formatted(fileSystemId)) """.formatted(fileSystemId))
@@ -100,7 +100,10 @@ class EfsIntegrationTest {
.statusCode(200) .statusCode(200)
.body("MountTargetId", startsWith("fsmt-")) .body("MountTargetId", startsWith("fsmt-"))
.body("FileSystemId", equalTo(fileSystemId)) .body("FileSystemId", equalTo(fileSystemId))
.body("SubnetId", equalTo("subnet-12345")) .body("SubnetId", equalTo("subnet-default-us-east-1-a"))
.body("VpcId", equalTo("vpc-default-us-east-1"))
.body("AvailabilityZoneName", equalTo("us-east-1a"))
.body("IpAddress", equalTo("172.31.0.4"))
.extract().jsonPath().getString("MountTargetId"); .extract().jsonPath().getString("MountTargetId");
} }
@@ -0,0 +1,91 @@
package io.github.hectorvent.floci.services.efs;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** Mount targets take their VPC, zone and address from the EC2 subnet they are created in. */
@QuarkusTest
class EfsMountTargetSubnetTest {
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
private String createFileSystem(String token) {
return given()
.contentType("application/json")
.body("{\"CreationToken\": \"" + token + "\"}")
.when()
.post("/2015-02-01/file-systems")
.then()
.statusCode(201)
.extract().jsonPath().getString("FileSystemId");
}
private io.restassured.response.ValidatableResponse createMountTarget(String fileSystemId, String subnetId) {
return given()
.contentType("application/json")
.body("{\"FileSystemId\": \"" + fileSystemId + "\", \"SubnetId\": \"" + subnetId + "\"}")
.when()
.post("/2015-02-01/mount-targets")
.then();
}
@Test
void usesTheZoneAndVpcOfEachSubnet() {
String fileSystemId = createFileSystem("subnet-zones");
createMountTarget(fileSystemId, "subnet-default-us-east-1-b")
.statusCode(200)
.body("VpcId", equalTo("vpc-default-us-east-1"))
.body("AvailabilityZoneName", equalTo("us-east-1b"))
.body("IpAddress", equalTo("172.31.16.4"));
createMountTarget(fileSystemId, "subnet-default-us-east-1-c")
.statusCode(200)
.body("AvailabilityZoneName", equalTo("us-east-1c"));
}
@Test
void refusesASubnetThatDoesNotExist() {
String fileSystemId = createFileSystem("subnet-missing");
createMountTarget(fileSystemId, "subnet-nope")
.statusCode(400)
.body("__type", equalTo("SubnetNotFound"));
}
@Test
void allowsOneMountTargetPerZone() {
String fileSystemId = createFileSystem("subnet-one-per-zone");
String vpcId = "vpc-default-us-east-1";
String secondSubnet = given()
.formParam("Action", "CreateSubnet")
.formParam("VpcId", vpcId)
.formParam("CidrBlock", "172.31.200.0/24")
.formParam("AvailabilityZone", "us-east-1a")
.header("Authorization", "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/ec2/aws4_request")
.when()
.post("/")
.then()
.statusCode(200)
.extract().path("CreateSubnetResponse.subnet.subnetId");
createMountTarget(fileSystemId, "subnet-default-us-east-1-a").statusCode(200);
createMountTarget(fileSystemId, secondSubnet)
.statusCode(409)
.body("__type", equalTo("MountTargetConflict"));
}
@Test
void picksAddressesAfterTheFourAwsReserves() {
assertEquals("10.0.1.4", EfsService.hostAddress("10.0.1.0/24", 0));
assertEquals("10.0.2.1", EfsService.hostAddress("10.0.1.0/24", 253));
}
}