backend services patching
This commit is contained in:
+86
@@ -4,8 +4,13 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||
import io.github.hectorvent.floci.services.apigateway.model.ApiGatewayResource;
|
||||
import io.github.hectorvent.floci.services.apigateway.model.Integration;
|
||||
import io.github.hectorvent.floci.services.apigateway.model.MethodConfig;
|
||||
import io.github.hectorvent.floci.services.apigatewayv2.ApiGatewayV2Service;
|
||||
import io.github.hectorvent.floci.services.apigatewayv2.model.Api;
|
||||
import io.github.hectorvent.floci.services.lambda.LambdaService;
|
||||
import io.github.hectorvent.floci.services.lambda.model.InvocationType;
|
||||
import io.github.hectorvent.floci.services.lambda.model.InvokeResult;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MultivaluedHashMap;
|
||||
@@ -22,6 +27,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -255,6 +262,85 @@ class ApiGatewayExecuteControllerTest {
|
||||
assertEquals("application/problem+json", response.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildProxyResponseRejectsBareLambdaPayload() {
|
||||
ApiGatewayExecuteController controller = controller(new ObjectMapper());
|
||||
|
||||
Response response = controller.buildProxyResponse(proxyPayload("\"Hello from Lambda\""), false);
|
||||
|
||||
assertEquals(502, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvokeProxyLogsSentEventAndDoesNotRequireStage() throws Exception {
|
||||
ApiGatewayService apiGatewayService = mock(ApiGatewayService.class);
|
||||
LambdaService lambdaService = mock(LambdaService.class);
|
||||
RegionResolver regionResolver = mock(RegionResolver.class);
|
||||
when(regionResolver.getAccountId()).thenReturn("000000000000");
|
||||
ApiGatewayExecuteController controller = new ApiGatewayExecuteController(apiGatewayService, null, lambdaService,
|
||||
regionResolver, new ObjectMapper(), null, null, null, null, null,
|
||||
new ApiGatewayExecuteRouteContext(), null, null, null);
|
||||
ApiGatewayResource resource = new ApiGatewayResource();
|
||||
resource.setId("resource-id");
|
||||
resource.setPath("/orders");
|
||||
MethodConfig method = new MethodConfig();
|
||||
Integration integration = new Integration();
|
||||
integration.setType("AWS_PROXY");
|
||||
integration.setUri("arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/"
|
||||
+ "arn:aws:lambda:us-east-1:000000000000:function:orders/invocations");
|
||||
method.setMethodIntegration(integration);
|
||||
when(apiGatewayService.getResource("us-east-1", "api-id", "resource-id")).thenReturn(resource);
|
||||
when(apiGatewayService.getMethod("us-east-1", "api-id", "resource-id", "POST")).thenReturn(method);
|
||||
when(lambdaService.invoke(eq("us-east-1"), eq("orders"), any(),
|
||||
eq(InvocationType.RequestResponse)))
|
||||
.thenReturn(proxyPayload("{\"statusCode\":201,\"body\":\"created\",\"headers\":{\"X-One\":\"one\"},"
|
||||
+ "\"multiValueHeaders\":{\"Set-Cookie\":[\"a=1\",\"b=2\"]}}"));
|
||||
|
||||
ApiGatewayExecuteController.TestInvokeResult result = controller.testInvoke("us-east-1", "api-id",
|
||||
"resource-id", "POST", new ApiGatewayExecuteController.TestInvokeRequest("/orders?q=one&q=two",
|
||||
"request-body", Map.of("X-Input", "input"), Map.of("X-Multi", List.of("one", "two")),
|
||||
Map.of("environment", "test")));
|
||||
|
||||
assertEquals(201, result.response().getStatus());
|
||||
assertTrue(result.log().contains("Endpoint request URI: " + integration.getUri()));
|
||||
assertTrue(result.log().contains("\"body\":\"request-body\""));
|
||||
assertTrue(result.log().contains("\"environment\":\"test\""));
|
||||
assertEquals(List.of("a=1", "b=2"), result.response().getStringHeaders().get("Set-Cookie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvokeProxyRejectsMalformedLambdaResponse() {
|
||||
ApiGatewayService apiGatewayService = mock(ApiGatewayService.class);
|
||||
LambdaService lambdaService = mock(LambdaService.class);
|
||||
RegionResolver regionResolver = mock(RegionResolver.class);
|
||||
when(regionResolver.getAccountId()).thenReturn("000000000000");
|
||||
ApiGatewayExecuteController controller = new ApiGatewayExecuteController(apiGatewayService, null, lambdaService,
|
||||
regionResolver, new ObjectMapper(), null, null, null, null, null,
|
||||
new ApiGatewayExecuteRouteContext(), null, null, null);
|
||||
ApiGatewayResource resource = new ApiGatewayResource();
|
||||
resource.setId("resource-id");
|
||||
resource.setPath("/");
|
||||
MethodConfig method = new MethodConfig();
|
||||
Integration integration = new Integration();
|
||||
integration.setType("AWS_PROXY");
|
||||
integration.setUri("arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/"
|
||||
+ "arn:aws:lambda:us-east-1:000000000000:function:orders/invocations");
|
||||
method.setMethodIntegration(integration);
|
||||
when(apiGatewayService.getResource("us-east-1", "api-id", "resource-id")).thenReturn(resource);
|
||||
when(apiGatewayService.getMethod("us-east-1", "api-id", "resource-id", "GET")).thenReturn(method);
|
||||
when(lambdaService.invoke(eq("us-east-1"), eq("orders"), any(),
|
||||
eq(InvocationType.RequestResponse)))
|
||||
.thenReturn(proxyPayload("[]"));
|
||||
|
||||
ApiGatewayExecuteController.TestInvokeResult result = controller.testInvoke("us-east-1", "api-id",
|
||||
"resource-id", "GET", new ApiGatewayExecuteController.TestInvokeRequest(null, null, Map.of(), Map.of(), Map.of()));
|
||||
|
||||
assertEquals(500, result.response().getStatus());
|
||||
assertEquals("Execution failed due to configuration error: Malformed Lambda proxy response",
|
||||
result.response().getEntity());
|
||||
assertTrue(result.log().contains("Execution failed due to configuration error: Malformed Lambda proxy response"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildProxyResponseMatchesExactCaseContentType() {
|
||||
// The pre-existing exact-case path must keep working — this is a regression guard,
|
||||
|
||||
+11
@@ -117,6 +117,17 @@ class ApiGatewayIntegrationTest {
|
||||
.body("authorizationType", equalTo("NONE"));
|
||||
}
|
||||
|
||||
@Test @Order(9)
|
||||
void getResourcesEmbedsMethods() {
|
||||
given()
|
||||
.queryParam("embed", "methods")
|
||||
.when().get("/restapis/" + apiId + "/resources")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("item.find { it.id == '" + resourceId + "' }.resourceMethods.GET.httpMethod", equalTo("GET"))
|
||||
.body("item.find { it.id == '" + rootId + "' }.resourceMethods", nullValue());
|
||||
}
|
||||
|
||||
@Test @Order(9)
|
||||
void putMethodResponse() {
|
||||
given()
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package io.github.hectorvent.floci.services.apigateway;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.http.ContentType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
|
||||
@QuarkusTest
|
||||
class ApiGatewayTestInvokeMethodIntegrationTest {
|
||||
|
||||
@Test
|
||||
void invokesMockMethodWithoutDeploymentOrStage() {
|
||||
String apiId = given()
|
||||
.contentType(ContentType.JSON)
|
||||
.body("{\"name\":\"test-invoke-method\"}")
|
||||
.post("/restapis")
|
||||
.then().statusCode(201)
|
||||
.extract().path("id");
|
||||
String resourceId = given()
|
||||
.get("/restapis/" + apiId + "/resources")
|
||||
.then().statusCode(200)
|
||||
.extract().path("item[0].id");
|
||||
|
||||
given()
|
||||
.contentType(ContentType.JSON)
|
||||
.body("{\"authorizationType\":\"NONE\"}")
|
||||
.put("/restapis/" + apiId + "/resources/" + resourceId + "/methods/POST")
|
||||
.then().statusCode(201);
|
||||
given()
|
||||
.contentType(ContentType.JSON)
|
||||
.body("{\"type\":\"MOCK\",\"httpMethod\":\"POST\"}")
|
||||
.put("/restapis/" + apiId + "/resources/" + resourceId + "/methods/POST/integration")
|
||||
.then().statusCode(201);
|
||||
|
||||
given()
|
||||
.contentType(ContentType.JSON)
|
||||
.body("""
|
||||
{
|
||||
"pathWithQueryString": "/?q=one&q=two",
|
||||
"body": "request-body",
|
||||
"headers": {"X-Input": "input"},
|
||||
"multiValueHeaders": {"X-Multi": ["one", "two"]},
|
||||
"stageVariables": {"environment": "test"}
|
||||
}
|
||||
""")
|
||||
.post("/restapis/" + apiId + "/resources/" + resourceId + "/methods/POST")
|
||||
.then().statusCode(200)
|
||||
.body("status", equalTo(200))
|
||||
.body("log", containsString("Endpoint request URI: null"));
|
||||
}
|
||||
}
|
||||
+29
-4
@@ -5,19 +5,44 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
|
||||
@QuarkusTest
|
||||
class ApiGatewayUpdateIntegrationResponseIntegrationTest {
|
||||
|
||||
@Test
|
||||
void testUpdateIntegrationResponse() {
|
||||
void updateIntegrationResponsePatchesAllCorsFields() {
|
||||
String apiId = given().contentType("application/json").body("{\"name\":\"ir-update\"}").when().post("/restapis").then().statusCode(201).extract().path("id");
|
||||
String rootId = given().when().get("/restapis/" + apiId + "/resources").then().statusCode(200).extract().path("item[0].id");
|
||||
String resourceId = given().contentType("application/json").body("{\"pathPart\":\"items\"}").when().post("/restapis/" + apiId + "/resources/" + rootId).then().statusCode(201).extract().path("id");
|
||||
given().contentType("application/json").body("{\"authorizationType\":\"NONE\"}").when().put("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET").then().statusCode(201);
|
||||
given().contentType("application/json").body("{\"type\":\"MOCK\"}").when().put("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration").then().statusCode(201);
|
||||
given().contentType("application/json").body("{\"selectionPattern\":\"before\",\"responseTemplates\":{\"application/json\":\"before\"}}").when().put("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200").then().statusCode(201);
|
||||
given().contentType("application/json").body("{\"patchOperations\":[{\"op\":\"replace\",\"path\":\"/selectionPattern\",\"value\":\"after\"}]}").when().patch("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200").then().statusCode(200).body("statusCode", equalTo("200")).body("selectionPattern", equalTo("after"));
|
||||
given().when().get("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200").then().statusCode(200).body("selectionPattern", equalTo("after"));
|
||||
given().contentType("application/json").body("""
|
||||
{"selectionPattern":"before","contentHandling":"CONVERT_TO_BINARY",
|
||||
"responseParameters":{"method.response.header.Access-Control-Allow-Origin":"'https://before.example'"},
|
||||
"responseTemplates":{"application/json":"before"}}
|
||||
""").when().put("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200").then().statusCode(201);
|
||||
given().contentType("application/json").body("""
|
||||
{"patchOperations":[
|
||||
{"op":"replace","path":"/selectionPattern","value":"after"},
|
||||
{"op":"replace","path":"/contentHandling","value":"CONVERT_TO_TEXT"},
|
||||
{"op":"replace","path":"/responseParameters/method.response.header.Access-Control-Allow-Origin","value":"'*'"},
|
||||
{"op":"add","path":"/responseParameters/method.response.header.Access-Control-Allow-Headers","value":"'Content-Type'"},
|
||||
{"op":"remove","path":"/responseParameters/method.response.header.Access-Control-Allow-Headers"},
|
||||
{"op":"add","path":"/responseTemplates/application~1xml","value":"after"},
|
||||
{"op":"remove","path":"/responseTemplates/application~1json"}]}
|
||||
""").when().patch("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200")
|
||||
.then().statusCode(200).body("statusCode", equalTo("200"))
|
||||
.body("selectionPattern", equalTo("after"))
|
||||
.body("contentHandling", equalTo("CONVERT_TO_TEXT"))
|
||||
.body("responseParameters.'method.response.header.Access-Control-Allow-Origin'", equalTo("'*'"))
|
||||
.body("responseParameters.'method.response.header.Access-Control-Allow-Headers'", nullValue())
|
||||
.body("responseTemplates.'application/json'", nullValue())
|
||||
.body("responseTemplates.'application/xml'", equalTo("after"));
|
||||
given().when().get("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200")
|
||||
.then().statusCode(200).body("selectionPattern", equalTo("after"))
|
||||
.body("contentHandling", equalTo("CONVERT_TO_TEXT"))
|
||||
.body("responseParameters.'method.response.header.Access-Control-Allow-Origin'", equalTo("'*'"))
|
||||
.body("responseTemplates.'application/xml'", equalTo("after"));
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package io.github.hectorvent.floci.services.apigateway;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
|
||||
@QuarkusTest
|
||||
class ApiGatewayUpdateMethodResponseIntegrationTest {
|
||||
|
||||
@Test
|
||||
void updateMethodResponsePatchesCorsConfigurationAndRoundTripsIt() {
|
||||
String apiId = given().contentType("application/json").body("{\"name\":\"method-response-update\"}")
|
||||
.when().post("/restapis").then().statusCode(201).extract().path("id");
|
||||
String rootId = given().when().get("/restapis/" + apiId + "/resources")
|
||||
.then().statusCode(200).extract().path("item[0].id");
|
||||
String resourceId = given().contentType("application/json").body("{\"pathPart\":\"items\"}")
|
||||
.when().post("/restapis/" + apiId + "/resources/" + rootId).then().statusCode(201).extract().path("id");
|
||||
String methodPath = "/restapis/" + apiId + "/resources/" + resourceId + "/methods/OPTIONS";
|
||||
|
||||
given().contentType("application/json").body("{\"authorizationType\":\"NONE\"}")
|
||||
.when().put(methodPath).then().statusCode(201);
|
||||
given().contentType("application/json").body("""
|
||||
{"responseParameters":{"method.response.header.Access-Control-Allow-Origin":false},
|
||||
"responseModels":{"application/json":"Empty"}}
|
||||
""").when().put(methodPath + "/responses/200").then().statusCode(201)
|
||||
.body("responseParameters.'method.response.header.Access-Control-Allow-Origin'", equalTo(false))
|
||||
.body("responseModels.'application/json'", equalTo("Empty"));
|
||||
|
||||
given().contentType("application/json").body("""
|
||||
{"patchOperations":[
|
||||
{"op":"replace","path":"/responseParameters/method.response.header.Access-Control-Allow-Origin","value":"true"},
|
||||
{"op":"add","path":"/responseParameters/method.response.header.Access-Control-Allow-Headers","value":"true"},
|
||||
{"op":"remove","path":"/responseParameters/method.response.header.Access-Control-Allow-Headers"},
|
||||
{"op":"replace","path":"/responseModels/application~1json","value":"CorsModel"}]}
|
||||
""").when().patch(methodPath + "/responses/200").then().statusCode(200)
|
||||
.body("responseParameters.'method.response.header.Access-Control-Allow-Origin'", equalTo(true))
|
||||
.body("responseParameters.'method.response.header.Access-Control-Allow-Headers'", nullValue())
|
||||
.body("responseModels.'application/json'", equalTo("CorsModel"));
|
||||
|
||||
given().when().get(methodPath + "/responses/200").then().statusCode(200)
|
||||
.body("responseParameters.'method.response.header.Access-Control-Allow-Origin'", equalTo(true))
|
||||
.body("responseModels.'application/json'", equalTo("CorsModel"));
|
||||
given().when().get(methodPath).then().statusCode(200)
|
||||
.body("methodResponses.'200'.responseParameters.'method.response.header.Access-Control-Allow-Origin'", equalTo(true))
|
||||
.body("methodResponses.'200'.responseModels.'application/json'", equalTo("CorsModel"));
|
||||
}
|
||||
}
|
||||
+15
@@ -92,6 +92,21 @@ class DynamoDbCfnProvisionerTest {
|
||||
provisioner.resourceTypes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tablePassesProvisionedThroughputToDynamoDb() throws Exception {
|
||||
when(dynamoDb.createTable(anyString(), anyList(), anyList(), any(), any(), anyList(), anyList(), anyString()))
|
||||
.thenReturn(table(false));
|
||||
StackResource r = resource("AWS::DynamoDB::Table", "Orders");
|
||||
|
||||
provisioner.provision(r, props("""
|
||||
{"TableName":"orders","AttributeDefinitions":[{"AttributeName":"id","AttributeType":"S"}],
|
||||
"KeySchema":[{"AttributeName":"id","KeyType":"HASH"}],
|
||||
"ProvisionedThroughput":{"ReadCapacityUnits":20,"WriteCapacityUnits":5}}
|
||||
"""), ctx());
|
||||
|
||||
verify(dynamoDb).createTable(eq("orders"), anyList(), anyList(), eq(20L), eq(5L), anyList(), anyList(), eq("us-east-1"));
|
||||
}
|
||||
|
||||
/** The registry schema declares only Arn and StreamArn read-only on a plain table; TableId belongs to the global table. */
|
||||
@Test
|
||||
void tablePublishesOnlyArnWithoutAStream() throws Exception {
|
||||
|
||||
@@ -863,7 +863,7 @@ class Ec2ContainerManagerTest {
|
||||
String script = Ec2ContainerManager.metadataProxyInstallCommand()[2];
|
||||
|
||||
assertTrue(script.contains("command -v yum"), script);
|
||||
assertTrue(script.contains("yum install -y iproute socat curl ca-certificates"), script);
|
||||
assertTrue(script.contains("yum install -y iproute iptables socat curl ca-certificates"), script);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -878,7 +878,7 @@ class Ec2ContainerManagerTest {
|
||||
// instance was left with no link-local IMDS endpoint.
|
||||
String script = Ec2ContainerManager.metadataProxyInstallCommand()[2];
|
||||
|
||||
assertTrue(script.contains("dnf install -y --allowerasing iproute socat curl ca-certificates"),
|
||||
assertTrue(script.contains("dnf install -y --allowerasing iproute iptables socat curl ca-certificates"),
|
||||
script);
|
||||
}
|
||||
|
||||
@@ -888,7 +888,8 @@ class Ec2ContainerManagerTest {
|
||||
|
||||
assertEquals("sh", command[0]);
|
||||
assertTrue(command[2].contains("169.254.169.254/32"));
|
||||
assertTrue(command[2].contains("TCP-LISTEN:80,bind=169.254.169.254"));
|
||||
assertTrue(command[2].contains("TCP-LISTEN:1338,bind=127.0.0.1"));
|
||||
assertTrue(command[2].contains("--dport 80 -j DNAT --to-destination 127.0.0.1:1338"));
|
||||
assertTrue(command[2].contains("TCP:floci:9169"));
|
||||
assertTrue(command[2].contains("http://169.254.169.254/latest/meta-data/instance-id"));
|
||||
}
|
||||
@@ -1798,6 +1799,23 @@ class Ec2ContainerManagerTest {
|
||||
awaitUntil(() -> "terminated".equals(instance.getState().getName()), Duration.ofSeconds(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminateInvokesPersistenceCallbackAfterRecordingFinalState() throws Exception {
|
||||
LaunchHarness harness = launchHarness();
|
||||
stubDockerPing(harness.dockerClient(), false);
|
||||
Instance instance = instance("i-persist-termination");
|
||||
CountDownLatch persisted = new CountDownLatch(1);
|
||||
|
||||
harness.manager().launch(instance, "ubuntu:24.04", null, "us-west-2");
|
||||
harness.manager().terminate(instance, () -> {
|
||||
assertEquals("terminated", instance.getState().getName());
|
||||
assertTrue(instance.getTerminatedAt() > 0);
|
||||
persisted.countDown();
|
||||
});
|
||||
|
||||
assertTrue(persisted.await(2, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
/** Stubs the daemon reachability probe every launch makes before touching Docker. */
|
||||
private static void stubDockerPing(DockerClient dockerClient, boolean reachable) {
|
||||
PingCmd ping = mock(PingCmd.class);
|
||||
|
||||
@@ -2,7 +2,6 @@ package io.github.hectorvent.floci.services.ec2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
@@ -12,7 +11,6 @@ import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
@QuarkusTest
|
||||
@@ -40,7 +38,8 @@ class Ec2ImageCatalogTest {
|
||||
"ami-ubuntu2404-cloud-arm64",
|
||||
"ami-debian12",
|
||||
"ami-alpine",
|
||||
"ami-0abcdef1234567893"), imageIds);
|
||||
"ami-fedora42",
|
||||
"ami-opensuse-leap156"), imageIds);
|
||||
|
||||
assertTrue(imageCatalog.findByIdOrAlias("ami-amazonlinux2").isPresent());
|
||||
assertTrue(imageCatalog.findByIdOrAlias("ami-amazonlinux2023").isPresent());
|
||||
@@ -64,11 +63,11 @@ class Ec2ImageCatalogTest {
|
||||
|
||||
@Test
|
||||
void resolverExposesCloudImageGuestRuntimeMetadata() {
|
||||
ResolvedAmiImage image = amiImageResolver.resolveImage("ami-ubuntu2404-cloud");
|
||||
ResolvedAmiImage image = amiImageResolver.resolveImage("ami-amazonlinux2023");
|
||||
|
||||
assertEquals("floci/ami-ubuntu:24.04-arm64", image.dockerImage());
|
||||
assertEquals("floci/ami-amazonlinux:2023", image.dockerImage());
|
||||
assertEquals(ResolvedAmiImage.SYSTEMD_RUNTIME, image.guestRuntime());
|
||||
assertTrue(image.cloudInit());
|
||||
assertFalse(image.cloudInit());
|
||||
assertTrue(image.systemd());
|
||||
}
|
||||
|
||||
@@ -79,11 +78,9 @@ class Ec2ImageCatalogTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolverRejectsWindowsImagesForContainerExecution() {
|
||||
AwsException error = assertThrows(AwsException.class,
|
||||
() -> amiImageResolver.resolveImage("ami-0abcdef1234567893"));
|
||||
|
||||
assertEquals("UnsupportedOperation", error.getErrorCode());
|
||||
void resolverUsesFedoraAndOpenSuseImages() {
|
||||
assertEquals("registry.fedoraproject.org/fedora:42", amiImageResolver.resolve("ami-fedora42"));
|
||||
assertEquals("registry.opensuse.org/opensuse/leap:15.6", amiImageResolver.resolve("ami-opensuse-leap156"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -19,11 +19,11 @@ class Ec2MetadataProxyTest {
|
||||
assertEquals("-c", command[1]);
|
||||
|
||||
String script = command[2];
|
||||
assertTrue(script.contains("command -v ip >/dev/null 2>&1 && command -v socat >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then exit 0; fi"));
|
||||
assertTrue(script.contains("apt-get install -y --no-install-recommends iproute2 socat curl ca-certificates"));
|
||||
assertTrue(script.contains("dnf install -y --allowerasing iproute socat curl ca-certificates"));
|
||||
assertTrue(script.contains("yum install -y iproute socat curl ca-certificates"));
|
||||
assertTrue(script.contains("apk add --no-cache iproute2 socat curl ca-certificates"));
|
||||
assertTrue(script.contains("command -v ip >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1 && command -v socat >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then exit 0; fi"));
|
||||
assertTrue(script.contains("apt-get install -y --no-install-recommends iproute2 iptables socat curl ca-certificates"));
|
||||
assertTrue(script.contains("dnf install -y --allowerasing iproute iptables socat curl ca-certificates"));
|
||||
assertTrue(script.contains("yum install -y iproute iptables socat curl ca-certificates"));
|
||||
assertTrue(script.contains("apk add --no-cache iproute2 iptables socat curl ca-certificates"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -39,7 +39,8 @@ class Ec2MetadataProxyTest {
|
||||
// Idempotent when pid file exists and process is alive
|
||||
assertTrue(script.contains("if [ -f /tmp/floci-imds-proxy.pid ] && kill -0 \"$(cat /tmp/floci-imds-proxy.pid)\" 2>/dev/null; then\n exit 0\nfi"));
|
||||
// Targets configured Floci host and IMDS port
|
||||
assertTrue(script.contains("nohup socat TCP-LISTEN:80,bind=169.254.169.254,fork,reuseaddr TCP:10.0.0.1:9169 >/tmp/floci-imds-proxy.log 2>&1 &"));
|
||||
assertTrue(script.contains("iptables -t nat -A OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j DNAT --to-destination 127.0.0.1:1338"));
|
||||
assertTrue(script.contains("nohup socat TCP-LISTEN:1338,bind=127.0.0.1,fork,reuseaddr TCP:10.0.0.1:9169 >/tmp/floci-imds-proxy.log 2>&1 &"));
|
||||
// Verifies via link-local curl
|
||||
assertTrue(script.contains("curl -fsS --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null && exit 0"));
|
||||
}
|
||||
|
||||
@@ -390,6 +390,31 @@ class Ec2ServiceTest {
|
||||
verifyNoInteractions(containerManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void prunesTerminatedInstancesAfterOneHourAndRetainsRecentOnes() {
|
||||
Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),
|
||||
mock(Ec2PortForwardManager.class), mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class),
|
||||
new Ec2InstanceTypeCatalog(), new InMemoryStorageFactory());
|
||||
Reservation expiredReservation = service.runInstances("us-east-1", "ami-1234567890abcdef0", "t3.micro",
|
||||
1, 1, null, List.of(), null, null, List.of(), null, null);
|
||||
Reservation recentReservation = service.runInstances("us-east-1", "ami-1234567890abcdef0", "t3.micro",
|
||||
1, 1, null, List.of(), null, null, List.of(), null, null);
|
||||
String expiredId = expiredReservation.getInstances().getFirst().getInstanceId();
|
||||
String recentId = recentReservation.getInstances().getFirst().getInstanceId();
|
||||
|
||||
service.terminateInstances("us-east-1", List.of(expiredId, recentId));
|
||||
expiredReservation.getInstances().getFirst().setTerminatedAt(System.currentTimeMillis() - 3_600_001L);
|
||||
|
||||
service.pruneTerminatedInstances();
|
||||
|
||||
List<String> remainingIds = service.describeInstances("us-east-1", List.of(), Map.of()).stream()
|
||||
.flatMap(reservation -> reservation.getInstances().stream())
|
||||
.map(Instance::getInstanceId)
|
||||
.toList();
|
||||
assertFalse(remainingIds.contains(expiredId));
|
||||
assertTrue(remainingIds.contains(recentId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void describeNetworkInterfacesGroupIdFilterSkipsNullGroupEntry() {
|
||||
Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package io.github.hectorvent.floci.services.ecs;
|
||||
|
||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.response.ValidatableResponse;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
|
||||
@QuarkusTest
|
||||
class EcsEc2BridgeIntegrationTest {
|
||||
|
||||
private static final String TARGET = "AmazonEC2ContainerServiceV20141113.";
|
||||
private static final String CONTENT_TYPE = "application/x-amz-json-1.1";
|
||||
|
||||
@BeforeAll
|
||||
static void configure() {
|
||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||
}
|
||||
|
||||
@Test
|
||||
void runTaskAndCreateServiceRejectFargateAndNonBridgeTasks() {
|
||||
call("RegisterTaskDefinition", "{\"family\":\"ec2-bridge\",\"networkMode\":\"bridge\","
|
||||
+ "\"containerDefinitions\":[{\"name\":\"app\",\"image\":\"nginx\"}]}", 200);
|
||||
call("RunTask", "{\"taskDefinition\":\"ec2-bridge\",\"launchType\":\"FARGATE\"}", 400)
|
||||
.body("message", containsString("EC2 launch type"));
|
||||
call("RunTask", "{\"taskDefinition\":\"ec2-bridge\",\"capacityProviderStrategy\":["
|
||||
+ "{\"capacityProvider\":\"FARGATE\",\"weight\":1}]}", 400)
|
||||
.body("message", containsString("FARGATE capacity providers"));
|
||||
call("CreateService", "{\"serviceName\":\"ec2-bridge\",\"taskDefinition\":\"ec2-bridge\","
|
||||
+ "\"desiredCount\":0,\"launchType\":\"FARGATE\"}", 400)
|
||||
.body("message", containsString("EC2 launch type"));
|
||||
|
||||
call("RegisterTaskDefinition", "{\"family\":\"not-bridge\",\"networkMode\":\"awsvpc\","
|
||||
+ "\"containerDefinitions\":[{\"name\":\"app\",\"image\":\"nginx\"}]}", 200);
|
||||
call("RunTask", "{\"taskDefinition\":\"not-bridge\",\"launchType\":\"EC2\"}", 400)
|
||||
.body("message", containsString("bridge network mode"));
|
||||
}
|
||||
|
||||
private static ValidatableResponse call(String action, String body, int status) {
|
||||
return given().contentType(CONTENT_TYPE).header("X-Amz-Target", TARGET + action).body(body)
|
||||
.when().post("/").then().statusCode(status);
|
||||
}
|
||||
}
|
||||
+34
-3
@@ -1,10 +1,15 @@
|
||||
package io.github.hectorvent.floci.services.ecs;
|
||||
|
||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||
import io.github.hectorvent.floci.services.elbv2.ElbV2Service;
|
||||
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
@@ -22,6 +27,9 @@ class EcsLoadBalancersIntegrationTest {
|
||||
private static final String TARGET = "AmazonEC2ContainerServiceV20141113.";
|
||||
private static final String CT = "application/x-amz-json-1.1";
|
||||
|
||||
@Inject
|
||||
ElbV2Service elbV2Service;
|
||||
|
||||
@BeforeAll
|
||||
static void configure() {
|
||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||
@@ -41,10 +49,17 @@ class EcsLoadBalancersIntegrationTest {
|
||||
+ "\"memory\":128,\"portMappings\":[{\"containerPort\":8080}]}]}");
|
||||
}
|
||||
|
||||
private String createTargetGroup(String name) {
|
||||
TargetGroup targetGroup = elbV2Service.createTargetGroup("us-east-1", name, "HTTP", "HTTP1",
|
||||
8080, "vpc-default", "ip", null, null, false, null, null, null, null, null, null,
|
||||
null, Map.of());
|
||||
return targetGroup.getTargetGroupArn();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createServiceParsesAndEchoesLoadBalancers() {
|
||||
seedClusterAndTaskDef("lb-cluster-1", "lb-td-1");
|
||||
String tgArn = "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/lbtg1/abc123";
|
||||
String tgArn = createTargetGroup("lbtg1");
|
||||
|
||||
given().contentType(CT).header("X-Amz-Target", TARGET + "CreateService")
|
||||
.body("{\"cluster\":\"lb-cluster-1\",\"serviceName\":\"lb-svc-1\","
|
||||
@@ -63,7 +78,7 @@ class EcsLoadBalancersIntegrationTest {
|
||||
@Test
|
||||
void describeServicesEchoesLoadBalancers() {
|
||||
seedClusterAndTaskDef("lb-cluster-2", "lb-td-2");
|
||||
String tgArn = "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/lbtg2/def456";
|
||||
String tgArn = createTargetGroup("lbtg2");
|
||||
call("CreateService", "{\"cluster\":\"lb-cluster-2\",\"serviceName\":\"lb-svc-2\","
|
||||
+ "\"taskDefinition\":\"lb-td-2\",\"desiredCount\":1,"
|
||||
+ "\"loadBalancers\":[{\"targetGroupArn\":\"" + tgArn + "\","
|
||||
@@ -116,11 +131,27 @@ class EcsLoadBalancersIntegrationTest {
|
||||
.body("__type", org.hamcrest.Matchers.containsString("InvalidParameterException"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createServiceRejectsTargetGroupBindingToAnUndeclaredContainerPort() {
|
||||
seedClusterAndTaskDef("lb-cluster-port", "lb-td-port");
|
||||
String tgArn = createTargetGroup("lbtg-port");
|
||||
|
||||
given().contentType(CT).header("X-Amz-Target", TARGET + "CreateService")
|
||||
.body("{\"cluster\":\"lb-cluster-port\",\"serviceName\":\"lb-svc-port\","
|
||||
+ "\"taskDefinition\":\"lb-td-port\",\"desiredCount\":1,"
|
||||
+ "\"loadBalancers\":[{\"targetGroupArn\":\"" + tgArn + "\","
|
||||
+ "\"containerName\":\"web\",\"containerPort\":9999}]}")
|
||||
.when().post("/")
|
||||
.then().statusCode(400)
|
||||
.body("__type", org.hamcrest.Matchers.containsString("InvalidParameterException"))
|
||||
.body("message", org.hamcrest.Matchers.containsString("does not expose port 9999"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceWithLoadBalancersIsStableInMockMode() {
|
||||
// Mock mode launches no real containers; the registrar must no-op cleanly.
|
||||
seedClusterAndTaskDef("lb-cluster-4", "lb-td-4");
|
||||
String tgArn = "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/lbtg4/ghi789";
|
||||
String tgArn = createTargetGroup("lbtg4");
|
||||
call("CreateService", "{\"cluster\":\"lb-cluster-4\",\"serviceName\":\"lb-svc-4\","
|
||||
+ "\"taskDefinition\":\"lb-td-4\",\"desiredCount\":1,"
|
||||
+ "\"loadBalancers\":[{\"targetGroupArn\":\"" + tgArn + "\","
|
||||
|
||||
@@ -10,6 +10,7 @@ import io.github.hectorvent.floci.core.storage.StorageFactory;
|
||||
import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager;
|
||||
import io.github.hectorvent.floci.services.ecs.container.EcsTaskHandle;
|
||||
import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition;
|
||||
import io.github.hectorvent.floci.services.ecs.model.EcsLoadBalancer;
|
||||
import io.github.hectorvent.floci.services.ecs.model.LaunchType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -62,7 +63,7 @@ class EcsServiceTeardownTest {
|
||||
cd.setImage("nginx:alpine");
|
||||
service.registerTaskDefinition("teardown-fam", List.of(cd), null, null, null,
|
||||
null, null, List.of(), REGION);
|
||||
service.runTask(null, "teardown-fam", 1, LaunchType.FARGATE, null, null,
|
||||
service.runTask(null, "teardown-fam", 1, LaunchType.EC2, null, null,
|
||||
List.of(), null, REGION);
|
||||
|
||||
service.stopManagedContainers();
|
||||
@@ -138,7 +139,7 @@ class EcsServiceTeardownTest {
|
||||
cd.setImage("nginx:alpine");
|
||||
service.registerTaskDefinition("retry-fam", List.of(cd), null, null, null,
|
||||
null, null, List.of(), REGION);
|
||||
String taskArn = service.runTask(null, "retry-fam", 1, LaunchType.FARGATE, null, null,
|
||||
String taskArn = service.runTask(null, "retry-fam", 1, LaunchType.EC2, null, null,
|
||||
List.of(), null, REGION).getFirst().getTaskArn();
|
||||
|
||||
service.stopTask(null, taskArn, null, REGION);
|
||||
@@ -151,6 +152,41 @@ class EcsServiceTeardownTest {
|
||||
verify(containerManager, times(2)).stopTaskAndCollectExitCodes(handle);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcilerDeregistersLoadBalancerTargetsWhenDockerWasKilledExternally() {
|
||||
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
|
||||
when(config.services().ecs().mock()).thenReturn(false);
|
||||
when(config.effectiveBaseUrl()).thenReturn("http://localhost:4566");
|
||||
EcsContainerManager containerManager = mock(EcsContainerManager.class);
|
||||
EcsLoadBalancerRegistrar registrar = mock(EcsLoadBalancerRegistrar.class);
|
||||
EcsTaskHandle handle = new EcsTaskHandle("task-arn", Map.of("app", "docker-id"), Map.of());
|
||||
when(containerManager.startTask(any(), any(), any(), anyString())).thenReturn(handle);
|
||||
when(containerManager.getExitCodeIfStopped("docker-id")).thenReturn(137);
|
||||
|
||||
EcsService service = new EcsService(
|
||||
new RegionResolver(REGION, "000000000000"), containerManager, config, registrar,
|
||||
new SingleUseStorageFactory(), null);
|
||||
service.initializeStorage();
|
||||
ContainerDefinition container = new ContainerDefinition();
|
||||
container.setName("app");
|
||||
container.setImage("nginx:alpine");
|
||||
service.createCluster("reconcile-lb", REGION);
|
||||
service.registerTaskDefinition("reconcile-lb", List.of(container), null, null, null,
|
||||
null, null, List.of(), REGION);
|
||||
EcsLoadBalancer loadBalancer = new EcsLoadBalancer();
|
||||
loadBalancer.setTargetGroupArn("arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/app/id");
|
||||
loadBalancer.setContainerName("app");
|
||||
loadBalancer.setContainerPort(8080);
|
||||
service.createService("reconcile-lb", "app", "reconcile-lb", 1, LaunchType.EC2,
|
||||
List.of(loadBalancer), null, REGION);
|
||||
|
||||
service.reconcileServices();
|
||||
service.reconcile();
|
||||
|
||||
verify(registrar).deregisterTask(any(), any(), anyString());
|
||||
verify(containerManager).cleanupStoppedTask(handle);
|
||||
}
|
||||
|
||||
private static final class SingleUseStorageFactory extends StorageFactory {
|
||||
private final Map<String, StorageBackend<String, ?>> stores = new HashMap<>();
|
||||
|
||||
|
||||
+38
-1
@@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.elbv2;
|
||||
|
||||
import io.github.hectorvent.floci.config.EmulatorConfig;
|
||||
import io.github.hectorvent.floci.services.ec2.Ec2Service;
|
||||
import io.github.hectorvent.floci.services.ec2.ContainerNetworkReachability;
|
||||
import io.github.hectorvent.floci.services.elbv2.model.TargetDescription;
|
||||
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
|
||||
import io.vertx.core.Vertx;
|
||||
@@ -75,6 +76,41 @@ class ElbV2HealthCheckerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void probeUsesConfiguredHealthCheckPortAndPath() throws Exception {
|
||||
AtomicInteger requests = new AtomicInteger();
|
||||
HttpServer server = vertx.createHttpServer()
|
||||
.requestHandler(request -> {
|
||||
if ("/ready".equals(request.path())) {
|
||||
requests.incrementAndGet();
|
||||
request.response().setStatusCode(204).end();
|
||||
} else {
|
||||
request.response().setStatusCode(404).end();
|
||||
}
|
||||
})
|
||||
.listen(0, "127.0.0.1")
|
||||
.toCompletionStage()
|
||||
.toCompletableFuture()
|
||||
.get(2, TimeUnit.SECONDS);
|
||||
try {
|
||||
ElbV2HealthChecker checker = healthChecker();
|
||||
TargetGroup targetGroup = targetGroup(1, "204", 1, 1);
|
||||
targetGroup.setHealthCheckPort(String.valueOf(server.actualPort()));
|
||||
targetGroup.setHealthCheckPath("/ready");
|
||||
TargetDescription target = target("127.0.0.1", 1);
|
||||
|
||||
checker.addTargets(targetGroup.getTargetGroupArn(), List.of(target), targetGroup);
|
||||
|
||||
await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> {
|
||||
assertEquals(1, requests.get());
|
||||
assertEquals("healthy", checker.getHealth(targetGroup.getTargetGroupArn(),
|
||||
target.getId(), server.actualPort()).state());
|
||||
});
|
||||
} finally {
|
||||
close(server);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void timedOutProbeTransitionsTargetToUnhealthyWithAwsReason() throws Exception {
|
||||
HttpServer server = startHangingServer();
|
||||
@@ -174,7 +210,8 @@ class ElbV2HealthCheckerTest {
|
||||
private ElbV2HealthChecker healthChecker() {
|
||||
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
|
||||
when(config.services().elbv2().mock()).thenReturn(false);
|
||||
return new ElbV2HealthChecker(vertx, config, mock(Ec2Service.class));
|
||||
return new ElbV2HealthChecker(vertx, config, mock(Ec2Service.class),
|
||||
mock(ContainerNetworkReachability.class));
|
||||
}
|
||||
|
||||
private HttpServer startServer(int statusCode) throws Exception {
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package io.github.hectorvent.floci.services.elbv2;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class ElbV2ListenerForwardManagerTest {
|
||||
|
||||
@Test
|
||||
void forwardContainerNameUsesListenerPort() {
|
||||
assertEquals("floci-elbv2-fwd-8080", ElbV2ListenerForwardManager.forwardContainerName(8080));
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,34 @@ class ElbV2ServiceTest {
|
||||
assertTrue(newTargetGroup.getLoadBalancerArns().contains(lbArn));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationListenerRejectsUnsupportedDataPlaneProtocolsAndTargetGroups() {
|
||||
String lbArn = service.createLoadBalancer(
|
||||
REGION, "http-only-lb", "internal", "application", "ipv4",
|
||||
ALB_SUBNETS, List.of(), Map.of()).getLoadBalancerArn();
|
||||
AwsException listenerError = assertThrows(AwsException.class, () -> service.createListener(
|
||||
REGION, lbArn, "HTTPS", 8443, null, List.of(), List.of(), List.of(), Map.of()));
|
||||
assertEquals("UnsupportedProtocol", listenerError.getErrorCode());
|
||||
|
||||
String tcpTargetGroupArn = service.createTargetGroup(
|
||||
REGION, "tcp-targets", "TCP", null, 8080, "vpc-a", "ip",
|
||||
"TCP", "traffic-port", true, null, null, null, null, null, null,
|
||||
"ipv4", Map.of()).getTargetGroupArn();
|
||||
AwsException targetGroupError = assertThrows(AwsException.class, () -> service.createListener(
|
||||
REGION, lbArn, "HTTP", 8080, null, List.of(),
|
||||
List.of(forwardAction(tcpTargetGroupArn)), List.of(), Map.of()));
|
||||
assertEquals("UnsupportedProtocol", targetGroupError.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultLoadBalancerDnsNameUsesHostResolvableLocalhostDomain() {
|
||||
String dnsName = service.createLoadBalancer(
|
||||
REGION, "host-resolvable-lb", "internal", "application", "ipv4",
|
||||
ALB_SUBNETS, List.of(), Map.of()).getDnsName();
|
||||
|
||||
assertTrue(dnsName.endsWith(".elb.localhost"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void modifyListenerPortRestartsListener() {
|
||||
String lbArn = service.createLoadBalancer(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.github.hectorvent.floci.services.elbv2;
|
||||
|
||||
import io.github.hectorvent.floci.services.ec2.Ec2Service;
|
||||
import io.github.hectorvent.floci.services.ec2.ContainerNetworkReachability;
|
||||
import io.github.hectorvent.floci.services.ec2.model.Instance;
|
||||
import io.github.hectorvent.floci.services.elbv2.model.TargetDescription;
|
||||
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
|
||||
@@ -44,6 +45,52 @@ class ElbV2TargetResolverTest {
|
||||
assertEquals("10.0.0.12", ElbV2TargetResolver.resolveHost(ec2Service, targetGroup, target));
|
||||
}
|
||||
|
||||
@Test
|
||||
void instanceTargetUsesBridgeEndpointWhenContainerNetworkIsRoutable() {
|
||||
Ec2Service ec2Service = mock(Ec2Service.class);
|
||||
ContainerNetworkReachability reachability = mock(ContainerNetworkReachability.class);
|
||||
Instance instance = instance("172.18.0.42");
|
||||
when(ec2Service.findInstanceById("i-1234567890abcdef0")).thenReturn(instance);
|
||||
when(reachability.isContainerIpRoutable("172.18.0.42")).thenReturn(true);
|
||||
|
||||
ElbV2TargetResolver.TargetEndpoint endpoint = ElbV2TargetResolver.resolveEndpoint(
|
||||
ec2Service, reachability, instanceTargetGroup(), instanceTarget(), 8080);
|
||||
|
||||
assertEquals("172.18.0.42", endpoint.host());
|
||||
assertEquals(8080, endpoint.port());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unroutableInstanceBridgeUsesPublishedPortFromDockerHost() {
|
||||
Ec2Service ec2Service = mock(Ec2Service.class);
|
||||
ContainerNetworkReachability reachability = mock(ContainerNetworkReachability.class);
|
||||
Instance instance = instance("172.18.0.42");
|
||||
instance.getPublishedPorts().put(8080, 30000);
|
||||
when(ec2Service.findInstanceById("i-1234567890abcdef0")).thenReturn(instance);
|
||||
when(reachability.isContainerIpRoutable("172.18.0.42")).thenReturn(false);
|
||||
|
||||
ElbV2TargetResolver.TargetEndpoint endpoint = ElbV2TargetResolver.resolveEndpoint(
|
||||
ec2Service, reachability, instanceTargetGroup(), instanceTarget(), 8080);
|
||||
|
||||
assertEquals(ElbV2TargetResolver.DOCKER_HOST, endpoint.host());
|
||||
assertEquals(30000, endpoint.port());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unroutableInstanceBridgeWithoutPublishedPortKeepsBridgeEndpoint() {
|
||||
Ec2Service ec2Service = mock(Ec2Service.class);
|
||||
ContainerNetworkReachability reachability = mock(ContainerNetworkReachability.class);
|
||||
Instance instance = instance("172.18.0.42");
|
||||
when(ec2Service.findInstanceById("i-1234567890abcdef0")).thenReturn(instance);
|
||||
when(reachability.isContainerIpRoutable("172.18.0.42")).thenReturn(false);
|
||||
|
||||
ElbV2TargetResolver.TargetEndpoint endpoint = ElbV2TargetResolver.resolveEndpoint(
|
||||
ec2Service, reachability, instanceTargetGroup(), instanceTarget(), 8080);
|
||||
|
||||
assertEquals("172.18.0.42", endpoint.host());
|
||||
assertEquals(8080, endpoint.port());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkedAddressReturnsAnIpLiteralForLoopbackAndPrivateTargets() throws IOException {
|
||||
assertEquals("127.0.0.1", ElbV2TargetResolver.resolveCheckedAddress("127.0.0.1"));
|
||||
@@ -57,4 +104,23 @@ class ElbV2TargetResolverTest {
|
||||
assertTrue(ex.getMessage().contains("link-local or metadata address"), ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Instance instance(String bridgeIp) {
|
||||
Instance instance = new Instance();
|
||||
instance.setInstanceId("i-1234567890abcdef0");
|
||||
instance.setContainerBridgeIp(bridgeIp);
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static TargetGroup instanceTargetGroup() {
|
||||
TargetGroup targetGroup = new TargetGroup();
|
||||
targetGroup.setTargetType("instance");
|
||||
return targetGroup;
|
||||
}
|
||||
|
||||
private static TargetDescription instanceTarget() {
|
||||
TargetDescription target = new TargetDescription();
|
||||
target.setId("i-1234567890abcdef0");
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,6 +686,19 @@ class S3ServiceTest {
|
||||
assertInstanceOf(S3Service.WebsiteResolution.ServeObject.class, resolution).key());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveWebsiteRedirectReturnsConfiguredTargetWithoutObjectLookup() {
|
||||
s3Service.createBucket("site", "us-east-1");
|
||||
s3Service.putBucketWebsite("site", new WebsiteConfiguration(null, null, "redirect.example", "https"));
|
||||
|
||||
S3Service.WebsiteResolution resolution = s3Service.resolveWebsiteRedirect("site");
|
||||
|
||||
S3Service.WebsiteResolution.RedirectAllRequestsTo redirect =
|
||||
assertInstanceOf(S3Service.WebsiteResolution.RedirectAllRequestsTo.class, resolution);
|
||||
assertEquals("redirect.example", redirect.hostName());
|
||||
assertEquals("https", redirect.protocol());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveWebsiteRequestRedirectsFolderWithoutTrailingSlash() {
|
||||
websiteBucket("index.html", null);
|
||||
@@ -1243,4 +1256,3 @@ class S3ServiceTest {
|
||||
assertDoesNotThrow(() -> s3Service.authorizeSignedDeleteObject("ASIAFAKEKEY00000001", "sessiontoken", "signed-del-bucket", "some/key"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+119
-1
@@ -411,6 +411,124 @@ class S3WebsiteIntegrationTest {
|
||||
|
||||
@Test
|
||||
@Order(24)
|
||||
void redirectAllConfigurationRoundTripAndValidation() {
|
||||
String redirect = """
|
||||
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<RedirectAllRequestsTo>
|
||||
<HostName>redirect.example</HostName>
|
||||
<Protocol>https</Protocol>
|
||||
</RedirectAllRequestsTo>
|
||||
</WebsiteConfiguration>
|
||||
""";
|
||||
given()
|
||||
.queryParam("website", "")
|
||||
.contentType("application/xml")
|
||||
.body(redirect)
|
||||
.when()
|
||||
.put("/" + BUCKET)
|
||||
.then()
|
||||
.statusCode(200);
|
||||
|
||||
given()
|
||||
.queryParam("website", "")
|
||||
.when()
|
||||
.get("/" + BUCKET)
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body(containsString("<RedirectAllRequestsTo><HostName>redirect.example</HostName><Protocol>https</Protocol></RedirectAllRequestsTo>"))
|
||||
.body(not(containsString("<IndexDocument>")));
|
||||
|
||||
String conflicting = """
|
||||
<WebsiteConfiguration>
|
||||
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
|
||||
<RedirectAllRequestsTo><HostName>redirect.example</HostName></RedirectAllRequestsTo>
|
||||
</WebsiteConfiguration>
|
||||
""";
|
||||
given()
|
||||
.queryParam("website", "")
|
||||
.contentType("application/xml")
|
||||
.body(conflicting)
|
||||
.when()
|
||||
.put("/" + BUCKET)
|
||||
.then()
|
||||
.statusCode(400)
|
||||
.body(containsString("InvalidRequest"));
|
||||
|
||||
String missingHost = "<WebsiteConfiguration><RedirectAllRequestsTo/></WebsiteConfiguration>";
|
||||
given()
|
||||
.queryParam("website", "")
|
||||
.contentType("application/xml")
|
||||
.body(missingHost)
|
||||
.when()
|
||||
.put("/" + BUCKET)
|
||||
.then()
|
||||
.statusCode(400)
|
||||
.body(containsString("MalformedXML"));
|
||||
|
||||
String invalidProtocol = """
|
||||
<WebsiteConfiguration>
|
||||
<RedirectAllRequestsTo><HostName>redirect.example</HostName><Protocol>FTP</Protocol></RedirectAllRequestsTo>
|
||||
</WebsiteConfiguration>
|
||||
""";
|
||||
given()
|
||||
.queryParam("website", "")
|
||||
.contentType("application/xml")
|
||||
.body(invalidProtocol)
|
||||
.when()
|
||||
.put("/" + BUCKET)
|
||||
.then()
|
||||
.statusCode(400)
|
||||
.body(containsString("MalformedXML"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(25)
|
||||
void redirectAllWebsiteRequestsPreserveRawPathAndQueryForGetAndHead() {
|
||||
String redirect = """
|
||||
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<RedirectAllRequestsTo><HostName>redirect.example</HostName></RedirectAllRequestsTo>
|
||||
</WebsiteConfiguration>
|
||||
""";
|
||||
given()
|
||||
.queryParam("website", "")
|
||||
.contentType("application/xml")
|
||||
.body(redirect)
|
||||
.when()
|
||||
.put("/" + BUCKET)
|
||||
.then()
|
||||
.statusCode(200);
|
||||
|
||||
given()
|
||||
.header("Host", websiteHost())
|
||||
.redirects().follow(false)
|
||||
.when()
|
||||
.get("/?code=abc&state=xyz")
|
||||
.then()
|
||||
.statusCode(301)
|
||||
.header("Location", equalTo("http://redirect.example/?code=abc&state=xyz"));
|
||||
|
||||
given()
|
||||
.header("Host", websiteHost())
|
||||
.redirects().follow(false)
|
||||
.when()
|
||||
.get("/nested/path?code=abc&state=xyz")
|
||||
.then()
|
||||
.statusCode(301)
|
||||
.header("Location", equalTo("http://redirect.example/nested/path?code=abc&state=xyz"));
|
||||
|
||||
given()
|
||||
.header("Host", websiteHost())
|
||||
.redirects().follow(false)
|
||||
.when()
|
||||
.head("/nested/path?code=abc&state=xyz")
|
||||
.then()
|
||||
.statusCode(301)
|
||||
.header("Location", equalTo("http://redirect.example/nested/path?code=abc&state=xyz"))
|
||||
.body(emptyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(26)
|
||||
void deleteWebsiteConfiguration() {
|
||||
given()
|
||||
.queryParam("website", "")
|
||||
@@ -428,7 +546,7 @@ class S3WebsiteIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(25)
|
||||
@Order(27)
|
||||
void cleanup() {
|
||||
given().delete("/" + BUCKET + "/index.html");
|
||||
given().delete("/" + BUCKET + "/error.html");
|
||||
|
||||
@@ -326,6 +326,7 @@ floci:
|
||||
elbv2:
|
||||
enabled: true
|
||||
mock: true
|
||||
listener-forwarder-image: alpine/socat:1.8.1.3
|
||||
codebuild:
|
||||
enabled: true
|
||||
# docker-network: floci-network
|
||||
|
||||
Reference in New Issue
Block a user