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