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
@@ -2799,6 +2799,10 @@ public interface EmulatorConfig {
@WithDefault("false")
boolean mock();
/** Exact image used by the Docker-host listener forwarding sidecar. */
@WithDefault("alpine/socat:1.8.1.3")
String listenerForwarderImage();
}
interface EksServiceConfig {
@@ -43,6 +43,7 @@ import io.github.hectorvent.floci.services.apigatewayv2.model.Route;
import io.github.hectorvent.floci.services.apigatewayv2.model.RouteResponse;
import io.github.hectorvent.floci.services.apigatewayv2.model.Stage;
import io.github.hectorvent.floci.services.apigatewayv2.model.VpcLink;
import io.smallrye.common.annotation.Blocking;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
@@ -72,16 +73,25 @@ public class ApiGatewayController {
private final ApiGatewayV2OpenApiImporter v2OpenApiImporter;
private final RegionResolver regionResolver;
private final ObjectMapper objectMapper;
private final ApiGatewayExecuteController executeController;
@Inject
public ApiGatewayController(ApiGatewayService service, ApiGatewayV2Service v2Service,
ApiGatewayV2OpenApiImporter v2OpenApiImporter,
RegionResolver regionResolver, ObjectMapper objectMapper) {
ApiGatewayV2OpenApiImporter v2OpenApiImporter,
RegionResolver regionResolver, ObjectMapper objectMapper,
ApiGatewayExecuteController executeController) {
this.service = service;
this.v2Service = v2Service;
this.v2OpenApiImporter = v2OpenApiImporter;
this.regionResolver = regionResolver;
this.objectMapper = objectMapper;
this.executeController = executeController;
}
ApiGatewayController(ApiGatewayService service, ApiGatewayV2Service v2Service,
ApiGatewayV2OpenApiImporter v2OpenApiImporter,
RegionResolver regionResolver, ObjectMapper objectMapper) {
this(service, v2Service, v2OpenApiImporter, regionResolver, objectMapper, null);
}
private static final TypeReference<List<Map<String, String>>> PATCH_OPERATIONS =
@@ -129,6 +139,81 @@ public class ApiGatewayController {
// ──────────────────────────── Specific v1 Paths (ORDER MATTERS) ────────────────────────────
@POST
@Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}")
@Blocking
public Response testInvokeMethod(@Context HttpHeaders headers,
@PathParam("apiId") String apiId,
@PathParam("resourceId") String resourceId,
@PathParam("httpMethod") String httpMethod,
String body) {
String region = regionResolver.resolveRegion(headers);
try {
@SuppressWarnings("unchecked")
Map<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
@Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}")
public Response getMethodResponse(@Context HttpHeaders headers,
@@ -159,6 +244,21 @@ public class ApiGatewayController {
}
}
@PATCH
@Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}")
public Response updateMethodResponse(@Context HttpHeaders headers,
@PathParam("apiId") String apiId,
@PathParam("resourceId") String resourceId,
@PathParam("httpMethod") String httpMethod,
@PathParam("statusCode") String statusCode,
String body) {
String region = regionResolver.resolveRegion(headers);
List<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
@Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}")
public Response deleteMethodResponse(@Context HttpHeaders headers,
@@ -387,12 +487,14 @@ public class ApiGatewayController {
@GET
@Path("/restapis/{apiId}/resources")
public Response getResources(@Context HttpHeaders headers, @PathParam("apiId") String apiId) {
public Response getResources(@Context HttpHeaders headers, @PathParam("apiId") String apiId,
@QueryParam("embed") List<String> embed) {
String region = regionResolver.resolveRegion(headers);
List<ApiGatewayResource> resources = service.getResources(region, apiId);
boolean embedMethods = embed != null && embed.contains("methods");
ObjectNode root = objectMapper.createObjectNode();
ArrayNode items = root.putArray("item");
resources.forEach(r -> items.add(toResourceNode(r)));
resources.forEach(r -> items.add(toResourceNode(r, embedMethods)));
return Response.ok(root.toString()).type(MediaType.APPLICATION_JSON).build();
}
@@ -2162,11 +2264,19 @@ public class ApiGatewayController {
}
private ObjectNode toResourceNode(ApiGatewayResource r) {
return toResourceNode(r, false);
}
private ObjectNode toResourceNode(ApiGatewayResource r, boolean embedMethods) {
ObjectNode node = objectMapper.createObjectNode();
node.put("id", r.getId());
if (r.getParentId() != null) node.put("parentId", r.getParentId());
if (r.getPathPart() != null) node.put("pathPart", r.getPathPart());
node.put("path", r.getPath());
if (embedMethods && !r.getResourceMethods().isEmpty()) {
ObjectNode methods = node.putObject("resourceMethods");
r.getResourceMethods().forEach((method, config) -> methods.set(method, toMethodNode(config)));
}
return node;
}
@@ -2186,6 +2296,11 @@ public class ApiGatewayController {
m.getRequestModels().forEach(models::put);
node.set("requestModels", models);
}
if (!m.getMethodResponses().isEmpty()) {
ObjectNode responses = node.putObject("methodResponses");
m.getMethodResponses().forEach((status, response) ->
responses.set(status, toMethodResponseNode(response)));
}
if (m.getMethodIntegration() != null) {
node.set("methodIntegration", toIntegrationNode(m.getMethodIntegration()));
}
@@ -2195,6 +2310,14 @@ public class ApiGatewayController {
private ObjectNode toMethodResponseNode(MethodResponse r) {
ObjectNode node = objectMapper.createObjectNode();
node.put("statusCode", r.statusCode());
if (r.responseParameters() != null && !r.responseParameters().isEmpty()) {
ObjectNode parameters = node.putObject("responseParameters");
r.responseParameters().forEach(parameters::put);
}
if (r.responseModels() != null && !r.responseModels().isEmpty()) {
ObjectNode models = node.putObject("responseModels");
r.responseModels().forEach(models::put);
}
return node;
}
@@ -43,6 +43,7 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.jboss.logging.Logger;
import java.net.URI;
import java.net.URLDecoder;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -54,6 +55,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -144,6 +146,12 @@ public class ApiGatewayExecuteController {
private record AuthorizerResult(Response errorResponse, String principalId, Map<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
* it: where the request landed and the raw request, for {@code method.request.*},
@@ -471,6 +479,85 @@ public class ApiGatewayExecuteController {
return response;
}
/**
* Executes a REST API method without requiring a deployment or stage. This is the execution
* path behind TestInvokeMethod, not an alternate data-plane endpoint.
*/
TestInvokeResult testInvoke(String region, String apiId, String resourceId, String httpMethod,
TestInvokeRequest request) {
ApiGatewayResource resource = apiGatewayService.getResource(region, apiId, resourceId);
MethodConfig method = apiGatewayService.getMethod(region, apiId, resourceId, httpMethod);
Integration integration = method.getMethodIntegration();
if (integration == null) {
Response response = Response.status(500).entity("No integration configured").build();
return new TestInvokeResult(response, "Execution failed due to configuration error: No integration configured", 0);
}
String pathWithQueryString = request.pathWithQueryString() == null || request.pathWithQueryString().isBlank()
? resource.getPath() : request.pathWithQueryString();
URI requestUri = URI.create("http://test-invoke" + (pathWithQueryString.startsWith("/")
? pathWithQueryString : "/" + pathWithQueryString));
String path = requestUri.getRawPath();
HttpHeaders headers = new TestInvokeHeaders(request.headers(), request.multiValueHeaders());
UriInfo uriInfo = new TestInvokeUriInfo(requestUri);
Stage stage = new Stage();
stage.setStageName("test-invoke-stage");
stage.setVariables(request.stageVariables());
byte[] body = request.body() == null ? null : request.body().getBytes(StandardCharsets.UTF_8);
GatewayResponseScope scope = new GatewayResponseScope(region, apiId, stage.getStageName(), stage,
httpMethod, path, resource, headers, uriInfo, body);
String integrationType = integration.getType();
if (integrationType == null || integrationType.isBlank()) {
Response response = Response.status(500).entity("No integration type configured").build();
return new TestInvokeResult(response,
"Execution failed due to configuration error: No integration type configured", 0);
}
long started = System.nanoTime();
Response response;
String log;
if ("AWS_PROXY".equalsIgnoreCase(integrationType)) {
String functionName = functionNameFromUri(integration.getUri());
if (functionName == null) {
response = Response.status(500).entity("Cannot resolve function from URI: " + integration.getUri()).build();
log = "Execution failed due to configuration error: Cannot resolve function from URI: "
+ integration.getUri();
} else {
String eventJson = buildProxyEvent(region, apiId, httpMethod, path, resource.getPath(), resource.getId(),
stage.getStageName(), stage, headers, uriInfo, body, UUID.randomUUID().toString(),
null, null, null, null);
log = "Endpoint request URI: " + integration.getUri()
+ "\nEndpoint request body after transformations: " + eventJson;
InvokeResult result = lambdaService.invoke(region, functionName, eventJson.getBytes(StandardCharsets.UTF_8),
InvocationType.RequestResponse);
if (isGatewayGeneratedProxyFailure(result)) {
response = Response.status(500)
.entity("Execution failed due to configuration error: Malformed Lambda proxy response")
.build();
log += "\nExecution failed due to configuration error: Malformed Lambda proxy response";
} else {
response = buildProxyResponse(result, false);
}
}
} else {
AuthorizerResult authorizerResult = new AuthorizerResult(null, null, null);
response = switch (integrationType.toUpperCase(Locale.ROOT)) {
case "AWS" -> invokeAwsIntegration(scope, region, httpMethod, path, stage.getStageName(), resource,
integration, headers, uriInfo, body, authorizerResult);
case "HTTP_PROXY" -> invokeHttpProxy(scope, apiId, httpMethod, path, null, stage.getStageName(), resource,
integration, headers, uriInfo, body);
case "HTTP" -> invokeHttpIntegration(scope, region, apiId, httpMethod, path, null, stage.getStageName(),
resource, integration, headers, uriInfo, body, authorizerResult);
case "MOCK" -> invokeMock(scope, region, httpMethod, path, stage.getStageName(), resource, integration,
headers, uriInfo, body, authorizerResult);
default -> Response.status(500).entity("Unsupported integration type: " + integration.getType()).build();
};
log = "Endpoint request URI: " + integration.getUri();
}
long latency = (System.nanoTime() - started) / 1_000_000L;
return new TestInvokeResult(response, log + "\nLatency: " + latency + " ms", latency);
}
private void applyApiOwnerContext(ApiGatewayV2Service.ApiOwner owner) {
requestContext.setAccountId(owner.accountId());
requestContext.setRegion(owner.region());
@@ -952,7 +1039,8 @@ public class ApiGatewayExecuteController {
return false;
}
try {
return !objectMapper.readTree(payload).isObject();
JsonNode response = objectMapper.readTree(payload);
return !response.isObject() || !response.has("statusCode");
} catch (IOException e) {
return true;
}
@@ -1413,6 +1501,9 @@ public class ApiGatewayExecuteController {
}
try {
JsonNode node = objectMapper.readTree(result.getPayload());
if (!node.isObject() || !node.has("statusCode")) {
return Response.status(502).build();
}
int statusCode = node.path("statusCode").asInt(200);
if (result.getFunctionError() != null && !node.has("statusCode")) statusCode = 502;
@@ -1475,6 +1566,191 @@ public class ApiGatewayExecuteController {
return Optional.empty();
}
private static final class TestInvokeHeaders implements HttpHeaders {
private final MultivaluedMap<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) ────────────────────────────
private MultivaluedMap<String, String> parseFormEncodedBody(String body) {
@@ -496,10 +496,16 @@ public class ApiGatewayService {
}
public MethodResponse putMethodResponse(String region, String apiId, String resourceId,
String httpMethod, String statusCode,
Map<String, Object> request) {
String httpMethod, String statusCode,
Map<String, Object> request) {
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);
resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId));
return mr;
@@ -524,6 +530,49 @@ public class ApiGatewayService {
resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId));
}
public MethodResponse updateMethodResponse(String region, String apiId, String resourceId,
String httpMethod, String statusCode,
List<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 ────────────────────────────
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) {
IntegrationResponse response = getIntegrationResponse(region, apiId, resourceId, httpMethod, statusCode);
String selectionPattern = response.selectionPattern();
if (patchOperations != null) {
for (Map<String, String> patch : patchOperations) {
String op = patch.get("op");
String path = patch.get("path");
String value = patch.get("value");
if (value == null || !("add".equals(op) || "replace".equals(op)) || !"/selectionPattern".equals(path)) {
throw new AwsException("BadRequestException", "Invalid patch operation", 400);
String contentHandling = response.contentHandling();
Map<String, String> responseParameters = new HashMap<>(response.responseParameters());
Map<String, String> responseTemplates = new HashMap<>(response.responseTemplates());
if (patchOperations == null) {
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);
resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId));
return newResponse;
@@ -10,6 +10,7 @@ import java.util.Map;
@RegisterForReflection
public record MethodResponse(
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<GlobalSecondaryIndex> gsis = new ArrayList<>();
List<LocalSecondaryIndex> lsis = new ArrayList<>();
Long readCapacity = null;
Long writeCapacity = null;
if (props != null && props.has("KeySchema")) {
for (JsonNode ks : props.get("KeySchema")) {
@@ -124,6 +126,11 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner {
attrDefs.add(new AttributeDefinition(attrName, attrType));
}
}
if (props != null && props.has("ProvisionedThroughput")) {
JsonNode throughput = props.get("ProvisionedThroughput");
readCapacity = Long.valueOf(engine.resolve(throughput.get("ReadCapacityUnits")));
writeCapacity = Long.valueOf(engine.resolve(throughput.get("WriteCapacityUnits")));
}
if (props != null && props.has("GlobalSecondaryIndexes")) {
for (JsonNode gsiNode : props.get("GlobalSecondaryIndexes")) {
@@ -179,7 +186,7 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner {
TableDefinition table;
try {
table = dynamoDbService.createTable(tableName, keySchema, attrDefs, null, null, gsis, lsis, region);
table = dynamoDbService.createTable(tableName, keySchema, attrDefs, readCapacity, writeCapacity, gsis, lsis, region);
} catch (AwsException e) {
if (!"ResourceInUseException".equals(e.getErrorCode())) {
throw e;
@@ -1228,6 +1228,15 @@ public class Ec2ContainerManager {
* Sets terminatedAt for TTL pruning.
*/
public void terminate(Instance instance) {
terminate(instance, () -> {
});
}
/**
* Terminates an instance and invokes {@code onTerminated} after its final state is recorded.
* The caller persists the instance because this manager deliberately has no storage dependency.
*/
public void terminate(Instance instance, Runnable onTerminated) {
String containerId;
String containerIp;
String imdsSourceIp;
@@ -1273,6 +1282,7 @@ public class Ec2ContainerManager {
instance.getPrivateIpAddress());
instance.setState(InstanceState.terminated());
instance.setTerminatedAt(System.currentTimeMillis());
onTerminated.run();
});
}
@@ -11,30 +11,32 @@ import java.util.Optional;
*/
public final class Ec2MetadataProxy {
private static final int IMDS_PROXY_PORT = 1338;
private Ec2MetadataProxy() {}
public static String[] installCommand() {
return new String[]{"sh", "-c", String.join("\n",
"set -eu",
"if command -v ip >/dev/null 2>&1 && command -v socat >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then exit 0; fi",
"if command -v ip >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1 && command -v socat >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then exit 0; fi",
"if command -v apt-get >/dev/null 2>&1; then",
" apt-get update -qq >/dev/null",
" DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iproute2 socat curl ca-certificates >/dev/null",
" DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iproute2 iptables socat curl ca-certificates >/dev/null",
"elif command -v dnf >/dev/null 2>&1; then",
// --allowerasing lets dnf swap the curl-minimal that
// public.ecr.aws/amazonlinux/amazonlinux:2023 ships by default for the full
// curl package this proxy needs. Without it, dnf aborts the whole transaction
// on a curl/curl-minimal conflict and iproute+socat never install either, even
// though neither of them conflicts with anything.
" dnf install -y --allowerasing iproute socat curl ca-certificates >/dev/null",
" dnf install -y --allowerasing iproute iptables socat curl ca-certificates >/dev/null",
// Same gap as the sshd probe: Amazon Linux 2 has only yum, so on an instance
// launched from ami-amazonlinux2 this chain reached its else branch and exited 1
// with "No supported package manager found for IMDS proxy dependencies",
// leaving the instance without a link-local IMDS endpoint.
"elif command -v yum >/dev/null 2>&1; then",
" yum install -y iproute socat curl ca-certificates >/dev/null",
" yum install -y iproute iptables socat curl ca-certificates >/dev/null",
"elif command -v apk >/dev/null 2>&1; then",
" apk add --no-cache iproute2 socat curl ca-certificates >/dev/null",
" apk add --no-cache iproute2 iptables socat curl ca-certificates >/dev/null",
"else",
" echo 'No supported package manager found for IMDS proxy dependencies' >&2",
" exit 1",
@@ -42,8 +44,24 @@ public final class Ec2MetadataProxy {
}
public static String[] startCommand(String flociHost, int imdsPort) {
return startCommand("imds", "169.254.169.254", 80, flociHost, imdsPort,
"curl -fsS --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null && exit 0");
return new String[]{"sh", "-c", String.join("\n",
"set -eu",
"ip addr show dev lo | grep -q '169.254.169.254/32' || ip addr add 169.254.169.254/32 dev lo",
"iptables -t nat -C OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j DNAT --to-destination 127.0.0.1:"
+ IMDS_PROXY_PORT + " 2>/dev/null || iptables -t nat -A OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j DNAT --to-destination 127.0.0.1:"
+ IMDS_PROXY_PORT,
"if [ -f /tmp/floci-imds-proxy.pid ] && kill -0 \"$(cat /tmp/floci-imds-proxy.pid)\" 2>/dev/null; then",
" exit 0",
"fi",
"nohup socat TCP-LISTEN:" + IMDS_PROXY_PORT + ",bind=127.0.0.1,fork,reuseaddr TCP:"
+ flociHost + ":" + imdsPort + " >/tmp/floci-imds-proxy.log 2>&1 &",
"echo $! > /tmp/floci-imds-proxy.pid",
"for i in 1 2 3 4 5 6 7 8 9 10 11 12; do",
" curl -fsS --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null && exit 0",
" sleep 1",
"done",
"cat /tmp/floci-imds-proxy.log >&2 || true",
"exit 1")};
}
public static String[] podIdentityStartCommand(String flociHost, int flociPort) {
@@ -21,6 +21,9 @@ import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiPredicate;
import java.util.function.Function;
@@ -31,6 +34,8 @@ import java.util.stream.Stream;
import org.jboss.logging.Logger;
import com.fasterxml.jackson.core.type.TypeReference;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.core.common.RequestContext;
import io.github.hectorvent.floci.core.common.AwsArnUtils;
@@ -216,6 +221,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
private final Set<String> seededAccountRegions = ConcurrentHashMap.newKeySet();
// subnetId → counter for IP assignment (runtime-only, not persisted)
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
@@ -513,6 +519,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
}
restoreVpcNetworks();
recoverInterruptedTerminations();
int restored = 0;
for (String key : instances.keys()) {
@@ -560,6 +567,17 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
return !"terminated".equals(state) && !"shutting-down".equals(state);
}
private void recoverInterruptedTerminations() {
for (String storageKey : instances.keys()) {
Instance instance = instances.get(storageKey).orElse(null);
if (instance == null || instance.getState() == null
|| !"shutting-down".equals(instance.getState().getName())) {
continue;
}
containerManager.terminate(instance, () -> instances.put(storageKey, instance));
}
}
/**
* Rebuilds the in-memory VPC address plan from persisted VPCs and subnets, after clearing
* out any Docker networks a previous run of this same emulator left behind.
@@ -3082,6 +3100,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
}
public List<Reservation> describeInstances(String region, List<String> instanceIds, Map<String, List<String>> filters) {
pruneTerminatedInstances();
ensureDefaultResources(region);
if (!instanceIds.isEmpty()) {
for (String id : instanceIds) {
@@ -3130,7 +3149,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
inst.setState(InstanceState.terminated());
inst.setTerminatedAt(System.currentTimeMillis());
} else {
containerManager.terminate(inst);
containerManager.terminate(inst, () -> instances.put(key(region, id), inst));
}
// Delete root volume if deleteOnTermination (matches real AWS behavior)
if (inst.getRootVolumeId() != null) {
@@ -3274,7 +3293,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
}
}
/** Removes terminated instances older than 1 hour. Called periodically by lifecycle. */
/** Removes terminated instances older than 1 hour. */
public void pruneTerminatedInstances() {
long cutoff = System.currentTimeMillis() - 3_600_000L;
for (String storeKey : new ArrayList<>(instances.keys())) {
@@ -3288,6 +3307,33 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
}
}
@PostConstruct
void startTerminatedInstancePruner() {
terminatedInstancePruner = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "ec2-terminated-instance-pruner");
thread.setDaemon(true);
return thread;
});
terminatedInstancePruner.scheduleAtFixedRate(this::pruneTerminatedInstancesSafely,
1, 1, TimeUnit.MINUTES);
LOG.infov("EC2 terminated-instance pruning scheduled (1-minute interval, 1-hour retention)");
}
@PreDestroy
void stopTerminatedInstancePruner() {
if (terminatedInstancePruner != null) {
terminatedInstancePruner.shutdownNow();
}
}
private void pruneTerminatedInstancesSafely() {
try {
pruneTerminatedInstances();
} catch (RuntimeException e) {
LOG.warnv("Could not prune expired terminated EC2 instances: {0}", e.getMessage());
}
}
public List<Instance> describeInstanceStatus(String region, List<String> instanceIds) {
ensureDefaultResources(region);
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).
*/
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;
}
@@ -1,12 +1,16 @@
package io.github.hectorvent.floci.services.ecs;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager;
import io.github.hectorvent.floci.services.ecs.model.Container;
import io.github.hectorvent.floci.services.ecs.model.EcsLoadBalancer;
import io.github.hectorvent.floci.services.ecs.model.EcsServiceModel;
import io.github.hectorvent.floci.services.ecs.model.EcsTask;
import io.github.hectorvent.floci.services.ecs.model.NetworkBinding;
import io.github.hectorvent.floci.services.ecs.model.PortMapping;
import io.github.hectorvent.floci.services.ecs.model.TaskDefinition;
import io.github.hectorvent.floci.services.elbv2.ElbV2Service;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
import io.github.hectorvent.floci.services.elbv2.model.TargetDescription;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@@ -14,6 +18,7 @@ import org.jboss.logging.Logger;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.stream.Stream;
/**
* Bridges ECS services to ELBv2: when an ECS service declares a {@code loadBalancers}
@@ -37,6 +42,40 @@ public class EcsLoadBalancerRegistrar {
this.containerManager = containerManager;
}
/**
* Resolves service target groups and their task-definition bindings before the service can
* launch a task. A late failure here otherwise leaves a running task that can never receive
* traffic.
*/
public void validateServiceLoadBalancers(List<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. */
public void registerTask(EcsTask task, EcsServiceModel svc, String region) {
forEachTarget(task, svc, (tgArn, td) -> {
@@ -1166,6 +1166,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
}
EcsCluster cluster = resolveClusterOrDefault(request.getCluster(), region);
TaskDefinition taskDef = resolveLaunchableTaskDefinition(request.getTaskDefinition(), region);
validateEc2BridgeLaunch(cluster, taskDef, request);
return launchTasks(cluster, taskDef, request, null, null, region);
}
@@ -1425,7 +1426,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
strategy = clusterDefaultStrategy(cluster);
}
if (strategy == null || strategy.isEmpty()) {
LaunchType launchType = request.getLaunchType() != null ? request.getLaunchType() : LaunchType.FARGATE;
LaunchType launchType = request.getLaunchType() != null ? request.getLaunchType() : LaunchType.EC2;
return Collections.nCopies(count, new Placement(launchType, null));
}
return distribute(strategy, count);
@@ -1552,6 +1553,28 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
return new Placement(launchType, capacityProvider);
}
/** Floci's Docker-backed service implementation supports EC2 bridge tasks only. */
private void validateEc2BridgeLaunch(EcsCluster cluster, TaskDefinition taskDef, RunTaskRequest request) {
if (taskDef.getNetworkMode() != NetworkMode.bridge) {
throw new AwsException("ClientException",
"Only bridge network mode is supported for ECS EC2 tasks.", 400);
}
LaunchType launchType = request.getLaunchType();
if (launchType != null && launchType != LaunchType.EC2) {
throw new AwsException("ClientException",
"Only the EC2 launch type is supported; FARGATE is not available.", 400);
}
List<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. */
private void requireCapacityProvider(String name) {
if (name == null || name.isBlank()) {
@@ -2026,6 +2049,11 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
// AWS resolves family / family:revision at create time and stores the ARN; the
// reconciler compares it with each task's taskDefinitionArn, so pin it here.
TaskDefinition taskDef = resolveLaunchableTaskDefinition(request.getTaskDefinition(), region);
RunTaskRequest launchValidation = new RunTaskRequest();
launchValidation.setLaunchType(request.getLaunchType());
launchValidation.setCapacityProviderStrategy(request.getCapacityProviderStrategy());
validateEc2BridgeLaunch(cluster, taskDef, launchValidation);
lbRegistrar.validateServiceLoadBalancers(request.getLoadBalancers(), taskDef, region);
String taskDefinition = taskDef.getTaskDefinitionArn();
String serviceName = request.getServiceName();
@@ -2335,7 +2363,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
strategy = clusterDefaultStrategy(cluster);
}
if (strategy == null || strategy.isEmpty()) {
return LaunchType.FARGATE;
return LaunchType.EC2;
}
validateCapacityProviderStrategy(strategy);
return placementFor(strategy.getFirst().capacityProvider()).launchType();
@@ -3842,6 +3870,9 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
}
// Close log streams and remove the stopped Docker containers without re-inspecting.
// Docker can be killed outside ECS. Remove its old target before the service reconciler
// creates a replacement, otherwise the target group retains a dead host port.
deregisterTaskFromLoadBalancers(task, taskRegion(task));
containerManager.cleanupStoppedTask(claimed);
if (task.getContainers() != null) {
@@ -5,6 +5,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.config.TlsProxyServer;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ec2.ContainerNetworkReachability;
import io.github.hectorvent.floci.services.ec2.SecurityGroupPolicy;
import io.github.hectorvent.floci.services.ec2.model.SecurityGroup;
import io.github.hectorvent.floci.services.elbv2.model.Action;
import io.github.hectorvent.floci.services.elbv2.model.Listener;
import io.github.hectorvent.floci.services.elbv2.model.LoadBalancer;
@@ -37,6 +40,7 @@ import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -76,6 +80,12 @@ public class ElbV2DataPlane {
@Inject
Ec2Service ec2Service;
@Inject
ContainerNetworkReachability containerNetworkReachability;
@Inject
ElbV2ListenerForwardManager listenerForwardManager;
@Inject
ObjectMapper objectMapper;
@@ -102,6 +112,9 @@ public class ElbV2DataPlane {
@PreDestroy
void shutdown() {
for (Map.Entry<String, ListenerBinding> entry : listenerBindings.entrySet()) {
listenerForwardManager.remove(entry.getValue().port());
}
for (Map.Entry<Integer, HttpServer> e : servers.entrySet()) {
e.getValue().close();
}
@@ -127,6 +140,10 @@ public class ElbV2DataPlane {
.put(binding.host(), listenerArn);
ensureReleaseHandlerRegistered();
servers.computeIfAbsent(binding.port(), this::startPortServer);
if (!isReservedByFloci(binding.port())
&& listenersByHostAndPort.get(binding.port()).size() == 1) {
listenerForwardManager.reconcile(binding.port());
}
}
public void restartListener(Listener listener, String region, List<Rule> rules) {
@@ -150,6 +167,9 @@ public class ElbV2DataPlane {
Map<String, String> listenersByHost = listenersByHostAndPort.get(oldBinding.port());
if (listenersByHost != null) {
listenersByHost.remove(oldBinding.host(), listenerArn);
if (listenersByHost.isEmpty()) {
listenerForwardManager.remove(oldBinding.port());
}
closePortServerIfUnused(oldBinding.port(), listenersByHost);
}
}
@@ -162,6 +182,9 @@ public class ElbV2DataPlane {
Map<String, String> listenersByHost = listenersByHostAndPort.get(binding.port());
if (listenersByHost != null) {
listenersByHost.remove(binding.host(), listenerArn);
if (listenersByHost.isEmpty()) {
listenerForwardManager.remove(binding.port());
}
closePortServerIfUnused(binding.port(), listenersByHost);
}
}
@@ -268,6 +291,7 @@ public class ElbV2DataPlane {
}
private void clearPortBindings(int port) {
listenerForwardManager.remove(port);
Map<String, String> listenersByHost = listenersByHostAndPort.remove(port);
if (listenersByHost == null) {
return;
@@ -296,6 +320,11 @@ public class ElbV2DataPlane {
req.response().setStatusCode(502).end("No listener region");
return;
}
if (!allowsIngress(listenerArn, region, req, port)) {
// Security groups are enforced at TCP ingress. Do not synthesize an HTTP response.
req.connection().close();
return;
}
AtomicReference<List<CompiledRule>> ref = ruleChains.get(listenerArn);
if (ref == null) {
req.response().setStatusCode(502).end("No rule chain");
@@ -378,18 +407,19 @@ public class ElbV2DataPlane {
List<TargetDescription> allTargets = tg.getTargets();
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());
List<TargetDescription> candidates = healthy.isEmpty() ? allTargets : healthy;
if (candidates.isEmpty()) {
if (healthy.isEmpty()) {
req.response().setStatusCode(503).end("No targets available");
return;
}
AtomicInteger counter = rrCounters.computeIfAbsent(tgArn, k -> new AtomicInteger(0));
int idx = Math.abs(counter.getAndIncrement() % candidates.size());
TargetDescription target = candidates.get(idx);
int idx = Math.floorMod(counter.getAndIncrement(), healthy.size());
TargetDescription target = healthy.get(idx);
int targetPort = ElbV2HealthChecker.effectivePort(target, tg);
proxyRequest(req, ElbV2TargetResolver.resolveHost(ec2Service, tg, target), targetPort,
ElbV2TargetResolver.TargetEndpoint endpoint = ElbV2TargetResolver.resolveEndpoint(
ec2Service, containerNetworkReachability, tg, target, targetPort);
proxyRequest(req, endpoint.host(), endpoint.port(),
preserveHostHeader(listenerArn, region));
}
@@ -404,6 +434,20 @@ public class ElbV2DataPlane {
&& Boolean.parseBoolean(loadBalancer.getAttributes().get(PRESERVE_HOST_HEADER_ATTRIBUTE));
}
private boolean allowsIngress(String listenerArn, String region, HttpServerRequest req, int port) {
ListenerBinding binding = listenerBindings.get(listenerArn);
LoadBalancer loadBalancer = binding != null
? elbV2Service.getLoadBalancer(region, binding.loadBalancerArn()) : null;
if (loadBalancer == null || loadBalancer.getSecurityGroups() == null || loadBalancer.getSecurityGroups().isEmpty()) {
return true;
}
List<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) {
if (req.isEnded()) {
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.core.common.Resettable;
import io.github.hectorvent.floci.services.ec2.ContainerNetworkReachability;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.elbv2.model.TargetDescription;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
@@ -27,6 +28,7 @@ public class ElbV2HealthChecker implements Resettable {
private final Vertx vertx;
private final EmulatorConfig config;
private final Ec2Service ec2Service;
private final ContainerNetworkReachability containerNetworkReachability;
// tgArn → (targetKey → TargetState)
private final Map<String, Map<String, TargetState>> states = new ConcurrentHashMap<>();
@@ -34,10 +36,12 @@ public class ElbV2HealthChecker implements Resettable {
private final Map<String, Long> timers = new ConcurrentHashMap<>();
@Inject
public ElbV2HealthChecker(Vertx vertx, EmulatorConfig config, Ec2Service ec2Service) {
public ElbV2HealthChecker(Vertx vertx, EmulatorConfig config, Ec2Service ec2Service,
ContainerNetworkReachability containerNetworkReachability) {
this.vertx = vertx;
this.config = config;
this.ec2Service = ec2Service;
this.containerNetworkReachability = containerNetworkReachability;
}
public static int effectivePort(TargetDescription target, TargetGroup tg) {
@@ -50,6 +54,18 @@ public class ElbV2HealthChecker implements Resettable {
return 80;
}
public static int effectiveHealthCheckPort(TargetDescription target, TargetGroup tg) {
String healthCheckPort = tg.getHealthCheckPort();
if (healthCheckPort != null && !"traffic-port".equalsIgnoreCase(healthCheckPort)) {
try {
return Integer.parseInt(healthCheckPort);
} catch (NumberFormatException e) {
return effectivePort(target, tg);
}
}
return effectivePort(target, tg);
}
public void startMonitoring(TargetGroup tg) {
if (config.services().elbv2().mock()) {
return;
@@ -60,6 +76,11 @@ public class ElbV2HealthChecker implements Resettable {
String tgArn = tg.getTargetGroupArn();
states.computeIfAbsent(tgArn, k -> new ConcurrentHashMap<>());
Long previousTimer = timers.remove(tgArn);
if (previousTimer != null) {
vertx.cancelTimer(previousTimer);
}
long intervalMs = (tg.getHealthCheckIntervalSeconds() != null ? tg.getHealthCheckIntervalSeconds() : 30) * 1000L;
long timerId = vertx.setPeriodic(intervalMs, id -> probeAll(tgArn, tg));
timers.put(tgArn, timerId);
@@ -85,7 +106,7 @@ public class ElbV2HealthChecker implements Resettable {
}
Map<String, TargetState> tgStates = states.computeIfAbsent(tgArn, k -> new ConcurrentHashMap<>());
for (TargetDescription t : targets) {
int port = effectivePort(t, tg);
int port = effectiveHealthCheckPort(t, tg);
String key = stateKey(t.getId(), port);
tgStates.putIfAbsent(key, new TargetState());
}
@@ -100,7 +121,7 @@ public class ElbV2HealthChecker implements Resettable {
return;
}
for (TargetDescription t : targets) {
int port = effectivePort(t, tg);
int port = effectiveHealthCheckPort(t, tg);
tgStates.remove(stateKey(t.getId(), port));
}
}
@@ -123,7 +144,7 @@ public class ElbV2HealthChecker implements Resettable {
public boolean isHealthy(String tgArn, TargetDescription target, int port) {
String state = getState(tgArn, target.getId(), port);
return "healthy".equals(state) || "initial".equals(state);
return "healthy".equals(state);
}
private void probeAll(String tgArn, TargetGroup tg) {
@@ -145,7 +166,11 @@ public class ElbV2HealthChecker implements Resettable {
} catch (NumberFormatException e) {
continue;
}
String host = ElbV2TargetResolver.resolveHost(ec2Service, tg, targetId);
TargetDescription target = new TargetDescription();
target.setId(targetId);
target.setPort(port);
ElbV2TargetResolver.TargetEndpoint endpoint = ElbV2TargetResolver.resolveEndpoint(
ec2Service, containerNetworkReachability, tg, target, port);
String path = tg.getHealthCheckPath() != null ? tg.getHealthCheckPath() : "/";
String matcher = tg.getMatcher() != null ? tg.getMatcher() : "200";
int timeout = tg.getHealthCheckTimeoutSeconds() != null ? tg.getHealthCheckTimeoutSeconds() : 5;
@@ -153,7 +178,7 @@ public class ElbV2HealthChecker implements Resettable {
int unhealthyThreshold = tg.getUnhealthyThresholdCount() != null ? tg.getUnhealthyThresholdCount() : 2;
vertx.executeBlocking(() -> {
return probe(host, port, path, timeout);
return probe(endpoint.host(), endpoint.port(), path, timeout);
}).onSuccess(statusCode -> {
boolean success = matchesStatusCode(statusCode, matcher);
if (success) {
@@ -189,7 +214,7 @@ public class ElbV2HealthChecker implements Resettable {
state.description = "Health checks failed";
}
}
LOG.debugv("Health check failed for {0}:{1} - {2}", host, port, err.getMessage());
LOG.debugv("Health check failed for {0}:{1} - {2}", endpoint.host(), endpoint.port(), err.getMessage());
});
}
}
@@ -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.RegionResolver;
import io.github.hectorvent.floci.core.common.SsrfProtection;
import io.github.hectorvent.floci.core.common.dns.EmbeddedDnsServer;
import io.github.hectorvent.floci.core.storage.StorageBackedMap;
import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
@@ -232,10 +231,11 @@ public class ElbV2Service implements ResourceProvider {
}
private String loadBalancerDnsSuffix() {
if (config == null) {
return EmbeddedDnsServer.DEFAULT_SUFFIX;
if (config != null && config.hostname().isPresent()) {
return config.hostname().get();
}
return config.hostname().orElse(EmbeddedDnsServer.DEFAULT_SUFFIX);
// RFC 6761 reserves *.localhost for loopback resolution without an external DNS server.
return "localhost";
}
public List<LoadBalancer> describeLoadBalancers(String region, List<String> arns, List<String> names,
@@ -357,6 +357,7 @@ public class ElbV2Service implements ResourceProvider {
String matcher, String ipAddressType,
Map<String, String> initialTags) {
validateName(name, "target group");
validateHttpTargetGroup(protocol, targetType, healthCheckProtocol);
Map<String, TargetGroup> regionTgs = targetGroups.computeIfAbsent(region, k -> new ConcurrentHashMap<>());
boolean duplicate = regionTgs.values().stream()
.anyMatch(tg -> tg.getTargetGroupName().equals(name));
@@ -450,6 +451,8 @@ public class ElbV2Service implements ResourceProvider {
Integer healthCheckTimeout, Integer healthyThreshold,
Integer unhealthyThreshold, String matcher) {
TargetGroup tg = requireTargetGroup(region, arn);
String effectiveHealthCheckProtocol = healthCheckProtocol != null ? healthCheckProtocol : tg.getHealthCheckProtocol();
validateHttpTargetGroup(tg.getProtocol(), tg.getTargetType(), effectiveHealthCheckProtocol);
if (healthCheckProtocol != null) tg.setHealthCheckProtocol(healthCheckProtocol);
if (healthCheckPort != null) tg.setHealthCheckPort(healthCheckPort);
if (healthCheckEnabled != null) tg.setHealthCheckEnabled(healthCheckEnabled);
@@ -460,6 +463,11 @@ public class ElbV2Service implements ResourceProvider {
if (unhealthyThreshold != null) tg.setUnhealthyThresholdCount(unhealthyThreshold);
if (matcher != null) tg.setMatcher(matcher);
persistRegion(targetGroups, region);
healthChecker.stopMonitoring(arn);
healthChecker.startMonitoring(tg);
if (!tg.getTargets().isEmpty()) {
healthChecker.addTargets(arn, tg.getTargets(), tg);
}
}
public Map<String, String> describeTargetGroupAttributes(String region, String arn) {
@@ -479,7 +487,9 @@ public class ElbV2Service implements ResourceProvider {
String sslPolicy, List<String> certificates,
List<Action> defaultActions, List<String> alpnPolicy,
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<>());
@@ -492,7 +502,7 @@ public class ElbV2Service implements ResourceProvider {
"A listener already exists on port " + port + " for this load balancer.", 400);
}
LoadBalancer lb = requireLoadBalancer(region, lbArn);
LoadBalancer lb = loadBalancer;
String lbType = lb.getType() != null ? lb.getType() : "application";
String typePrefix = lbTypePrefix(lbType);
String lbId = arnId(lbArn);
@@ -584,6 +594,11 @@ public class ElbV2Service implements ResourceProvider {
String sslPolicy, List<String> certificates,
List<Action> defaultActions, List<String> alpnPolicy) {
Listener listener = requireListener(region, listenerArn);
validateHttpListener(requireLoadBalancer(region, listener.getLoadBalancerArn()),
protocol != null ? protocol : listener.getProtocol());
if (defaultActions != null) {
validateForwardActions(region, defaultActions);
}
boolean restartDataPlane = false;
boolean recompileRules = false;
@@ -632,6 +647,7 @@ public class ElbV2Service implements ResourceProvider {
public Rule createRule(String region, String listenerArn, List<RuleCondition> conditions,
int priority, List<Action> actions, Map<String, String> initialTags) {
requireListener(region, listenerArn);
validateForwardActions(region, actions);
if (priority < 1 || priority > 50000) {
throw new AwsException("ValidationError", "Priority must be between 1 and 50000.", 400);
}
@@ -724,6 +740,9 @@ public class ElbV2Service implements ResourceProvider {
public Rule modifyRule(String region, String ruleArn, List<RuleCondition> conditions, List<Action> actions) {
Rule rule = requireRule(region, ruleArn);
if (actions != null) {
validateForwardActions(region, actions);
}
String listenerArn = rule.getListenerArn();
if (conditions != null) rule.setConditions(new ArrayList<>(conditions));
if (actions != null) rule.setActions(new ArrayList<>(actions));
@@ -830,7 +849,7 @@ public class ElbV2Service implements ResourceProvider {
th.setState("healthy");
return th;
}
int port = ElbV2HealthChecker.effectivePort(t, tg);
int port = ElbV2HealthChecker.effectiveHealthCheckPort(t, tg);
th.setHealthCheckPort(String.valueOf(port));
if (hasFilterTargets && !isRegisteredTarget(tg, t, port)) {
th.setState("unused");
@@ -849,7 +868,60 @@ public class ElbV2Service implements ResourceProvider {
private static boolean isRegisteredTarget(TargetGroup targetGroup, TargetDescription candidate, int candidatePort) {
return targetGroup.getTargets().stream()
.anyMatch(registered -> Objects.equals(registered.getId(), candidate.getId())
&& ElbV2HealthChecker.effectivePort(registered, targetGroup) == candidatePort);
&& ElbV2HealthChecker.effectiveHealthCheckPort(registered, targetGroup) == candidatePort);
}
private static void validateHttpListener(LoadBalancer loadBalancer, String protocol) {
if (!"application".equals(loadBalancer.getType())) {
return;
}
if (!"HTTP".equalsIgnoreCase(protocol)) {
throw new AwsException("UnsupportedProtocol",
"Floci's Application Load Balancer data plane supports HTTP listeners only.", 400);
}
}
private static void validateHttpTargetGroup(String protocol, String targetType, String healthCheckProtocol) {
if (protocol != null && !"HTTP".equalsIgnoreCase(protocol)) {
return;
}
String effectiveTargetType = targetType != null ? targetType : "instance";
if (!"instance".equals(effectiveTargetType) && !"ip".equals(effectiveTargetType)
&& !"lambda".equals(effectiveTargetType)) {
throw new AwsException("UnsupportedTargetType",
"Floci's HTTP target-group data plane supports instance, ip, and lambda targets only.", 400);
}
if (healthCheckProtocol != null && !"HTTP".equalsIgnoreCase(healthCheckProtocol)) {
throw new AwsException("UnsupportedProtocol",
"Floci's HTTP target-group data plane supports HTTP health checks only.", 400);
}
}
private void validateForwardActions(String region, List<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 ──────────────────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
package io.github.hectorvent.floci.services.elbv2;
import io.github.hectorvent.floci.core.common.SsrfProtection;
import io.github.hectorvent.floci.services.ec2.ContainerNetworkReachability;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ec2.model.Instance;
import io.github.hectorvent.floci.services.elbv2.model.TargetDescription;
@@ -11,6 +12,8 @@ import java.net.InetAddress;
final class ElbV2TargetResolver {
static final String DOCKER_HOST = "host.docker.internal";
private ElbV2TargetResolver() {}
static String resolveHost(Ec2Service ec2Service, TargetGroup targetGroup, TargetDescription target) {
@@ -37,6 +40,30 @@ final class ElbV2TargetResolver {
return targetId;
}
static TargetEndpoint resolveEndpoint(Ec2Service ec2Service, ContainerNetworkReachability reachability,
TargetGroup targetGroup, TargetDescription target, int port) {
String targetId = target != null ? target.getId() : null;
if (targetGroup == null || !"instance".equals(targetGroup.getTargetType()) || ec2Service == null) {
return new TargetEndpoint(targetId, port);
}
Instance instance = ec2Service.findInstanceById(targetId);
if (instance == null) {
return new TargetEndpoint(targetId, port);
}
String bridgeIp = instance.getContainerBridgeIp();
if (bridgeIp == null || bridgeIp.isBlank() || reachability == null
|| reachability.isContainerIpRoutable(bridgeIp)) {
return new TargetEndpoint(bridgeIp != null && !bridgeIp.isBlank() ? bridgeIp : targetId, port);
}
// Docker Desktop and DinD can isolate Floci from an EC2 container's bridge IP. Only in
// that case, use the already-published application port through the Docker host gateway.
Integer publishedPort = instance.getPublishedPorts().get(port);
if (publishedPort != null) {
return new TargetEndpoint(DOCKER_HOST, publishedPort);
}
return new TargetEndpoint(bridgeIp, port);
}
/** Resolves once, rejects metadata addresses, and returns the address to connect to instead of the name. */
static String resolveCheckedAddress(String host) throws IOException {
InetAddress[] addresses = SsrfProtection.rejectMetadataAddresses(InetAddress.getAllByName(host), host);
@@ -46,4 +73,7 @@ final class ElbV2TargetResolver {
static boolean isIpLiteral(String host) {
return host != null && (host.contains(":") || host.matches("[0-9.]+"));
}
record TargetEndpoint(String host, int port) {
}
}
@@ -34,6 +34,7 @@ public class LambdaExecutorService {
private final WarmPool warmPool;
private final ObjectMapper objectMapper;
private final LambdaConcurrencyLimiter concurrencyLimiter;
private final LambdaPlatformLogWriter platformLogWriter;
private final ExecutorService asyncExecutor = new ThreadPoolExecutor(
Math.max(4, Runtime.getRuntime().availableProcessors() * 2),
Math.max(8, Runtime.getRuntime().availableProcessors() * 4),
@@ -43,11 +44,17 @@ public class LambdaExecutorService {
@Inject
public LambdaExecutorService(WarmPool warmPool,
ObjectMapper objectMapper,
LambdaConcurrencyLimiter concurrencyLimiter) {
ObjectMapper objectMapper,
LambdaConcurrencyLimiter concurrencyLimiter,
LambdaPlatformLogWriter platformLogWriter) {
this.warmPool = warmPool;
this.objectMapper = objectMapper;
this.concurrencyLimiter = concurrencyLimiter;
this.platformLogWriter = platformLogWriter;
}
LambdaExecutorService(WarmPool warmPool, ObjectMapper objectMapper, LambdaConcurrencyLimiter concurrencyLimiter) {
this(warmPool, objectMapper, concurrencyLimiter, null);
}
public InvokeResult invoke(LambdaFunction fn, byte[] payload, InvocationType type) {
@@ -83,14 +90,19 @@ public class LambdaExecutorService {
}
private InvokeResult executeSync(LambdaFunction fn, byte[] payload, String requestId) {
long startedAt = System.nanoTime();
platformLogStart(fn, requestId);
InvokeResult result = null;
ContainerHandle handle;
try {
handle = warmPool.acquire(fn);
} catch (Exception e) {
LOG.warnv("Failed to acquire container for function {0}: {1}", fn.getFunctionName(), e.getMessage());
return new InvokeResult(200, "Unhandled",
result = new InvokeResult(200, "Unhandled",
buildErrorPayload("Failed to start Lambda container: " + e.getMessage(), "Lambda.InitError"),
null, requestId);
platformLogComplete(fn, requestId, startedAt, result);
return result;
}
try {
long deadlineMs = System.currentTimeMillis() + (long) fn.getTimeout() * 1000;
@@ -103,7 +115,7 @@ public class LambdaExecutorService {
java.util.concurrent.CompletableFuture.anyOf(
invocation.getDispatchedFuture(), invocation.getResultFuture())
.get(fn.getTimeout() + RUNTIME_DISPATCH_GRACE_SECONDS, TimeUnit.SECONDS);
InvokeResult result = invocation.getResultFuture().get();
result = invocation.getResultFuture().get();
warmPool.release(handle);
return result;
@@ -111,20 +123,37 @@ public class LambdaExecutorService {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
warmPool.destroyHandle(handle);
return new InvokeResult(200, "Unhandled", buildErrorPayload("Invocation interrupted", "Interrupted"), null, requestId);
result = new InvokeResult(200, "Unhandled", buildErrorPayload("Invocation interrupted", "Interrupted"), null, requestId);
return result;
} catch (Exception e) {
Throwable cause = e instanceof ExecutionException && e.getCause() != null ? e.getCause() : e;
if (cause instanceof TimeoutException) {
LOG.warnv("Function {0} timed out after {1}s", fn.getFunctionName(), fn.getTimeout());
warmPool.destroyHandle(handle);
return new InvokeResult(200, "Unhandled",
result = new InvokeResult(200, "Unhandled",
buildErrorPayload("Task timed out after " + fn.getTimeout() + " seconds", "Function.TimedOut"),
null, requestId);
return result;
}
LOG.warnv("Invocation error for function {0}: {1}", fn.getFunctionName(), cause.getMessage());
warmPool.destroyHandle(handle);
return new InvokeResult(200, "Unhandled",
result = new InvokeResult(200, "Unhandled",
buildErrorPayload(cause.getMessage(), "InvocationError"), null, requestId);
return result;
} finally {
platformLogComplete(fn, requestId, startedAt, result);
}
}
private void platformLogStart(LambdaFunction function, String requestId) {
if (platformLogWriter != null) {
platformLogWriter.start(function, requestId);
}
}
private void platformLogComplete(LambdaFunction function, String requestId, long startedAt, InvokeResult result) {
if (platformLogWriter != null && result != null) {
platformLogWriter.complete(function, requestId, System.nanoTime() - startedAt, result);
}
}
@@ -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 HttpHeaders httpHeaders) {
try {
if (isWebsiteRequest(httpHeaders, uriInfo)) {
Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo);
if (websiteRedirect != null) {
return headOnlyResponse(websiteRedirect);
}
}
S3Service.RequestAuthorization authorization = S3RequestAuthorizationParser.parseIfRequired(
s3Service.isAuthEnforced(), httpHeaders, uriInfo);
if (isWebsiteRequest(httpHeaders, uriInfo)) {
@@ -543,11 +549,17 @@ public class S3Controller {
@QueryParam("encoding-type") String encodingType,
@QueryParam("key-marker") String keyMarker,
@QueryParam("version-id-marker") String versionIdMarker,
@QueryParam("marker") String marker,
@Context UriInfo uriInfo,
@QueryParam("marker") String marker,
@Context UriInfo uriInfo,
@Context HttpHeaders httpHeaders) {
try {
validateRawUri();
if (isWebsiteRequest(httpHeaders, uriInfo)) {
Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo);
if (websiteRedirect != null) {
return websiteRedirect;
}
}
S3Service.RequestAuthorization authorization = S3RequestAuthorizationParser.parseIfRequired(
s3Service.isAuthEnforced(), httpHeaders, uriInfo);
if (hasQueryParam(uriInfo, "uploads")) {
@@ -926,6 +938,12 @@ public class S3Controller {
S3Service.RequestAuthorization authorization = S3Service.RequestAuthorization.unsigned();
try {
key = extractObjectKey(uriInfo, bucket, key);
if (isWebsiteRequest(httpHeaders, uriInfo)) {
Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo);
if (websiteRedirect != null) {
return websiteRedirect;
}
}
authorization = S3RequestAuthorizationParser.parseIfRequired(
s3Service.isAuthEnforced(), httpHeaders, uriInfo);
@@ -1169,6 +1187,12 @@ public class S3Controller {
S3Service.RequestAuthorization authorization = S3Service.RequestAuthorization.unsigned();
try {
key = extractObjectKey(uriInfo, bucket, key);
if (isWebsiteRequest(httpHeaders, uriInfo)) {
Response websiteRedirect = serveWebsiteRedirect(bucket, uriInfo);
if (websiteRedirect != null) {
return headOnlyResponse(websiteRedirect);
}
}
authorization = S3RequestAuthorizationParser.parseIfRequired(
s3Service.isAuthEnforced(), httpHeaders, uriInfo);
if (isWebsiteRequest(httpHeaders, uriInfo)) {
@@ -2982,6 +3006,23 @@ public class S3Controller {
rawPath, includeBody);
}
private Response serveWebsiteRedirect(String bucket, UriInfo uriInfo) {
S3Service.WebsiteResolution resolution = s3Service.resolveWebsiteRedirect(bucket);
if (!(resolution instanceof S3Service.WebsiteResolution.RedirectAllRequestsTo redirect)) {
return null;
}
String protocol = redirect.protocol() != null
? redirect.protocol().toLowerCase(Locale.ROOT)
: currentVertxRequest.getCurrent().request().scheme();
String rawQuery = uriInfo.getRequestUri().getRawQuery();
String location = protocol + "://" + redirect.hostName()
+ currentVertxRequest.getCurrent().request().path()
+ (rawQuery == null || rawQuery.isEmpty() ? "" : "?" + rawQuery);
return Response.status(Response.Status.MOVED_PERMANENTLY)
.header("Location", location)
.build();
}
private Response serveWebsiteErrorResponse(String bucket,
S3Service.RequestAuthorization authorization,
AwsException cause) {
@@ -3027,6 +3068,8 @@ public class S3Controller {
.header("x-amz-error-code", websiteErrorCode(status))
.header("x-amz-error-message", websiteErrorMessage(status))
.build();
case S3Service.WebsiteResolution.RedirectAllRequestsTo(String hostName, String protocol) ->
throw new IllegalStateException("Redirect-all website configuration must be rendered first.");
case S3Service.WebsiteResolution.DefaultError(int status) -> defaultWebsiteErrorResponse(status);
case S3Service.WebsiteResolution.NotAWebsite() -> null;
};
@@ -3830,10 +3873,19 @@ public class S3Controller {
WebsiteConfiguration config = s3Service.getBucketWebsite(bucket);
XmlBuilder xml = new XmlBuilder()
.raw("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
.start("WebsiteConfiguration", AwsNamespaces.S3)
.start("IndexDocument")
.elem("Suffix", config.getIndexDocument())
.end("IndexDocument");
.start("WebsiteConfiguration", AwsNamespaces.S3);
if (config.getRedirectAllRequestsToHostName() != null) {
xml.start("RedirectAllRequestsTo")
.elem("HostName", config.getRedirectAllRequestsToHostName());
if (config.getRedirectAllRequestsToProtocol() != null) {
xml.elem("Protocol", config.getRedirectAllRequestsToProtocol());
}
xml.end("RedirectAllRequestsTo");
} else {
xml.start("IndexDocument")
.elem("Suffix", config.getIndexDocument())
.end("IndexDocument");
}
if (config.getErrorDocument() != null) {
xml.start("ErrorDocument")
.elem("Key", config.getErrorDocument())
@@ -3864,15 +3916,54 @@ public class S3Controller {
private Response handlePutBucketWebsite(String bucket, byte[] body) {
String xml = new String(body, StandardCharsets.UTF_8);
if (!"WebsiteConfiguration".equals(XmlParser.rootElementName(xml))) {
throw new AwsException("MalformedXML", "The XML you provided was not well-formed.", 400);
}
boolean hasRedirect = websiteConfigurationHasElement(xml, "RedirectAllRequestsTo");
boolean hasIndex = websiteConfigurationHasElement(xml, "IndexDocument");
boolean hasError = websiteConfigurationHasElement(xml, "ErrorDocument");
if (hasRedirect && (hasIndex || hasError)) {
throw new AwsException("InvalidRequest",
"RedirectAllRequestsTo cannot be specified with IndexDocument or ErrorDocument.", 400);
}
if (hasRedirect) {
String hostName = XmlParser.extractFirst(xml, "HostName", null);
String protocol = XmlParser.extractFirst(xml, "Protocol", null);
if (hostName == null || hostName.isBlank()) {
throw new AwsException("MalformedXML", "RedirectAllRequestsTo.HostName is required.", 400);
}
if (protocol != null && !"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) {
throw new AwsException("MalformedXML", "RedirectAllRequestsTo.Protocol must be http or https.", 400);
}
s3Service.putBucketWebsite(bucket, new WebsiteConfiguration(null, null, hostName,
protocol == null ? null : protocol.toLowerCase(Locale.ROOT)));
return Response.ok().build();
}
String indexDoc = XmlParser.extractFirst(xml, "Suffix", null);
String errorDoc = XmlParser.extractFirst(xml, "Key", null);
if (indexDoc == null) {
if (!hasIndex || indexDoc == null || indexDoc.isBlank()) {
throw new AwsException("MalformedXML", "IndexDocument.Suffix is required.", 400);
}
s3Service.putBucketWebsite(bucket, new WebsiteConfiguration(indexDoc, errorDoc));
return Response.ok().build();
}
private static boolean websiteConfigurationHasElement(String xml, String elementName) {
try {
XMLStreamReader reader = XmlParser.newStreamReader(xml);
while (reader.hasNext()) {
if (reader.next() == XMLStreamConstants.START_ELEMENT && elementName.equals(reader.getLocalName())) {
reader.close();
return true;
}
}
reader.close();
return false;
} catch (XMLStreamException e) {
throw new AwsException("MalformedXML", "The XML you provided was not well-formed.", 400);
}
}
/**
* Authorizes {@code s3:GetObject} on the CopyObject/UploadPartCopy source, which only ever appears
* in the {@code x-amz-copy-source} header and is otherwise invisible to the request-level IAM filter
@@ -2480,6 +2480,24 @@ public class S3Service implements Resettable, ResourceProvider {
return bucket.getWebsiteConfiguration();
}
/** Resolves a redirect-all website configuration without authorizing or looking up any objects. */
public WebsiteResolution resolveWebsiteRedirect(String bucket) {
WebsiteConfiguration cfg;
try {
cfg = getBucketWebsite(bucket);
} catch (AwsException e) {
if (!"NoSuchWebsiteConfiguration".equals(e.getErrorCode())) {
throw e;
}
return new WebsiteResolution.NotAWebsite();
}
if (cfg.getRedirectAllRequestsToHostName() == null) {
return new WebsiteResolution.NotAWebsite();
}
return new WebsiteResolution.RedirectAllRequestsTo(cfg.getRedirectAllRequestsToHostName(),
cfg.getRedirectAllRequestsToProtocol());
}
public void deleteBucketWebsite(String bucketName) {
Bucket bucket = bucketStore.get(bucketName)
.orElseThrow(() -> new AwsException("NoSuchBucket",
@@ -2510,6 +2528,9 @@ public class S3Service implements Resettable, ResourceProvider {
/** Serve the bucket's custom error document with {@code status}. */
record ErrorDocument(S3Object object, int status) implements WebsiteResolution {}
/** Redirect every website request to the configured hostname and optional protocol. */
record RedirectAllRequestsTo(String hostName, String protocol) implements WebsiteResolution {}
/** No usable custom error document: render S3's built-in error page with {@code status}. */
record DefaultError(int status) implements WebsiteResolution {}
@@ -2543,6 +2564,10 @@ public class S3Service implements Resettable, ResourceProvider {
}
return new WebsiteResolution.NotAWebsite();
}
if (cfg.getRedirectAllRequestsToHostName() != null) {
return new WebsiteResolution.RedirectAllRequestsTo(cfg.getRedirectAllRequestsToHostName(),
cfg.getRedirectAllRequestsToProtocol());
}
String index = cfg.getIndexDocument();
if (index == null) {
return new WebsiteResolution.NotAWebsite();
@@ -6,6 +6,8 @@ import io.quarkus.runtime.annotations.RegisterForReflection;
public class WebsiteConfiguration {
private String indexDocument;
private String errorDocument;
private String redirectAllRequestsToHostName;
private String redirectAllRequestsToProtocol;
public WebsiteConfiguration() {}
@@ -14,9 +16,27 @@ public class WebsiteConfiguration {
this.errorDocument = errorDocument;
}
public WebsiteConfiguration(String indexDocument, String errorDocument, String redirectAllRequestsToHostName,
String redirectAllRequestsToProtocol) {
this.indexDocument = indexDocument;
this.errorDocument = errorDocument;
this.redirectAllRequestsToHostName = redirectAllRequestsToHostName;
this.redirectAllRequestsToProtocol = redirectAllRequestsToProtocol;
}
public String getIndexDocument() { return indexDocument; }
public void setIndexDocument(String indexDocument) { this.indexDocument = indexDocument; }
public String getErrorDocument() { return errorDocument; }
public void setErrorDocument(String errorDocument) { this.errorDocument = errorDocument; }
public String getRedirectAllRequestsToHostName() { return redirectAllRequestsToHostName; }
public void setRedirectAllRequestsToHostName(String redirectAllRequestsToHostName) {
this.redirectAllRequestsToHostName = redirectAllRequestsToHostName;
}
public String getRedirectAllRequestsToProtocol() { return redirectAllRequestsToProtocol; }
public void setRedirectAllRequestsToProtocol(String redirectAllRequestsToProtocol) {
this.redirectAllRequestsToProtocol = redirectAllRequestsToProtocol;
}
}
+1
View File
@@ -623,6 +623,7 @@ floci:
elbv2:
enabled: true
mock: false
listener-forwarder-image: alpine/socat:1.8.1.3
codebuild:
enabled: true
# docker-network: floci-network
+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-minimal-kernel-default-x86_64
- /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64
dockerImage: public.ecr.aws/amazonlinux/amazonlinux:2023
dockerImage: floci/ami-amazonlinux:2023
name: al2023-ami-2023.0.20230315.0-kernel-6.1-x86_64
description: Amazon Linux 2023 AMI
ownerId: "137112412989"
imageOwnerAlias: amazon
architecture: x86_64
creationDate: "2023-03-15T00:00:00.000Z"
guestRuntime: systemd
- imageId: ami-0abcdef1234567892
aliases:
@@ -101,10 +102,16 @@ images:
architecture: x86_64
creationDate: "2023-04-01T00:00:00.000Z"
- imageId: ami-0abcdef1234567893
dockerImage: public.ecr.aws/amazonlinux/amazonlinux:2023
name: Windows_Server-2022-English-Full-Base-2023.04.12
description: Microsoft Windows Server 2022 Full Locale English AMI
- imageId: ami-fedora42
dockerImage: registry.fedoraproject.org/fedora:42
name: Fedora-Cloud-Base-42-x86_64
description: Fedora Linux 42 AMI
architecture: x86_64
platform: windows
creationDate: "2023-04-12T00:00:00.000Z"
creationDate: "2025-04-15T00:00:00.000Z"
- imageId: ami-opensuse-leap156
dockerImage: registry.opensuse.org/opensuse/leap:15.6
name: openSUSE-Leap-15.6-x86_64
description: openSUSE Leap 15.6 AMI
architecture: x86_64
creationDate: "2024-06-12T00:00:00.000Z"
@@ -4,8 +4,13 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.services.apigateway.model.ApiGatewayResource;
import io.github.hectorvent.floci.services.apigateway.model.Integration;
import io.github.hectorvent.floci.services.apigateway.model.MethodConfig;
import io.github.hectorvent.floci.services.apigatewayv2.ApiGatewayV2Service;
import io.github.hectorvent.floci.services.apigatewayv2.model.Api;
import io.github.hectorvent.floci.services.lambda.LambdaService;
import io.github.hectorvent.floci.services.lambda.model.InvocationType;
import io.github.hectorvent.floci.services.lambda.model.InvokeResult;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MultivaluedHashMap;
@@ -22,6 +27,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -255,6 +262,85 @@ class ApiGatewayExecuteControllerTest {
assertEquals("application/problem+json", response.getMediaType().toString());
}
@Test
void buildProxyResponseRejectsBareLambdaPayload() {
ApiGatewayExecuteController controller = controller(new ObjectMapper());
Response response = controller.buildProxyResponse(proxyPayload("\"Hello from Lambda\""), false);
assertEquals(502, response.getStatus());
}
@Test
void testInvokeProxyLogsSentEventAndDoesNotRequireStage() throws Exception {
ApiGatewayService apiGatewayService = mock(ApiGatewayService.class);
LambdaService lambdaService = mock(LambdaService.class);
RegionResolver regionResolver = mock(RegionResolver.class);
when(regionResolver.getAccountId()).thenReturn("000000000000");
ApiGatewayExecuteController controller = new ApiGatewayExecuteController(apiGatewayService, null, lambdaService,
regionResolver, new ObjectMapper(), null, null, null, null, null,
new ApiGatewayExecuteRouteContext(), null, null, null);
ApiGatewayResource resource = new ApiGatewayResource();
resource.setId("resource-id");
resource.setPath("/orders");
MethodConfig method = new MethodConfig();
Integration integration = new Integration();
integration.setType("AWS_PROXY");
integration.setUri("arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/"
+ "arn:aws:lambda:us-east-1:000000000000:function:orders/invocations");
method.setMethodIntegration(integration);
when(apiGatewayService.getResource("us-east-1", "api-id", "resource-id")).thenReturn(resource);
when(apiGatewayService.getMethod("us-east-1", "api-id", "resource-id", "POST")).thenReturn(method);
when(lambdaService.invoke(eq("us-east-1"), eq("orders"), any(),
eq(InvocationType.RequestResponse)))
.thenReturn(proxyPayload("{\"statusCode\":201,\"body\":\"created\",\"headers\":{\"X-One\":\"one\"},"
+ "\"multiValueHeaders\":{\"Set-Cookie\":[\"a=1\",\"b=2\"]}}"));
ApiGatewayExecuteController.TestInvokeResult result = controller.testInvoke("us-east-1", "api-id",
"resource-id", "POST", new ApiGatewayExecuteController.TestInvokeRequest("/orders?q=one&q=two",
"request-body", Map.of("X-Input", "input"), Map.of("X-Multi", List.of("one", "two")),
Map.of("environment", "test")));
assertEquals(201, result.response().getStatus());
assertTrue(result.log().contains("Endpoint request URI: " + integration.getUri()));
assertTrue(result.log().contains("\"body\":\"request-body\""));
assertTrue(result.log().contains("\"environment\":\"test\""));
assertEquals(List.of("a=1", "b=2"), result.response().getStringHeaders().get("Set-Cookie"));
}
@Test
void testInvokeProxyRejectsMalformedLambdaResponse() {
ApiGatewayService apiGatewayService = mock(ApiGatewayService.class);
LambdaService lambdaService = mock(LambdaService.class);
RegionResolver regionResolver = mock(RegionResolver.class);
when(regionResolver.getAccountId()).thenReturn("000000000000");
ApiGatewayExecuteController controller = new ApiGatewayExecuteController(apiGatewayService, null, lambdaService,
regionResolver, new ObjectMapper(), null, null, null, null, null,
new ApiGatewayExecuteRouteContext(), null, null, null);
ApiGatewayResource resource = new ApiGatewayResource();
resource.setId("resource-id");
resource.setPath("/");
MethodConfig method = new MethodConfig();
Integration integration = new Integration();
integration.setType("AWS_PROXY");
integration.setUri("arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/"
+ "arn:aws:lambda:us-east-1:000000000000:function:orders/invocations");
method.setMethodIntegration(integration);
when(apiGatewayService.getResource("us-east-1", "api-id", "resource-id")).thenReturn(resource);
when(apiGatewayService.getMethod("us-east-1", "api-id", "resource-id", "GET")).thenReturn(method);
when(lambdaService.invoke(eq("us-east-1"), eq("orders"), any(),
eq(InvocationType.RequestResponse)))
.thenReturn(proxyPayload("[]"));
ApiGatewayExecuteController.TestInvokeResult result = controller.testInvoke("us-east-1", "api-id",
"resource-id", "GET", new ApiGatewayExecuteController.TestInvokeRequest(null, null, Map.of(), Map.of(), Map.of()));
assertEquals(500, result.response().getStatus());
assertEquals("Execution failed due to configuration error: Malformed Lambda proxy response",
result.response().getEntity());
assertTrue(result.log().contains("Execution failed due to configuration error: Malformed Lambda proxy response"));
}
@Test
void buildProxyResponseMatchesExactCaseContentType() {
// The pre-existing exact-case path must keep working — this is a regression guard,
@@ -117,6 +117,17 @@ class ApiGatewayIntegrationTest {
.body("authorizationType", equalTo("NONE"));
}
@Test @Order(9)
void getResourcesEmbedsMethods() {
given()
.queryParam("embed", "methods")
.when().get("/restapis/" + apiId + "/resources")
.then()
.statusCode(200)
.body("item.find { it.id == '" + resourceId + "' }.resourceMethods.GET.httpMethod", equalTo("GET"))
.body("item.find { it.id == '" + rootId + "' }.resourceMethods", nullValue());
}
@Test @Order(9)
void putMethodResponse() {
given()
@@ -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 org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
@QuarkusTest
class ApiGatewayUpdateIntegrationResponseIntegrationTest {
@Test
void testUpdateIntegrationResponse() {
void updateIntegrationResponsePatchesAllCorsFields() {
String apiId = given().contentType("application/json").body("{\"name\":\"ir-update\"}").when().post("/restapis").then().statusCode(201).extract().path("id");
String rootId = given().when().get("/restapis/" + apiId + "/resources").then().statusCode(200).extract().path("item[0].id");
String resourceId = given().contentType("application/json").body("{\"pathPart\":\"items\"}").when().post("/restapis/" + apiId + "/resources/" + rootId).then().statusCode(201).extract().path("id");
given().contentType("application/json").body("{\"authorizationType\":\"NONE\"}").when().put("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET").then().statusCode(201);
given().contentType("application/json").body("{\"type\":\"MOCK\"}").when().put("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration").then().statusCode(201);
given().contentType("application/json").body("{\"selectionPattern\":\"before\",\"responseTemplates\":{\"application/json\":\"before\"}}").when().put("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200").then().statusCode(201);
given().contentType("application/json").body("{\"patchOperations\":[{\"op\":\"replace\",\"path\":\"/selectionPattern\",\"value\":\"after\"}]}").when().patch("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200").then().statusCode(200).body("statusCode", equalTo("200")).body("selectionPattern", equalTo("after"));
given().when().get("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200").then().statusCode(200).body("selectionPattern", equalTo("after"));
given().contentType("application/json").body("""
{"selectionPattern":"before","contentHandling":"CONVERT_TO_BINARY",
"responseParameters":{"method.response.header.Access-Control-Allow-Origin":"'https://before.example'"},
"responseTemplates":{"application/json":"before"}}
""").when().put("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200").then().statusCode(201);
given().contentType("application/json").body("""
{"patchOperations":[
{"op":"replace","path":"/selectionPattern","value":"after"},
{"op":"replace","path":"/contentHandling","value":"CONVERT_TO_TEXT"},
{"op":"replace","path":"/responseParameters/method.response.header.Access-Control-Allow-Origin","value":"'*'"},
{"op":"add","path":"/responseParameters/method.response.header.Access-Control-Allow-Headers","value":"'Content-Type'"},
{"op":"remove","path":"/responseParameters/method.response.header.Access-Control-Allow-Headers"},
{"op":"add","path":"/responseTemplates/application~1xml","value":"after"},
{"op":"remove","path":"/responseTemplates/application~1json"}]}
""").when().patch("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200")
.then().statusCode(200).body("statusCode", equalTo("200"))
.body("selectionPattern", equalTo("after"))
.body("contentHandling", equalTo("CONVERT_TO_TEXT"))
.body("responseParameters.'method.response.header.Access-Control-Allow-Origin'", equalTo("'*'"))
.body("responseParameters.'method.response.header.Access-Control-Allow-Headers'", nullValue())
.body("responseTemplates.'application/json'", nullValue())
.body("responseTemplates.'application/xml'", equalTo("after"));
given().when().get("/restapis/" + apiId + "/resources/" + resourceId + "/methods/GET/integration/responses/200")
.then().statusCode(200).body("selectionPattern", equalTo("after"))
.body("contentHandling", equalTo("CONVERT_TO_TEXT"))
.body("responseParameters.'method.response.header.Access-Control-Allow-Origin'", equalTo("'*'"))
.body("responseTemplates.'application/xml'", equalTo("after"));
}
}
@@ -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());
}
@Test
void tablePassesProvisionedThroughputToDynamoDb() throws Exception {
when(dynamoDb.createTable(anyString(), anyList(), anyList(), any(), any(), anyList(), anyList(), anyString()))
.thenReturn(table(false));
StackResource r = resource("AWS::DynamoDB::Table", "Orders");
provisioner.provision(r, props("""
{"TableName":"orders","AttributeDefinitions":[{"AttributeName":"id","AttributeType":"S"}],
"KeySchema":[{"AttributeName":"id","KeyType":"HASH"}],
"ProvisionedThroughput":{"ReadCapacityUnits":20,"WriteCapacityUnits":5}}
"""), ctx());
verify(dynamoDb).createTable(eq("orders"), anyList(), anyList(), eq(20L), eq(5L), anyList(), anyList(), eq("us-east-1"));
}
/** The registry schema declares only Arn and StreamArn read-only on a plain table; TableId belongs to the global table. */
@Test
void tablePublishesOnlyArnWithoutAStream() throws Exception {
@@ -863,7 +863,7 @@ class Ec2ContainerManagerTest {
String script = Ec2ContainerManager.metadataProxyInstallCommand()[2];
assertTrue(script.contains("command -v yum"), script);
assertTrue(script.contains("yum install -y iproute socat curl ca-certificates"), script);
assertTrue(script.contains("yum install -y iproute iptables socat curl ca-certificates"), script);
}
@Test
@@ -878,7 +878,7 @@ class Ec2ContainerManagerTest {
// instance was left with no link-local IMDS endpoint.
String script = Ec2ContainerManager.metadataProxyInstallCommand()[2];
assertTrue(script.contains("dnf install -y --allowerasing iproute socat curl ca-certificates"),
assertTrue(script.contains("dnf install -y --allowerasing iproute iptables socat curl ca-certificates"),
script);
}
@@ -888,7 +888,8 @@ class Ec2ContainerManagerTest {
assertEquals("sh", command[0]);
assertTrue(command[2].contains("169.254.169.254/32"));
assertTrue(command[2].contains("TCP-LISTEN:80,bind=169.254.169.254"));
assertTrue(command[2].contains("TCP-LISTEN:1338,bind=127.0.0.1"));
assertTrue(command[2].contains("--dport 80 -j DNAT --to-destination 127.0.0.1:1338"));
assertTrue(command[2].contains("TCP:floci:9169"));
assertTrue(command[2].contains("http://169.254.169.254/latest/meta-data/instance-id"));
}
@@ -1798,6 +1799,23 @@ class Ec2ContainerManagerTest {
awaitUntil(() -> "terminated".equals(instance.getState().getName()), Duration.ofSeconds(2));
}
@Test
void terminateInvokesPersistenceCallbackAfterRecordingFinalState() throws Exception {
LaunchHarness harness = launchHarness();
stubDockerPing(harness.dockerClient(), false);
Instance instance = instance("i-persist-termination");
CountDownLatch persisted = new CountDownLatch(1);
harness.manager().launch(instance, "ubuntu:24.04", null, "us-west-2");
harness.manager().terminate(instance, () -> {
assertEquals("terminated", instance.getState().getName());
assertTrue(instance.getTerminatedAt() > 0);
persisted.countDown();
});
assertTrue(persisted.await(2, TimeUnit.SECONDS));
}
/** Stubs the daemon reachability probe every launch makes before touching Docker. */
private static void stubDockerPing(DockerClient dockerClient, boolean reachable) {
PingCmd ping = mock(PingCmd.class);
@@ -2,7 +2,6 @@ package io.github.hectorvent.floci.services.ec2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
@@ -12,7 +11,6 @@ import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.github.hectorvent.floci.core.common.AwsException;
import jakarta.inject.Inject;
@QuarkusTest
@@ -40,7 +38,8 @@ class Ec2ImageCatalogTest {
"ami-ubuntu2404-cloud-arm64",
"ami-debian12",
"ami-alpine",
"ami-0abcdef1234567893"), imageIds);
"ami-fedora42",
"ami-opensuse-leap156"), imageIds);
assertTrue(imageCatalog.findByIdOrAlias("ami-amazonlinux2").isPresent());
assertTrue(imageCatalog.findByIdOrAlias("ami-amazonlinux2023").isPresent());
@@ -64,11 +63,11 @@ class Ec2ImageCatalogTest {
@Test
void resolverExposesCloudImageGuestRuntimeMetadata() {
ResolvedAmiImage image = amiImageResolver.resolveImage("ami-ubuntu2404-cloud");
ResolvedAmiImage image = amiImageResolver.resolveImage("ami-amazonlinux2023");
assertEquals("floci/ami-ubuntu:24.04-arm64", image.dockerImage());
assertEquals("floci/ami-amazonlinux:2023", image.dockerImage());
assertEquals(ResolvedAmiImage.SYSTEMD_RUNTIME, image.guestRuntime());
assertTrue(image.cloudInit());
assertFalse(image.cloudInit());
assertTrue(image.systemd());
}
@@ -79,11 +78,9 @@ class Ec2ImageCatalogTest {
}
@Test
void resolverRejectsWindowsImagesForContainerExecution() {
AwsException error = assertThrows(AwsException.class,
() -> amiImageResolver.resolveImage("ami-0abcdef1234567893"));
assertEquals("UnsupportedOperation", error.getErrorCode());
void resolverUsesFedoraAndOpenSuseImages() {
assertEquals("registry.fedoraproject.org/fedora:42", amiImageResolver.resolve("ami-fedora42"));
assertEquals("registry.opensuse.org/opensuse/leap:15.6", amiImageResolver.resolve("ami-opensuse-leap156"));
}
@Test
@@ -19,11 +19,11 @@ class Ec2MetadataProxyTest {
assertEquals("-c", command[1]);
String script = command[2];
assertTrue(script.contains("command -v ip >/dev/null 2>&1 && command -v socat >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then exit 0; fi"));
assertTrue(script.contains("apt-get install -y --no-install-recommends iproute2 socat curl ca-certificates"));
assertTrue(script.contains("dnf install -y --allowerasing iproute socat curl ca-certificates"));
assertTrue(script.contains("yum install -y iproute socat curl ca-certificates"));
assertTrue(script.contains("apk add --no-cache iproute2 socat curl ca-certificates"));
assertTrue(script.contains("command -v ip >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1 && command -v socat >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then exit 0; fi"));
assertTrue(script.contains("apt-get install -y --no-install-recommends iproute2 iptables socat curl ca-certificates"));
assertTrue(script.contains("dnf install -y --allowerasing iproute iptables socat curl ca-certificates"));
assertTrue(script.contains("yum install -y iproute iptables socat curl ca-certificates"));
assertTrue(script.contains("apk add --no-cache iproute2 iptables socat curl ca-certificates"));
}
@Test
@@ -39,7 +39,8 @@ class Ec2MetadataProxyTest {
// Idempotent when pid file exists and process is alive
assertTrue(script.contains("if [ -f /tmp/floci-imds-proxy.pid ] && kill -0 \"$(cat /tmp/floci-imds-proxy.pid)\" 2>/dev/null; then\n exit 0\nfi"));
// Targets configured Floci host and IMDS port
assertTrue(script.contains("nohup socat TCP-LISTEN:80,bind=169.254.169.254,fork,reuseaddr TCP:10.0.0.1:9169 >/tmp/floci-imds-proxy.log 2>&1 &"));
assertTrue(script.contains("iptables -t nat -A OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j DNAT --to-destination 127.0.0.1:1338"));
assertTrue(script.contains("nohup socat TCP-LISTEN:1338,bind=127.0.0.1,fork,reuseaddr TCP:10.0.0.1:9169 >/tmp/floci-imds-proxy.log 2>&1 &"));
// Verifies via link-local curl
assertTrue(script.contains("curl -fsS --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null && exit 0"));
}
@@ -390,6 +390,31 @@ class Ec2ServiceTest {
verifyNoInteractions(containerManager);
}
@Test
void prunesTerminatedInstancesAfterOneHourAndRetainsRecentOnes() {
Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),
mock(Ec2PortForwardManager.class), mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class),
new Ec2InstanceTypeCatalog(), new InMemoryStorageFactory());
Reservation expiredReservation = service.runInstances("us-east-1", "ami-1234567890abcdef0", "t3.micro",
1, 1, null, List.of(), null, null, List.of(), null, null);
Reservation recentReservation = service.runInstances("us-east-1", "ami-1234567890abcdef0", "t3.micro",
1, 1, null, List.of(), null, null, List.of(), null, null);
String expiredId = expiredReservation.getInstances().getFirst().getInstanceId();
String recentId = recentReservation.getInstances().getFirst().getInstanceId();
service.terminateInstances("us-east-1", List.of(expiredId, recentId));
expiredReservation.getInstances().getFirst().setTerminatedAt(System.currentTimeMillis() - 3_600_001L);
service.pruneTerminatedInstances();
List<String> remainingIds = service.describeInstances("us-east-1", List.of(), Map.of()).stream()
.flatMap(reservation -> reservation.getInstances().stream())
.map(Instance::getInstanceId)
.toList();
assertFalse(remainingIds.contains(expiredId));
assertTrue(remainingIds.contains(recentId));
}
@Test
void describeNetworkInterfacesGroupIdFilterSkipsNullGroupEntry() {
Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),
@@ -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;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.github.hectorvent.floci.services.elbv2.ElbV2Service;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
@@ -22,6 +27,9 @@ class EcsLoadBalancersIntegrationTest {
private static final String TARGET = "AmazonEC2ContainerServiceV20141113.";
private static final String CT = "application/x-amz-json-1.1";
@Inject
ElbV2Service elbV2Service;
@BeforeAll
static void configure() {
RestAssuredJsonUtils.configureAwsContentTypes();
@@ -41,10 +49,17 @@ class EcsLoadBalancersIntegrationTest {
+ "\"memory\":128,\"portMappings\":[{\"containerPort\":8080}]}]}");
}
private String createTargetGroup(String name) {
TargetGroup targetGroup = elbV2Service.createTargetGroup("us-east-1", name, "HTTP", "HTTP1",
8080, "vpc-default", "ip", null, null, false, null, null, null, null, null, null,
null, Map.of());
return targetGroup.getTargetGroupArn();
}
@Test
void createServiceParsesAndEchoesLoadBalancers() {
seedClusterAndTaskDef("lb-cluster-1", "lb-td-1");
String tgArn = "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/lbtg1/abc123";
String tgArn = createTargetGroup("lbtg1");
given().contentType(CT).header("X-Amz-Target", TARGET + "CreateService")
.body("{\"cluster\":\"lb-cluster-1\",\"serviceName\":\"lb-svc-1\","
@@ -63,7 +78,7 @@ class EcsLoadBalancersIntegrationTest {
@Test
void describeServicesEchoesLoadBalancers() {
seedClusterAndTaskDef("lb-cluster-2", "lb-td-2");
String tgArn = "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/lbtg2/def456";
String tgArn = createTargetGroup("lbtg2");
call("CreateService", "{\"cluster\":\"lb-cluster-2\",\"serviceName\":\"lb-svc-2\","
+ "\"taskDefinition\":\"lb-td-2\",\"desiredCount\":1,"
+ "\"loadBalancers\":[{\"targetGroupArn\":\"" + tgArn + "\","
@@ -116,11 +131,27 @@ class EcsLoadBalancersIntegrationTest {
.body("__type", org.hamcrest.Matchers.containsString("InvalidParameterException"));
}
@Test
void createServiceRejectsTargetGroupBindingToAnUndeclaredContainerPort() {
seedClusterAndTaskDef("lb-cluster-port", "lb-td-port");
String tgArn = createTargetGroup("lbtg-port");
given().contentType(CT).header("X-Amz-Target", TARGET + "CreateService")
.body("{\"cluster\":\"lb-cluster-port\",\"serviceName\":\"lb-svc-port\","
+ "\"taskDefinition\":\"lb-td-port\",\"desiredCount\":1,"
+ "\"loadBalancers\":[{\"targetGroupArn\":\"" + tgArn + "\","
+ "\"containerName\":\"web\",\"containerPort\":9999}]}")
.when().post("/")
.then().statusCode(400)
.body("__type", org.hamcrest.Matchers.containsString("InvalidParameterException"))
.body("message", org.hamcrest.Matchers.containsString("does not expose port 9999"));
}
@Test
void serviceWithLoadBalancersIsStableInMockMode() {
// Mock mode launches no real containers; the registrar must no-op cleanly.
seedClusterAndTaskDef("lb-cluster-4", "lb-td-4");
String tgArn = "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/lbtg4/ghi789";
String tgArn = createTargetGroup("lbtg4");
call("CreateService", "{\"cluster\":\"lb-cluster-4\",\"serviceName\":\"lb-svc-4\","
+ "\"taskDefinition\":\"lb-td-4\",\"desiredCount\":1,"
+ "\"loadBalancers\":[{\"targetGroupArn\":\"" + tgArn + "\","
@@ -10,6 +10,7 @@ import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager;
import io.github.hectorvent.floci.services.ecs.container.EcsTaskHandle;
import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition;
import io.github.hectorvent.floci.services.ecs.model.EcsLoadBalancer;
import io.github.hectorvent.floci.services.ecs.model.LaunchType;
import org.junit.jupiter.api.Test;
@@ -62,7 +63,7 @@ class EcsServiceTeardownTest {
cd.setImage("nginx:alpine");
service.registerTaskDefinition("teardown-fam", List.of(cd), null, null, null,
null, null, List.of(), REGION);
service.runTask(null, "teardown-fam", 1, LaunchType.FARGATE, null, null,
service.runTask(null, "teardown-fam", 1, LaunchType.EC2, null, null,
List.of(), null, REGION);
service.stopManagedContainers();
@@ -138,7 +139,7 @@ class EcsServiceTeardownTest {
cd.setImage("nginx:alpine");
service.registerTaskDefinition("retry-fam", List.of(cd), null, null, null,
null, null, List.of(), REGION);
String taskArn = service.runTask(null, "retry-fam", 1, LaunchType.FARGATE, null, null,
String taskArn = service.runTask(null, "retry-fam", 1, LaunchType.EC2, null, null,
List.of(), null, REGION).getFirst().getTaskArn();
service.stopTask(null, taskArn, null, REGION);
@@ -151,6 +152,41 @@ class EcsServiceTeardownTest {
verify(containerManager, times(2)).stopTaskAndCollectExitCodes(handle);
}
@Test
void reconcilerDeregistersLoadBalancerTargetsWhenDockerWasKilledExternally() {
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.services().ecs().mock()).thenReturn(false);
when(config.effectiveBaseUrl()).thenReturn("http://localhost:4566");
EcsContainerManager containerManager = mock(EcsContainerManager.class);
EcsLoadBalancerRegistrar registrar = mock(EcsLoadBalancerRegistrar.class);
EcsTaskHandle handle = new EcsTaskHandle("task-arn", Map.of("app", "docker-id"), Map.of());
when(containerManager.startTask(any(), any(), any(), anyString())).thenReturn(handle);
when(containerManager.getExitCodeIfStopped("docker-id")).thenReturn(137);
EcsService service = new EcsService(
new RegionResolver(REGION, "000000000000"), containerManager, config, registrar,
new SingleUseStorageFactory(), null);
service.initializeStorage();
ContainerDefinition container = new ContainerDefinition();
container.setName("app");
container.setImage("nginx:alpine");
service.createCluster("reconcile-lb", REGION);
service.registerTaskDefinition("reconcile-lb", List.of(container), null, null, null,
null, null, List.of(), REGION);
EcsLoadBalancer loadBalancer = new EcsLoadBalancer();
loadBalancer.setTargetGroupArn("arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/app/id");
loadBalancer.setContainerName("app");
loadBalancer.setContainerPort(8080);
service.createService("reconcile-lb", "app", "reconcile-lb", 1, LaunchType.EC2,
List.of(loadBalancer), null, REGION);
service.reconcileServices();
service.reconcile();
verify(registrar).deregisterTask(any(), any(), anyString());
verify(containerManager).cleanupStoppedTask(handle);
}
private static final class SingleUseStorageFactory extends StorageFactory {
private final Map<String, StorageBackend<String, ?>> stores = new HashMap<>();
@@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.elbv2;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ec2.ContainerNetworkReachability;
import io.github.hectorvent.floci.services.elbv2.model.TargetDescription;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
import io.vertx.core.Vertx;
@@ -75,6 +76,41 @@ class ElbV2HealthCheckerTest {
}
}
@Test
void probeUsesConfiguredHealthCheckPortAndPath() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = vertx.createHttpServer()
.requestHandler(request -> {
if ("/ready".equals(request.path())) {
requests.incrementAndGet();
request.response().setStatusCode(204).end();
} else {
request.response().setStatusCode(404).end();
}
})
.listen(0, "127.0.0.1")
.toCompletionStage()
.toCompletableFuture()
.get(2, TimeUnit.SECONDS);
try {
ElbV2HealthChecker checker = healthChecker();
TargetGroup targetGroup = targetGroup(1, "204", 1, 1);
targetGroup.setHealthCheckPort(String.valueOf(server.actualPort()));
targetGroup.setHealthCheckPath("/ready");
TargetDescription target = target("127.0.0.1", 1);
checker.addTargets(targetGroup.getTargetGroupArn(), List.of(target), targetGroup);
await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> {
assertEquals(1, requests.get());
assertEquals("healthy", checker.getHealth(targetGroup.getTargetGroupArn(),
target.getId(), server.actualPort()).state());
});
} finally {
close(server);
}
}
@Test
void timedOutProbeTransitionsTargetToUnhealthyWithAwsReason() throws Exception {
HttpServer server = startHangingServer();
@@ -174,7 +210,8 @@ class ElbV2HealthCheckerTest {
private ElbV2HealthChecker healthChecker() {
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.services().elbv2().mock()).thenReturn(false);
return new ElbV2HealthChecker(vertx, config, mock(Ec2Service.class));
return new ElbV2HealthChecker(vertx, config, mock(Ec2Service.class),
mock(ContainerNetworkReachability.class));
}
private HttpServer startServer(int statusCode) throws Exception {
@@ -0,0 +1,13 @@
package io.github.hectorvent.floci.services.elbv2;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ElbV2ListenerForwardManagerTest {
@Test
void forwardContainerNameUsesListenerPort() {
assertEquals("floci-elbv2-fwd-8080", ElbV2ListenerForwardManager.forwardContainerName(8080));
}
}
@@ -107,6 +107,34 @@ class ElbV2ServiceTest {
assertTrue(newTargetGroup.getLoadBalancerArns().contains(lbArn));
}
@Test
void applicationListenerRejectsUnsupportedDataPlaneProtocolsAndTargetGroups() {
String lbArn = service.createLoadBalancer(
REGION, "http-only-lb", "internal", "application", "ipv4",
ALB_SUBNETS, List.of(), Map.of()).getLoadBalancerArn();
AwsException listenerError = assertThrows(AwsException.class, () -> service.createListener(
REGION, lbArn, "HTTPS", 8443, null, List.of(), List.of(), List.of(), Map.of()));
assertEquals("UnsupportedProtocol", listenerError.getErrorCode());
String tcpTargetGroupArn = service.createTargetGroup(
REGION, "tcp-targets", "TCP", null, 8080, "vpc-a", "ip",
"TCP", "traffic-port", true, null, null, null, null, null, null,
"ipv4", Map.of()).getTargetGroupArn();
AwsException targetGroupError = assertThrows(AwsException.class, () -> service.createListener(
REGION, lbArn, "HTTP", 8080, null, List.of(),
List.of(forwardAction(tcpTargetGroupArn)), List.of(), Map.of()));
assertEquals("UnsupportedProtocol", targetGroupError.getErrorCode());
}
@Test
void defaultLoadBalancerDnsNameUsesHostResolvableLocalhostDomain() {
String dnsName = service.createLoadBalancer(
REGION, "host-resolvable-lb", "internal", "application", "ipv4",
ALB_SUBNETS, List.of(), Map.of()).getDnsName();
assertTrue(dnsName.endsWith(".elb.localhost"));
}
@Test
void modifyListenerPortRestartsListener() {
String lbArn = service.createLoadBalancer(
@@ -1,6 +1,7 @@
package io.github.hectorvent.floci.services.elbv2;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ec2.ContainerNetworkReachability;
import io.github.hectorvent.floci.services.ec2.model.Instance;
import io.github.hectorvent.floci.services.elbv2.model.TargetDescription;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
@@ -44,6 +45,52 @@ class ElbV2TargetResolverTest {
assertEquals("10.0.0.12", ElbV2TargetResolver.resolveHost(ec2Service, targetGroup, target));
}
@Test
void instanceTargetUsesBridgeEndpointWhenContainerNetworkIsRoutable() {
Ec2Service ec2Service = mock(Ec2Service.class);
ContainerNetworkReachability reachability = mock(ContainerNetworkReachability.class);
Instance instance = instance("172.18.0.42");
when(ec2Service.findInstanceById("i-1234567890abcdef0")).thenReturn(instance);
when(reachability.isContainerIpRoutable("172.18.0.42")).thenReturn(true);
ElbV2TargetResolver.TargetEndpoint endpoint = ElbV2TargetResolver.resolveEndpoint(
ec2Service, reachability, instanceTargetGroup(), instanceTarget(), 8080);
assertEquals("172.18.0.42", endpoint.host());
assertEquals(8080, endpoint.port());
}
@Test
void unroutableInstanceBridgeUsesPublishedPortFromDockerHost() {
Ec2Service ec2Service = mock(Ec2Service.class);
ContainerNetworkReachability reachability = mock(ContainerNetworkReachability.class);
Instance instance = instance("172.18.0.42");
instance.getPublishedPorts().put(8080, 30000);
when(ec2Service.findInstanceById("i-1234567890abcdef0")).thenReturn(instance);
when(reachability.isContainerIpRoutable("172.18.0.42")).thenReturn(false);
ElbV2TargetResolver.TargetEndpoint endpoint = ElbV2TargetResolver.resolveEndpoint(
ec2Service, reachability, instanceTargetGroup(), instanceTarget(), 8080);
assertEquals(ElbV2TargetResolver.DOCKER_HOST, endpoint.host());
assertEquals(30000, endpoint.port());
}
@Test
void unroutableInstanceBridgeWithoutPublishedPortKeepsBridgeEndpoint() {
Ec2Service ec2Service = mock(Ec2Service.class);
ContainerNetworkReachability reachability = mock(ContainerNetworkReachability.class);
Instance instance = instance("172.18.0.42");
when(ec2Service.findInstanceById("i-1234567890abcdef0")).thenReturn(instance);
when(reachability.isContainerIpRoutable("172.18.0.42")).thenReturn(false);
ElbV2TargetResolver.TargetEndpoint endpoint = ElbV2TargetResolver.resolveEndpoint(
ec2Service, reachability, instanceTargetGroup(), instanceTarget(), 8080);
assertEquals("172.18.0.42", endpoint.host());
assertEquals(8080, endpoint.port());
}
@Test
void checkedAddressReturnsAnIpLiteralForLoopbackAndPrivateTargets() throws IOException {
assertEquals("127.0.0.1", ElbV2TargetResolver.resolveCheckedAddress("127.0.0.1"));
@@ -57,4 +104,23 @@ class ElbV2TargetResolverTest {
assertTrue(ex.getMessage().contains("link-local or metadata address"), ex.getMessage());
}
}
private static Instance instance(String bridgeIp) {
Instance instance = new Instance();
instance.setInstanceId("i-1234567890abcdef0");
instance.setContainerBridgeIp(bridgeIp);
return instance;
}
private static TargetGroup instanceTargetGroup() {
TargetGroup targetGroup = new TargetGroup();
targetGroup.setTargetType("instance");
return targetGroup;
}
private static TargetDescription instanceTarget() {
TargetDescription target = new TargetDescription();
target.setId("i-1234567890abcdef0");
return target;
}
}
@@ -686,6 +686,19 @@ class S3ServiceTest {
assertInstanceOf(S3Service.WebsiteResolution.ServeObject.class, resolution).key());
}
@Test
void resolveWebsiteRedirectReturnsConfiguredTargetWithoutObjectLookup() {
s3Service.createBucket("site", "us-east-1");
s3Service.putBucketWebsite("site", new WebsiteConfiguration(null, null, "redirect.example", "https"));
S3Service.WebsiteResolution resolution = s3Service.resolveWebsiteRedirect("site");
S3Service.WebsiteResolution.RedirectAllRequestsTo redirect =
assertInstanceOf(S3Service.WebsiteResolution.RedirectAllRequestsTo.class, resolution);
assertEquals("redirect.example", redirect.hostName());
assertEquals("https", redirect.protocol());
}
@Test
void resolveWebsiteRequestRedirectsFolderWithoutTrailingSlash() {
websiteBucket("index.html", null);
@@ -1243,4 +1256,3 @@ class S3ServiceTest {
assertDoesNotThrow(() -> s3Service.authorizeSignedDeleteObject("ASIAFAKEKEY00000001", "sessiontoken", "signed-del-bucket", "some/key"));
}
}
@@ -411,6 +411,124 @@ class S3WebsiteIntegrationTest {
@Test
@Order(24)
void redirectAllConfigurationRoundTripAndValidation() {
String redirect = """
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<RedirectAllRequestsTo>
<HostName>redirect.example</HostName>
<Protocol>https</Protocol>
</RedirectAllRequestsTo>
</WebsiteConfiguration>
""";
given()
.queryParam("website", "")
.contentType("application/xml")
.body(redirect)
.when()
.put("/" + BUCKET)
.then()
.statusCode(200);
given()
.queryParam("website", "")
.when()
.get("/" + BUCKET)
.then()
.statusCode(200)
.body(containsString("<RedirectAllRequestsTo><HostName>redirect.example</HostName><Protocol>https</Protocol></RedirectAllRequestsTo>"))
.body(not(containsString("<IndexDocument>")));
String conflicting = """
<WebsiteConfiguration>
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
<RedirectAllRequestsTo><HostName>redirect.example</HostName></RedirectAllRequestsTo>
</WebsiteConfiguration>
""";
given()
.queryParam("website", "")
.contentType("application/xml")
.body(conflicting)
.when()
.put("/" + BUCKET)
.then()
.statusCode(400)
.body(containsString("InvalidRequest"));
String missingHost = "<WebsiteConfiguration><RedirectAllRequestsTo/></WebsiteConfiguration>";
given()
.queryParam("website", "")
.contentType("application/xml")
.body(missingHost)
.when()
.put("/" + BUCKET)
.then()
.statusCode(400)
.body(containsString("MalformedXML"));
String invalidProtocol = """
<WebsiteConfiguration>
<RedirectAllRequestsTo><HostName>redirect.example</HostName><Protocol>FTP</Protocol></RedirectAllRequestsTo>
</WebsiteConfiguration>
""";
given()
.queryParam("website", "")
.contentType("application/xml")
.body(invalidProtocol)
.when()
.put("/" + BUCKET)
.then()
.statusCode(400)
.body(containsString("MalformedXML"));
}
@Test
@Order(25)
void redirectAllWebsiteRequestsPreserveRawPathAndQueryForGetAndHead() {
String redirect = """
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<RedirectAllRequestsTo><HostName>redirect.example</HostName></RedirectAllRequestsTo>
</WebsiteConfiguration>
""";
given()
.queryParam("website", "")
.contentType("application/xml")
.body(redirect)
.when()
.put("/" + BUCKET)
.then()
.statusCode(200);
given()
.header("Host", websiteHost())
.redirects().follow(false)
.when()
.get("/?code=abc&state=xyz")
.then()
.statusCode(301)
.header("Location", equalTo("http://redirect.example/?code=abc&state=xyz"));
given()
.header("Host", websiteHost())
.redirects().follow(false)
.when()
.get("/nested/path?code=abc&state=xyz")
.then()
.statusCode(301)
.header("Location", equalTo("http://redirect.example/nested/path?code=abc&state=xyz"));
given()
.header("Host", websiteHost())
.redirects().follow(false)
.when()
.head("/nested/path?code=abc&state=xyz")
.then()
.statusCode(301)
.header("Location", equalTo("http://redirect.example/nested/path?code=abc&state=xyz"))
.body(emptyString());
}
@Test
@Order(26)
void deleteWebsiteConfiguration() {
given()
.queryParam("website", "")
@@ -428,7 +546,7 @@ class S3WebsiteIntegrationTest {
}
@Test
@Order(25)
@Order(27)
void cleanup() {
given().delete("/" + BUCKET + "/index.html");
given().delete("/" + BUCKET + "/error.html");
+1
View File
@@ -326,6 +326,7 @@ floci:
elbv2:
enabled: true
mock: true
listener-forwarder-image: alpine/socat:1.8.1.3
codebuild:
enabled: true
# docker-network: floci-network