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