From 23d24288cdc2e7c2eb24a699577bb4302a8eba11 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Wed, 30 Sep 2026 17:46:10 -0400 Subject: [PATCH] backend services patching --- .../ami-images/amazonlinux-2023/Dockerfile | 11 + .../ec2/ami-images/amazonlinux-2023/README.md | 10 + docker/ec2/ami-images/amazonlinux-2023/motd | 3 + docs/services/elb.md | 12 +- docs/services/s3.md | 3 +- .../floci/config/EmulatorConfig.java | 4 + .../apigateway/ApiGatewayController.java | 131 ++++++++- .../ApiGatewayExecuteController.java | 278 +++++++++++++++++- .../apigateway/ApiGatewayService.java | 99 ++++++- .../apigateway/model/MethodResponse.java | 3 +- .../provisioners/DynamoDbCfnProvisioner.java | 9 +- .../services/ec2/Ec2ContainerManager.java | 10 + .../floci/services/ec2/Ec2MetadataProxy.java | 32 +- .../floci/services/ec2/Ec2Service.java | 50 +++- .../ecr/registry/EcrRegistryManager.java | 6 + .../ecs/EcsLoadBalancerRegistrar.java | 39 +++ .../floci/services/ecs/EcsService.java | 35 ++- .../floci/services/elbv2/ElbV2DataPlane.java | 56 +++- .../services/elbv2/ElbV2HealthChecker.java | 39 ++- .../elbv2/ElbV2ListenerForwardManager.java | 78 +++++ .../floci/services/elbv2/ElbV2Service.java | 88 +++++- .../services/elbv2/ElbV2TargetResolver.java | 30 ++ .../lambda/LambdaExecutorService.java | 43 ++- .../lambda/LambdaPlatformLogWriter.java | 71 +++++ .../floci/services/s3/S3Controller.java | 105 ++++++- .../floci/services/s3/S3Service.java | 25 ++ .../s3/model/WebsiteConfiguration.java | 20 ++ src/main/resources/application.yml | 1 + src/main/resources/ec2/image-catalog.yaml | 21 +- .../ApiGatewayExecuteControllerTest.java | 86 ++++++ .../apigateway/ApiGatewayIntegrationTest.java | 11 + ...atewayTestInvokeMethodIntegrationTest.java | 54 ++++ ...ateIntegrationResponseIntegrationTest.java | 33 ++- ...ayUpdateMethodResponseIntegrationTest.java | 50 ++++ .../DynamoDbCfnProvisionerTest.java | 15 + .../services/ec2/Ec2ContainerManagerTest.java | 24 +- .../services/ec2/Ec2ImageCatalogTest.java | 19 +- .../services/ec2/Ec2MetadataProxyTest.java | 13 +- .../floci/services/ec2/Ec2ServiceTest.java | 25 ++ .../ecs/EcsEc2BridgeIntegrationTest.java | 46 +++ .../ecs/EcsLoadBalancersIntegrationTest.java | 37 ++- .../services/ecs/EcsServiceTeardownTest.java | 40 ++- .../elbv2/ElbV2HealthCheckerTest.java | 39 ++- .../ElbV2ListenerForwardManagerTest.java | 13 + .../services/elbv2/ElbV2ServiceTest.java | 28 ++ .../elbv2/ElbV2TargetResolverTest.java | 66 +++++ .../floci/services/s3/S3ServiceTest.java | 14 +- .../services/s3/S3WebsiteIntegrationTest.java | 120 +++++++- src/test/resources/application.yml | 1 + 49 files changed, 1940 insertions(+), 106 deletions(-) create mode 100644 docker/ec2/ami-images/amazonlinux-2023/Dockerfile create mode 100644 docker/ec2/ami-images/amazonlinux-2023/README.md create mode 100644 docker/ec2/ami-images/amazonlinux-2023/motd create mode 100644 src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2ListenerForwardManager.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/lambda/LambdaPlatformLogWriter.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayTestInvokeMethodIntegrationTest.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayUpdateMethodResponseIntegrationTest.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/ecs/EcsEc2BridgeIntegrationTest.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2ListenerForwardManagerTest.java diff --git a/docker/ec2/ami-images/amazonlinux-2023/Dockerfile b/docker/ec2/ami-images/amazonlinux-2023/Dockerfile new file mode 100644 index 0000000..a48c4f6 --- /dev/null +++ b/docker/ec2/ami-images/amazonlinux-2023/Dockerfile @@ -0,0 +1,11 @@ +FROM public.ecr.aws/amazonlinux/amazonlinux:2023 + +RUN dnf install -y openssh-clients openssh-server systemd \ + && dnf clean all \ + && systemctl enable sshd + +COPY motd /etc/motd + +ENV container=docker +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] diff --git a/docker/ec2/ami-images/amazonlinux-2023/README.md b/docker/ec2/ami-images/amazonlinux-2023/README.md new file mode 100644 index 0000000..e8273bf --- /dev/null +++ b/docker/ec2/ami-images/amazonlinux-2023/README.md @@ -0,0 +1,10 @@ +# Amazon Linux 2023 EC2 guest + +Build the local guest image before launching `ami-0abcdef1234567891`: + +```text +docker build --platform linux/amd64 -t floci/ami-amazonlinux:2023 docker/ec2/ami-images/amazonlinux-2023 +``` + +The image runs systemd, provides SSH, and uses the Amazon Linux 2023 login banner. +Allow TCP 80 in an instance security group to have Floci publish an Apache service on an allocated host port. diff --git a/docker/ec2/ami-images/amazonlinux-2023/motd b/docker/ec2/ami-images/amazonlinux-2023/motd new file mode 100644 index 0000000..e189d2a --- /dev/null +++ b/docker/ec2/ami-images/amazonlinux-2023/motd @@ -0,0 +1,3 @@ + __| __|_ ) + _| ( / Amazon Linux 2023 + ___|\___|___| diff --git a/docs/services/elb.md b/docs/services/elb.md index 9b4b4c7..975da8b 100644 --- a/docs/services/elb.md +++ b/docs/services/elb.md @@ -170,7 +170,17 @@ aws elbv2 delete-target-group \ | Environment variable | Default | Description | |---|---|---| | `FLOCI_SERVICES_ELBV2_ENABLED` | `true` | Enable or disable the ELBv2 service | +| `FLOCI_SERVICES_ELBV2_LISTENER_FORWARDER_IMAGE` | `alpine/socat:1.8.1.3` | Pinned sidecar image that publishes ALB listener ports from a Docker-hosted Floci container. | ## Listener Ports -Listener sockets bind on the Floci host. Expose any listener ports you need in Docker Compose when Floci itself runs in a container, similar to RDS and ElastiCache proxy ports. +ALB DNS names default to `name-id.elb.localhost`, which resolves to the host loopback address. +When Floci runs in Docker, it creates a lifecycle-managed `socat` sidecar per active listener +port. The sidecar publishes that same port on the host and forwards it to Floci, so no static +Docker Compose port range is required. A host-port conflict prevents that listener from being +reachable until the conflict is removed. + +Instance targets use their container bridge IP as the primary route. When Floci cannot route to +that bridge IP, such as Docker Desktop or DinD isolation, it falls back to the instance's existing +published application port through `host.docker.internal`. This fallback is conditional; direct +bridge routing remains the normal path whenever it is reachable. diff --git a/docs/services/s3.md b/docs/services/s3.md index 13a012b..3444aa3 100644 --- a/docs/services/s3.md +++ b/docs/services/s3.md @@ -83,9 +83,10 @@ Static website requests use a bucket website hostname such as - index-document resolution for the site root and slash-terminated prefixes - `302` redirects that append a slash when an index document exists below a prefix - configured error documents and the default website error response +- `RedirectAllRequestsTo` with a required hostname and optional `HTTP` or `HTTPS` protocol. Website GET and HEAD return a `301` absolute redirect that preserves the request path and query string. HEAD returns the same status and object metadata as GET without a response body. -Redirect-all and advanced routing-rule configurations are not implemented. +Advanced routing-rule configurations are not implemented. ## Range Reads diff --git a/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java index 7d94f7d..4d96d8f 100644 --- a/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java +++ b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java @@ -2799,6 +2799,10 @@ public interface EmulatorConfig { @WithDefault("false") boolean mock(); + + /** Exact image used by the Docker-host listener forwarding sidecar. */ + @WithDefault("alpine/socat:1.8.1.3") + String listenerForwarderImage(); } interface EksServiceConfig { diff --git a/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayController.java b/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayController.java index 5b2a82f..4faf2ab 100644 --- a/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayController.java +++ b/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayController.java @@ -43,6 +43,7 @@ import io.github.hectorvent.floci.services.apigatewayv2.model.Route; import io.github.hectorvent.floci.services.apigatewayv2.model.RouteResponse; import io.github.hectorvent.floci.services.apigatewayv2.model.Stage; import io.github.hectorvent.floci.services.apigatewayv2.model.VpcLink; +import io.smallrye.common.annotation.Blocking; import jakarta.inject.Inject; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; @@ -72,16 +73,25 @@ public class ApiGatewayController { private final ApiGatewayV2OpenApiImporter v2OpenApiImporter; private final RegionResolver regionResolver; private final ObjectMapper objectMapper; + private final ApiGatewayExecuteController executeController; @Inject public ApiGatewayController(ApiGatewayService service, ApiGatewayV2Service v2Service, - ApiGatewayV2OpenApiImporter v2OpenApiImporter, - RegionResolver regionResolver, ObjectMapper objectMapper) { + ApiGatewayV2OpenApiImporter v2OpenApiImporter, + RegionResolver regionResolver, ObjectMapper objectMapper, + ApiGatewayExecuteController executeController) { this.service = service; this.v2Service = v2Service; this.v2OpenApiImporter = v2OpenApiImporter; this.regionResolver = regionResolver; this.objectMapper = objectMapper; + this.executeController = executeController; + } + + ApiGatewayController(ApiGatewayService service, ApiGatewayV2Service v2Service, + ApiGatewayV2OpenApiImporter v2OpenApiImporter, + RegionResolver regionResolver, ObjectMapper objectMapper) { + this(service, v2Service, v2OpenApiImporter, regionResolver, objectMapper, null); } private static final TypeReference>> PATCH_OPERATIONS = @@ -129,6 +139,81 @@ public class ApiGatewayController { // ──────────────────────────── Specific v1 Paths (ORDER MATTERS) ──────────────────────────── + @POST + @Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}") + @Blocking + public Response testInvokeMethod(@Context HttpHeaders headers, + @PathParam("apiId") String apiId, + @PathParam("resourceId") String resourceId, + @PathParam("httpMethod") String httpMethod, + String body) { + String region = regionResolver.resolveRegion(headers); + try { + @SuppressWarnings("unchecked") + Map request = objectMapper.readValue(body == null ? "{}" : body, Map.class); + ApiGatewayExecuteController.TestInvokeRequest testRequest = + new ApiGatewayExecuteController.TestInvokeRequest( + (String) request.get("pathWithQueryString"), + (String) request.get("body"), + stringMap(request.get("headers")), + stringListMap(request.get("multiValueHeaders")), + stringMap(request.get("stageVariables"))); + ApiGatewayExecuteController.TestInvokeResult result = + executeController.testInvoke(region, apiId, resourceId, httpMethod, testRequest); + return Response.ok(toTestInvokeNode(result).toString()).type(MediaType.APPLICATION_JSON).build(); + } catch (IOException | ClassCastException e) { + throw new AwsException("BadRequestException", e.getMessage(), 400); + } + } + + private Map stringMap(Object value) { + if (!(value instanceof Map map)) { + return Map.of(); + } + Map result = new HashMap<>(); + map.forEach((key, entryValue) -> { + if (key instanceof String stringKey && entryValue instanceof String stringValue) { + result.put(stringKey, stringValue); + } + }); + return result; + } + + private Map> stringListMap(Object value) { + if (!(value instanceof Map map)) { + return Map.of(); + } + Map> result = new HashMap<>(); + map.forEach((key, entryValue) -> { + if (key instanceof String stringKey && entryValue instanceof List list) { + result.put(stringKey, list.stream().filter(String.class::isInstance).map(String.class::cast).toList()); + } + }); + return result; + } + + private ObjectNode toTestInvokeNode(ApiGatewayExecuteController.TestInvokeResult result) { + Response executionResponse = result.response(); + ObjectNode node = objectMapper.createObjectNode(); + node.put("status", executionResponse.getStatus()); + Object entity = executionResponse.getEntity(); + if (entity != null) { + node.put("body", entity instanceof byte[] bytes ? new String(bytes, StandardCharsets.UTF_8) : entity.toString()); + } + ObjectNode responseHeaders = node.putObject("headers"); + ObjectNode multiValueResponseHeaders = node.putObject("multiValueHeaders"); + executionResponse.getStringHeaders().forEach((name, values) -> { + if (!values.isEmpty()) { + responseHeaders.put(name, values.get(values.size() - 1)); + ArrayNode multiValues = multiValueResponseHeaders.putArray(name); + values.forEach(multiValues::add); + } + }); + node.put("log", result.log()); + node.put("latency", result.latency()); + return node; + } + @GET @Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}") public Response getMethodResponse(@Context HttpHeaders headers, @@ -159,6 +244,21 @@ public class ApiGatewayController { } } + @PATCH + @Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}") + public Response updateMethodResponse(@Context HttpHeaders headers, + @PathParam("apiId") String apiId, + @PathParam("resourceId") String resourceId, + @PathParam("httpMethod") String httpMethod, + @PathParam("statusCode") String statusCode, + String body) { + String region = regionResolver.resolveRegion(headers); + List> patchOperations = parsePatchOperations(body); + MethodResponse response = service.updateMethodResponse(region, apiId, resourceId, httpMethod, + statusCode, patchOperations); + return Response.ok(toMethodResponseNode(response).toString()).type(MediaType.APPLICATION_JSON).build(); + } + @DELETE @Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}") public Response deleteMethodResponse(@Context HttpHeaders headers, @@ -387,12 +487,14 @@ public class ApiGatewayController { @GET @Path("/restapis/{apiId}/resources") - public Response getResources(@Context HttpHeaders headers, @PathParam("apiId") String apiId) { + public Response getResources(@Context HttpHeaders headers, @PathParam("apiId") String apiId, + @QueryParam("embed") List embed) { String region = regionResolver.resolveRegion(headers); List resources = service.getResources(region, apiId); + boolean embedMethods = embed != null && embed.contains("methods"); ObjectNode root = objectMapper.createObjectNode(); ArrayNode items = root.putArray("item"); - resources.forEach(r -> items.add(toResourceNode(r))); + resources.forEach(r -> items.add(toResourceNode(r, embedMethods))); return Response.ok(root.toString()).type(MediaType.APPLICATION_JSON).build(); } @@ -2162,11 +2264,19 @@ public class ApiGatewayController { } private ObjectNode toResourceNode(ApiGatewayResource r) { + return toResourceNode(r, false); + } + + private ObjectNode toResourceNode(ApiGatewayResource r, boolean embedMethods) { ObjectNode node = objectMapper.createObjectNode(); node.put("id", r.getId()); if (r.getParentId() != null) node.put("parentId", r.getParentId()); if (r.getPathPart() != null) node.put("pathPart", r.getPathPart()); node.put("path", r.getPath()); + if (embedMethods && !r.getResourceMethods().isEmpty()) { + ObjectNode methods = node.putObject("resourceMethods"); + r.getResourceMethods().forEach((method, config) -> methods.set(method, toMethodNode(config))); + } return node; } @@ -2186,6 +2296,11 @@ public class ApiGatewayController { m.getRequestModels().forEach(models::put); node.set("requestModels", models); } + if (!m.getMethodResponses().isEmpty()) { + ObjectNode responses = node.putObject("methodResponses"); + m.getMethodResponses().forEach((status, response) -> + responses.set(status, toMethodResponseNode(response))); + } if (m.getMethodIntegration() != null) { node.set("methodIntegration", toIntegrationNode(m.getMethodIntegration())); } @@ -2195,6 +2310,14 @@ public class ApiGatewayController { private ObjectNode toMethodResponseNode(MethodResponse r) { ObjectNode node = objectMapper.createObjectNode(); node.put("statusCode", r.statusCode()); + if (r.responseParameters() != null && !r.responseParameters().isEmpty()) { + ObjectNode parameters = node.putObject("responseParameters"); + r.responseParameters().forEach(parameters::put); + } + if (r.responseModels() != null && !r.responseModels().isEmpty()) { + ObjectNode models = node.putObject("responseModels"); + r.responseModels().forEach(models::put); + } return node; } diff --git a/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteController.java b/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteController.java index 7906e71..890b6fa 100644 --- a/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteController.java +++ b/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteController.java @@ -43,6 +43,7 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.*; import org.jboss.logging.Logger; +import java.net.URI; import java.net.URLDecoder; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -54,6 +55,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -144,6 +146,12 @@ public class ApiGatewayExecuteController { private record AuthorizerResult(Response errorResponse, String principalId, Map context) {} + record TestInvokeRequest(String pathWithQueryString, String body, Map headers, + Map> multiValueHeaders, + Map stageVariables) {} + + record TestInvokeResult(Response response, String log, long latency) {} + /** * What a gateway-generated error needs in order to render the REST API's gateway response for * it: where the request landed and the raw request, for {@code method.request.*}, @@ -471,6 +479,85 @@ public class ApiGatewayExecuteController { return response; } + /** + * Executes a REST API method without requiring a deployment or stage. This is the execution + * path behind TestInvokeMethod, not an alternate data-plane endpoint. + */ + TestInvokeResult testInvoke(String region, String apiId, String resourceId, String httpMethod, + TestInvokeRequest request) { + ApiGatewayResource resource = apiGatewayService.getResource(region, apiId, resourceId); + MethodConfig method = apiGatewayService.getMethod(region, apiId, resourceId, httpMethod); + Integration integration = method.getMethodIntegration(); + if (integration == null) { + Response response = Response.status(500).entity("No integration configured").build(); + return new TestInvokeResult(response, "Execution failed due to configuration error: No integration configured", 0); + } + + String pathWithQueryString = request.pathWithQueryString() == null || request.pathWithQueryString().isBlank() + ? resource.getPath() : request.pathWithQueryString(); + URI requestUri = URI.create("http://test-invoke" + (pathWithQueryString.startsWith("/") + ? pathWithQueryString : "/" + pathWithQueryString)); + String path = requestUri.getRawPath(); + HttpHeaders headers = new TestInvokeHeaders(request.headers(), request.multiValueHeaders()); + UriInfo uriInfo = new TestInvokeUriInfo(requestUri); + Stage stage = new Stage(); + stage.setStageName("test-invoke-stage"); + stage.setVariables(request.stageVariables()); + byte[] body = request.body() == null ? null : request.body().getBytes(StandardCharsets.UTF_8); + GatewayResponseScope scope = new GatewayResponseScope(region, apiId, stage.getStageName(), stage, + httpMethod, path, resource, headers, uriInfo, body); + String integrationType = integration.getType(); + if (integrationType == null || integrationType.isBlank()) { + Response response = Response.status(500).entity("No integration type configured").build(); + return new TestInvokeResult(response, + "Execution failed due to configuration error: No integration type configured", 0); + } + + long started = System.nanoTime(); + Response response; + String log; + if ("AWS_PROXY".equalsIgnoreCase(integrationType)) { + String functionName = functionNameFromUri(integration.getUri()); + if (functionName == null) { + response = Response.status(500).entity("Cannot resolve function from URI: " + integration.getUri()).build(); + log = "Execution failed due to configuration error: Cannot resolve function from URI: " + + integration.getUri(); + } else { + String eventJson = buildProxyEvent(region, apiId, httpMethod, path, resource.getPath(), resource.getId(), + stage.getStageName(), stage, headers, uriInfo, body, UUID.randomUUID().toString(), + null, null, null, null); + log = "Endpoint request URI: " + integration.getUri() + + "\nEndpoint request body after transformations: " + eventJson; + InvokeResult result = lambdaService.invoke(region, functionName, eventJson.getBytes(StandardCharsets.UTF_8), + InvocationType.RequestResponse); + if (isGatewayGeneratedProxyFailure(result)) { + response = Response.status(500) + .entity("Execution failed due to configuration error: Malformed Lambda proxy response") + .build(); + log += "\nExecution failed due to configuration error: Malformed Lambda proxy response"; + } else { + response = buildProxyResponse(result, false); + } + } + } else { + AuthorizerResult authorizerResult = new AuthorizerResult(null, null, null); + response = switch (integrationType.toUpperCase(Locale.ROOT)) { + case "AWS" -> invokeAwsIntegration(scope, region, httpMethod, path, stage.getStageName(), resource, + integration, headers, uriInfo, body, authorizerResult); + case "HTTP_PROXY" -> invokeHttpProxy(scope, apiId, httpMethod, path, null, stage.getStageName(), resource, + integration, headers, uriInfo, body); + case "HTTP" -> invokeHttpIntegration(scope, region, apiId, httpMethod, path, null, stage.getStageName(), + resource, integration, headers, uriInfo, body, authorizerResult); + case "MOCK" -> invokeMock(scope, region, httpMethod, path, stage.getStageName(), resource, integration, + headers, uriInfo, body, authorizerResult); + default -> Response.status(500).entity("Unsupported integration type: " + integration.getType()).build(); + }; + log = "Endpoint request URI: " + integration.getUri(); + } + long latency = (System.nanoTime() - started) / 1_000_000L; + return new TestInvokeResult(response, log + "\nLatency: " + latency + " ms", latency); + } + private void applyApiOwnerContext(ApiGatewayV2Service.ApiOwner owner) { requestContext.setAccountId(owner.accountId()); requestContext.setRegion(owner.region()); @@ -952,7 +1039,8 @@ public class ApiGatewayExecuteController { return false; } try { - return !objectMapper.readTree(payload).isObject(); + JsonNode response = objectMapper.readTree(payload); + return !response.isObject() || !response.has("statusCode"); } catch (IOException e) { return true; } @@ -1413,6 +1501,9 @@ public class ApiGatewayExecuteController { } try { JsonNode node = objectMapper.readTree(result.getPayload()); + if (!node.isObject() || !node.has("statusCode")) { + return Response.status(502).build(); + } int statusCode = node.path("statusCode").asInt(200); if (result.getFunctionError() != null && !node.has("statusCode")) statusCode = 502; @@ -1475,6 +1566,191 @@ public class ApiGatewayExecuteController { return Optional.empty(); } + private static final class TestInvokeHeaders implements HttpHeaders { + private final MultivaluedMap headers = new MultivaluedHashMap<>(); + + private TestInvokeHeaders(Map singleValueHeaders, + Map> multiValueHeaders) { + if (singleValueHeaders != null) { + singleValueHeaders.forEach(headers::putSingle); + } + if (multiValueHeaders != null) { + multiValueHeaders.forEach((name, values) -> headers.put(name, + values == null ? new ArrayList<>() : new ArrayList<>(values))); + } + } + + @Override + public List getRequestHeader(String name) { + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue(); + } + } + return List.of(); + } + + @Override + public String getHeaderString(String name) { + List values = getRequestHeader(name); + return values == null || values.isEmpty() ? null : String.join(",", values); + } + + @Override + public MultivaluedMap getRequestHeaders() { + return headers; + } + + @Override + public List getAcceptableMediaTypes() { + return List.of(); + } + + @Override + public List getAcceptableLanguages() { + return List.of(); + } + + @Override + public MediaType getMediaType() { + return null; + } + + @Override + public Locale getLanguage() { + return null; + } + + @Override + public Map getCookies() { + return Map.of(); + } + + @Override + public Date getDate() { + return null; + } + + @Override + public int getLength() { + return -1; + } + } + + private static final class TestInvokeUriInfo implements UriInfo { + private final URI requestUri; + private final MultivaluedMap queryParameters = new MultivaluedHashMap<>(); + + private TestInvokeUriInfo(URI requestUri) { + this.requestUri = requestUri; + String rawQuery = requestUri.getRawQuery(); + if (rawQuery == null || rawQuery.isEmpty()) { + return; + } + for (String part : rawQuery.split("&")) { + String[] keyValue = part.split("=", 2); + String key = URLDecoder.decode(keyValue[0], StandardCharsets.UTF_8); + String value = keyValue.length == 2 ? URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8) : ""; + queryParameters.add(key, value); + } + } + + @Override + public String getPath() { + return requestUri.getPath(); + } + + @Override + public String getPath(boolean decode) { + return decode ? requestUri.getPath() : requestUri.getRawPath(); + } + + @Override + public List getPathSegments() { + return List.of(); + } + + @Override + public List getPathSegments(boolean decode) { + return List.of(); + } + + @Override + public URI getRequestUri() { + return requestUri; + } + + @Override + public UriBuilder getRequestUriBuilder() { + return UriBuilder.fromUri(requestUri); + } + + @Override + public URI getAbsolutePath() { + return UriBuilder.fromUri(requestUri).replaceQuery(null).build(); + } + + @Override + public UriBuilder getAbsolutePathBuilder() { + return UriBuilder.fromUri(getAbsolutePath()); + } + + @Override + public URI getBaseUri() { + return URI.create("http://test-invoke/"); + } + + @Override + public UriBuilder getBaseUriBuilder() { + return UriBuilder.fromUri(getBaseUri()); + } + + @Override + public MultivaluedMap getPathParameters() { + return new MultivaluedHashMap<>(); + } + + @Override + public MultivaluedMap getPathParameters(boolean decode) { + return getPathParameters(); + } + + @Override + public MultivaluedMap getQueryParameters() { + return queryParameters; + } + + @Override + public MultivaluedMap getQueryParameters(boolean decode) { + return queryParameters; + } + + @Override + public List getMatchedURIs() { + return List.of(); + } + + @Override + public List getMatchedURIs(boolean decode) { + return List.of(); + } + + @Override + public List getMatchedResources() { + return List.of(); + } + + @Override + public URI resolve(URI uri) { + return getBaseUri().resolve(uri); + } + + @Override + public URI relativize(URI uri) { + return getBaseUri().relativize(uri); + } + } + // ──────────────────────────── AWS (non-proxy) ──────────────────────────── private MultivaluedMap parseFormEncodedBody(String body) { diff --git a/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayService.java b/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayService.java index c42dea6..606a20d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayService.java +++ b/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayService.java @@ -496,10 +496,16 @@ public class ApiGatewayService { } public MethodResponse putMethodResponse(String region, String apiId, String resourceId, - String httpMethod, String statusCode, - Map request) { + String httpMethod, String statusCode, + Map request) { MethodConfig method = getMethod(region, apiId, resourceId, httpMethod); - MethodResponse mr = new MethodResponse(statusCode, new HashMap<>()); + @SuppressWarnings("unchecked") + Map responseParameters = (Map) request.get("responseParameters"); + @SuppressWarnings("unchecked") + Map responseModels = (Map) request.get("responseModels"); + MethodResponse mr = new MethodResponse(statusCode, + responseParameters != null ? new HashMap<>(responseParameters) : new HashMap<>(), + responseModels != null ? new HashMap<>(responseModels) : new HashMap<>()); method.getMethodResponses().put(statusCode, mr); resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId)); return mr; @@ -524,6 +530,49 @@ public class ApiGatewayService { resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId)); } + public MethodResponse updateMethodResponse(String region, String apiId, String resourceId, + String httpMethod, String statusCode, + List> patchOperations) { + MethodResponse response = getMethodResponse(region, apiId, resourceId, httpMethod, statusCode); + if (patchOperations == null) { + throw new AwsException("BadRequestException", "Invalid patch operation", 400); + } + Map responseParameters = response.responseParameters() != null + ? new HashMap<>(response.responseParameters()) : new HashMap<>(); + Map responseModels = response.responseModels() != null + ? new HashMap<>(response.responseModels()) : new HashMap<>(); + for (Map patch : patchOperations) { + String op = patch.get("op"); + String path = patch.get("path"); + String value = patch.get("value"); + boolean remove = "remove".equals(op); + if (path == null || (!remove && !"add".equals(op) && !"replace".equals(op)) || (!remove && value == null)) { + throw new AwsException("BadRequestException", "Invalid patch operation", 400); + } + if (path.startsWith("/responseParameters/")) { + String name = unescapeJsonPointer(path.substring("/responseParameters/".length())); + if (remove) { + responseParameters.remove(name); + } else { + responseParameters.put(name, Boolean.parseBoolean(value)); + } + } else if (path.startsWith("/responseModels/")) { + String contentType = unescapeJsonPointer(path.substring("/responseModels/".length())); + if (remove) { + responseModels.remove(contentType); + } else { + responseModels.put(contentType, value); + } + } else { + throw new AwsException("BadRequestException", "Invalid patch path '" + path + "'", 400); + } + } + MethodResponse updated = new MethodResponse(statusCode, responseParameters, responseModels); + getMethod(region, apiId, resourceId, httpMethod).getMethodResponses().put(statusCode, updated); + resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId)); + return updated; + } + // ──────────────────────────── Integrations ──────────────────────────── public Integration putIntegration(String region, String apiId, String resourceId, String httpMethod, Map request) { @@ -643,18 +692,44 @@ public class ApiGatewayService { public IntegrationResponse updateIntegrationResponse(String region, String apiId, String resourceId, String httpMethod, String statusCode, List> patchOperations) { IntegrationResponse response = getIntegrationResponse(region, apiId, resourceId, httpMethod, statusCode); String selectionPattern = response.selectionPattern(); - if (patchOperations != null) { - for (Map patch : patchOperations) { - String op = patch.get("op"); - String path = patch.get("path"); - String value = patch.get("value"); - if (value == null || !("add".equals(op) || "replace".equals(op)) || !"/selectionPattern".equals(path)) { - throw new AwsException("BadRequestException", "Invalid patch operation", 400); + String contentHandling = response.contentHandling(); + Map responseParameters = new HashMap<>(response.responseParameters()); + Map responseTemplates = new HashMap<>(response.responseTemplates()); + if (patchOperations == null) { + throw new AwsException("BadRequestException", "Invalid patch operation", 400); + } + for (Map patch : patchOperations) { + String op = patch.get("op"); + String path = patch.get("path"); + String value = patch.get("value"); + boolean remove = "remove".equals(op); + if (path == null || (!remove && !"add".equals(op) && !"replace".equals(op)) || (!remove && value == null)) { + throw new AwsException("BadRequestException", "Invalid patch operation", 400); + } + if ("/selectionPattern".equals(path)) { + selectionPattern = remove ? null : value; + } else if ("/contentHandling".equals(path)) { + contentHandling = remove ? null : value; + } else if (path.startsWith("/responseParameters/")) { + String name = unescapeJsonPointer(path.substring("/responseParameters/".length())); + if (remove) { + responseParameters.remove(name); + } else { + responseParameters.put(name, value); } - selectionPattern = value; + } else if (path.startsWith("/responseTemplates/")) { + String contentType = unescapeJsonPointer(path.substring("/responseTemplates/".length())); + if (remove) { + responseTemplates.remove(contentType); + } else { + responseTemplates.put(contentType, value); + } + } else { + throw new AwsException("BadRequestException", "Invalid patch path '" + path + "'", 400); } } - IntegrationResponse newResponse = new IntegrationResponse(response.statusCode(), selectionPattern, response.responseParameters(), response.responseTemplates()); + IntegrationResponse newResponse = new IntegrationResponse(response.statusCode(), selectionPattern, + responseParameters, responseTemplates, contentHandling); getIntegration(region, apiId, resourceId, httpMethod).getIntegrationResponses().put(statusCode, newResponse); resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId)); return newResponse; diff --git a/src/main/java/io/github/hectorvent/floci/services/apigateway/model/MethodResponse.java b/src/main/java/io/github/hectorvent/floci/services/apigateway/model/MethodResponse.java index 9fee234..cd9b805 100644 --- a/src/main/java/io/github/hectorvent/floci/services/apigateway/model/MethodResponse.java +++ b/src/main/java/io/github/hectorvent/floci/services/apigateway/model/MethodResponse.java @@ -10,6 +10,7 @@ import java.util.Map; @RegisterForReflection public record MethodResponse( String statusCode, - Map responseParameters + Map responseParameters, + Map responseModels ) { } diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/DynamoDbCfnProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/DynamoDbCfnProvisioner.java index 4724ecb..924b09e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/DynamoDbCfnProvisioner.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/DynamoDbCfnProvisioner.java @@ -109,6 +109,8 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner { List attrDefs = new ArrayList<>(); List gsis = new ArrayList<>(); List lsis = new ArrayList<>(); + Long readCapacity = null; + Long writeCapacity = null; if (props != null && props.has("KeySchema")) { for (JsonNode ks : props.get("KeySchema")) { @@ -124,6 +126,11 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner { attrDefs.add(new AttributeDefinition(attrName, attrType)); } } + if (props != null && props.has("ProvisionedThroughput")) { + JsonNode throughput = props.get("ProvisionedThroughput"); + readCapacity = Long.valueOf(engine.resolve(throughput.get("ReadCapacityUnits"))); + writeCapacity = Long.valueOf(engine.resolve(throughput.get("WriteCapacityUnits"))); + } if (props != null && props.has("GlobalSecondaryIndexes")) { for (JsonNode gsiNode : props.get("GlobalSecondaryIndexes")) { @@ -179,7 +186,7 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner { TableDefinition table; try { - table = dynamoDbService.createTable(tableName, keySchema, attrDefs, null, null, gsis, lsis, region); + table = dynamoDbService.createTable(tableName, keySchema, attrDefs, readCapacity, writeCapacity, gsis, lsis, region); } catch (AwsException e) { if (!"ResourceInUseException".equals(e.getErrorCode())) { throw e; diff --git a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManager.java b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManager.java index 2f5c280..151ca7a 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManager.java +++ b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManager.java @@ -1228,6 +1228,15 @@ public class Ec2ContainerManager { * Sets terminatedAt for TTL pruning. */ public void terminate(Instance instance) { + terminate(instance, () -> { + }); + } + + /** + * Terminates an instance and invokes {@code onTerminated} after its final state is recorded. + * The caller persists the instance because this manager deliberately has no storage dependency. + */ + public void terminate(Instance instance, Runnable onTerminated) { String containerId; String containerIp; String imdsSourceIp; @@ -1273,6 +1282,7 @@ public class Ec2ContainerManager { instance.getPrivateIpAddress()); instance.setState(InstanceState.terminated()); instance.setTerminatedAt(System.currentTimeMillis()); + onTerminated.run(); }); } diff --git a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxy.java b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxy.java index 46d6839..e141265 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxy.java +++ b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxy.java @@ -11,30 +11,32 @@ import java.util.Optional; */ public final class Ec2MetadataProxy { + private static final int IMDS_PROXY_PORT = 1338; + private Ec2MetadataProxy() {} public static String[] installCommand() { return new String[]{"sh", "-c", String.join("\n", "set -eu", - "if 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", + "if 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", "if command -v apt-get >/dev/null 2>&1; then", " apt-get update -qq >/dev/null", - " DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iproute2 socat curl ca-certificates >/dev/null", + " DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iproute2 iptables socat curl ca-certificates >/dev/null", "elif command -v dnf >/dev/null 2>&1; then", // --allowerasing lets dnf swap the curl-minimal that // public.ecr.aws/amazonlinux/amazonlinux:2023 ships by default for the full // curl package this proxy needs. Without it, dnf aborts the whole transaction // on a curl/curl-minimal conflict and iproute+socat never install either, even // though neither of them conflicts with anything. - " dnf install -y --allowerasing iproute socat curl ca-certificates >/dev/null", + " dnf install -y --allowerasing iproute iptables socat curl ca-certificates >/dev/null", // Same gap as the sshd probe: Amazon Linux 2 has only yum, so on an instance // launched from ami-amazonlinux2 this chain reached its else branch and exited 1 // with "No supported package manager found for IMDS proxy dependencies", // leaving the instance without a link-local IMDS endpoint. "elif command -v yum >/dev/null 2>&1; then", - " yum install -y iproute socat curl ca-certificates >/dev/null", + " yum install -y iproute iptables socat curl ca-certificates >/dev/null", "elif command -v apk >/dev/null 2>&1; then", - " apk add --no-cache iproute2 socat curl ca-certificates >/dev/null", + " apk add --no-cache iproute2 iptables socat curl ca-certificates >/dev/null", "else", " echo 'No supported package manager found for IMDS proxy dependencies' >&2", " exit 1", @@ -42,8 +44,24 @@ public final class Ec2MetadataProxy { } public static String[] startCommand(String flociHost, int imdsPort) { - return startCommand("imds", "169.254.169.254", 80, flociHost, imdsPort, - "curl -fsS --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null && exit 0"); + return new String[]{"sh", "-c", String.join("\n", + "set -eu", + "ip addr show dev lo | grep -q '169.254.169.254/32' || ip addr add 169.254.169.254/32 dev lo", + "iptables -t nat -C OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j DNAT --to-destination 127.0.0.1:" + + IMDS_PROXY_PORT + " 2>/dev/null || iptables -t nat -A OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j DNAT --to-destination 127.0.0.1:" + + IMDS_PROXY_PORT, + "if [ -f /tmp/floci-imds-proxy.pid ] && kill -0 \"$(cat /tmp/floci-imds-proxy.pid)\" 2>/dev/null; then", + " exit 0", + "fi", + "nohup socat TCP-LISTEN:" + IMDS_PROXY_PORT + ",bind=127.0.0.1,fork,reuseaddr TCP:" + + flociHost + ":" + imdsPort + " >/tmp/floci-imds-proxy.log 2>&1 &", + "echo $! > /tmp/floci-imds-proxy.pid", + "for i in 1 2 3 4 5 6 7 8 9 10 11 12; do", + " curl -fsS --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null && exit 0", + " sleep 1", + "done", + "cat /tmp/floci-imds-proxy.log >&2 || true", + "exit 1")}; } public static String[] podIdentityStartCommand(String flociHost, int flociPort) { 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 acd45a5..7036585 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 @@ -21,6 +21,9 @@ import java.util.Random; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiPredicate; import java.util.function.Function; @@ -31,6 +34,8 @@ import java.util.stream.Stream; import org.jboss.logging.Logger; import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.core.common.RequestContext; import io.github.hectorvent.floci.core.common.AwsArnUtils; @@ -216,6 +221,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider { private final Set seededAccountRegions = ConcurrentHashMap.newKeySet(); // subnetId → counter for IP assignment (runtime-only, not persisted) private final Map subnetIpCounters = new ConcurrentHashMap<>(); + private ScheduledExecutorService terminatedInstancePruner; /** * Null in the hermetic unit tests, which reach the constructors that do not take it; CDI always @@ -513,6 +519,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider { } restoreVpcNetworks(); + recoverInterruptedTerminations(); int restored = 0; for (String key : instances.keys()) { @@ -560,6 +567,17 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider { return !"terminated".equals(state) && !"shutting-down".equals(state); } + private void recoverInterruptedTerminations() { + for (String storageKey : instances.keys()) { + Instance instance = instances.get(storageKey).orElse(null); + if (instance == null || instance.getState() == null + || !"shutting-down".equals(instance.getState().getName())) { + continue; + } + containerManager.terminate(instance, () -> instances.put(storageKey, instance)); + } + } + /** * Rebuilds the in-memory VPC address plan from persisted VPCs and subnets, after clearing * out any Docker networks a previous run of this same emulator left behind. @@ -3082,6 +3100,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider { } public List describeInstances(String region, List instanceIds, Map> filters) { + pruneTerminatedInstances(); ensureDefaultResources(region); if (!instanceIds.isEmpty()) { for (String id : instanceIds) { @@ -3130,7 +3149,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider { inst.setState(InstanceState.terminated()); inst.setTerminatedAt(System.currentTimeMillis()); } else { - containerManager.terminate(inst); + containerManager.terminate(inst, () -> instances.put(key(region, id), inst)); } // Delete root volume if deleteOnTermination (matches real AWS behavior) if (inst.getRootVolumeId() != null) { @@ -3274,7 +3293,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider { } } - /** Removes terminated instances older than 1 hour. Called periodically by lifecycle. */ + /** Removes terminated instances older than 1 hour. */ public void pruneTerminatedInstances() { long cutoff = System.currentTimeMillis() - 3_600_000L; for (String storeKey : new ArrayList<>(instances.keys())) { @@ -3288,6 +3307,33 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider { } } + @PostConstruct + void startTerminatedInstancePruner() { + terminatedInstancePruner = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "ec2-terminated-instance-pruner"); + thread.setDaemon(true); + return thread; + }); + terminatedInstancePruner.scheduleAtFixedRate(this::pruneTerminatedInstancesSafely, + 1, 1, TimeUnit.MINUTES); + LOG.infov("EC2 terminated-instance pruning scheduled (1-minute interval, 1-hour retention)"); + } + + @PreDestroy + void stopTerminatedInstancePruner() { + if (terminatedInstancePruner != null) { + terminatedInstancePruner.shutdownNow(); + } + } + + private void pruneTerminatedInstancesSafely() { + try { + pruneTerminatedInstances(); + } catch (RuntimeException e) { + LOG.warnv("Could not prune expired terminated EC2 instances: {0}", e.getMessage()); + } + } + public List describeInstanceStatus(String region, List instanceIds) { ensureDefaultResources(region); if (config.services().ec2().mock()) { diff --git a/src/main/java/io/github/hectorvent/floci/services/ecr/registry/EcrRegistryManager.java b/src/main/java/io/github/hectorvent/floci/services/ecr/registry/EcrRegistryManager.java index 48c52c9..4a7ff73 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ecr/registry/EcrRegistryManager.java +++ b/src/main/java/io/github/hectorvent/floci/services/ecr/registry/EcrRegistryManager.java @@ -174,6 +174,12 @@ public class EcrRegistryManager { * the container name plus the container-internal port (not the published host port). */ public String internalEndpoint() { + if (containerId != null) { + String ipAddress = lifecycleManager.resolveContainerNetworkIp(containerId, null); + if (ipAddress != null && !ipAddress.isBlank()) { + return "http://" + ipAddress + ":" + CONTAINER_INTERNAL_PORT; + } + } return "http://" + registryContainerName() + ":" + CONTAINER_INTERNAL_PORT; } diff --git a/src/main/java/io/github/hectorvent/floci/services/ecs/EcsLoadBalancerRegistrar.java b/src/main/java/io/github/hectorvent/floci/services/ecs/EcsLoadBalancerRegistrar.java index d8d4372..a33d0ef 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ecs/EcsLoadBalancerRegistrar.java +++ b/src/main/java/io/github/hectorvent/floci/services/ecs/EcsLoadBalancerRegistrar.java @@ -1,12 +1,16 @@ package io.github.hectorvent.floci.services.ecs; +import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager; import io.github.hectorvent.floci.services.ecs.model.Container; import io.github.hectorvent.floci.services.ecs.model.EcsLoadBalancer; import io.github.hectorvent.floci.services.ecs.model.EcsServiceModel; import io.github.hectorvent.floci.services.ecs.model.EcsTask; import io.github.hectorvent.floci.services.ecs.model.NetworkBinding; +import io.github.hectorvent.floci.services.ecs.model.PortMapping; +import io.github.hectorvent.floci.services.ecs.model.TaskDefinition; import io.github.hectorvent.floci.services.elbv2.ElbV2Service; +import io.github.hectorvent.floci.services.elbv2.model.TargetGroup; import io.github.hectorvent.floci.services.elbv2.model.TargetDescription; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -14,6 +18,7 @@ import org.jboss.logging.Logger; import java.util.List; import java.util.function.BiConsumer; +import java.util.stream.Stream; /** * Bridges ECS services to ELBv2: when an ECS service declares a {@code loadBalancers} @@ -37,6 +42,40 @@ public class EcsLoadBalancerRegistrar { this.containerManager = containerManager; } + /** + * Resolves service target groups and their task-definition bindings before the service can + * launch a task. A late failure here otherwise leaves a running task that can never receive + * traffic. + */ + public void validateServiceLoadBalancers(List loadBalancers, + TaskDefinition taskDefinition, String region) { + if (loadBalancers == null || loadBalancers.isEmpty()) { + return; + } + for (EcsLoadBalancer loadBalancer : loadBalancers) { + if (loadBalancer.getTargetGroupArn() == null || loadBalancer.getTargetGroupArn().isBlank()) { + continue; + } + TargetGroup targetGroup = elbV2Service.getTargetGroup(region, loadBalancer.getTargetGroupArn()); + if (targetGroup == null) { + throw new AwsException("TargetGroupNotFound", + "Target group " + loadBalancer.getTargetGroupArn() + " was not found.", 400); + } + boolean declared = taskDefinition.getContainerDefinitions().stream() + .filter(container -> loadBalancer.getContainerName().equals(container.getName())) + .flatMap(container -> container.getPortMappings() == null + ? Stream.empty() + : container.getPortMappings().stream()) + .anyMatch(mapping -> mapping.containerPort() == loadBalancer.getContainerPort()); + if (!declared) { + throw new AwsException("InvalidParameterException", + "The container " + loadBalancer.getContainerName() + " does not expose port " + + loadBalancer.getContainerPort() + " in task definition " + + taskDefinition.getTaskDefinitionArn() + ".", 400); + } + } + } + /** Registers the task's load-balanced containers as ELBv2 targets. */ public void registerTask(EcsTask task, EcsServiceModel svc, String region) { forEachTarget(task, svc, (tgArn, td) -> { diff --git a/src/main/java/io/github/hectorvent/floci/services/ecs/EcsService.java b/src/main/java/io/github/hectorvent/floci/services/ecs/EcsService.java index 3fa51b0..c176c55 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ecs/EcsService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ecs/EcsService.java @@ -1166,6 +1166,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab } EcsCluster cluster = resolveClusterOrDefault(request.getCluster(), region); TaskDefinition taskDef = resolveLaunchableTaskDefinition(request.getTaskDefinition(), region); + validateEc2BridgeLaunch(cluster, taskDef, request); return launchTasks(cluster, taskDef, request, null, null, region); } @@ -1425,7 +1426,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab strategy = clusterDefaultStrategy(cluster); } if (strategy == null || strategy.isEmpty()) { - LaunchType launchType = request.getLaunchType() != null ? request.getLaunchType() : LaunchType.FARGATE; + LaunchType launchType = request.getLaunchType() != null ? request.getLaunchType() : LaunchType.EC2; return Collections.nCopies(count, new Placement(launchType, null)); } return distribute(strategy, count); @@ -1552,6 +1553,28 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab return new Placement(launchType, capacityProvider); } + /** Floci's Docker-backed service implementation supports EC2 bridge tasks only. */ + private void validateEc2BridgeLaunch(EcsCluster cluster, TaskDefinition taskDef, RunTaskRequest request) { + if (taskDef.getNetworkMode() != NetworkMode.bridge) { + throw new AwsException("ClientException", + "Only bridge network mode is supported for ECS EC2 tasks.", 400); + } + LaunchType launchType = request.getLaunchType(); + if (launchType != null && launchType != LaunchType.EC2) { + throw new AwsException("ClientException", + "Only the EC2 launch type is supported; FARGATE is not available.", 400); + } + List strategy = request.getCapacityProviderStrategy(); + if (strategy == null || strategy.isEmpty()) { + strategy = clusterDefaultStrategy(cluster); + } + if (strategy != null && strategy.stream() + .anyMatch(item -> FARGATE_CAPACITY_PROVIDERS.contains(item.capacityProvider()))) { + throw new AwsException("ClientException", + "FARGATE capacity providers are not supported; use an EC2 capacity provider.", 400); + } + } + /** Accepts the two built-in Fargate providers as well as anything CreateCapacityProvider made. */ private void requireCapacityProvider(String name) { if (name == null || name.isBlank()) { @@ -2026,6 +2049,11 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab // AWS resolves family / family:revision at create time and stores the ARN; the // reconciler compares it with each task's taskDefinitionArn, so pin it here. TaskDefinition taskDef = resolveLaunchableTaskDefinition(request.getTaskDefinition(), region); + RunTaskRequest launchValidation = new RunTaskRequest(); + launchValidation.setLaunchType(request.getLaunchType()); + launchValidation.setCapacityProviderStrategy(request.getCapacityProviderStrategy()); + validateEc2BridgeLaunch(cluster, taskDef, launchValidation); + lbRegistrar.validateServiceLoadBalancers(request.getLoadBalancers(), taskDef, region); String taskDefinition = taskDef.getTaskDefinitionArn(); String serviceName = request.getServiceName(); @@ -2335,7 +2363,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab strategy = clusterDefaultStrategy(cluster); } if (strategy == null || strategy.isEmpty()) { - return LaunchType.FARGATE; + return LaunchType.EC2; } validateCapacityProviderStrategy(strategy); return placementFor(strategy.getFirst().capacityProvider()).launchType(); @@ -3842,6 +3870,9 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab } // Close log streams and remove the stopped Docker containers without re-inspecting. + // Docker can be killed outside ECS. Remove its old target before the service reconciler + // creates a replacement, otherwise the target group retains a dead host port. + deregisterTaskFromLoadBalancers(task, taskRegion(task)); containerManager.cleanupStoppedTask(claimed); if (task.getContainers() != null) { diff --git a/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2DataPlane.java b/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2DataPlane.java index e4f864a..4e63f03 100644 --- a/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2DataPlane.java +++ b/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2DataPlane.java @@ -5,6 +5,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.config.TlsProxyServer; import io.github.hectorvent.floci.services.ec2.Ec2Service; +import io.github.hectorvent.floci.services.ec2.ContainerNetworkReachability; +import io.github.hectorvent.floci.services.ec2.SecurityGroupPolicy; +import io.github.hectorvent.floci.services.ec2.model.SecurityGroup; import io.github.hectorvent.floci.services.elbv2.model.Action; import io.github.hectorvent.floci.services.elbv2.model.Listener; import io.github.hectorvent.floci.services.elbv2.model.LoadBalancer; @@ -37,6 +40,7 @@ import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -76,6 +80,12 @@ public class ElbV2DataPlane { @Inject Ec2Service ec2Service; + @Inject + ContainerNetworkReachability containerNetworkReachability; + + @Inject + ElbV2ListenerForwardManager listenerForwardManager; + @Inject ObjectMapper objectMapper; @@ -102,6 +112,9 @@ public class ElbV2DataPlane { @PreDestroy void shutdown() { + for (Map.Entry entry : listenerBindings.entrySet()) { + listenerForwardManager.remove(entry.getValue().port()); + } for (Map.Entry e : servers.entrySet()) { e.getValue().close(); } @@ -127,6 +140,10 @@ public class ElbV2DataPlane { .put(binding.host(), listenerArn); ensureReleaseHandlerRegistered(); servers.computeIfAbsent(binding.port(), this::startPortServer); + if (!isReservedByFloci(binding.port()) + && listenersByHostAndPort.get(binding.port()).size() == 1) { + listenerForwardManager.reconcile(binding.port()); + } } public void restartListener(Listener listener, String region, List rules) { @@ -150,6 +167,9 @@ public class ElbV2DataPlane { Map listenersByHost = listenersByHostAndPort.get(oldBinding.port()); if (listenersByHost != null) { listenersByHost.remove(oldBinding.host(), listenerArn); + if (listenersByHost.isEmpty()) { + listenerForwardManager.remove(oldBinding.port()); + } closePortServerIfUnused(oldBinding.port(), listenersByHost); } } @@ -162,6 +182,9 @@ public class ElbV2DataPlane { Map listenersByHost = listenersByHostAndPort.get(binding.port()); if (listenersByHost != null) { listenersByHost.remove(binding.host(), listenerArn); + if (listenersByHost.isEmpty()) { + listenerForwardManager.remove(binding.port()); + } closePortServerIfUnused(binding.port(), listenersByHost); } } @@ -268,6 +291,7 @@ public class ElbV2DataPlane { } private void clearPortBindings(int port) { + listenerForwardManager.remove(port); Map listenersByHost = listenersByHostAndPort.remove(port); if (listenersByHost == null) { return; @@ -296,6 +320,11 @@ public class ElbV2DataPlane { req.response().setStatusCode(502).end("No listener region"); return; } + if (!allowsIngress(listenerArn, region, req, port)) { + // Security groups are enforced at TCP ingress. Do not synthesize an HTTP response. + req.connection().close(); + return; + } AtomicReference> ref = ruleChains.get(listenerArn); if (ref == null) { req.response().setStatusCode(502).end("No rule chain"); @@ -378,18 +407,19 @@ public class ElbV2DataPlane { List allTargets = tg.getTargets(); List healthy = allTargets.stream() - .filter(t -> healthChecker.isHealthy(tgArn, t, ElbV2HealthChecker.effectivePort(t, tg))) + .filter(t -> healthChecker.isHealthy(tgArn, t, ElbV2HealthChecker.effectiveHealthCheckPort(t, tg))) .collect(Collectors.toList()); - List candidates = healthy.isEmpty() ? allTargets : healthy; - if (candidates.isEmpty()) { + if (healthy.isEmpty()) { req.response().setStatusCode(503).end("No targets available"); return; } AtomicInteger counter = rrCounters.computeIfAbsent(tgArn, k -> new AtomicInteger(0)); - int idx = Math.abs(counter.getAndIncrement() % candidates.size()); - TargetDescription target = candidates.get(idx); + int idx = Math.floorMod(counter.getAndIncrement(), healthy.size()); + TargetDescription target = healthy.get(idx); int targetPort = ElbV2HealthChecker.effectivePort(target, tg); - proxyRequest(req, ElbV2TargetResolver.resolveHost(ec2Service, tg, target), targetPort, + ElbV2TargetResolver.TargetEndpoint endpoint = ElbV2TargetResolver.resolveEndpoint( + ec2Service, containerNetworkReachability, tg, target, targetPort); + proxyRequest(req, endpoint.host(), endpoint.port(), preserveHostHeader(listenerArn, region)); } @@ -404,6 +434,20 @@ public class ElbV2DataPlane { && Boolean.parseBoolean(loadBalancer.getAttributes().get(PRESERVE_HOST_HEADER_ATTRIBUTE)); } + private boolean allowsIngress(String listenerArn, String region, HttpServerRequest req, int port) { + ListenerBinding binding = listenerBindings.get(listenerArn); + LoadBalancer loadBalancer = binding != null + ? elbV2Service.getLoadBalancer(region, binding.loadBalancerArn()) : null; + if (loadBalancer == null || loadBalancer.getSecurityGroups() == null || loadBalancer.getSecurityGroups().isEmpty()) { + return true; + } + List groups = ec2Service.describeSecurityGroups(region, loadBalancer.getSecurityGroups(), List.of(), Map.of()); + String remoteAddress = req.remoteAddress() != null ? req.remoteAddress().host() : null; + SecurityGroupPolicy.Peer peer = new SecurityGroupPolicy.Peer(remoteAddress, null, + loadBalancer.getVpcId(), Set.of()); + return SecurityGroupPolicy.allows(groups, false, "tcp", port, -1, peer, Map.of()); + } + private void invokeLambdaTarget(io.vertx.core.http.HttpServerRequest req, String functionArn, String region) { if (req.isEnded()) { invokeLambdaWithBody(req, functionArn, region, Buffer.buffer()); diff --git a/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2HealthChecker.java b/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2HealthChecker.java index 3489817..a110625 100644 --- a/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2HealthChecker.java +++ b/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2HealthChecker.java @@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.elbv2; import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.core.common.Resettable; +import io.github.hectorvent.floci.services.ec2.ContainerNetworkReachability; import io.github.hectorvent.floci.services.ec2.Ec2Service; import io.github.hectorvent.floci.services.elbv2.model.TargetDescription; import io.github.hectorvent.floci.services.elbv2.model.TargetGroup; @@ -27,6 +28,7 @@ public class ElbV2HealthChecker implements Resettable { private final Vertx vertx; private final EmulatorConfig config; private final Ec2Service ec2Service; + private final ContainerNetworkReachability containerNetworkReachability; // tgArn → (targetKey → TargetState) private final Map> states = new ConcurrentHashMap<>(); @@ -34,10 +36,12 @@ public class ElbV2HealthChecker implements Resettable { private final Map timers = new ConcurrentHashMap<>(); @Inject - public ElbV2HealthChecker(Vertx vertx, EmulatorConfig config, Ec2Service ec2Service) { + public ElbV2HealthChecker(Vertx vertx, EmulatorConfig config, Ec2Service ec2Service, + ContainerNetworkReachability containerNetworkReachability) { this.vertx = vertx; this.config = config; this.ec2Service = ec2Service; + this.containerNetworkReachability = containerNetworkReachability; } public static int effectivePort(TargetDescription target, TargetGroup tg) { @@ -50,6 +54,18 @@ public class ElbV2HealthChecker implements Resettable { return 80; } + public static int effectiveHealthCheckPort(TargetDescription target, TargetGroup tg) { + String healthCheckPort = tg.getHealthCheckPort(); + if (healthCheckPort != null && !"traffic-port".equalsIgnoreCase(healthCheckPort)) { + try { + return Integer.parseInt(healthCheckPort); + } catch (NumberFormatException e) { + return effectivePort(target, tg); + } + } + return effectivePort(target, tg); + } + public void startMonitoring(TargetGroup tg) { if (config.services().elbv2().mock()) { return; @@ -60,6 +76,11 @@ public class ElbV2HealthChecker implements Resettable { String tgArn = tg.getTargetGroupArn(); states.computeIfAbsent(tgArn, k -> new ConcurrentHashMap<>()); + Long previousTimer = timers.remove(tgArn); + if (previousTimer != null) { + vertx.cancelTimer(previousTimer); + } + long intervalMs = (tg.getHealthCheckIntervalSeconds() != null ? tg.getHealthCheckIntervalSeconds() : 30) * 1000L; long timerId = vertx.setPeriodic(intervalMs, id -> probeAll(tgArn, tg)); timers.put(tgArn, timerId); @@ -85,7 +106,7 @@ public class ElbV2HealthChecker implements Resettable { } Map tgStates = states.computeIfAbsent(tgArn, k -> new ConcurrentHashMap<>()); for (TargetDescription t : targets) { - int port = effectivePort(t, tg); + int port = effectiveHealthCheckPort(t, tg); String key = stateKey(t.getId(), port); tgStates.putIfAbsent(key, new TargetState()); } @@ -100,7 +121,7 @@ public class ElbV2HealthChecker implements Resettable { return; } for (TargetDescription t : targets) { - int port = effectivePort(t, tg); + int port = effectiveHealthCheckPort(t, tg); tgStates.remove(stateKey(t.getId(), port)); } } @@ -123,7 +144,7 @@ public class ElbV2HealthChecker implements Resettable { public boolean isHealthy(String tgArn, TargetDescription target, int port) { String state = getState(tgArn, target.getId(), port); - return "healthy".equals(state) || "initial".equals(state); + return "healthy".equals(state); } private void probeAll(String tgArn, TargetGroup tg) { @@ -145,7 +166,11 @@ public class ElbV2HealthChecker implements Resettable { } catch (NumberFormatException e) { continue; } - String host = ElbV2TargetResolver.resolveHost(ec2Service, tg, targetId); + TargetDescription target = new TargetDescription(); + target.setId(targetId); + target.setPort(port); + ElbV2TargetResolver.TargetEndpoint endpoint = ElbV2TargetResolver.resolveEndpoint( + ec2Service, containerNetworkReachability, tg, target, port); String path = tg.getHealthCheckPath() != null ? tg.getHealthCheckPath() : "/"; String matcher = tg.getMatcher() != null ? tg.getMatcher() : "200"; int timeout = tg.getHealthCheckTimeoutSeconds() != null ? tg.getHealthCheckTimeoutSeconds() : 5; @@ -153,7 +178,7 @@ public class ElbV2HealthChecker implements Resettable { int unhealthyThreshold = tg.getUnhealthyThresholdCount() != null ? tg.getUnhealthyThresholdCount() : 2; vertx.executeBlocking(() -> { - return probe(host, port, path, timeout); + return probe(endpoint.host(), endpoint.port(), path, timeout); }).onSuccess(statusCode -> { boolean success = matchesStatusCode(statusCode, matcher); if (success) { @@ -189,7 +214,7 @@ public class ElbV2HealthChecker implements Resettable { state.description = "Health checks failed"; } } - LOG.debugv("Health check failed for {0}:{1} - {2}", host, port, err.getMessage()); + LOG.debugv("Health check failed for {0}:{1} - {2}", endpoint.host(), endpoint.port(), err.getMessage()); }); } } diff --git a/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2ListenerForwardManager.java b/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2ListenerForwardManager.java new file mode 100644 index 0000000..ae25487 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2ListenerForwardManager.java @@ -0,0 +1,78 @@ +package io.github.hectorvent.floci.services.elbv2; + +import io.github.hectorvent.floci.config.EmulatorConfig; +import io.github.hectorvent.floci.core.common.docker.ContainerBuilder; +import io.github.hectorvent.floci.core.common.docker.ContainerDetector; +import io.github.hectorvent.floci.core.common.docker.ContainerLifecycleManager; +import io.github.hectorvent.floci.core.common.docker.ContainerSpec; +import io.github.hectorvent.floci.core.common.docker.CurrentContainerNetworkResolver; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.util.List; +import java.util.Optional; + +/** Publishes an in-container ALB listener through a sibling socat container when Floci runs in Docker. */ +@ApplicationScoped +public class ElbV2ListenerForwardManager { + + private static final Logger LOG = Logger.getLogger(ElbV2ListenerForwardManager.class); + + private final ContainerBuilder containerBuilder; + private final ContainerDetector containerDetector; + private final ContainerLifecycleManager lifecycleManager; + private final CurrentContainerNetworkResolver networkResolver; + private final EmulatorConfig config; + + @Inject + public ElbV2ListenerForwardManager(ContainerBuilder containerBuilder, ContainerDetector containerDetector, + ContainerLifecycleManager lifecycleManager, + CurrentContainerNetworkResolver networkResolver, EmulatorConfig config) { + this.containerBuilder = containerBuilder; + this.containerDetector = containerDetector; + this.lifecycleManager = lifecycleManager; + this.networkResolver = networkResolver; + this.config = config; + } + + public void reconcile(int port) { + if (!containerDetector.isRunningInContainer()) { + return; + } + Optional targetIp = networkResolver.resolveContainerIp(); + if (targetIp.isEmpty()) { + LOG.warnv("Could not resolve Floci container IP; ALB listener port {0} is not host-published", + String.valueOf(port)); + return; + } + String name = forwardContainerName(port); + try { + lifecycleManager.removeIfExists(name); + ContainerBuilder.Builder builder = containerBuilder.newContainer(config.services().elbv2().listenerForwarderImage()) + .withName(name) + .withEntrypoint(List.of("socat")) + .withCmd(List.of("TCP-LISTEN:" + port + ",fork,reuseaddr", "TCP:" + targetIp.get() + ":" + port)) + .withPortBinding(port, port) + .withLogRotation(); + networkResolver.resolveNetworkName().ifPresent(builder::withNetworkMode); + ContainerSpec spec = builder.build(); + lifecycleManager.createAndStart(spec); + LOG.infov("Published ALB listener port {0} on the host (socat -> {1}:{0})", + String.valueOf(port), targetIp.get()); + } catch (Exception e) { + LOG.warnv("Failed to publish ALB listener port {0} on the host: {1}", + String.valueOf(port), e.getMessage()); + } + } + + public void remove(int port) { + if (containerDetector.isRunningInContainer()) { + lifecycleManager.removeIfExists(forwardContainerName(port)); + } + } + + static String forwardContainerName(int port) { + return "floci-elbv2-fwd-" + port; + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2Service.java b/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2Service.java index 3e5fa55..78b11fd 100644 --- a/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2Service.java +++ b/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2Service.java @@ -6,7 +6,6 @@ import io.github.hectorvent.floci.core.common.AwsArnUtils; import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.core.common.RegionResolver; import io.github.hectorvent.floci.core.common.SsrfProtection; -import io.github.hectorvent.floci.core.common.dns.EmbeddedDnsServer; import io.github.hectorvent.floci.core.storage.StorageBackedMap; import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.services.ec2.Ec2Service; @@ -232,10 +231,11 @@ public class ElbV2Service implements ResourceProvider { } private String loadBalancerDnsSuffix() { - if (config == null) { - return EmbeddedDnsServer.DEFAULT_SUFFIX; + if (config != null && config.hostname().isPresent()) { + return config.hostname().get(); } - return config.hostname().orElse(EmbeddedDnsServer.DEFAULT_SUFFIX); + // RFC 6761 reserves *.localhost for loopback resolution without an external DNS server. + return "localhost"; } public List describeLoadBalancers(String region, List arns, List names, @@ -357,6 +357,7 @@ public class ElbV2Service implements ResourceProvider { String matcher, String ipAddressType, Map initialTags) { validateName(name, "target group"); + validateHttpTargetGroup(protocol, targetType, healthCheckProtocol); Map regionTgs = targetGroups.computeIfAbsent(region, k -> new ConcurrentHashMap<>()); boolean duplicate = regionTgs.values().stream() .anyMatch(tg -> tg.getTargetGroupName().equals(name)); @@ -450,6 +451,8 @@ public class ElbV2Service implements ResourceProvider { Integer healthCheckTimeout, Integer healthyThreshold, Integer unhealthyThreshold, String matcher) { TargetGroup tg = requireTargetGroup(region, arn); + String effectiveHealthCheckProtocol = healthCheckProtocol != null ? healthCheckProtocol : tg.getHealthCheckProtocol(); + validateHttpTargetGroup(tg.getProtocol(), tg.getTargetType(), effectiveHealthCheckProtocol); if (healthCheckProtocol != null) tg.setHealthCheckProtocol(healthCheckProtocol); if (healthCheckPort != null) tg.setHealthCheckPort(healthCheckPort); if (healthCheckEnabled != null) tg.setHealthCheckEnabled(healthCheckEnabled); @@ -460,6 +463,11 @@ public class ElbV2Service implements ResourceProvider { if (unhealthyThreshold != null) tg.setUnhealthyThresholdCount(unhealthyThreshold); if (matcher != null) tg.setMatcher(matcher); persistRegion(targetGroups, region); + healthChecker.stopMonitoring(arn); + healthChecker.startMonitoring(tg); + if (!tg.getTargets().isEmpty()) { + healthChecker.addTargets(arn, tg.getTargets(), tg); + } } public Map describeTargetGroupAttributes(String region, String arn) { @@ -479,7 +487,9 @@ public class ElbV2Service implements ResourceProvider { String sslPolicy, List certificates, List defaultActions, List alpnPolicy, Map initialTags) { - requireLoadBalancer(region, lbArn); + LoadBalancer loadBalancer = requireLoadBalancer(region, lbArn); + validateHttpListener(loadBalancer, protocol); + validateForwardActions(region, defaultActions); Map regionListeners = listeners.computeIfAbsent(region, k -> new ConcurrentHashMap<>()); @@ -492,7 +502,7 @@ public class ElbV2Service implements ResourceProvider { "A listener already exists on port " + port + " for this load balancer.", 400); } - LoadBalancer lb = requireLoadBalancer(region, lbArn); + LoadBalancer lb = loadBalancer; String lbType = lb.getType() != null ? lb.getType() : "application"; String typePrefix = lbTypePrefix(lbType); String lbId = arnId(lbArn); @@ -584,6 +594,11 @@ public class ElbV2Service implements ResourceProvider { String sslPolicy, List certificates, List defaultActions, List alpnPolicy) { Listener listener = requireListener(region, listenerArn); + validateHttpListener(requireLoadBalancer(region, listener.getLoadBalancerArn()), + protocol != null ? protocol : listener.getProtocol()); + if (defaultActions != null) { + validateForwardActions(region, defaultActions); + } boolean restartDataPlane = false; boolean recompileRules = false; @@ -632,6 +647,7 @@ public class ElbV2Service implements ResourceProvider { public Rule createRule(String region, String listenerArn, List conditions, int priority, List actions, Map initialTags) { requireListener(region, listenerArn); + validateForwardActions(region, actions); if (priority < 1 || priority > 50000) { throw new AwsException("ValidationError", "Priority must be between 1 and 50000.", 400); } @@ -724,6 +740,9 @@ public class ElbV2Service implements ResourceProvider { public Rule modifyRule(String region, String ruleArn, List conditions, List actions) { Rule rule = requireRule(region, ruleArn); + if (actions != null) { + validateForwardActions(region, actions); + } String listenerArn = rule.getListenerArn(); if (conditions != null) rule.setConditions(new ArrayList<>(conditions)); if (actions != null) rule.setActions(new ArrayList<>(actions)); @@ -830,7 +849,7 @@ public class ElbV2Service implements ResourceProvider { th.setState("healthy"); return th; } - int port = ElbV2HealthChecker.effectivePort(t, tg); + int port = ElbV2HealthChecker.effectiveHealthCheckPort(t, tg); th.setHealthCheckPort(String.valueOf(port)); if (hasFilterTargets && !isRegisteredTarget(tg, t, port)) { th.setState("unused"); @@ -849,7 +868,60 @@ public class ElbV2Service implements ResourceProvider { private static boolean isRegisteredTarget(TargetGroup targetGroup, TargetDescription candidate, int candidatePort) { return targetGroup.getTargets().stream() .anyMatch(registered -> Objects.equals(registered.getId(), candidate.getId()) - && ElbV2HealthChecker.effectivePort(registered, targetGroup) == candidatePort); + && ElbV2HealthChecker.effectiveHealthCheckPort(registered, targetGroup) == candidatePort); + } + + private static void validateHttpListener(LoadBalancer loadBalancer, String protocol) { + if (!"application".equals(loadBalancer.getType())) { + return; + } + if (!"HTTP".equalsIgnoreCase(protocol)) { + throw new AwsException("UnsupportedProtocol", + "Floci's Application Load Balancer data plane supports HTTP listeners only.", 400); + } + } + + private static void validateHttpTargetGroup(String protocol, String targetType, String healthCheckProtocol) { + if (protocol != null && !"HTTP".equalsIgnoreCase(protocol)) { + return; + } + String effectiveTargetType = targetType != null ? targetType : "instance"; + if (!"instance".equals(effectiveTargetType) && !"ip".equals(effectiveTargetType) + && !"lambda".equals(effectiveTargetType)) { + throw new AwsException("UnsupportedTargetType", + "Floci's HTTP target-group data plane supports instance, ip, and lambda targets only.", 400); + } + if (healthCheckProtocol != null && !"HTTP".equalsIgnoreCase(healthCheckProtocol)) { + throw new AwsException("UnsupportedProtocol", + "Floci's HTTP target-group data plane supports HTTP health checks only.", 400); + } + } + + private void validateForwardActions(String region, List actions) { + if (actions == null) { + return; + } + for (Action action : actions) { + if (!"forward".equals(action.getType())) { + continue; + } + List targetGroupArns = new ArrayList<>(); + if (action.getTargetGroupArn() != null) { + targetGroupArns.add(action.getTargetGroupArn()); + } + if (action.getTargetGroups() != null) { + action.getTargetGroups().forEach(tuple -> targetGroupArns.add(tuple.getTargetGroupArn())); + } + for (String targetGroupArn : targetGroupArns) { + TargetGroup targetGroup = requireTargetGroup(region, targetGroupArn); + if (!"HTTP".equalsIgnoreCase(targetGroup.getProtocol())) { + throw new AwsException("UnsupportedProtocol", + "Floci's Application Load Balancer data plane can forward only to HTTP target groups.", 400); + } + validateHttpTargetGroup(targetGroup.getProtocol(), targetGroup.getTargetType(), + targetGroup.getHealthCheckProtocol()); + } + } } // ── Tags ────────────────────────────────────────────────────────────────── diff --git a/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2TargetResolver.java b/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2TargetResolver.java index 5c20441..e665b74 100644 --- a/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2TargetResolver.java +++ b/src/main/java/io/github/hectorvent/floci/services/elbv2/ElbV2TargetResolver.java @@ -1,6 +1,7 @@ package io.github.hectorvent.floci.services.elbv2; import io.github.hectorvent.floci.core.common.SsrfProtection; +import io.github.hectorvent.floci.services.ec2.ContainerNetworkReachability; import io.github.hectorvent.floci.services.ec2.Ec2Service; import io.github.hectorvent.floci.services.ec2.model.Instance; import io.github.hectorvent.floci.services.elbv2.model.TargetDescription; @@ -11,6 +12,8 @@ import java.net.InetAddress; final class ElbV2TargetResolver { + static final String DOCKER_HOST = "host.docker.internal"; + private ElbV2TargetResolver() {} static String resolveHost(Ec2Service ec2Service, TargetGroup targetGroup, TargetDescription target) { @@ -37,6 +40,30 @@ final class ElbV2TargetResolver { return targetId; } + static TargetEndpoint resolveEndpoint(Ec2Service ec2Service, ContainerNetworkReachability reachability, + TargetGroup targetGroup, TargetDescription target, int port) { + String targetId = target != null ? target.getId() : null; + if (targetGroup == null || !"instance".equals(targetGroup.getTargetType()) || ec2Service == null) { + return new TargetEndpoint(targetId, port); + } + Instance instance = ec2Service.findInstanceById(targetId); + if (instance == null) { + return new TargetEndpoint(targetId, port); + } + String bridgeIp = instance.getContainerBridgeIp(); + if (bridgeIp == null || bridgeIp.isBlank() || reachability == null + || reachability.isContainerIpRoutable(bridgeIp)) { + return new TargetEndpoint(bridgeIp != null && !bridgeIp.isBlank() ? bridgeIp : targetId, port); + } + // Docker Desktop and DinD can isolate Floci from an EC2 container's bridge IP. Only in + // that case, use the already-published application port through the Docker host gateway. + Integer publishedPort = instance.getPublishedPorts().get(port); + if (publishedPort != null) { + return new TargetEndpoint(DOCKER_HOST, publishedPort); + } + return new TargetEndpoint(bridgeIp, port); + } + /** Resolves once, rejects metadata addresses, and returns the address to connect to instead of the name. */ static String resolveCheckedAddress(String host) throws IOException { InetAddress[] addresses = SsrfProtection.rejectMetadataAddresses(InetAddress.getAllByName(host), host); @@ -46,4 +73,7 @@ final class ElbV2TargetResolver { static boolean isIpLiteral(String host) { return host != null && (host.contains(":") || host.matches("[0-9.]+")); } + + record TargetEndpoint(String host, int port) { + } } diff --git a/src/main/java/io/github/hectorvent/floci/services/lambda/LambdaExecutorService.java b/src/main/java/io/github/hectorvent/floci/services/lambda/LambdaExecutorService.java index 4ab412a..00a3c36 100644 --- a/src/main/java/io/github/hectorvent/floci/services/lambda/LambdaExecutorService.java +++ b/src/main/java/io/github/hectorvent/floci/services/lambda/LambdaExecutorService.java @@ -34,6 +34,7 @@ public class LambdaExecutorService { private final WarmPool warmPool; private final ObjectMapper objectMapper; private final LambdaConcurrencyLimiter concurrencyLimiter; + private final LambdaPlatformLogWriter platformLogWriter; private final ExecutorService asyncExecutor = new ThreadPoolExecutor( Math.max(4, Runtime.getRuntime().availableProcessors() * 2), Math.max(8, Runtime.getRuntime().availableProcessors() * 4), @@ -43,11 +44,17 @@ public class LambdaExecutorService { @Inject public LambdaExecutorService(WarmPool warmPool, - ObjectMapper objectMapper, - LambdaConcurrencyLimiter concurrencyLimiter) { + ObjectMapper objectMapper, + LambdaConcurrencyLimiter concurrencyLimiter, + LambdaPlatformLogWriter platformLogWriter) { this.warmPool = warmPool; this.objectMapper = objectMapper; this.concurrencyLimiter = concurrencyLimiter; + this.platformLogWriter = platformLogWriter; + } + + LambdaExecutorService(WarmPool warmPool, ObjectMapper objectMapper, LambdaConcurrencyLimiter concurrencyLimiter) { + this(warmPool, objectMapper, concurrencyLimiter, null); } public InvokeResult invoke(LambdaFunction fn, byte[] payload, InvocationType type) { @@ -83,14 +90,19 @@ public class LambdaExecutorService { } private InvokeResult executeSync(LambdaFunction fn, byte[] payload, String requestId) { + long startedAt = System.nanoTime(); + platformLogStart(fn, requestId); + InvokeResult result = null; ContainerHandle handle; try { handle = warmPool.acquire(fn); } catch (Exception e) { LOG.warnv("Failed to acquire container for function {0}: {1}", fn.getFunctionName(), e.getMessage()); - return new InvokeResult(200, "Unhandled", + result = new InvokeResult(200, "Unhandled", buildErrorPayload("Failed to start Lambda container: " + e.getMessage(), "Lambda.InitError"), null, requestId); + platformLogComplete(fn, requestId, startedAt, result); + return result; } try { long deadlineMs = System.currentTimeMillis() + (long) fn.getTimeout() * 1000; @@ -103,7 +115,7 @@ public class LambdaExecutorService { java.util.concurrent.CompletableFuture.anyOf( invocation.getDispatchedFuture(), invocation.getResultFuture()) .get(fn.getTimeout() + RUNTIME_DISPATCH_GRACE_SECONDS, TimeUnit.SECONDS); - InvokeResult result = invocation.getResultFuture().get(); + result = invocation.getResultFuture().get(); warmPool.release(handle); return result; @@ -111,20 +123,37 @@ public class LambdaExecutorService { } catch (InterruptedException e) { Thread.currentThread().interrupt(); warmPool.destroyHandle(handle); - return new InvokeResult(200, "Unhandled", buildErrorPayload("Invocation interrupted", "Interrupted"), null, requestId); + result = new InvokeResult(200, "Unhandled", buildErrorPayload("Invocation interrupted", "Interrupted"), null, requestId); + return result; } catch (Exception e) { Throwable cause = e instanceof ExecutionException && e.getCause() != null ? e.getCause() : e; if (cause instanceof TimeoutException) { LOG.warnv("Function {0} timed out after {1}s", fn.getFunctionName(), fn.getTimeout()); warmPool.destroyHandle(handle); - return new InvokeResult(200, "Unhandled", + result = new InvokeResult(200, "Unhandled", buildErrorPayload("Task timed out after " + fn.getTimeout() + " seconds", "Function.TimedOut"), null, requestId); + return result; } LOG.warnv("Invocation error for function {0}: {1}", fn.getFunctionName(), cause.getMessage()); warmPool.destroyHandle(handle); - return new InvokeResult(200, "Unhandled", + result = new InvokeResult(200, "Unhandled", buildErrorPayload(cause.getMessage(), "InvocationError"), null, requestId); + return result; + } finally { + platformLogComplete(fn, requestId, startedAt, result); + } + } + + private void platformLogStart(LambdaFunction function, String requestId) { + if (platformLogWriter != null) { + platformLogWriter.start(function, requestId); + } + } + + private void platformLogComplete(LambdaFunction function, String requestId, long startedAt, InvokeResult result) { + if (platformLogWriter != null && result != null) { + platformLogWriter.complete(function, requestId, System.nanoTime() - startedAt, result); } } diff --git a/src/main/java/io/github/hectorvent/floci/services/lambda/LambdaPlatformLogWriter.java b/src/main/java/io/github/hectorvent/floci/services/lambda/LambdaPlatformLogWriter.java new file mode 100644 index 0000000..216b58d --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/lambda/LambdaPlatformLogWriter.java @@ -0,0 +1,71 @@ +package io.github.hectorvent.floci.services.lambda; + +import io.github.hectorvent.floci.core.common.AwsArnUtils; +import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.services.cloudwatch.logs.CloudWatchLogsService; +import io.github.hectorvent.floci.services.lambda.model.InvokeResult; +import io.github.hectorvent.floci.services.lambda.model.LambdaFunction; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; + +import java.util.List; +import java.util.Map; + +@ApplicationScoped +public class LambdaPlatformLogWriter { + + private static final Logger LOG = Logger.getLogger(LambdaPlatformLogWriter.class); + + private final CloudWatchLogsService cloudWatchLogsService; + + public LambdaPlatformLogWriter(CloudWatchLogsService cloudWatchLogsService) { + this.cloudWatchLogsService = cloudWatchLogsService; + } + + public void start(LambdaFunction function, String requestId) { + write(function, requestId, "START RequestId: " + requestId + " Version: " + function.getVersion()); + } + + public void complete(LambdaFunction function, String requestId, long durationNanos, InvokeResult result) { + write(function, requestId, "END RequestId: " + requestId); + double durationMs = durationNanos / 1_000_000.0; + long billedDurationMs = Math.max(1, Math.round(durationMs)); + String functionError = result.getFunctionError() == null ? "" : " Error Type: " + result.getFunctionError(); + write(function, requestId, "REPORT RequestId: " + requestId + + " Duration: %.2f ms Billed Duration: %d ms Memory Size: %d MB Max Memory Used: %d MB%s" + .formatted(durationMs, billedDurationMs, function.getMemorySize(), function.getMemorySize(), functionError)); + } + + private void write(LambdaFunction function, String requestId, String message) { + String accountId = function.getAccountId() == null || function.getAccountId().isBlank() + ? AwsArnUtils.accountOrDefault(function.getFunctionArn(), "000000000000") : function.getAccountId(); + String region = AwsArnUtils.regionOrDefault(function.getFunctionArn(), "us-east-1"); + String groupName = function.getLogGroup() == null || function.getLogGroup().isBlank() + ? "/aws/lambda/" + function.getFunctionName() : function.getLogGroup(); + String streamName = "[$LATEST]platform-" + requestId; + try { + createDestination(accountId, region, groupName, streamName); + cloudWatchLogsService.putLogEventsForAccount(accountId, groupName, streamName, + List.of(Map.of("timestamp", System.currentTimeMillis(), "message", message)), region); + } catch (Exception e) { + LOG.debugv("Unable to write Lambda platform log for function {0}: {1}", function.getFunctionName(), e.getMessage()); + } + } + + private void createDestination(String accountId, String region, String groupName, String streamName) { + try { + cloudWatchLogsService.createLogGroupForAccount(accountId, groupName, null, Map.of(), region); + } catch (AwsException e) { + if (!"ResourceAlreadyExistsException".equals(e.getErrorCode())) { + throw e; + } + } + try { + cloudWatchLogsService.createLogStreamForAccount(accountId, groupName, streamName, region); + } catch (AwsException e) { + if (!"ResourceAlreadyExistsException".equals(e.getErrorCode())) { + throw e; + } + } + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java b/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java index 83508e5..d03c24a 100644 --- a/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java +++ b/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java @@ -229,6 +229,12 @@ public class S3Controller { @Context UriInfo uriInfo, @Context HttpHeaders httpHeaders) { try { + if (isWebsiteRequest(httpHeaders, uriInfo)) { + Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo); + if (websiteRedirect != null) { + return headOnlyResponse(websiteRedirect); + } + } S3Service.RequestAuthorization authorization = S3RequestAuthorizationParser.parseIfRequired( s3Service.isAuthEnforced(), httpHeaders, uriInfo); if (isWebsiteRequest(httpHeaders, uriInfo)) { @@ -543,11 +549,17 @@ public class S3Controller { @QueryParam("encoding-type") String encodingType, @QueryParam("key-marker") String keyMarker, @QueryParam("version-id-marker") String versionIdMarker, - @QueryParam("marker") String marker, - @Context UriInfo uriInfo, + @QueryParam("marker") String marker, + @Context UriInfo uriInfo, @Context HttpHeaders httpHeaders) { try { validateRawUri(); + if (isWebsiteRequest(httpHeaders, uriInfo)) { + Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo); + if (websiteRedirect != null) { + return websiteRedirect; + } + } S3Service.RequestAuthorization authorization = S3RequestAuthorizationParser.parseIfRequired( s3Service.isAuthEnforced(), httpHeaders, uriInfo); if (hasQueryParam(uriInfo, "uploads")) { @@ -926,6 +938,12 @@ public class S3Controller { S3Service.RequestAuthorization authorization = S3Service.RequestAuthorization.unsigned(); try { key = extractObjectKey(uriInfo, bucket, key); + if (isWebsiteRequest(httpHeaders, uriInfo)) { + Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo); + if (websiteRedirect != null) { + return websiteRedirect; + } + } authorization = S3RequestAuthorizationParser.parseIfRequired( s3Service.isAuthEnforced(), httpHeaders, uriInfo); @@ -1169,6 +1187,12 @@ public class S3Controller { S3Service.RequestAuthorization authorization = S3Service.RequestAuthorization.unsigned(); try { key = extractObjectKey(uriInfo, bucket, key); + if (isWebsiteRequest(httpHeaders, uriInfo)) { + Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo); + if (websiteRedirect != null) { + return headOnlyResponse(websiteRedirect); + } + } authorization = S3RequestAuthorizationParser.parseIfRequired( s3Service.isAuthEnforced(), httpHeaders, uriInfo); if (isWebsiteRequest(httpHeaders, uriInfo)) { @@ -2982,6 +3006,23 @@ public class S3Controller { rawPath, includeBody); } + private Response serveWebsiteRedirect(String bucket, UriInfo uriInfo) { + S3Service.WebsiteResolution resolution = s3Service.resolveWebsiteRedirect(bucket); + if (!(resolution instanceof S3Service.WebsiteResolution.RedirectAllRequestsTo redirect)) { + return null; + } + String protocol = redirect.protocol() != null + ? redirect.protocol().toLowerCase(Locale.ROOT) + : currentVertxRequest.getCurrent().request().scheme(); + String rawQuery = uriInfo.getRequestUri().getRawQuery(); + String location = protocol + "://" + redirect.hostName() + + currentVertxRequest.getCurrent().request().path() + + (rawQuery == null || rawQuery.isEmpty() ? "" : "?" + rawQuery); + return Response.status(Response.Status.MOVED_PERMANENTLY) + .header("Location", location) + .build(); + } + private Response serveWebsiteErrorResponse(String bucket, S3Service.RequestAuthorization authorization, AwsException cause) { @@ -3027,6 +3068,8 @@ public class S3Controller { .header("x-amz-error-code", websiteErrorCode(status)) .header("x-amz-error-message", websiteErrorMessage(status)) .build(); + case S3Service.WebsiteResolution.RedirectAllRequestsTo(String hostName, String protocol) -> + throw new IllegalStateException("Redirect-all website configuration must be rendered first."); case S3Service.WebsiteResolution.DefaultError(int status) -> defaultWebsiteErrorResponse(status); case S3Service.WebsiteResolution.NotAWebsite() -> null; }; @@ -3830,10 +3873,19 @@ public class S3Controller { WebsiteConfiguration config = s3Service.getBucketWebsite(bucket); XmlBuilder xml = new XmlBuilder() .raw("") - .start("WebsiteConfiguration", AwsNamespaces.S3) - .start("IndexDocument") - .elem("Suffix", config.getIndexDocument()) - .end("IndexDocument"); + .start("WebsiteConfiguration", AwsNamespaces.S3); + if (config.getRedirectAllRequestsToHostName() != null) { + xml.start("RedirectAllRequestsTo") + .elem("HostName", config.getRedirectAllRequestsToHostName()); + if (config.getRedirectAllRequestsToProtocol() != null) { + xml.elem("Protocol", config.getRedirectAllRequestsToProtocol()); + } + xml.end("RedirectAllRequestsTo"); + } else { + xml.start("IndexDocument") + .elem("Suffix", config.getIndexDocument()) + .end("IndexDocument"); + } if (config.getErrorDocument() != null) { xml.start("ErrorDocument") .elem("Key", config.getErrorDocument()) @@ -3864,15 +3916,54 @@ public class S3Controller { private Response handlePutBucketWebsite(String bucket, byte[] body) { String xml = new String(body, StandardCharsets.UTF_8); + if (!"WebsiteConfiguration".equals(XmlParser.rootElementName(xml))) { + throw new AwsException("MalformedXML", "The XML you provided was not well-formed.", 400); + } + boolean hasRedirect = websiteConfigurationHasElement(xml, "RedirectAllRequestsTo"); + boolean hasIndex = websiteConfigurationHasElement(xml, "IndexDocument"); + boolean hasError = websiteConfigurationHasElement(xml, "ErrorDocument"); + if (hasRedirect && (hasIndex || hasError)) { + throw new AwsException("InvalidRequest", + "RedirectAllRequestsTo cannot be specified with IndexDocument or ErrorDocument.", 400); + } + if (hasRedirect) { + String hostName = XmlParser.extractFirst(xml, "HostName", null); + String protocol = XmlParser.extractFirst(xml, "Protocol", null); + if (hostName == null || hostName.isBlank()) { + throw new AwsException("MalformedXML", "RedirectAllRequestsTo.HostName is required.", 400); + } + if (protocol != null && !"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) { + throw new AwsException("MalformedXML", "RedirectAllRequestsTo.Protocol must be http or https.", 400); + } + s3Service.putBucketWebsite(bucket, new WebsiteConfiguration(null, null, hostName, + protocol == null ? null : protocol.toLowerCase(Locale.ROOT))); + return Response.ok().build(); + } String indexDoc = XmlParser.extractFirst(xml, "Suffix", null); String errorDoc = XmlParser.extractFirst(xml, "Key", null); - if (indexDoc == null) { + if (!hasIndex || indexDoc == null || indexDoc.isBlank()) { throw new AwsException("MalformedXML", "IndexDocument.Suffix is required.", 400); } s3Service.putBucketWebsite(bucket, new WebsiteConfiguration(indexDoc, errorDoc)); return Response.ok().build(); } + private static boolean websiteConfigurationHasElement(String xml, String elementName) { + try { + XMLStreamReader reader = XmlParser.newStreamReader(xml); + while (reader.hasNext()) { + if (reader.next() == XMLStreamConstants.START_ELEMENT && elementName.equals(reader.getLocalName())) { + reader.close(); + return true; + } + } + reader.close(); + return false; + } catch (XMLStreamException e) { + throw new AwsException("MalformedXML", "The XML you provided was not well-formed.", 400); + } + } + /** * Authorizes {@code s3:GetObject} on the CopyObject/UploadPartCopy source, which only ever appears * in the {@code x-amz-copy-source} header and is otherwise invisible to the request-level IAM filter diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java b/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java index eb417ce..e018eb9 100644 --- a/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java +++ b/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java @@ -2480,6 +2480,24 @@ public class S3Service implements Resettable, ResourceProvider { return bucket.getWebsiteConfiguration(); } + /** Resolves a redirect-all website configuration without authorizing or looking up any objects. */ + public WebsiteResolution resolveWebsiteRedirect(String bucket) { + WebsiteConfiguration cfg; + try { + cfg = getBucketWebsite(bucket); + } catch (AwsException e) { + if (!"NoSuchWebsiteConfiguration".equals(e.getErrorCode())) { + throw e; + } + return new WebsiteResolution.NotAWebsite(); + } + if (cfg.getRedirectAllRequestsToHostName() == null) { + return new WebsiteResolution.NotAWebsite(); + } + return new WebsiteResolution.RedirectAllRequestsTo(cfg.getRedirectAllRequestsToHostName(), + cfg.getRedirectAllRequestsToProtocol()); + } + public void deleteBucketWebsite(String bucketName) { Bucket bucket = bucketStore.get(bucketName) .orElseThrow(() -> new AwsException("NoSuchBucket", @@ -2510,6 +2528,9 @@ public class S3Service implements Resettable, ResourceProvider { /** Serve the bucket's custom error document with {@code status}. */ record ErrorDocument(S3Object object, int status) implements WebsiteResolution {} + /** Redirect every website request to the configured hostname and optional protocol. */ + record RedirectAllRequestsTo(String hostName, String protocol) implements WebsiteResolution {} + /** No usable custom error document: render S3's built-in error page with {@code status}. */ record DefaultError(int status) implements WebsiteResolution {} @@ -2543,6 +2564,10 @@ public class S3Service implements Resettable, ResourceProvider { } return new WebsiteResolution.NotAWebsite(); } + if (cfg.getRedirectAllRequestsToHostName() != null) { + return new WebsiteResolution.RedirectAllRequestsTo(cfg.getRedirectAllRequestsToHostName(), + cfg.getRedirectAllRequestsToProtocol()); + } String index = cfg.getIndexDocument(); if (index == null) { return new WebsiteResolution.NotAWebsite(); diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/model/WebsiteConfiguration.java b/src/main/java/io/github/hectorvent/floci/services/s3/model/WebsiteConfiguration.java index a95f21d..ab6fa1f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/s3/model/WebsiteConfiguration.java +++ b/src/main/java/io/github/hectorvent/floci/services/s3/model/WebsiteConfiguration.java @@ -6,6 +6,8 @@ import io.quarkus.runtime.annotations.RegisterForReflection; public class WebsiteConfiguration { private String indexDocument; private String errorDocument; + private String redirectAllRequestsToHostName; + private String redirectAllRequestsToProtocol; public WebsiteConfiguration() {} @@ -14,9 +16,27 @@ public class WebsiteConfiguration { this.errorDocument = errorDocument; } + public WebsiteConfiguration(String indexDocument, String errorDocument, String redirectAllRequestsToHostName, + String redirectAllRequestsToProtocol) { + this.indexDocument = indexDocument; + this.errorDocument = errorDocument; + this.redirectAllRequestsToHostName = redirectAllRequestsToHostName; + this.redirectAllRequestsToProtocol = redirectAllRequestsToProtocol; + } + public String getIndexDocument() { return indexDocument; } public void setIndexDocument(String indexDocument) { this.indexDocument = indexDocument; } public String getErrorDocument() { return errorDocument; } public void setErrorDocument(String errorDocument) { this.errorDocument = errorDocument; } + + public String getRedirectAllRequestsToHostName() { return redirectAllRequestsToHostName; } + public void setRedirectAllRequestsToHostName(String redirectAllRequestsToHostName) { + this.redirectAllRequestsToHostName = redirectAllRequestsToHostName; + } + + public String getRedirectAllRequestsToProtocol() { return redirectAllRequestsToProtocol; } + public void setRedirectAllRequestsToProtocol(String redirectAllRequestsToProtocol) { + this.redirectAllRequestsToProtocol = redirectAllRequestsToProtocol; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 43140af..1f5a380 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -623,6 +623,7 @@ floci: elbv2: enabled: true mock: false + listener-forwarder-image: alpine/socat:1.8.1.3 codebuild: enabled: true # docker-network: floci-network diff --git a/src/main/resources/ec2/image-catalog.yaml b/src/main/resources/ec2/image-catalog.yaml index 58dee1c..66ef560 100644 --- a/src/main/resources/ec2/image-catalog.yaml +++ b/src/main/resources/ec2/image-catalog.yaml @@ -23,13 +23,14 @@ images: - /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 - /aws/service/ami-amazon-linux-latest/al2023-ami-minimal-kernel-default-x86_64 - /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64 - dockerImage: public.ecr.aws/amazonlinux/amazonlinux:2023 + dockerImage: floci/ami-amazonlinux:2023 name: al2023-ami-2023.0.20230315.0-kernel-6.1-x86_64 description: Amazon Linux 2023 AMI ownerId: "137112412989" imageOwnerAlias: amazon architecture: x86_64 creationDate: "2023-03-15T00:00:00.000Z" + guestRuntime: systemd - imageId: ami-0abcdef1234567892 aliases: @@ -101,10 +102,16 @@ images: architecture: x86_64 creationDate: "2023-04-01T00:00:00.000Z" - - imageId: ami-0abcdef1234567893 - dockerImage: public.ecr.aws/amazonlinux/amazonlinux:2023 - name: Windows_Server-2022-English-Full-Base-2023.04.12 - description: Microsoft Windows Server 2022 Full Locale English AMI + - imageId: ami-fedora42 + dockerImage: registry.fedoraproject.org/fedora:42 + name: Fedora-Cloud-Base-42-x86_64 + description: Fedora Linux 42 AMI architecture: x86_64 - platform: windows - creationDate: "2023-04-12T00:00:00.000Z" + creationDate: "2025-04-15T00:00:00.000Z" + + - imageId: ami-opensuse-leap156 + dockerImage: registry.opensuse.org/opensuse/leap:15.6 + name: openSUSE-Leap-15.6-x86_64 + description: openSUSE Leap 15.6 AMI + architecture: x86_64 + creationDate: "2024-06-12T00:00:00.000Z" diff --git a/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteControllerTest.java b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteControllerTest.java index f031ed3..ef3ff62 100644 --- a/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteControllerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteControllerTest.java @@ -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, diff --git a/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayIntegrationTest.java index 0fb98b6..fbe307f 100644 --- a/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayIntegrationTest.java @@ -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() diff --git a/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayTestInvokeMethodIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayTestInvokeMethodIntegrationTest.java new file mode 100644 index 0000000..54205f4 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayTestInvokeMethodIntegrationTest.java @@ -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")); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayUpdateIntegrationResponseIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayUpdateIntegrationResponseIntegrationTest.java index 3f2dc6c..128f8d7 100644 --- a/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayUpdateIntegrationResponseIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayUpdateIntegrationResponseIntegrationTest.java @@ -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")); } } diff --git a/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayUpdateMethodResponseIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayUpdateMethodResponseIntegrationTest.java new file mode 100644 index 0000000..853c416 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayUpdateMethodResponseIntegrationTest.java @@ -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")); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/DynamoDbCfnProvisionerTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/DynamoDbCfnProvisionerTest.java index 570051f..af52de7 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/DynamoDbCfnProvisionerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/DynamoDbCfnProvisionerTest.java @@ -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 { diff --git a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManagerTest.java b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManagerTest.java index 7d93295..db102d2 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManagerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ContainerManagerTest.java @@ -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); diff --git a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ImageCatalogTest.java b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ImageCatalogTest.java index ecd26a4..cfe06b6 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ImageCatalogTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ImageCatalogTest.java @@ -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 diff --git a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxyTest.java b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxyTest.java index 577d0d9..41f9e54 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxyTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2MetadataProxyTest.java @@ -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")); } diff --git a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ServiceTest.java index bcbd75f..4ea3faa 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ServiceTest.java @@ -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 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), diff --git a/src/test/java/io/github/hectorvent/floci/services/ecs/EcsEc2BridgeIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ecs/EcsEc2BridgeIntegrationTest.java new file mode 100644 index 0000000..9e31dc9 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/ecs/EcsEc2BridgeIntegrationTest.java @@ -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); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ecs/EcsLoadBalancersIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ecs/EcsLoadBalancersIntegrationTest.java index fab49bf..77cd963 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ecs/EcsLoadBalancersIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ecs/EcsLoadBalancersIntegrationTest.java @@ -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 + "\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ecs/EcsServiceTeardownTest.java b/src/test/java/io/github/hectorvent/floci/services/ecs/EcsServiceTeardownTest.java index bd93eb1..5581f2e 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ecs/EcsServiceTeardownTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ecs/EcsServiceTeardownTest.java @@ -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> stores = new HashMap<>(); diff --git a/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2HealthCheckerTest.java b/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2HealthCheckerTest.java index 8d1684d..8912712 100644 --- a/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2HealthCheckerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2HealthCheckerTest.java @@ -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 { diff --git a/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2ListenerForwardManagerTest.java b/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2ListenerForwardManagerTest.java new file mode 100644 index 0000000..f8a3ad1 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2ListenerForwardManagerTest.java @@ -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)); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2ServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2ServiceTest.java index 2041783..fdc3d2f 100644 --- a/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2ServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2ServiceTest.java @@ -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( diff --git a/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2TargetResolverTest.java b/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2TargetResolverTest.java index f5ce289..42384fc 100644 --- a/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2TargetResolverTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/elbv2/ElbV2TargetResolverTest.java @@ -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; + } } diff --git a/src/test/java/io/github/hectorvent/floci/services/s3/S3ServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/s3/S3ServiceTest.java index 8f48075..7d1b0fc 100644 --- a/src/test/java/io/github/hectorvent/floci/services/s3/S3ServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/s3/S3ServiceTest.java @@ -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")); } } - diff --git a/src/test/java/io/github/hectorvent/floci/services/s3/S3WebsiteIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/s3/S3WebsiteIntegrationTest.java index f6425bc..ee994ea 100644 --- a/src/test/java/io/github/hectorvent/floci/services/s3/S3WebsiteIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/s3/S3WebsiteIntegrationTest.java @@ -411,6 +411,124 @@ class S3WebsiteIntegrationTest { @Test @Order(24) + void redirectAllConfigurationRoundTripAndValidation() { + String redirect = """ + + + redirect.example + https + + + """; + 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("redirect.examplehttps")) + .body(not(containsString(""))); + + String conflicting = """ + + index.html + redirect.example + + """; + given() + .queryParam("website", "") + .contentType("application/xml") + .body(conflicting) + .when() + .put("/" + BUCKET) + .then() + .statusCode(400) + .body(containsString("InvalidRequest")); + + String missingHost = ""; + given() + .queryParam("website", "") + .contentType("application/xml") + .body(missingHost) + .when() + .put("/" + BUCKET) + .then() + .statusCode(400) + .body(containsString("MalformedXML")); + + String invalidProtocol = """ + + redirect.exampleFTP + + """; + given() + .queryParam("website", "") + .contentType("application/xml") + .body(invalidProtocol) + .when() + .put("/" + BUCKET) + .then() + .statusCode(400) + .body(containsString("MalformedXML")); + } + + @Test + @Order(25) + void redirectAllWebsiteRequestsPreserveRawPathAndQueryForGetAndHead() { + String redirect = """ + + redirect.example + + """; + 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"); diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 712036c..3c37c56 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -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