diff --git a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java index 25cfab01b..59fca84fd 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java +++ b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java @@ -1,18 +1,48 @@ package io.github.hectorvent.floci.services.ec2; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import org.jboss.logging.Logger; + import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.core.common.AwsArnUtils; import io.github.hectorvent.floci.core.common.AwsException; -import io.github.hectorvent.floci.services.ec2.model.*; +import io.github.hectorvent.floci.services.ec2.model.Address; +import io.github.hectorvent.floci.services.ec2.model.GroupIdentifier; +import io.github.hectorvent.floci.services.ec2.model.Image; +import io.github.hectorvent.floci.services.ec2.model.Instance; +import io.github.hectorvent.floci.services.ec2.model.InstanceNetworkInterface; +import io.github.hectorvent.floci.services.ec2.model.InstanceState; +import io.github.hectorvent.floci.services.ec2.model.InternetGateway; +import io.github.hectorvent.floci.services.ec2.model.InternetGatewayAttachment; +import io.github.hectorvent.floci.services.ec2.model.IpPermission; +import io.github.hectorvent.floci.services.ec2.model.IpRange; +import io.github.hectorvent.floci.services.ec2.model.KeyPair; +import io.github.hectorvent.floci.services.ec2.model.Placement; +import io.github.hectorvent.floci.services.ec2.model.Reservation; +import io.github.hectorvent.floci.services.ec2.model.Route; +import io.github.hectorvent.floci.services.ec2.model.RouteTable; +import io.github.hectorvent.floci.services.ec2.model.RouteTableAssociation; +import io.github.hectorvent.floci.services.ec2.model.SecurityGroup; +import io.github.hectorvent.floci.services.ec2.model.SecurityGroupRule; +import io.github.hectorvent.floci.services.ec2.model.Subnet; +import io.github.hectorvent.floci.services.ec2.model.Tag; +import io.github.hectorvent.floci.services.ec2.model.Volume; +import io.github.hectorvent.floci.services.ec2.model.Vpc; +import io.github.hectorvent.floci.services.ec2.model.VpcCidrBlockAssociation; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import org.jboss.logging.Logger; - -import java.time.Instant; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Collectors; @ApplicationScoped public class Ec2Service { @@ -1211,6 +1241,52 @@ private Map buildInstanceType(String name, int vcpu, int memMib) // ─── Filter matching ─────────────────────────────────────────────────────── + private boolean matchesValue(String resourceValue, List filterValues) { + String normalizedResourceValue = Objects.toString(resourceValue, ""); + return filterValues.stream() + .map(filterValue -> Objects.toString(filterValue, "")) + .anyMatch(filterValue -> normalizedResourceValue.matches(wildcardToRegex(filterValue))); + } + + private String wildcardToRegex(String pattern) { + String normalizedPattern = Objects.toString(pattern, ""); + StringBuilder regex = new StringBuilder("^"); + for (int i = 0; i < normalizedPattern.length(); i++) { + char c = normalizedPattern.charAt(i); + switch (c) { + case '*': + regex.append(".*"); + break; + case '?': + regex.append("."); + break; + case '.': + case '\\': + case '^': + case '$': + case '+': + case '{': + case '}': + case '[': + case ']': + case '(': + case ')': + case '|': + regex.append("\\").append(c); + break; + default: + regex.append(c); + } + } + regex.append("$"); + return regex.toString(); + } + + private boolean matchesValue(List patterns, String value) { + return patterns.stream() + .anyMatch(pattern -> value.matches(wildcardToRegex(pattern))); + } + private boolean matchesFilters(Object resource, Map> filters, String region) { if (filters == null || filters.isEmpty()) { return true; @@ -1230,83 +1306,83 @@ private boolean matchesFilter(Object resource, String filterName, List v String tagKey = filterName.substring(4); List resourceTags = getResourceTags(resource); return resourceTags.stream() - .anyMatch(t -> t.getKey().equals(tagKey) && values.contains(t.getValue())); + .anyMatch(t -> t.getKey().equals(tagKey) && matchesValue(values, t.getValue())); } if ("tag-key".equals(filterName)) { List resourceTags = getResourceTags(resource); - return resourceTags.stream().anyMatch(t -> values.contains(t.getKey())); + return resourceTags.stream().anyMatch(t -> matchesValue(values, t.getKey())); } if ("tag-value".equals(filterName)) { List resourceTags = getResourceTags(resource); - return resourceTags.stream().anyMatch(t -> values.contains(t.getValue())); + return resourceTags.stream().anyMatch(t -> matchesValue(values, t.getValue())); } // Resource-specific field filters if (resource instanceof Vpc vpc) { return switch (filterName) { - case "vpc-id" -> values.contains(vpc.getVpcId()); - case "state" -> values.contains(vpc.getState()); - case "isDefault", "is-default" -> values.contains(String.valueOf(vpc.isDefault())); - case "cidr" -> values.contains(vpc.getCidrBlock()); + case "vpc-id" -> matchesValue(values, vpc.getVpcId()); + case "state" -> matchesValue(values, vpc.getState()); + case "isDefault", "is-default" -> matchesValue(values, String.valueOf(vpc.isDefault())); + case "cidr" -> matchesValue(values, vpc.getCidrBlock()); default -> true; }; } if (resource instanceof Subnet subnet) { return switch (filterName) { - case "subnet-id" -> values.contains(subnet.getSubnetId()); - case "vpc-id" -> values.contains(subnet.getVpcId()); - case "state" -> values.contains(subnet.getState()); - case "availabilityZone", "availability-zone" -> values.contains(subnet.getAvailabilityZone()); + case "subnet-id" -> matchesValue(values, subnet.getSubnetId()); + case "vpc-id" -> matchesValue(values, subnet.getVpcId()); + case "state" -> matchesValue(values, subnet.getState()); + case "availabilityZone", "availability-zone" -> matchesValue(values, subnet.getAvailabilityZone()); default -> true; }; } if (resource instanceof SecurityGroup sg) { return switch (filterName) { - case "group-id" -> values.contains(sg.getGroupId()); - case "group-name" -> values.contains(sg.getGroupName()); - case "vpc-id" -> values.contains(sg.getVpcId()); + case "group-id" -> matchesValue(values, sg.getGroupId()); + case "group-name" -> matchesValue(values, sg.getGroupName()); + case "vpc-id" -> matchesValue(values, sg.getVpcId()); default -> true; }; } if (resource instanceof Instance inst) { return switch (filterName) { - case "instance-id" -> values.contains(inst.getInstanceId()); - case "instance-state-name" -> values.contains(inst.getState().getName()); - case "instance-type" -> values.contains(inst.getInstanceType()); - case "vpc-id" -> values.contains(inst.getVpcId()); - case "subnet-id" -> values.contains(inst.getSubnetId()); + case "instance-id" -> matchesValue(values, inst.getInstanceId()); + case "instance-state-name" -> matchesValue(values, inst.getState().getName()); + case "instance-type" -> matchesValue(values, inst.getInstanceType()); + case "vpc-id" -> matchesValue(values, inst.getVpcId()); + case "subnet-id" -> matchesValue(values, inst.getSubnetId()); default -> true; }; } if (resource instanceof InternetGateway igw) { return switch (filterName) { - case "internet-gateway-id" -> values.contains(igw.getInternetGatewayId()); + case "internet-gateway-id" -> matchesValue(values, igw.getInternetGatewayId()); case "attachment.vpc-id" -> igw.getAttachments().stream() - .anyMatch(a -> values.contains(a.getVpcId())); + .anyMatch(a -> matchesValue(values, a.getVpcId())); default -> true; }; } if (resource instanceof RouteTable rt) { return switch (filterName) { - case "route-table-id" -> values.contains(rt.getRouteTableId()); - case "vpc-id" -> values.contains(rt.getVpcId()); + case "route-table-id" -> matchesValue(values, rt.getRouteTableId()); + case "vpc-id" -> matchesValue(values, rt.getVpcId()); case "association.route-table-association-id" -> rt.getAssociations().stream() - .anyMatch(a -> values.contains(a.getRouteTableAssociationId())); + .anyMatch(a -> matchesValue(values, a.getRouteTableAssociationId())); case "association.subnet-id" -> rt.getAssociations().stream() - .anyMatch(a -> a.getSubnetId() != null && values.contains(a.getSubnetId())); + .anyMatch(a -> a.getSubnetId() != null && matchesValue(values, a.getSubnetId())); case "association.gateway-id" -> rt.getAssociations().stream() - .anyMatch(a -> a.getGatewayId() != null && values.contains(a.getGatewayId())); + .anyMatch(a -> a.getGatewayId() != null && matchesValue(values, a.getGatewayId())); case "association.main" -> rt.getAssociations().stream() - .anyMatch(a -> values.contains(String.valueOf(a.isMain()))); + .anyMatch(a -> matchesValue(values, String.valueOf(a.isMain()))); default -> true; }; } if (resource instanceof Volume vol) { return switch (filterName) { - case "volume-id" -> values.contains(vol.getVolumeId()); - case "status" -> values.contains(vol.getState()); - case "volume-type" -> values.contains(vol.getVolumeType()); - case "availability-zone" -> values.contains(vol.getAvailabilityZone()); - case "encrypted" -> values.contains(String.valueOf(vol.isEncrypted())); + case "volume-id" -> matchesValue(values, vol.getVolumeId()); + case "status" -> matchesValue(values, vol.getState()); + case "volume-type" -> matchesValue(values, vol.getVolumeType()); + case "availability-zone" -> matchesValue(values, vol.getAvailabilityZone()); + case "encrypted" -> matchesValue(values, String.valueOf(vol.isEncrypted())); default -> true; }; } diff --git a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2IntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2IntegrationTest.java index 98a12ee3c..34d5a558e 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2IntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2IntegrationTest.java @@ -1,13 +1,17 @@ package io.github.hectorvent.floci.services.ec2; -import io.quarkus.test.junit.QuarkusTest; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.startsWith; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; +import io.quarkus.test.junit.QuarkusTest; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; /** * Integration tests for EC2 via the EC2 Query Protocol (form-encoded POST, XML response). @@ -1117,4 +1121,126 @@ void unsupportedAction() { .statusCode(400) .body("Response.Errors.Error.Code", equalTo("UnsupportedOperation")); } + + // ========================================================================= + // Wildcard filtering + // ========================================================================= + + @Test + @Order(300) + void describeVpcsWithWildcardTagFilter() { + // Create a VPC with a specific tag value + String vpcWithWildcard = given() + .formParam("Action", "CreateVpc") + .formParam("CidrBlock", "10.1.0.0/16") + .header("Authorization", AUTH_HEADER) + .when() + .post("/") + .then() + .statusCode(200) + .extract().path("CreateVpcResponse.vpc.vpcId"); + + // Tag it with BEGINANDEND + given() + .formParam("Action", "CreateTags") + .formParam("ResourceId.1", vpcWithWildcard) + .formParam("Tag.1.Key", "Name") + .formParam("Tag.1.Value", "BEGINANDEND") + .header("Authorization", AUTH_HEADER) + .when() + .post("/") + .then() + .statusCode(200); + + // Test exact match still works + given() + .formParam("Action", "DescribeVpcs") + .formParam("Filter.1.Name", "tag:Name") + .formParam("Filter.1.Value.1", "BEGINANDEND") + .header("Authorization", AUTH_HEADER) + .when() + .post("/") + .then() + .statusCode(200) + .body("DescribeVpcsResponse.vpcSet.item.vpcId", equalTo(vpcWithWildcard)); + + // Test wildcard with asterisk: BEGIN*END should match BEGINANDEND + given() + .formParam("Action", "DescribeVpcs") + .formParam("Filter.1.Name", "tag:Name") + .formParam("Filter.1.Value.1", "BEGIN*END") + .header("Authorization", AUTH_HEADER) + .when() + .post("/") + .then() + .statusCode(200) + .body("DescribeVpcsResponse.vpcSet.item.vpcId", equalTo(vpcWithWildcard)); + + // Test wildcard with middle asterisk: *AND* should match BEGINANDEND + given() + .formParam("Action", "DescribeVpcs") + .formParam("Filter.1.Name", "tag:Name") + .formParam("Filter.1.Value.1", "*AND*") + .header("Authorization", AUTH_HEADER) + .when() + .post("/") + .then() + .statusCode(200) + .body("DescribeVpcsResponse.vpcSet.item.vpcId", equalTo(vpcWithWildcard)); + + // Cleanup + given() + .formParam("Action", "DeleteVpc") + .formParam("VpcId", vpcWithWildcard) + .header("Authorization", AUTH_HEADER) + .when() + .post("/"); + } + + @Test + @Order(301) + void describeVpcsWithWildcardQuestionMark() { + // Create a VPC with a specific tag value + String vpcId1 = given() + .formParam("Action", "CreateVpc") + .formParam("CidrBlock", "10.2.0.0/16") + .header("Authorization", AUTH_HEADER) + .when() + .post("/") + .then() + .statusCode(200) + .extract().path("CreateVpcResponse.vpc.vpcId"); + + // Tag it with "test1" + given() + .formParam("Action", "CreateTags") + .formParam("ResourceId.1", vpcId1) + .formParam("Tag.1.Key", "Name") + .formParam("Tag.1.Value", "test1") + .header("Authorization", AUTH_HEADER) + .when() + .post("/") + .then() + .statusCode(200); + + // Test wildcard with question mark: test? should match test1 + given() + .formParam("Action", "DescribeVpcs") + .formParam("Filter.1.Name", "tag:Name") + .formParam("Filter.1.Value.1", "test?") + .header("Authorization", AUTH_HEADER) + .when() + .post("/") + .then() + .statusCode(200) + .body("DescribeVpcsResponse.vpcSet.item.vpcId", equalTo(vpcId1)); + + // Cleanup + given() + .formParam("Action", "DeleteVpc") + .formParam("VpcId", vpcId1) + .header("Authorization", AUTH_HEADER) + .when() + .post("/"); + } } \ No newline at end of file