backend services patching

This commit is contained in:
Zakaria
2026-09-30 17:46:10 -04:00
parent cede01969e
commit 23d24288cd
49 changed files with 1940 additions and 106 deletions
@@ -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"]
@@ -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.
@@ -0,0 +1,3 @@
__| __|_ )
_| ( / Amazon Linux 2023
___|\___|___|
+11 -1
View File
@@ -170,7 +170,17 @@ aws elbv2 delete-target-group \
| Environment variable | Default | Description | | Environment variable | Default | Description |
|---|---|---| |---|---|---|
| `FLOCI_SERVICES_ELBV2_ENABLED` | `true` | Enable or disable the ELBv2 service | | `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 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.
+2 -1
View File
@@ -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 - 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 - `302` redirects that append a slash when an index document exists below a prefix
- configured error documents and the default website error response - 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. 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 ## Range Reads
@@ -2799,6 +2799,10 @@ public interface EmulatorConfig {
@WithDefault("false") @WithDefault("false")
boolean mock(); boolean mock();
/** Exact image used by the Docker-host listener forwarding sidecar. */
@WithDefault("alpine/socat:1.8.1.3")
String listenerForwarderImage();
} }
interface EksServiceConfig { interface EksServiceConfig {
@@ -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.RouteResponse;
import io.github.hectorvent.floci.services.apigatewayv2.model.Stage; import io.github.hectorvent.floci.services.apigatewayv2.model.Stage;
import io.github.hectorvent.floci.services.apigatewayv2.model.VpcLink; import io.github.hectorvent.floci.services.apigatewayv2.model.VpcLink;
import io.smallrye.common.annotation.Blocking;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE; import jakarta.ws.rs.DELETE;
@@ -72,16 +73,25 @@ public class ApiGatewayController {
private final ApiGatewayV2OpenApiImporter v2OpenApiImporter; private final ApiGatewayV2OpenApiImporter v2OpenApiImporter;
private final RegionResolver regionResolver; private final RegionResolver regionResolver;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ApiGatewayExecuteController executeController;
@Inject @Inject
public ApiGatewayController(ApiGatewayService service, ApiGatewayV2Service v2Service, public ApiGatewayController(ApiGatewayService service, ApiGatewayV2Service v2Service,
ApiGatewayV2OpenApiImporter v2OpenApiImporter, ApiGatewayV2OpenApiImporter v2OpenApiImporter,
RegionResolver regionResolver, ObjectMapper objectMapper) { RegionResolver regionResolver, ObjectMapper objectMapper,
ApiGatewayExecuteController executeController) {
this.service = service; this.service = service;
this.v2Service = v2Service; this.v2Service = v2Service;
this.v2OpenApiImporter = v2OpenApiImporter; this.v2OpenApiImporter = v2OpenApiImporter;
this.regionResolver = regionResolver; this.regionResolver = regionResolver;
this.objectMapper = objectMapper; 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<List<Map<String, String>>> PATCH_OPERATIONS = private static final TypeReference<List<Map<String, String>>> PATCH_OPERATIONS =
@@ -129,6 +139,81 @@ public class ApiGatewayController {
// ──────────────────────────── Specific v1 Paths (ORDER MATTERS) ──────────────────────────── // ──────────────────────────── 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<String, Object> 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<String, String> stringMap(Object value) {
if (!(value instanceof Map<?, ?> map)) {
return Map.of();
}
Map<String, String> result = new HashMap<>();
map.forEach((key, entryValue) -> {
if (key instanceof String stringKey && entryValue instanceof String stringValue) {
result.put(stringKey, stringValue);
}
});
return result;
}
private Map<String, List<String>> stringListMap(Object value) {
if (!(value instanceof Map<?, ?> map)) {
return Map.of();
}
Map<String, List<String>> 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 @GET
@Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}") @Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}")
public Response getMethodResponse(@Context HttpHeaders headers, 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<Map<String, String>> 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 @DELETE
@Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}") @Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}")
public Response deleteMethodResponse(@Context HttpHeaders headers, public Response deleteMethodResponse(@Context HttpHeaders headers,
@@ -387,12 +487,14 @@ public class ApiGatewayController {
@GET @GET
@Path("/restapis/{apiId}/resources") @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<String> embed) {
String region = regionResolver.resolveRegion(headers); String region = regionResolver.resolveRegion(headers);
List<ApiGatewayResource> resources = service.getResources(region, apiId); List<ApiGatewayResource> resources = service.getResources(region, apiId);
boolean embedMethods = embed != null && embed.contains("methods");
ObjectNode root = objectMapper.createObjectNode(); ObjectNode root = objectMapper.createObjectNode();
ArrayNode items = root.putArray("item"); 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(); return Response.ok(root.toString()).type(MediaType.APPLICATION_JSON).build();
} }
@@ -2162,11 +2264,19 @@ public class ApiGatewayController {
} }
private ObjectNode toResourceNode(ApiGatewayResource r) { private ObjectNode toResourceNode(ApiGatewayResource r) {
return toResourceNode(r, false);
}
private ObjectNode toResourceNode(ApiGatewayResource r, boolean embedMethods) {
ObjectNode node = objectMapper.createObjectNode(); ObjectNode node = objectMapper.createObjectNode();
node.put("id", r.getId()); node.put("id", r.getId());
if (r.getParentId() != null) node.put("parentId", r.getParentId()); if (r.getParentId() != null) node.put("parentId", r.getParentId());
if (r.getPathPart() != null) node.put("pathPart", r.getPathPart()); if (r.getPathPart() != null) node.put("pathPart", r.getPathPart());
node.put("path", r.getPath()); 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; return node;
} }
@@ -2186,6 +2296,11 @@ public class ApiGatewayController {
m.getRequestModels().forEach(models::put); m.getRequestModels().forEach(models::put);
node.set("requestModels", models); 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) { if (m.getMethodIntegration() != null) {
node.set("methodIntegration", toIntegrationNode(m.getMethodIntegration())); node.set("methodIntegration", toIntegrationNode(m.getMethodIntegration()));
} }
@@ -2195,6 +2310,14 @@ public class ApiGatewayController {
private ObjectNode toMethodResponseNode(MethodResponse r) { private ObjectNode toMethodResponseNode(MethodResponse r) {
ObjectNode node = objectMapper.createObjectNode(); ObjectNode node = objectMapper.createObjectNode();
node.put("statusCode", r.statusCode()); 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; return node;
} }
@@ -43,6 +43,7 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*; import jakarta.ws.rs.core.*;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import java.net.URI;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -54,6 +55,7 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.Collections; import java.util.Collections;
import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -144,6 +146,12 @@ public class ApiGatewayExecuteController {
private record AuthorizerResult(Response errorResponse, String principalId, Map<String, Object> context) {} private record AuthorizerResult(Response errorResponse, String principalId, Map<String, Object> context) {}
record TestInvokeRequest(String pathWithQueryString, String body, Map<String, String> headers,
Map<String, List<String>> multiValueHeaders,
Map<String, String> 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 * 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.*}, * it: where the request landed and the raw request, for {@code method.request.*},
@@ -471,6 +479,85 @@ public class ApiGatewayExecuteController {
return response; 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) { private void applyApiOwnerContext(ApiGatewayV2Service.ApiOwner owner) {
requestContext.setAccountId(owner.accountId()); requestContext.setAccountId(owner.accountId());
requestContext.setRegion(owner.region()); requestContext.setRegion(owner.region());
@@ -952,7 +1039,8 @@ public class ApiGatewayExecuteController {
return false; return false;
} }
try { try {
return !objectMapper.readTree(payload).isObject(); JsonNode response = objectMapper.readTree(payload);
return !response.isObject() || !response.has("statusCode");
} catch (IOException e) { } catch (IOException e) {
return true; return true;
} }
@@ -1413,6 +1501,9 @@ public class ApiGatewayExecuteController {
} }
try { try {
JsonNode node = objectMapper.readTree(result.getPayload()); JsonNode node = objectMapper.readTree(result.getPayload());
if (!node.isObject() || !node.has("statusCode")) {
return Response.status(502).build();
}
int statusCode = node.path("statusCode").asInt(200); int statusCode = node.path("statusCode").asInt(200);
if (result.getFunctionError() != null && !node.has("statusCode")) statusCode = 502; if (result.getFunctionError() != null && !node.has("statusCode")) statusCode = 502;
@@ -1475,6 +1566,191 @@ public class ApiGatewayExecuteController {
return Optional.empty(); return Optional.empty();
} }
private static final class TestInvokeHeaders implements HttpHeaders {
private final MultivaluedMap<String, String> headers = new MultivaluedHashMap<>();
private TestInvokeHeaders(Map<String, String> singleValueHeaders,
Map<String, List<String>> 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<String> getRequestHeader(String name) {
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
if (entry.getKey().equalsIgnoreCase(name)) {
return entry.getValue();
}
}
return List.of();
}
@Override
public String getHeaderString(String name) {
List<String> values = getRequestHeader(name);
return values == null || values.isEmpty() ? null : String.join(",", values);
}
@Override
public MultivaluedMap<String, String> getRequestHeaders() {
return headers;
}
@Override
public List<MediaType> getAcceptableMediaTypes() {
return List.of();
}
@Override
public List<Locale> getAcceptableLanguages() {
return List.of();
}
@Override
public MediaType getMediaType() {
return null;
}
@Override
public Locale getLanguage() {
return null;
}
@Override
public Map<String, Cookie> 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<String, String> 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<PathSegment> getPathSegments() {
return List.of();
}
@Override
public List<PathSegment> 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<String, String> getPathParameters() {
return new MultivaluedHashMap<>();
}
@Override
public MultivaluedMap<String, String> getPathParameters(boolean decode) {
return getPathParameters();
}
@Override
public MultivaluedMap<String, String> getQueryParameters() {
return queryParameters;
}
@Override
public MultivaluedMap<String, String> getQueryParameters(boolean decode) {
return queryParameters;
}
@Override
public List<String> getMatchedURIs() {
return List.of();
}
@Override
public List<String> getMatchedURIs(boolean decode) {
return List.of();
}
@Override
public List<Object> 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) ──────────────────────────── // ──────────────────────────── AWS (non-proxy) ────────────────────────────
private MultivaluedMap<String, String> parseFormEncodedBody(String body) { private MultivaluedMap<String, String> parseFormEncodedBody(String body) {
@@ -496,10 +496,16 @@ public class ApiGatewayService {
} }
public MethodResponse putMethodResponse(String region, String apiId, String resourceId, public MethodResponse putMethodResponse(String region, String apiId, String resourceId,
String httpMethod, String statusCode, String httpMethod, String statusCode,
Map<String, Object> request) { Map<String, Object> request) {
MethodConfig method = getMethod(region, apiId, resourceId, httpMethod); MethodConfig method = getMethod(region, apiId, resourceId, httpMethod);
MethodResponse mr = new MethodResponse(statusCode, new HashMap<>()); @SuppressWarnings("unchecked")
Map<String, Boolean> responseParameters = (Map<String, Boolean>) request.get("responseParameters");
@SuppressWarnings("unchecked")
Map<String, String> responseModels = (Map<String, String>) 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); method.getMethodResponses().put(statusCode, mr);
resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId)); resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId));
return mr; return mr;
@@ -524,6 +530,49 @@ public class ApiGatewayService {
resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId)); resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId));
} }
public MethodResponse updateMethodResponse(String region, String apiId, String resourceId,
String httpMethod, String statusCode,
List<Map<String, String>> patchOperations) {
MethodResponse response = getMethodResponse(region, apiId, resourceId, httpMethod, statusCode);
if (patchOperations == null) {
throw new AwsException("BadRequestException", "Invalid patch operation", 400);
}
Map<String, Boolean> responseParameters = response.responseParameters() != null
? new HashMap<>(response.responseParameters()) : new HashMap<>();
Map<String, String> responseModels = response.responseModels() != null
? new HashMap<>(response.responseModels()) : new HashMap<>();
for (Map<String, String> 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 ──────────────────────────── // ──────────────────────────── Integrations ────────────────────────────
public Integration putIntegration(String region, String apiId, String resourceId, String httpMethod, Map<String, Object> request) { public Integration putIntegration(String region, String apiId, String resourceId, String httpMethod, Map<String, Object> request) {
@@ -643,18 +692,44 @@ public class ApiGatewayService {
public IntegrationResponse updateIntegrationResponse(String region, String apiId, String resourceId, String httpMethod, String statusCode, List<Map<String, String>> patchOperations) { public IntegrationResponse updateIntegrationResponse(String region, String apiId, String resourceId, String httpMethod, String statusCode, List<Map<String, String>> patchOperations) {
IntegrationResponse response = getIntegrationResponse(region, apiId, resourceId, httpMethod, statusCode); IntegrationResponse response = getIntegrationResponse(region, apiId, resourceId, httpMethod, statusCode);
String selectionPattern = response.selectionPattern(); String selectionPattern = response.selectionPattern();
if (patchOperations != null) { String contentHandling = response.contentHandling();
for (Map<String, String> patch : patchOperations) { Map<String, String> responseParameters = new HashMap<>(response.responseParameters());
String op = patch.get("op"); Map<String, String> responseTemplates = new HashMap<>(response.responseTemplates());
String path = patch.get("path"); if (patchOperations == null) {
String value = patch.get("value"); throw new AwsException("BadRequestException", "Invalid patch operation", 400);
if (value == null || !("add".equals(op) || "replace".equals(op)) || !"/selectionPattern".equals(path)) { }
throw new AwsException("BadRequestException", "Invalid patch operation", 400); for (Map<String, String> 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); getIntegration(region, apiId, resourceId, httpMethod).getIntegrationResponses().put(statusCode, newResponse);
resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId)); resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId));
return newResponse; return newResponse;
@@ -10,6 +10,7 @@ import java.util.Map;
@RegisterForReflection @RegisterForReflection
public record MethodResponse( public record MethodResponse(
String statusCode, String statusCode,
Map<String, Boolean> responseParameters Map<String, Boolean> responseParameters,
Map<String, String> responseModels
) { ) {
} }
@@ -109,6 +109,8 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner {
List<AttributeDefinition> attrDefs = new ArrayList<>(); List<AttributeDefinition> attrDefs = new ArrayList<>();
List<GlobalSecondaryIndex> gsis = new ArrayList<>(); List<GlobalSecondaryIndex> gsis = new ArrayList<>();
List<LocalSecondaryIndex> lsis = new ArrayList<>(); List<LocalSecondaryIndex> lsis = new ArrayList<>();
Long readCapacity = null;
Long writeCapacity = null;
if (props != null && props.has("KeySchema")) { if (props != null && props.has("KeySchema")) {
for (JsonNode ks : props.get("KeySchema")) { for (JsonNode ks : props.get("KeySchema")) {
@@ -124,6 +126,11 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner {
attrDefs.add(new AttributeDefinition(attrName, attrType)); 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")) { if (props != null && props.has("GlobalSecondaryIndexes")) {
for (JsonNode gsiNode : props.get("GlobalSecondaryIndexes")) { for (JsonNode gsiNode : props.get("GlobalSecondaryIndexes")) {
@@ -179,7 +186,7 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner {
TableDefinition table; TableDefinition table;
try { 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) { } catch (AwsException e) {
if (!"ResourceInUseException".equals(e.getErrorCode())) { if (!"ResourceInUseException".equals(e.getErrorCode())) {
throw e; throw e;
@@ -1228,6 +1228,15 @@ public class Ec2ContainerManager {
* Sets terminatedAt for TTL pruning. * Sets terminatedAt for TTL pruning.
*/ */
public void terminate(Instance instance) { 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 containerId;
String containerIp; String containerIp;
String imdsSourceIp; String imdsSourceIp;
@@ -1273,6 +1282,7 @@ public class Ec2ContainerManager {
instance.getPrivateIpAddress()); instance.getPrivateIpAddress());
instance.setState(InstanceState.terminated()); instance.setState(InstanceState.terminated());
instance.setTerminatedAt(System.currentTimeMillis()); instance.setTerminatedAt(System.currentTimeMillis());
onTerminated.run();
}); });
} }
@@ -11,30 +11,32 @@ import java.util.Optional;
*/ */
public final class Ec2MetadataProxy { public final class Ec2MetadataProxy {
private static final int IMDS_PROXY_PORT = 1338;
private Ec2MetadataProxy() {} private Ec2MetadataProxy() {}
public static String[] installCommand() { public static String[] installCommand() {
return new String[]{"sh", "-c", String.join("\n", return new String[]{"sh", "-c", String.join("\n",
"set -eu", "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", "if command -v apt-get >/dev/null 2>&1; then",
" apt-get update -qq >/dev/null", " 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", "elif command -v dnf >/dev/null 2>&1; then",
// --allowerasing lets dnf swap the curl-minimal that // --allowerasing lets dnf swap the curl-minimal that
// public.ecr.aws/amazonlinux/amazonlinux:2023 ships by default for the full // public.ecr.aws/amazonlinux/amazonlinux:2023 ships by default for the full
// curl package this proxy needs. Without it, dnf aborts the whole transaction // 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 // on a curl/curl-minimal conflict and iproute+socat never install either, even
// though neither of them conflicts with anything. // 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 // 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 // launched from ami-amazonlinux2 this chain reached its else branch and exited 1
// with "No supported package manager found for IMDS proxy dependencies", // with "No supported package manager found for IMDS proxy dependencies",
// leaving the instance without a link-local IMDS endpoint. // leaving the instance without a link-local IMDS endpoint.
"elif command -v yum >/dev/null 2>&1; then", "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", "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", "else",
" echo 'No supported package manager found for IMDS proxy dependencies' >&2", " echo 'No supported package manager found for IMDS proxy dependencies' >&2",
" exit 1", " exit 1",
@@ -42,8 +44,24 @@ public final class Ec2MetadataProxy {
} }
public static String[] startCommand(String flociHost, int imdsPort) { public static String[] startCommand(String flociHost, int imdsPort) {
return startCommand("imds", "169.254.169.254", 80, flociHost, imdsPort, return new String[]{"sh", "-c", String.join("\n",
"curl -fsS --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null && exit 0"); "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) { public static String[] podIdentityStartCommand(String flociHost, int flociPort) {
@@ -21,6 +21,9 @@ import java.util.Random;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; 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.concurrent.atomic.AtomicInteger;
import java.util.function.BiPredicate; import java.util.function.BiPredicate;
import java.util.function.Function; import java.util.function.Function;
@@ -31,6 +34,8 @@ import java.util.stream.Stream;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import com.fasterxml.jackson.core.type.TypeReference; 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.config.EmulatorConfig;
import io.github.hectorvent.floci.core.common.RequestContext; import io.github.hectorvent.floci.core.common.RequestContext;
import io.github.hectorvent.floci.core.common.AwsArnUtils; import io.github.hectorvent.floci.core.common.AwsArnUtils;
@@ -216,6 +221,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
private final Set<String> seededAccountRegions = ConcurrentHashMap.newKeySet(); private final Set<String> seededAccountRegions = ConcurrentHashMap.newKeySet();
// subnetId → counter for IP assignment (runtime-only, not persisted) // subnetId → counter for IP assignment (runtime-only, not persisted)
private final Map<String, AtomicInteger> subnetIpCounters = new ConcurrentHashMap<>(); private final Map<String, AtomicInteger> subnetIpCounters = new ConcurrentHashMap<>();
private ScheduledExecutorService terminatedInstancePruner;
/** /**
* Null in the hermetic unit tests, which reach the constructors that do not take it; CDI always * 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(); restoreVpcNetworks();
recoverInterruptedTerminations();
int restored = 0; int restored = 0;
for (String key : instances.keys()) { for (String key : instances.keys()) {
@@ -560,6 +567,17 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
return !"terminated".equals(state) && !"shutting-down".equals(state); 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 * 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. * 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<Reservation> describeInstances(String region, List<String> instanceIds, Map<String, List<String>> filters) { public List<Reservation> describeInstances(String region, List<String> instanceIds, Map<String, List<String>> filters) {
pruneTerminatedInstances();
ensureDefaultResources(region); ensureDefaultResources(region);
if (!instanceIds.isEmpty()) { if (!instanceIds.isEmpty()) {
for (String id : instanceIds) { for (String id : instanceIds) {
@@ -3130,7 +3149,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
inst.setState(InstanceState.terminated()); inst.setState(InstanceState.terminated());
inst.setTerminatedAt(System.currentTimeMillis()); inst.setTerminatedAt(System.currentTimeMillis());
} else { } else {
containerManager.terminate(inst); containerManager.terminate(inst, () -> instances.put(key(region, id), inst));
} }
// Delete root volume if deleteOnTermination (matches real AWS behavior) // Delete root volume if deleteOnTermination (matches real AWS behavior)
if (inst.getRootVolumeId() != null) { 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() { public void pruneTerminatedInstances() {
long cutoff = System.currentTimeMillis() - 3_600_000L; long cutoff = System.currentTimeMillis() - 3_600_000L;
for (String storeKey : new ArrayList<>(instances.keys())) { 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<Instance> describeInstanceStatus(String region, List<String> instanceIds) { public List<Instance> describeInstanceStatus(String region, List<String> instanceIds) {
ensureDefaultResources(region); ensureDefaultResources(region);
if (config.services().ec2().mock()) { if (config.services().ec2().mock()) {
@@ -174,6 +174,12 @@ public class EcrRegistryManager {
* the container name plus the container-internal port (not the published host port). * the container name plus the container-internal port (not the published host port).
*/ */
public String internalEndpoint() { 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; return "http://" + registryContainerName() + ":" + CONTAINER_INTERNAL_PORT;
} }
@@ -1,12 +1,16 @@
package io.github.hectorvent.floci.services.ecs; 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.container.EcsContainerManager;
import io.github.hectorvent.floci.services.ecs.model.Container; 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.EcsLoadBalancer;
import io.github.hectorvent.floci.services.ecs.model.EcsServiceModel; 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.EcsTask;
import io.github.hectorvent.floci.services.ecs.model.NetworkBinding; 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.ElbV2Service;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
import io.github.hectorvent.floci.services.elbv2.model.TargetDescription; import io.github.hectorvent.floci.services.elbv2.model.TargetDescription;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@@ -14,6 +18,7 @@ import org.jboss.logging.Logger;
import java.util.List; import java.util.List;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.stream.Stream;
/** /**
* Bridges ECS services to ELBv2: when an ECS service declares a {@code loadBalancers} * Bridges ECS services to ELBv2: when an ECS service declares a {@code loadBalancers}
@@ -37,6 +42,40 @@ public class EcsLoadBalancerRegistrar {
this.containerManager = containerManager; 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<EcsLoadBalancer> 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.<PortMapping>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. */ /** Registers the task's load-balanced containers as ELBv2 targets. */
public void registerTask(EcsTask task, EcsServiceModel svc, String region) { public void registerTask(EcsTask task, EcsServiceModel svc, String region) {
forEachTarget(task, svc, (tgArn, td) -> { forEachTarget(task, svc, (tgArn, td) -> {
@@ -1166,6 +1166,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
} }
EcsCluster cluster = resolveClusterOrDefault(request.getCluster(), region); EcsCluster cluster = resolveClusterOrDefault(request.getCluster(), region);
TaskDefinition taskDef = resolveLaunchableTaskDefinition(request.getTaskDefinition(), region); TaskDefinition taskDef = resolveLaunchableTaskDefinition(request.getTaskDefinition(), region);
validateEc2BridgeLaunch(cluster, taskDef, request);
return launchTasks(cluster, taskDef, request, null, null, region); return launchTasks(cluster, taskDef, request, null, null, region);
} }
@@ -1425,7 +1426,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
strategy = clusterDefaultStrategy(cluster); strategy = clusterDefaultStrategy(cluster);
} }
if (strategy == null || strategy.isEmpty()) { 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 Collections.nCopies(count, new Placement(launchType, null));
} }
return distribute(strategy, count); return distribute(strategy, count);
@@ -1552,6 +1553,28 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
return new Placement(launchType, capacityProvider); 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<CapacityProviderStrategyItem> 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. */ /** Accepts the two built-in Fargate providers as well as anything CreateCapacityProvider made. */
private void requireCapacityProvider(String name) { private void requireCapacityProvider(String name) {
if (name == null || name.isBlank()) { 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 // 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. // reconciler compares it with each task's taskDefinitionArn, so pin it here.
TaskDefinition taskDef = resolveLaunchableTaskDefinition(request.getTaskDefinition(), region); 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 taskDefinition = taskDef.getTaskDefinitionArn();
String serviceName = request.getServiceName(); String serviceName = request.getServiceName();
@@ -2335,7 +2363,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
strategy = clusterDefaultStrategy(cluster); strategy = clusterDefaultStrategy(cluster);
} }
if (strategy == null || strategy.isEmpty()) { if (strategy == null || strategy.isEmpty()) {
return LaunchType.FARGATE; return LaunchType.EC2;
} }
validateCapacityProviderStrategy(strategy); validateCapacityProviderStrategy(strategy);
return placementFor(strategy.getFirst().capacityProvider()).launchType(); 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. // 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); containerManager.cleanupStoppedTask(claimed);
if (task.getContainers() != null) { if (task.getContainers() != null) {
@@ -5,6 +5,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.config.TlsProxyServer; import io.github.hectorvent.floci.config.TlsProxyServer;
import io.github.hectorvent.floci.services.ec2.Ec2Service; 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.Action;
import io.github.hectorvent.floci.services.elbv2.model.Listener; import io.github.hectorvent.floci.services.elbv2.model.Listener;
import io.github.hectorvent.floci.services.elbv2.model.LoadBalancer; import io.github.hectorvent.floci.services.elbv2.model.LoadBalancer;
@@ -37,6 +40,7 @@ import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
@@ -76,6 +80,12 @@ public class ElbV2DataPlane {
@Inject @Inject
Ec2Service ec2Service; Ec2Service ec2Service;
@Inject
ContainerNetworkReachability containerNetworkReachability;
@Inject
ElbV2ListenerForwardManager listenerForwardManager;
@Inject @Inject
ObjectMapper objectMapper; ObjectMapper objectMapper;
@@ -102,6 +112,9 @@ public class ElbV2DataPlane {
@PreDestroy @PreDestroy
void shutdown() { void shutdown() {
for (Map.Entry<String, ListenerBinding> entry : listenerBindings.entrySet()) {
listenerForwardManager.remove(entry.getValue().port());
}
for (Map.Entry<Integer, HttpServer> e : servers.entrySet()) { for (Map.Entry<Integer, HttpServer> e : servers.entrySet()) {
e.getValue().close(); e.getValue().close();
} }
@@ -127,6 +140,10 @@ public class ElbV2DataPlane {
.put(binding.host(), listenerArn); .put(binding.host(), listenerArn);
ensureReleaseHandlerRegistered(); ensureReleaseHandlerRegistered();
servers.computeIfAbsent(binding.port(), this::startPortServer); 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<Rule> rules) { public void restartListener(Listener listener, String region, List<Rule> rules) {
@@ -150,6 +167,9 @@ public class ElbV2DataPlane {
Map<String, String> listenersByHost = listenersByHostAndPort.get(oldBinding.port()); Map<String, String> listenersByHost = listenersByHostAndPort.get(oldBinding.port());
if (listenersByHost != null) { if (listenersByHost != null) {
listenersByHost.remove(oldBinding.host(), listenerArn); listenersByHost.remove(oldBinding.host(), listenerArn);
if (listenersByHost.isEmpty()) {
listenerForwardManager.remove(oldBinding.port());
}
closePortServerIfUnused(oldBinding.port(), listenersByHost); closePortServerIfUnused(oldBinding.port(), listenersByHost);
} }
} }
@@ -162,6 +182,9 @@ public class ElbV2DataPlane {
Map<String, String> listenersByHost = listenersByHostAndPort.get(binding.port()); Map<String, String> listenersByHost = listenersByHostAndPort.get(binding.port());
if (listenersByHost != null) { if (listenersByHost != null) {
listenersByHost.remove(binding.host(), listenerArn); listenersByHost.remove(binding.host(), listenerArn);
if (listenersByHost.isEmpty()) {
listenerForwardManager.remove(binding.port());
}
closePortServerIfUnused(binding.port(), listenersByHost); closePortServerIfUnused(binding.port(), listenersByHost);
} }
} }
@@ -268,6 +291,7 @@ public class ElbV2DataPlane {
} }
private void clearPortBindings(int port) { private void clearPortBindings(int port) {
listenerForwardManager.remove(port);
Map<String, String> listenersByHost = listenersByHostAndPort.remove(port); Map<String, String> listenersByHost = listenersByHostAndPort.remove(port);
if (listenersByHost == null) { if (listenersByHost == null) {
return; return;
@@ -296,6 +320,11 @@ public class ElbV2DataPlane {
req.response().setStatusCode(502).end("No listener region"); req.response().setStatusCode(502).end("No listener region");
return; 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<List<CompiledRule>> ref = ruleChains.get(listenerArn); AtomicReference<List<CompiledRule>> ref = ruleChains.get(listenerArn);
if (ref == null) { if (ref == null) {
req.response().setStatusCode(502).end("No rule chain"); req.response().setStatusCode(502).end("No rule chain");
@@ -378,18 +407,19 @@ public class ElbV2DataPlane {
List<TargetDescription> allTargets = tg.getTargets(); List<TargetDescription> allTargets = tg.getTargets();
List<TargetDescription> healthy = allTargets.stream() List<TargetDescription> 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()); .collect(Collectors.toList());
List<TargetDescription> candidates = healthy.isEmpty() ? allTargets : healthy; if (healthy.isEmpty()) {
if (candidates.isEmpty()) {
req.response().setStatusCode(503).end("No targets available"); req.response().setStatusCode(503).end("No targets available");
return; return;
} }
AtomicInteger counter = rrCounters.computeIfAbsent(tgArn, k -> new AtomicInteger(0)); AtomicInteger counter = rrCounters.computeIfAbsent(tgArn, k -> new AtomicInteger(0));
int idx = Math.abs(counter.getAndIncrement() % candidates.size()); int idx = Math.floorMod(counter.getAndIncrement(), healthy.size());
TargetDescription target = candidates.get(idx); TargetDescription target = healthy.get(idx);
int targetPort = ElbV2HealthChecker.effectivePort(target, tg); 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)); preserveHostHeader(listenerArn, region));
} }
@@ -404,6 +434,20 @@ public class ElbV2DataPlane {
&& Boolean.parseBoolean(loadBalancer.getAttributes().get(PRESERVE_HOST_HEADER_ATTRIBUTE)); && 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<SecurityGroup> 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) { private void invokeLambdaTarget(io.vertx.core.http.HttpServerRequest req, String functionArn, String region) {
if (req.isEnded()) { if (req.isEnded()) {
invokeLambdaWithBody(req, functionArn, region, Buffer.buffer()); invokeLambdaWithBody(req, functionArn, region, Buffer.buffer());
@@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.elbv2;
import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.core.common.Resettable; 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.ec2.Ec2Service;
import io.github.hectorvent.floci.services.elbv2.model.TargetDescription; import io.github.hectorvent.floci.services.elbv2.model.TargetDescription;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup; import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
@@ -27,6 +28,7 @@ public class ElbV2HealthChecker implements Resettable {
private final Vertx vertx; private final Vertx vertx;
private final EmulatorConfig config; private final EmulatorConfig config;
private final Ec2Service ec2Service; private final Ec2Service ec2Service;
private final ContainerNetworkReachability containerNetworkReachability;
// tgArn → (targetKey → TargetState) // tgArn → (targetKey → TargetState)
private final Map<String, Map<String, TargetState>> states = new ConcurrentHashMap<>(); private final Map<String, Map<String, TargetState>> states = new ConcurrentHashMap<>();
@@ -34,10 +36,12 @@ public class ElbV2HealthChecker implements Resettable {
private final Map<String, Long> timers = new ConcurrentHashMap<>(); private final Map<String, Long> timers = new ConcurrentHashMap<>();
@Inject @Inject
public ElbV2HealthChecker(Vertx vertx, EmulatorConfig config, Ec2Service ec2Service) { public ElbV2HealthChecker(Vertx vertx, EmulatorConfig config, Ec2Service ec2Service,
ContainerNetworkReachability containerNetworkReachability) {
this.vertx = vertx; this.vertx = vertx;
this.config = config; this.config = config;
this.ec2Service = ec2Service; this.ec2Service = ec2Service;
this.containerNetworkReachability = containerNetworkReachability;
} }
public static int effectivePort(TargetDescription target, TargetGroup tg) { public static int effectivePort(TargetDescription target, TargetGroup tg) {
@@ -50,6 +54,18 @@ public class ElbV2HealthChecker implements Resettable {
return 80; 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) { public void startMonitoring(TargetGroup tg) {
if (config.services().elbv2().mock()) { if (config.services().elbv2().mock()) {
return; return;
@@ -60,6 +76,11 @@ public class ElbV2HealthChecker implements Resettable {
String tgArn = tg.getTargetGroupArn(); String tgArn = tg.getTargetGroupArn();
states.computeIfAbsent(tgArn, k -> new ConcurrentHashMap<>()); 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 intervalMs = (tg.getHealthCheckIntervalSeconds() != null ? tg.getHealthCheckIntervalSeconds() : 30) * 1000L;
long timerId = vertx.setPeriodic(intervalMs, id -> probeAll(tgArn, tg)); long timerId = vertx.setPeriodic(intervalMs, id -> probeAll(tgArn, tg));
timers.put(tgArn, timerId); timers.put(tgArn, timerId);
@@ -85,7 +106,7 @@ public class ElbV2HealthChecker implements Resettable {
} }
Map<String, TargetState> tgStates = states.computeIfAbsent(tgArn, k -> new ConcurrentHashMap<>()); Map<String, TargetState> tgStates = states.computeIfAbsent(tgArn, k -> new ConcurrentHashMap<>());
for (TargetDescription t : targets) { for (TargetDescription t : targets) {
int port = effectivePort(t, tg); int port = effectiveHealthCheckPort(t, tg);
String key = stateKey(t.getId(), port); String key = stateKey(t.getId(), port);
tgStates.putIfAbsent(key, new TargetState()); tgStates.putIfAbsent(key, new TargetState());
} }
@@ -100,7 +121,7 @@ public class ElbV2HealthChecker implements Resettable {
return; return;
} }
for (TargetDescription t : targets) { for (TargetDescription t : targets) {
int port = effectivePort(t, tg); int port = effectiveHealthCheckPort(t, tg);
tgStates.remove(stateKey(t.getId(), port)); tgStates.remove(stateKey(t.getId(), port));
} }
} }
@@ -123,7 +144,7 @@ public class ElbV2HealthChecker implements Resettable {
public boolean isHealthy(String tgArn, TargetDescription target, int port) { public boolean isHealthy(String tgArn, TargetDescription target, int port) {
String state = getState(tgArn, target.getId(), 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) { private void probeAll(String tgArn, TargetGroup tg) {
@@ -145,7 +166,11 @@ public class ElbV2HealthChecker implements Resettable {
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
continue; 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 path = tg.getHealthCheckPath() != null ? tg.getHealthCheckPath() : "/";
String matcher = tg.getMatcher() != null ? tg.getMatcher() : "200"; String matcher = tg.getMatcher() != null ? tg.getMatcher() : "200";
int timeout = tg.getHealthCheckTimeoutSeconds() != null ? tg.getHealthCheckTimeoutSeconds() : 5; 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; int unhealthyThreshold = tg.getUnhealthyThresholdCount() != null ? tg.getUnhealthyThresholdCount() : 2;
vertx.executeBlocking(() -> { vertx.executeBlocking(() -> {
return probe(host, port, path, timeout); return probe(endpoint.host(), endpoint.port(), path, timeout);
}).onSuccess(statusCode -> { }).onSuccess(statusCode -> {
boolean success = matchesStatusCode(statusCode, matcher); boolean success = matchesStatusCode(statusCode, matcher);
if (success) { if (success) {
@@ -189,7 +214,7 @@ public class ElbV2HealthChecker implements Resettable {
state.description = "Health checks failed"; 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());
}); });
} }
} }
@@ -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<String> 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;
}
}
@@ -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.AwsException;
import io.github.hectorvent.floci.core.common.RegionResolver; import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.common.SsrfProtection; 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.StorageBackedMap;
import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.ec2.Ec2Service; import io.github.hectorvent.floci.services.ec2.Ec2Service;
@@ -232,10 +231,11 @@ public class ElbV2Service implements ResourceProvider {
} }
private String loadBalancerDnsSuffix() { private String loadBalancerDnsSuffix() {
if (config == null) { if (config != null && config.hostname().isPresent()) {
return EmbeddedDnsServer.DEFAULT_SUFFIX; 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<LoadBalancer> describeLoadBalancers(String region, List<String> arns, List<String> names, public List<LoadBalancer> describeLoadBalancers(String region, List<String> arns, List<String> names,
@@ -357,6 +357,7 @@ public class ElbV2Service implements ResourceProvider {
String matcher, String ipAddressType, String matcher, String ipAddressType,
Map<String, String> initialTags) { Map<String, String> initialTags) {
validateName(name, "target group"); validateName(name, "target group");
validateHttpTargetGroup(protocol, targetType, healthCheckProtocol);
Map<String, TargetGroup> regionTgs = targetGroups.computeIfAbsent(region, k -> new ConcurrentHashMap<>()); Map<String, TargetGroup> regionTgs = targetGroups.computeIfAbsent(region, k -> new ConcurrentHashMap<>());
boolean duplicate = regionTgs.values().stream() boolean duplicate = regionTgs.values().stream()
.anyMatch(tg -> tg.getTargetGroupName().equals(name)); .anyMatch(tg -> tg.getTargetGroupName().equals(name));
@@ -450,6 +451,8 @@ public class ElbV2Service implements ResourceProvider {
Integer healthCheckTimeout, Integer healthyThreshold, Integer healthCheckTimeout, Integer healthyThreshold,
Integer unhealthyThreshold, String matcher) { Integer unhealthyThreshold, String matcher) {
TargetGroup tg = requireTargetGroup(region, arn); 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 (healthCheckProtocol != null) tg.setHealthCheckProtocol(healthCheckProtocol);
if (healthCheckPort != null) tg.setHealthCheckPort(healthCheckPort); if (healthCheckPort != null) tg.setHealthCheckPort(healthCheckPort);
if (healthCheckEnabled != null) tg.setHealthCheckEnabled(healthCheckEnabled); if (healthCheckEnabled != null) tg.setHealthCheckEnabled(healthCheckEnabled);
@@ -460,6 +463,11 @@ public class ElbV2Service implements ResourceProvider {
if (unhealthyThreshold != null) tg.setUnhealthyThresholdCount(unhealthyThreshold); if (unhealthyThreshold != null) tg.setUnhealthyThresholdCount(unhealthyThreshold);
if (matcher != null) tg.setMatcher(matcher); if (matcher != null) tg.setMatcher(matcher);
persistRegion(targetGroups, region); persistRegion(targetGroups, region);
healthChecker.stopMonitoring(arn);
healthChecker.startMonitoring(tg);
if (!tg.getTargets().isEmpty()) {
healthChecker.addTargets(arn, tg.getTargets(), tg);
}
} }
public Map<String, String> describeTargetGroupAttributes(String region, String arn) { public Map<String, String> describeTargetGroupAttributes(String region, String arn) {
@@ -479,7 +487,9 @@ public class ElbV2Service implements ResourceProvider {
String sslPolicy, List<String> certificates, String sslPolicy, List<String> certificates,
List<Action> defaultActions, List<String> alpnPolicy, List<Action> defaultActions, List<String> alpnPolicy,
Map<String, String> initialTags) { Map<String, String> initialTags) {
requireLoadBalancer(region, lbArn); LoadBalancer loadBalancer = requireLoadBalancer(region, lbArn);
validateHttpListener(loadBalancer, protocol);
validateForwardActions(region, defaultActions);
Map<String, Listener> regionListeners = listeners.computeIfAbsent(region, k -> new ConcurrentHashMap<>()); Map<String, Listener> 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); "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 lbType = lb.getType() != null ? lb.getType() : "application";
String typePrefix = lbTypePrefix(lbType); String typePrefix = lbTypePrefix(lbType);
String lbId = arnId(lbArn); String lbId = arnId(lbArn);
@@ -584,6 +594,11 @@ public class ElbV2Service implements ResourceProvider {
String sslPolicy, List<String> certificates, String sslPolicy, List<String> certificates,
List<Action> defaultActions, List<String> alpnPolicy) { List<Action> defaultActions, List<String> alpnPolicy) {
Listener listener = requireListener(region, listenerArn); 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 restartDataPlane = false;
boolean recompileRules = false; boolean recompileRules = false;
@@ -632,6 +647,7 @@ public class ElbV2Service implements ResourceProvider {
public Rule createRule(String region, String listenerArn, List<RuleCondition> conditions, public Rule createRule(String region, String listenerArn, List<RuleCondition> conditions,
int priority, List<Action> actions, Map<String, String> initialTags) { int priority, List<Action> actions, Map<String, String> initialTags) {
requireListener(region, listenerArn); requireListener(region, listenerArn);
validateForwardActions(region, actions);
if (priority < 1 || priority > 50000) { if (priority < 1 || priority > 50000) {
throw new AwsException("ValidationError", "Priority must be between 1 and 50000.", 400); 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<RuleCondition> conditions, List<Action> actions) { public Rule modifyRule(String region, String ruleArn, List<RuleCondition> conditions, List<Action> actions) {
Rule rule = requireRule(region, ruleArn); Rule rule = requireRule(region, ruleArn);
if (actions != null) {
validateForwardActions(region, actions);
}
String listenerArn = rule.getListenerArn(); String listenerArn = rule.getListenerArn();
if (conditions != null) rule.setConditions(new ArrayList<>(conditions)); if (conditions != null) rule.setConditions(new ArrayList<>(conditions));
if (actions != null) rule.setActions(new ArrayList<>(actions)); if (actions != null) rule.setActions(new ArrayList<>(actions));
@@ -830,7 +849,7 @@ public class ElbV2Service implements ResourceProvider {
th.setState("healthy"); th.setState("healthy");
return th; return th;
} }
int port = ElbV2HealthChecker.effectivePort(t, tg); int port = ElbV2HealthChecker.effectiveHealthCheckPort(t, tg);
th.setHealthCheckPort(String.valueOf(port)); th.setHealthCheckPort(String.valueOf(port));
if (hasFilterTargets && !isRegisteredTarget(tg, t, port)) { if (hasFilterTargets && !isRegisteredTarget(tg, t, port)) {
th.setState("unused"); th.setState("unused");
@@ -849,7 +868,60 @@ public class ElbV2Service implements ResourceProvider {
private static boolean isRegisteredTarget(TargetGroup targetGroup, TargetDescription candidate, int candidatePort) { private static boolean isRegisteredTarget(TargetGroup targetGroup, TargetDescription candidate, int candidatePort) {
return targetGroup.getTargets().stream() return targetGroup.getTargets().stream()
.anyMatch(registered -> Objects.equals(registered.getId(), candidate.getId()) .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<Action> actions) {
if (actions == null) {
return;
}
for (Action action : actions) {
if (!"forward".equals(action.getType())) {
continue;
}
List<String> 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 ────────────────────────────────────────────────────────────────── // ── Tags ──────────────────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
package io.github.hectorvent.floci.services.elbv2; package io.github.hectorvent.floci.services.elbv2;
import io.github.hectorvent.floci.core.common.SsrfProtection; 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.Ec2Service;
import io.github.hectorvent.floci.services.ec2.model.Instance; 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.TargetDescription;
@@ -11,6 +12,8 @@ import java.net.InetAddress;
final class ElbV2TargetResolver { final class ElbV2TargetResolver {
static final String DOCKER_HOST = "host.docker.internal";
private ElbV2TargetResolver() {} private ElbV2TargetResolver() {}
static String resolveHost(Ec2Service ec2Service, TargetGroup targetGroup, TargetDescription target) { static String resolveHost(Ec2Service ec2Service, TargetGroup targetGroup, TargetDescription target) {
@@ -37,6 +40,30 @@ final class ElbV2TargetResolver {
return targetId; 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. */ /** Resolves once, rejects metadata addresses, and returns the address to connect to instead of the name. */
static String resolveCheckedAddress(String host) throws IOException { static String resolveCheckedAddress(String host) throws IOException {
InetAddress[] addresses = SsrfProtection.rejectMetadataAddresses(InetAddress.getAllByName(host), host); InetAddress[] addresses = SsrfProtection.rejectMetadataAddresses(InetAddress.getAllByName(host), host);
@@ -46,4 +73,7 @@ final class ElbV2TargetResolver {
static boolean isIpLiteral(String host) { static boolean isIpLiteral(String host) {
return host != null && (host.contains(":") || host.matches("[0-9.]+")); return host != null && (host.contains(":") || host.matches("[0-9.]+"));
} }
record TargetEndpoint(String host, int port) {
}
} }
@@ -34,6 +34,7 @@ public class LambdaExecutorService {
private final WarmPool warmPool; private final WarmPool warmPool;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final LambdaConcurrencyLimiter concurrencyLimiter; private final LambdaConcurrencyLimiter concurrencyLimiter;
private final LambdaPlatformLogWriter platformLogWriter;
private final ExecutorService asyncExecutor = new ThreadPoolExecutor( private final ExecutorService asyncExecutor = new ThreadPoolExecutor(
Math.max(4, Runtime.getRuntime().availableProcessors() * 2), Math.max(4, Runtime.getRuntime().availableProcessors() * 2),
Math.max(8, Runtime.getRuntime().availableProcessors() * 4), Math.max(8, Runtime.getRuntime().availableProcessors() * 4),
@@ -43,11 +44,17 @@ public class LambdaExecutorService {
@Inject @Inject
public LambdaExecutorService(WarmPool warmPool, public LambdaExecutorService(WarmPool warmPool,
ObjectMapper objectMapper, ObjectMapper objectMapper,
LambdaConcurrencyLimiter concurrencyLimiter) { LambdaConcurrencyLimiter concurrencyLimiter,
LambdaPlatformLogWriter platformLogWriter) {
this.warmPool = warmPool; this.warmPool = warmPool;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.concurrencyLimiter = concurrencyLimiter; 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) { 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) { private InvokeResult executeSync(LambdaFunction fn, byte[] payload, String requestId) {
long startedAt = System.nanoTime();
platformLogStart(fn, requestId);
InvokeResult result = null;
ContainerHandle handle; ContainerHandle handle;
try { try {
handle = warmPool.acquire(fn); handle = warmPool.acquire(fn);
} catch (Exception e) { } catch (Exception e) {
LOG.warnv("Failed to acquire container for function {0}: {1}", fn.getFunctionName(), e.getMessage()); 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"), buildErrorPayload("Failed to start Lambda container: " + e.getMessage(), "Lambda.InitError"),
null, requestId); null, requestId);
platformLogComplete(fn, requestId, startedAt, result);
return result;
} }
try { try {
long deadlineMs = System.currentTimeMillis() + (long) fn.getTimeout() * 1000; long deadlineMs = System.currentTimeMillis() + (long) fn.getTimeout() * 1000;
@@ -103,7 +115,7 @@ public class LambdaExecutorService {
java.util.concurrent.CompletableFuture.anyOf( java.util.concurrent.CompletableFuture.anyOf(
invocation.getDispatchedFuture(), invocation.getResultFuture()) invocation.getDispatchedFuture(), invocation.getResultFuture())
.get(fn.getTimeout() + RUNTIME_DISPATCH_GRACE_SECONDS, TimeUnit.SECONDS); .get(fn.getTimeout() + RUNTIME_DISPATCH_GRACE_SECONDS, TimeUnit.SECONDS);
InvokeResult result = invocation.getResultFuture().get(); result = invocation.getResultFuture().get();
warmPool.release(handle); warmPool.release(handle);
return result; return result;
@@ -111,20 +123,37 @@ public class LambdaExecutorService {
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
warmPool.destroyHandle(handle); 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) { } catch (Exception e) {
Throwable cause = e instanceof ExecutionException && e.getCause() != null ? e.getCause() : e; Throwable cause = e instanceof ExecutionException && e.getCause() != null ? e.getCause() : e;
if (cause instanceof TimeoutException) { if (cause instanceof TimeoutException) {
LOG.warnv("Function {0} timed out after {1}s", fn.getFunctionName(), fn.getTimeout()); LOG.warnv("Function {0} timed out after {1}s", fn.getFunctionName(), fn.getTimeout());
warmPool.destroyHandle(handle); warmPool.destroyHandle(handle);
return new InvokeResult(200, "Unhandled", result = new InvokeResult(200, "Unhandled",
buildErrorPayload("Task timed out after " + fn.getTimeout() + " seconds", "Function.TimedOut"), buildErrorPayload("Task timed out after " + fn.getTimeout() + " seconds", "Function.TimedOut"),
null, requestId); null, requestId);
return result;
} }
LOG.warnv("Invocation error for function {0}: {1}", fn.getFunctionName(), cause.getMessage()); LOG.warnv("Invocation error for function {0}: {1}", fn.getFunctionName(), cause.getMessage());
warmPool.destroyHandle(handle); warmPool.destroyHandle(handle);
return new InvokeResult(200, "Unhandled", result = new InvokeResult(200, "Unhandled",
buildErrorPayload(cause.getMessage(), "InvocationError"), null, requestId); 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);
} }
} }
@@ -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;
}
}
}
}
@@ -229,6 +229,12 @@ public class S3Controller {
@Context UriInfo uriInfo, @Context UriInfo uriInfo,
@Context HttpHeaders httpHeaders) { @Context HttpHeaders httpHeaders) {
try { try {
if (isWebsiteRequest(httpHeaders, uriInfo)) {
Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo);
if (websiteRedirect != null) {
return headOnlyResponse(websiteRedirect);
}
}
S3Service.RequestAuthorization authorization = S3RequestAuthorizationParser.parseIfRequired( S3Service.RequestAuthorization authorization = S3RequestAuthorizationParser.parseIfRequired(
s3Service.isAuthEnforced(), httpHeaders, uriInfo); s3Service.isAuthEnforced(), httpHeaders, uriInfo);
if (isWebsiteRequest(httpHeaders, uriInfo)) { if (isWebsiteRequest(httpHeaders, uriInfo)) {
@@ -543,11 +549,17 @@ public class S3Controller {
@QueryParam("encoding-type") String encodingType, @QueryParam("encoding-type") String encodingType,
@QueryParam("key-marker") String keyMarker, @QueryParam("key-marker") String keyMarker,
@QueryParam("version-id-marker") String versionIdMarker, @QueryParam("version-id-marker") String versionIdMarker,
@QueryParam("marker") String marker, @QueryParam("marker") String marker,
@Context UriInfo uriInfo, @Context UriInfo uriInfo,
@Context HttpHeaders httpHeaders) { @Context HttpHeaders httpHeaders) {
try { try {
validateRawUri(); validateRawUri();
if (isWebsiteRequest(httpHeaders, uriInfo)) {
Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo);
if (websiteRedirect != null) {
return websiteRedirect;
}
}
S3Service.RequestAuthorization authorization = S3RequestAuthorizationParser.parseIfRequired( S3Service.RequestAuthorization authorization = S3RequestAuthorizationParser.parseIfRequired(
s3Service.isAuthEnforced(), httpHeaders, uriInfo); s3Service.isAuthEnforced(), httpHeaders, uriInfo);
if (hasQueryParam(uriInfo, "uploads")) { if (hasQueryParam(uriInfo, "uploads")) {
@@ -926,6 +938,12 @@ public class S3Controller {
S3Service.RequestAuthorization authorization = S3Service.RequestAuthorization.unsigned(); S3Service.RequestAuthorization authorization = S3Service.RequestAuthorization.unsigned();
try { try {
key = extractObjectKey(uriInfo, bucket, key); key = extractObjectKey(uriInfo, bucket, key);
if (isWebsiteRequest(httpHeaders, uriInfo)) {
Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo);
if (websiteRedirect != null) {
return websiteRedirect;
}
}
authorization = S3RequestAuthorizationParser.parseIfRequired( authorization = S3RequestAuthorizationParser.parseIfRequired(
s3Service.isAuthEnforced(), httpHeaders, uriInfo); s3Service.isAuthEnforced(), httpHeaders, uriInfo);
@@ -1169,6 +1187,12 @@ public class S3Controller {
S3Service.RequestAuthorization authorization = S3Service.RequestAuthorization.unsigned(); S3Service.RequestAuthorization authorization = S3Service.RequestAuthorization.unsigned();
try { try {
key = extractObjectKey(uriInfo, bucket, key); key = extractObjectKey(uriInfo, bucket, key);
if (isWebsiteRequest(httpHeaders, uriInfo)) {
Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo);
if (websiteRedirect != null) {
return headOnlyResponse(websiteRedirect);
}
}
authorization = S3RequestAuthorizationParser.parseIfRequired( authorization = S3RequestAuthorizationParser.parseIfRequired(
s3Service.isAuthEnforced(), httpHeaders, uriInfo); s3Service.isAuthEnforced(), httpHeaders, uriInfo);
if (isWebsiteRequest(httpHeaders, uriInfo)) { if (isWebsiteRequest(httpHeaders, uriInfo)) {
@@ -2982,6 +3006,23 @@ public class S3Controller {
rawPath, includeBody); 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, private Response serveWebsiteErrorResponse(String bucket,
S3Service.RequestAuthorization authorization, S3Service.RequestAuthorization authorization,
AwsException cause) { AwsException cause) {
@@ -3027,6 +3068,8 @@ public class S3Controller {
.header("x-amz-error-code", websiteErrorCode(status)) .header("x-amz-error-code", websiteErrorCode(status))
.header("x-amz-error-message", websiteErrorMessage(status)) .header("x-amz-error-message", websiteErrorMessage(status))
.build(); .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.DefaultError(int status) -> defaultWebsiteErrorResponse(status);
case S3Service.WebsiteResolution.NotAWebsite() -> null; case S3Service.WebsiteResolution.NotAWebsite() -> null;
}; };
@@ -3830,10 +3873,19 @@ public class S3Controller {
WebsiteConfiguration config = s3Service.getBucketWebsite(bucket); WebsiteConfiguration config = s3Service.getBucketWebsite(bucket);
XmlBuilder xml = new XmlBuilder() XmlBuilder xml = new XmlBuilder()
.raw("<?xml version=\"1.0\" encoding=\"UTF-8\"?>") .raw("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
.start("WebsiteConfiguration", AwsNamespaces.S3) .start("WebsiteConfiguration", AwsNamespaces.S3);
.start("IndexDocument") if (config.getRedirectAllRequestsToHostName() != null) {
.elem("Suffix", config.getIndexDocument()) xml.start("RedirectAllRequestsTo")
.end("IndexDocument"); .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) { if (config.getErrorDocument() != null) {
xml.start("ErrorDocument") xml.start("ErrorDocument")
.elem("Key", config.getErrorDocument()) .elem("Key", config.getErrorDocument())
@@ -3864,15 +3916,54 @@ public class S3Controller {
private Response handlePutBucketWebsite(String bucket, byte[] body) { private Response handlePutBucketWebsite(String bucket, byte[] body) {
String xml = new String(body, StandardCharsets.UTF_8); 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 indexDoc = XmlParser.extractFirst(xml, "Suffix", null);
String errorDoc = XmlParser.extractFirst(xml, "Key", 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); throw new AwsException("MalformedXML", "IndexDocument.Suffix is required.", 400);
} }
s3Service.putBucketWebsite(bucket, new WebsiteConfiguration(indexDoc, errorDoc)); s3Service.putBucketWebsite(bucket, new WebsiteConfiguration(indexDoc, errorDoc));
return Response.ok().build(); 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 * 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 * in the {@code x-amz-copy-source} header and is otherwise invisible to the request-level IAM filter
@@ -2480,6 +2480,24 @@ public class S3Service implements Resettable, ResourceProvider {
return bucket.getWebsiteConfiguration(); 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) { public void deleteBucketWebsite(String bucketName) {
Bucket bucket = bucketStore.get(bucketName) Bucket bucket = bucketStore.get(bucketName)
.orElseThrow(() -> new AwsException("NoSuchBucket", .orElseThrow(() -> new AwsException("NoSuchBucket",
@@ -2510,6 +2528,9 @@ public class S3Service implements Resettable, ResourceProvider {
/** Serve the bucket's custom error document with {@code status}. */ /** Serve the bucket's custom error document with {@code status}. */
record ErrorDocument(S3Object object, int status) implements WebsiteResolution {} 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}. */ /** No usable custom error document: render S3's built-in error page with {@code status}. */
record DefaultError(int status) implements WebsiteResolution {} record DefaultError(int status) implements WebsiteResolution {}
@@ -2543,6 +2564,10 @@ public class S3Service implements Resettable, ResourceProvider {
} }
return new WebsiteResolution.NotAWebsite(); return new WebsiteResolution.NotAWebsite();
} }
if (cfg.getRedirectAllRequestsToHostName() != null) {
return new WebsiteResolution.RedirectAllRequestsTo(cfg.getRedirectAllRequestsToHostName(),
cfg.getRedirectAllRequestsToProtocol());
}
String index = cfg.getIndexDocument(); String index = cfg.getIndexDocument();
if (index == null) { if (index == null) {
return new WebsiteResolution.NotAWebsite(); return new WebsiteResolution.NotAWebsite();
@@ -6,6 +6,8 @@ import io.quarkus.runtime.annotations.RegisterForReflection;
public class WebsiteConfiguration { public class WebsiteConfiguration {
private String indexDocument; private String indexDocument;
private String errorDocument; private String errorDocument;
private String redirectAllRequestsToHostName;
private String redirectAllRequestsToProtocol;
public WebsiteConfiguration() {} public WebsiteConfiguration() {}
@@ -14,9 +16,27 @@ public class WebsiteConfiguration {
this.errorDocument = errorDocument; 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 String getIndexDocument() { return indexDocument; }
public void setIndexDocument(String indexDocument) { this.indexDocument = indexDocument; } public void setIndexDocument(String indexDocument) { this.indexDocument = indexDocument; }
public String getErrorDocument() { return errorDocument; } public String getErrorDocument() { return errorDocument; }
public void setErrorDocument(String errorDocument) { this.errorDocument = 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;
}
} }
+1
View File
@@ -623,6 +623,7 @@ floci:
elbv2: elbv2:
enabled: true enabled: true
mock: false mock: false
listener-forwarder-image: alpine/socat:1.8.1.3
codebuild: codebuild:
enabled: true enabled: true
# docker-network: floci-network # docker-network: floci-network
+14 -7
View File
@@ -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-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-minimal-kernel-default-x86_64
- /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-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 name: al2023-ami-2023.0.20230315.0-kernel-6.1-x86_64
description: Amazon Linux 2023 AMI description: Amazon Linux 2023 AMI
ownerId: "137112412989" ownerId: "137112412989"
imageOwnerAlias: amazon imageOwnerAlias: amazon
architecture: x86_64 architecture: x86_64
creationDate: "2023-03-15T00:00:00.000Z" creationDate: "2023-03-15T00:00:00.000Z"
guestRuntime: systemd
- imageId: ami-0abcdef1234567892 - imageId: ami-0abcdef1234567892
aliases: aliases:
@@ -101,10 +102,16 @@ images:
architecture: x86_64 architecture: x86_64
creationDate: "2023-04-01T00:00:00.000Z" creationDate: "2023-04-01T00:00:00.000Z"
- imageId: ami-0abcdef1234567893 - imageId: ami-fedora42
dockerImage: public.ecr.aws/amazonlinux/amazonlinux:2023 dockerImage: registry.fedoraproject.org/fedora:42
name: Windows_Server-2022-English-Full-Base-2023.04.12 name: Fedora-Cloud-Base-42-x86_64
description: Microsoft Windows Server 2022 Full Locale English AMI description: Fedora Linux 42 AMI
architecture: x86_64 architecture: x86_64
platform: windows creationDate: "2025-04-15T00:00:00.000Z"
creationDate: "2023-04-12T00: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"
@@ -4,8 +4,13 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.RegionResolver; 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.ApiGatewayV2Service;
import io.github.hectorvent.floci.services.apigatewayv2.model.Api; 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 io.github.hectorvent.floci.services.lambda.model.InvokeResult;
import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MultivaluedHashMap; 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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString; 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.atLeastOnce;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -255,6 +262,85 @@ class ApiGatewayExecuteControllerTest {
assertEquals("application/problem+json", response.getMediaType().toString()); 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 @Test
void buildProxyResponseMatchesExactCaseContentType() { void buildProxyResponseMatchesExactCaseContentType() {
// The pre-existing exact-case path must keep working — this is a regression guard, // The pre-existing exact-case path must keep working — this is a regression guard,
@@ -117,6 +117,17 @@ class ApiGatewayIntegrationTest {
.body("authorizationType", equalTo("NONE")); .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) @Test @Order(9)
void putMethodResponse() { void putMethodResponse() {
given() given()
@@ -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"));
}
}
@@ -5,19 +5,44 @@ import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
@QuarkusTest @QuarkusTest
class ApiGatewayUpdateIntegrationResponseIntegrationTest { class ApiGatewayUpdateIntegrationResponseIntegrationTest {
@Test @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 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 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 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("{\"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("{\"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("""
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")); {"selectionPattern":"before","contentHandling":"CONVERT_TO_BINARY",
given().when().get("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200").then().statusCode(200).body("selectionPattern", equalTo("after")); "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"));
} }
} }
@@ -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"));
}
}
@@ -92,6 +92,21 @@ class DynamoDbCfnProvisionerTest {
provisioner.resourceTypes()); 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. */ /** The registry schema declares only Arn and StreamArn read-only on a plain table; TableId belongs to the global table. */
@Test @Test
void tablePublishesOnlyArnWithoutAStream() throws Exception { void tablePublishesOnlyArnWithoutAStream() throws Exception {
@@ -863,7 +863,7 @@ class Ec2ContainerManagerTest {
String script = Ec2ContainerManager.metadataProxyInstallCommand()[2]; String script = Ec2ContainerManager.metadataProxyInstallCommand()[2];
assertTrue(script.contains("command -v yum"), script); 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 @Test
@@ -878,7 +878,7 @@ class Ec2ContainerManagerTest {
// instance was left with no link-local IMDS endpoint. // instance was left with no link-local IMDS endpoint.
String script = Ec2ContainerManager.metadataProxyInstallCommand()[2]; 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); script);
} }
@@ -888,7 +888,8 @@ class Ec2ContainerManagerTest {
assertEquals("sh", command[0]); assertEquals("sh", command[0]);
assertTrue(command[2].contains("169.254.169.254/32")); 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("TCP:floci:9169"));
assertTrue(command[2].contains("http://169.254.169.254/latest/meta-data/instance-id")); 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)); 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. */ /** Stubs the daemon reachability probe every launch makes before touching Docker. */
private static void stubDockerPing(DockerClient dockerClient, boolean reachable) { private static void stubDockerPing(DockerClient dockerClient, boolean reachable) {
PingCmd ping = mock(PingCmd.class); PingCmd ping = mock(PingCmd.class);
@@ -2,7 +2,6 @@ package io.github.hectorvent.floci.services.ec2;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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 static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List; import java.util.List;
@@ -12,7 +11,6 @@ import java.util.stream.Collectors;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTest;
import io.github.hectorvent.floci.core.common.AwsException;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@QuarkusTest @QuarkusTest
@@ -40,7 +38,8 @@ class Ec2ImageCatalogTest {
"ami-ubuntu2404-cloud-arm64", "ami-ubuntu2404-cloud-arm64",
"ami-debian12", "ami-debian12",
"ami-alpine", "ami-alpine",
"ami-0abcdef1234567893"), imageIds); "ami-fedora42",
"ami-opensuse-leap156"), imageIds);
assertTrue(imageCatalog.findByIdOrAlias("ami-amazonlinux2").isPresent()); assertTrue(imageCatalog.findByIdOrAlias("ami-amazonlinux2").isPresent());
assertTrue(imageCatalog.findByIdOrAlias("ami-amazonlinux2023").isPresent()); assertTrue(imageCatalog.findByIdOrAlias("ami-amazonlinux2023").isPresent());
@@ -64,11 +63,11 @@ class Ec2ImageCatalogTest {
@Test @Test
void resolverExposesCloudImageGuestRuntimeMetadata() { 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()); assertEquals(ResolvedAmiImage.SYSTEMD_RUNTIME, image.guestRuntime());
assertTrue(image.cloudInit()); assertFalse(image.cloudInit());
assertTrue(image.systemd()); assertTrue(image.systemd());
} }
@@ -79,11 +78,9 @@ class Ec2ImageCatalogTest {
} }
@Test @Test
void resolverRejectsWindowsImagesForContainerExecution() { void resolverUsesFedoraAndOpenSuseImages() {
AwsException error = assertThrows(AwsException.class, assertEquals("registry.fedoraproject.org/fedora:42", amiImageResolver.resolve("ami-fedora42"));
() -> amiImageResolver.resolveImage("ami-0abcdef1234567893")); assertEquals("registry.opensuse.org/opensuse/leap:15.6", amiImageResolver.resolve("ami-opensuse-leap156"));
assertEquals("UnsupportedOperation", error.getErrorCode());
} }
@Test @Test
@@ -19,11 +19,11 @@ class Ec2MetadataProxyTest {
assertEquals("-c", command[1]); assertEquals("-c", command[1]);
String script = command[2]; 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("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 socat curl ca-certificates")); assertTrue(script.contains("apt-get install -y --no-install-recommends iproute2 iptables socat curl ca-certificates"));
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"));
assertTrue(script.contains("yum install -y iproute socat curl ca-certificates")); assertTrue(script.contains("yum install -y iproute iptables socat curl ca-certificates"));
assertTrue(script.contains("apk add --no-cache iproute2 socat curl ca-certificates")); assertTrue(script.contains("apk add --no-cache iproute2 iptables socat curl ca-certificates"));
} }
@Test @Test
@@ -39,7 +39,8 @@ class Ec2MetadataProxyTest {
// Idempotent when pid file exists and process is alive // 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")); 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 // 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 // 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")); assertTrue(script.contains("curl -fsS --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null && exit 0"));
} }
@@ -390,6 +390,31 @@ class Ec2ServiceTest {
verifyNoInteractions(containerManager); verifyNoInteractions(containerManager);
} }
@Test
void prunesTerminatedInstancesAfterOneHourAndRetainsRecentOnes() {
Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),
mock(Ec2PortForwardManager.class), mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class),
new Ec2InstanceTypeCatalog(), new InMemoryStorageFactory());
Reservation expiredReservation = service.runInstances("us-east-1", "ami-1234567890abcdef0", "t3.micro",
1, 1, null, List.of(), null, null, List.of(), null, null);
Reservation recentReservation = service.runInstances("us-east-1", "ami-1234567890abcdef0", "t3.micro",
1, 1, null, List.of(), null, null, List.of(), null, null);
String expiredId = expiredReservation.getInstances().getFirst().getInstanceId();
String recentId = recentReservation.getInstances().getFirst().getInstanceId();
service.terminateInstances("us-east-1", List.of(expiredId, recentId));
expiredReservation.getInstances().getFirst().setTerminatedAt(System.currentTimeMillis() - 3_600_001L);
service.pruneTerminatedInstances();
List<String> remainingIds = service.describeInstances("us-east-1", List.of(), Map.of()).stream()
.flatMap(reservation -> reservation.getInstances().stream())
.map(Instance::getInstanceId)
.toList();
assertFalse(remainingIds.contains(expiredId));
assertTrue(remainingIds.contains(recentId));
}
@Test @Test
void describeNetworkInterfacesGroupIdFilterSkipsNullGroupEntry() { void describeNetworkInterfacesGroupIdFilterSkipsNullGroupEntry() {
Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class), Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),
@@ -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);
}
}
@@ -1,10 +1,15 @@
package io.github.hectorvent.floci.services.ecs; package io.github.hectorvent.floci.services.ecs;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils; 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 io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
@@ -22,6 +27,9 @@ class EcsLoadBalancersIntegrationTest {
private static final String TARGET = "AmazonEC2ContainerServiceV20141113."; private static final String TARGET = "AmazonEC2ContainerServiceV20141113.";
private static final String CT = "application/x-amz-json-1.1"; private static final String CT = "application/x-amz-json-1.1";
@Inject
ElbV2Service elbV2Service;
@BeforeAll @BeforeAll
static void configure() { static void configure() {
RestAssuredJsonUtils.configureAwsContentTypes(); RestAssuredJsonUtils.configureAwsContentTypes();
@@ -41,10 +49,17 @@ class EcsLoadBalancersIntegrationTest {
+ "\"memory\":128,\"portMappings\":[{\"containerPort\":8080}]}]}"); + "\"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 @Test
void createServiceParsesAndEchoesLoadBalancers() { void createServiceParsesAndEchoesLoadBalancers() {
seedClusterAndTaskDef("lb-cluster-1", "lb-td-1"); 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") given().contentType(CT).header("X-Amz-Target", TARGET + "CreateService")
.body("{\"cluster\":\"lb-cluster-1\",\"serviceName\":\"lb-svc-1\"," .body("{\"cluster\":\"lb-cluster-1\",\"serviceName\":\"lb-svc-1\","
@@ -63,7 +78,7 @@ class EcsLoadBalancersIntegrationTest {
@Test @Test
void describeServicesEchoesLoadBalancers() { void describeServicesEchoesLoadBalancers() {
seedClusterAndTaskDef("lb-cluster-2", "lb-td-2"); 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\"," call("CreateService", "{\"cluster\":\"lb-cluster-2\",\"serviceName\":\"lb-svc-2\","
+ "\"taskDefinition\":\"lb-td-2\",\"desiredCount\":1," + "\"taskDefinition\":\"lb-td-2\",\"desiredCount\":1,"
+ "\"loadBalancers\":[{\"targetGroupArn\":\"" + tgArn + "\"," + "\"loadBalancers\":[{\"targetGroupArn\":\"" + tgArn + "\","
@@ -116,11 +131,27 @@ class EcsLoadBalancersIntegrationTest {
.body("__type", org.hamcrest.Matchers.containsString("InvalidParameterException")); .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 @Test
void serviceWithLoadBalancersIsStableInMockMode() { void serviceWithLoadBalancersIsStableInMockMode() {
// Mock mode launches no real containers; the registrar must no-op cleanly. // Mock mode launches no real containers; the registrar must no-op cleanly.
seedClusterAndTaskDef("lb-cluster-4", "lb-td-4"); 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\"," call("CreateService", "{\"cluster\":\"lb-cluster-4\",\"serviceName\":\"lb-svc-4\","
+ "\"taskDefinition\":\"lb-td-4\",\"desiredCount\":1," + "\"taskDefinition\":\"lb-td-4\",\"desiredCount\":1,"
+ "\"loadBalancers\":[{\"targetGroupArn\":\"" + tgArn + "\"," + "\"loadBalancers\":[{\"targetGroupArn\":\"" + tgArn + "\","
@@ -10,6 +10,7 @@ import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager; import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager;
import io.github.hectorvent.floci.services.ecs.container.EcsTaskHandle; 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.ContainerDefinition;
import io.github.hectorvent.floci.services.ecs.model.EcsLoadBalancer;
import io.github.hectorvent.floci.services.ecs.model.LaunchType; import io.github.hectorvent.floci.services.ecs.model.LaunchType;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -62,7 +63,7 @@ class EcsServiceTeardownTest {
cd.setImage("nginx:alpine"); cd.setImage("nginx:alpine");
service.registerTaskDefinition("teardown-fam", List.of(cd), null, null, null, service.registerTaskDefinition("teardown-fam", List.of(cd), null, null, null,
null, null, List.of(), REGION); 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); List.of(), null, REGION);
service.stopManagedContainers(); service.stopManagedContainers();
@@ -138,7 +139,7 @@ class EcsServiceTeardownTest {
cd.setImage("nginx:alpine"); cd.setImage("nginx:alpine");
service.registerTaskDefinition("retry-fam", List.of(cd), null, null, null, service.registerTaskDefinition("retry-fam", List.of(cd), null, null, null,
null, null, List.of(), REGION); 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(); List.of(), null, REGION).getFirst().getTaskArn();
service.stopTask(null, taskArn, null, REGION); service.stopTask(null, taskArn, null, REGION);
@@ -151,6 +152,41 @@ class EcsServiceTeardownTest {
verify(containerManager, times(2)).stopTaskAndCollectExitCodes(handle); 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 static final class SingleUseStorageFactory extends StorageFactory {
private final Map<String, StorageBackend<String, ?>> stores = new HashMap<>(); private final Map<String, StorageBackend<String, ?>> stores = new HashMap<>();
@@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.elbv2;
import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.services.ec2.Ec2Service; 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.TargetDescription;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup; import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
import io.vertx.core.Vertx; 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 @Test
void timedOutProbeTransitionsTargetToUnhealthyWithAwsReason() throws Exception { void timedOutProbeTransitionsTargetToUnhealthyWithAwsReason() throws Exception {
HttpServer server = startHangingServer(); HttpServer server = startHangingServer();
@@ -174,7 +210,8 @@ class ElbV2HealthCheckerTest {
private ElbV2HealthChecker healthChecker() { private ElbV2HealthChecker healthChecker() {
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS); EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.services().elbv2().mock()).thenReturn(false); 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 { private HttpServer startServer(int statusCode) throws Exception {
@@ -0,0 +1,13 @@
package io.github.hectorvent.floci.services.elbv2;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ElbV2ListenerForwardManagerTest {
@Test
void forwardContainerNameUsesListenerPort() {
assertEquals("floci-elbv2-fwd-8080", ElbV2ListenerForwardManager.forwardContainerName(8080));
}
}
@@ -107,6 +107,34 @@ class ElbV2ServiceTest {
assertTrue(newTargetGroup.getLoadBalancerArns().contains(lbArn)); 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 @Test
void modifyListenerPortRestartsListener() { void modifyListenerPortRestartsListener() {
String lbArn = service.createLoadBalancer( String lbArn = service.createLoadBalancer(
@@ -1,6 +1,7 @@
package io.github.hectorvent.floci.services.elbv2; package io.github.hectorvent.floci.services.elbv2;
import io.github.hectorvent.floci.services.ec2.Ec2Service; 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.ec2.model.Instance;
import io.github.hectorvent.floci.services.elbv2.model.TargetDescription; import io.github.hectorvent.floci.services.elbv2.model.TargetDescription;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup; 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)); 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 @Test
void checkedAddressReturnsAnIpLiteralForLoopbackAndPrivateTargets() throws IOException { void checkedAddressReturnsAnIpLiteralForLoopbackAndPrivateTargets() throws IOException {
assertEquals("127.0.0.1", ElbV2TargetResolver.resolveCheckedAddress("127.0.0.1")); 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()); assertTrue(ex.getMessage().contains("link-local or metadata address"), ex.getMessage());
} }
} }
private static Instance instance(String bridgeIp) {
Instance instance = new Instance();
instance.setInstanceId("i-1234567890abcdef0");
instance.setContainerBridgeIp(bridgeIp);
return instance;
}
private static TargetGroup instanceTargetGroup() {
TargetGroup targetGroup = new TargetGroup();
targetGroup.setTargetType("instance");
return targetGroup;
}
private static TargetDescription instanceTarget() {
TargetDescription target = new TargetDescription();
target.setId("i-1234567890abcdef0");
return target;
}
} }
@@ -686,6 +686,19 @@ class S3ServiceTest {
assertInstanceOf(S3Service.WebsiteResolution.ServeObject.class, resolution).key()); 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 @Test
void resolveWebsiteRequestRedirectsFolderWithoutTrailingSlash() { void resolveWebsiteRequestRedirectsFolderWithoutTrailingSlash() {
websiteBucket("index.html", null); websiteBucket("index.html", null);
@@ -1243,4 +1256,3 @@ class S3ServiceTest {
assertDoesNotThrow(() -> s3Service.authorizeSignedDeleteObject("ASIAFAKEKEY00000001", "sessiontoken", "signed-del-bucket", "some/key")); assertDoesNotThrow(() -> s3Service.authorizeSignedDeleteObject("ASIAFAKEKEY00000001", "sessiontoken", "signed-del-bucket", "some/key"));
} }
} }
@@ -411,6 +411,124 @@ class S3WebsiteIntegrationTest {
@Test @Test
@Order(24) @Order(24)
void redirectAllConfigurationRoundTripAndValidation() {
String redirect = """
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<RedirectAllRequestsTo>
<HostName>redirect.example</HostName>
<Protocol>https</Protocol>
</RedirectAllRequestsTo>
</WebsiteConfiguration>
""";
given()
.queryParam("website", "")
.contentType("application/xml")
.body(redirect)
.when()
.put("/" + BUCKET)
.then()
.statusCode(200);
given()
.queryParam("website", "")
.when()
.get("/" + BUCKET)
.then()
.statusCode(200)
.body(containsString("<RedirectAllRequestsTo><HostName>redirect.example</HostName><Protocol>https</Protocol></RedirectAllRequestsTo>"))
.body(not(containsString("<IndexDocument>")));
String conflicting = """
<WebsiteConfiguration>
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
<RedirectAllRequestsTo><HostName>redirect.example</HostName></RedirectAllRequestsTo>
</WebsiteConfiguration>
""";
given()
.queryParam("website", "")
.contentType("application/xml")
.body(conflicting)
.when()
.put("/" + BUCKET)
.then()
.statusCode(400)
.body(containsString("InvalidRequest"));
String missingHost = "<WebsiteConfiguration><RedirectAllRequestsTo/></WebsiteConfiguration>";
given()
.queryParam("website", "")
.contentType("application/xml")
.body(missingHost)
.when()
.put("/" + BUCKET)
.then()
.statusCode(400)
.body(containsString("MalformedXML"));
String invalidProtocol = """
<WebsiteConfiguration>
<RedirectAllRequestsTo><HostName>redirect.example</HostName><Protocol>FTP</Protocol></RedirectAllRequestsTo>
</WebsiteConfiguration>
""";
given()
.queryParam("website", "")
.contentType("application/xml")
.body(invalidProtocol)
.when()
.put("/" + BUCKET)
.then()
.statusCode(400)
.body(containsString("MalformedXML"));
}
@Test
@Order(25)
void redirectAllWebsiteRequestsPreserveRawPathAndQueryForGetAndHead() {
String redirect = """
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<RedirectAllRequestsTo><HostName>redirect.example</HostName></RedirectAllRequestsTo>
</WebsiteConfiguration>
""";
given()
.queryParam("website", "")
.contentType("application/xml")
.body(redirect)
.when()
.put("/" + BUCKET)
.then()
.statusCode(200);
given()
.header("Host", websiteHost())
.redirects().follow(false)
.when()
.get("/?code=abc&state=xyz")
.then()
.statusCode(301)
.header("Location", equalTo("http://redirect.example/?code=abc&state=xyz"));
given()
.header("Host", websiteHost())
.redirects().follow(false)
.when()
.get("/nested/path?code=abc&state=xyz")
.then()
.statusCode(301)
.header("Location", equalTo("http://redirect.example/nested/path?code=abc&state=xyz"));
given()
.header("Host", websiteHost())
.redirects().follow(false)
.when()
.head("/nested/path?code=abc&state=xyz")
.then()
.statusCode(301)
.header("Location", equalTo("http://redirect.example/nested/path?code=abc&state=xyz"))
.body(emptyString());
}
@Test
@Order(26)
void deleteWebsiteConfiguration() { void deleteWebsiteConfiguration() {
given() given()
.queryParam("website", "") .queryParam("website", "")
@@ -428,7 +546,7 @@ class S3WebsiteIntegrationTest {
} }
@Test @Test
@Order(25) @Order(27)
void cleanup() { void cleanup() {
given().delete("/" + BUCKET + "/index.html"); given().delete("/" + BUCKET + "/index.html");
given().delete("/" + BUCKET + "/error.html"); given().delete("/" + BUCKET + "/error.html");
+1
View File
@@ -326,6 +326,7 @@ floci:
elbv2: elbv2:
enabled: true enabled: true
mock: true mock: true
listener-forwarder-image: alpine/socat:1.8.1.3
codebuild: codebuild:
enabled: true enabled: true
# docker-network: floci-network # docker-network: floci-network