feat: Fargate, ECS, ALB,Codebuild, CodePipeline
This commit is contained in:
+94
@@ -3,8 +3,16 @@ package io.github.hectorvent.floci.services.cloudformation;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
@@ -259,6 +267,92 @@ class CloudFormationLambdaAddressingIntegrationTest {
|
||||
awaitStackGone(stackName);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePublishesVersionAfterDependentFunctionS3CodeChanges() throws IOException {
|
||||
String suffix = Long.toString(System.nanoTime(), 36);
|
||||
String bucket = "cfn-version-code-" + suffix;
|
||||
String stackName = "cfn-version-code-" + suffix;
|
||||
String fnName = "version-code-fn-" + suffix;
|
||||
|
||||
given().when().put("/" + bucket).then().statusCode(200);
|
||||
given().contentType("application/zip").body(handlerZip("v1"))
|
||||
.when().put("/" + bucket + "/v1.zip").then().statusCode(200);
|
||||
|
||||
createStack(stackName, versionCodeTemplate(fnName, bucket, "v1.zip"));
|
||||
|
||||
String versionOneSha = given()
|
||||
.queryParam("Qualifier", "1")
|
||||
.when().get("/2015-03-31/functions/" + fnName + "/configuration")
|
||||
.then().statusCode(200)
|
||||
.extract().path("CodeSha256");
|
||||
|
||||
given().contentType("application/zip").body(handlerZip("v2"))
|
||||
.when().put("/" + bucket + "/v2.zip").then().statusCode(200);
|
||||
|
||||
updateStack(stackName, versionCodeTemplate(fnName, bucket, "v2.zip"));
|
||||
|
||||
String latestSha = given()
|
||||
.when().get("/2015-03-31/functions/" + fnName + "/configuration")
|
||||
.then().statusCode(200)
|
||||
.extract().path("CodeSha256");
|
||||
|
||||
given()
|
||||
.queryParam("Qualifier", "2")
|
||||
.when().get("/2015-03-31/functions/" + fnName + "/configuration")
|
||||
.then().statusCode(200)
|
||||
.body("CodeSha256", equalTo(latestSha))
|
||||
.body("CodeSha256", not(equalTo(versionOneSha)));
|
||||
}
|
||||
|
||||
private static void createStack(String stackName, String template) {
|
||||
given()
|
||||
.contentType("application/x-www-form-urlencoded")
|
||||
.header("Authorization", CFN_AUTH)
|
||||
.formParam("Action", "CreateStack")
|
||||
.formParam("StackName", stackName)
|
||||
.formParam("TemplateBody", template)
|
||||
.when()
|
||||
.post("/")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
}
|
||||
|
||||
private static void updateStack(String stackName, String template) {
|
||||
given()
|
||||
.contentType("application/x-www-form-urlencoded")
|
||||
.header("Authorization", CFN_AUTH)
|
||||
.formParam("Action", "UpdateStack")
|
||||
.formParam("StackName", stackName)
|
||||
.formParam("TemplateBody", template)
|
||||
.when()
|
||||
.post("/")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
}
|
||||
|
||||
private static String versionCodeTemplate(String fnName, String bucket, String key) {
|
||||
return """
|
||||
{"Resources": {
|
||||
"Ver": {"Type": "AWS::Lambda::Version", "DependsOn": "Fn",
|
||||
"Properties": {"FunctionName": {"Ref": "Fn"}}},
|
||||
"Fn": {"Type": "AWS::Lambda::Function", "Properties": {
|
||||
"FunctionName": "%s", "Runtime": "nodejs20.x", "Handler": "index.handler",
|
||||
"Role": "arn:aws:iam::000000000000:role/r",
|
||||
"Code": {"S3Bucket": "%s", "S3Key": "%s"}}}
|
||||
}}
|
||||
""".formatted(fnName, bucket, key);
|
||||
}
|
||||
|
||||
private static byte[] handlerZip(String response) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
|
||||
zip.putNextEntry(new ZipEntry("index.js"));
|
||||
zip.write(("exports.handler = async () => '" + response + "';").getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static void awaitStackGone(String stackName) throws InterruptedException {
|
||||
for (int i = 0; i < 200; i++) {
|
||||
var response = given()
|
||||
|
||||
+15
@@ -90,6 +90,21 @@ class LambdaVersionAliasCfnProvisionerTest {
|
||||
r.getAttributes().get("FunctionArn"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePublishesThroughLambdaInsteadOfRetainingThePriorVersion() {
|
||||
when(lambda.publishVersion(eq("us-east-1"), eq("my-fn"), isNull())).thenReturn(publishedVersion());
|
||||
|
||||
ObjectNode props = mapper.createObjectNode();
|
||||
props.put("FunctionName", "my-fn");
|
||||
StackResource r = resource("AWS::Lambda::Version", "MyFuncVersion");
|
||||
r.setPhysicalId("arn:aws:lambda:us-east-1:000000000000:function:my-fn:2");
|
||||
|
||||
provisioner.provision(r, props, ctx());
|
||||
|
||||
verify(lambda).publishVersion("us-east-1", "my-fn", null);
|
||||
assertEquals("arn:aws:lambda:us-east-1:000000000000:function:my-fn:3", r.getPhysicalId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void versionWithoutFunctionNameFails() {
|
||||
StackResource r = resource("AWS::Lambda::Version", "MyFuncVersion");
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package io.github.hectorvent.floci.services.codebuild;
|
||||
|
||||
import com.github.dockerjava.api.DockerClient;
|
||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
@QuarkusTest
|
||||
class CodeBuildDockerIntegrationTest {
|
||||
|
||||
private static final String CONTENT_TYPE = "application/x-amz-json-1.1";
|
||||
|
||||
@Inject
|
||||
DockerClient dockerClient;
|
||||
|
||||
@BeforeAll
|
||||
static void configureRestAssured() {
|
||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void requireDocker() {
|
||||
try {
|
||||
dockerClient.pingCmd().exec();
|
||||
} catch (Exception e) {
|
||||
Assumptions.assumeTrue(false,
|
||||
"Docker daemon must be available for CodeBuild Docker integration tests");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void privilegedBuildCanUseMountedDockerSocket() throws InterruptedException {
|
||||
String projectName = "codebuild-docker-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
given()
|
||||
.header("X-Amz-Target", "CodeBuild_20161006.CreateProject")
|
||||
.contentType(CONTENT_TYPE)
|
||||
.body("""
|
||||
{
|
||||
"name": "%s",
|
||||
"source": {"type": "NO_SOURCE"},
|
||||
"artifacts": {"type": "NO_ARTIFACTS"},
|
||||
"environment": {
|
||||
"type": "LINUX_CONTAINER",
|
||||
"image": "docker:27-cli",
|
||||
"computeType": "BUILD_GENERAL1_SMALL",
|
||||
"privilegedMode": true
|
||||
},
|
||||
"serviceRole": "arn:aws:iam::000000000000:role/codebuild-role"
|
||||
}
|
||||
""".formatted(projectName))
|
||||
.when()
|
||||
.post("/")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
|
||||
String buildId = given()
|
||||
.header("X-Amz-Target", "CodeBuild_20161006.StartBuild")
|
||||
.contentType(CONTENT_TYPE)
|
||||
.body("""
|
||||
{
|
||||
"projectName": "%s",
|
||||
"buildspecOverride": "version: 0.2\\nphases:\\n build:\\n commands:\\n - docker version\\n - printf 'FROM scratch\\\\n' > Dockerfile\\n - docker build -t floci-codebuild-socket-test .\\n - docker image rm floci-codebuild-socket-test\\n"
|
||||
}
|
||||
""".formatted(projectName))
|
||||
.when()
|
||||
.post("/")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.path("build.id");
|
||||
|
||||
String status = waitForBuild(buildId);
|
||||
assertEquals("SUCCEEDED", status);
|
||||
}
|
||||
|
||||
private String waitForBuild(String buildId) throws InterruptedException {
|
||||
for (int attempts = 0; attempts < 120; attempts++) {
|
||||
String status = given()
|
||||
.header("X-Amz-Target", "CodeBuild_20161006.BatchGetBuilds")
|
||||
.contentType(CONTENT_TYPE)
|
||||
.body("{\"ids\":[\"" + buildId + "\"]}")
|
||||
.when()
|
||||
.post("/")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.path("builds[0].buildStatus");
|
||||
if (!"IN_PROGRESS".equals(status)) {
|
||||
return status;
|
||||
}
|
||||
Thread.sleep(500);
|
||||
}
|
||||
return "TIMED_OUT";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package io.github.hectorvent.floci.services.codebuild;
|
||||
|
||||
import io.github.hectorvent.floci.config.EmulatorConfig;
|
||||
import io.github.hectorvent.floci.core.common.docker.ContainerBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class CodeBuildRunnerTest {
|
||||
|
||||
@Test
|
||||
void privilegedBuildMountsConfiguredDockerSocket() {
|
||||
ContainerBuilder.Builder container = mock(ContainerBuilder.Builder.class);
|
||||
|
||||
CodeBuildRunner.exposeDockerSocketIfPrivileged(container, true,
|
||||
dockerConfig("unix:///run/user/1000/docker.sock"));
|
||||
|
||||
verify(container).withBind("/run/user/1000/docker.sock", "/var/run/docker.sock");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unprivilegedBuildCannotAccessDockerSocket() {
|
||||
ContainerBuilder.Builder container = mock(ContainerBuilder.Builder.class);
|
||||
|
||||
CodeBuildRunner.exposeDockerSocketIfPrivileged(container, false,
|
||||
dockerConfig("unix:///run/user/1000/docker.sock"));
|
||||
|
||||
verifyNoInteractions(container);
|
||||
}
|
||||
|
||||
@Test
|
||||
void privilegedBuildDoesNotInventSocketForTcpDockerHost() {
|
||||
ContainerBuilder.Builder container = mock(ContainerBuilder.Builder.class);
|
||||
|
||||
CodeBuildRunner.exposeDockerSocketIfPrivileged(container, true,
|
||||
dockerConfig("tcp://docker.example.test:2376"));
|
||||
|
||||
verifyNoInteractions(container);
|
||||
}
|
||||
|
||||
private EmulatorConfig.DockerConfig dockerConfig(String dockerHost) {
|
||||
EmulatorConfig.DockerConfig dockerConfig = mock(EmulatorConfig.DockerConfig.class);
|
||||
when(dockerConfig.dockerHost()).thenReturn(dockerHost);
|
||||
when(dockerConfig.dockerConfigPath()).thenReturn(Optional.empty());
|
||||
return dockerConfig;
|
||||
}
|
||||
}
|
||||
+197
-9
@@ -5,21 +5,28 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||
import io.github.hectorvent.floci.services.codedeploy.model.Deployment;
|
||||
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.Reservation;
|
||||
import io.github.hectorvent.floci.services.ecs.EcsService;
|
||||
import io.github.hectorvent.floci.services.elbv2.ElbV2Service;
|
||||
import io.github.hectorvent.floci.services.lambda.LambdaService;
|
||||
import io.github.hectorvent.floci.services.lambda.model.LambdaAlias;
|
||||
import io.github.hectorvent.floci.services.lambda.model.LambdaFunction;
|
||||
import io.github.hectorvent.floci.services.lambda.model.InvocationType;
|
||||
import io.github.hectorvent.floci.services.lambda.model.InvokeResult;
|
||||
import io.github.hectorvent.floci.services.ssm.SsmCommandService;
|
||||
import io.github.hectorvent.floci.testing.MutableClock;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.awaitility.Awaitility.await;
|
||||
@@ -31,6 +38,7 @@ import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -48,6 +56,14 @@ class CodeDeployServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lambdaService = mock(LambdaService.class);
|
||||
LambdaAlias alias = new LambdaAlias();
|
||||
alias.setFunctionVersion("1");
|
||||
when(lambdaService.getAlias(eq(REGION), anyString(), anyString())).thenReturn(alias);
|
||||
LambdaFunction versionOne = new LambdaFunction();
|
||||
versionOne.setVersion("1");
|
||||
LambdaFunction versionTwo = new LambdaFunction();
|
||||
versionTwo.setVersion("2");
|
||||
when(lambdaService.listVersionsByFunction(eq(REGION), anyString())).thenReturn(List.of(versionOne, versionTwo));
|
||||
ssmCommandService = mock(SsmCommandService.class);
|
||||
service = newService(Duration.ofHours(1));
|
||||
}
|
||||
@@ -57,9 +73,13 @@ class CodeDeployServiceTest {
|
||||
}
|
||||
|
||||
private CodeDeployService newService(Duration hookCallbackTimeout, Clock clock) {
|
||||
return newService(hookCallbackTimeout, clock, Thread::sleep);
|
||||
}
|
||||
|
||||
private CodeDeployService newService(Duration hookCallbackTimeout, Clock clock, CodeDeployService.Sleeper sleeper) {
|
||||
CodeDeployService svc = new CodeDeployService(lambdaService, mock(EcsService.class),
|
||||
mock(ElbV2Service.class), ssmCommandService, mock(Ec2Service.class), new ObjectMapper(),
|
||||
new RegionResolver(REGION, ACCOUNT_ID), null, hookCallbackTimeout, clock);
|
||||
new RegionResolver(REGION, ACCOUNT_ID), null, hookCallbackTimeout, clock, null, sleeper);
|
||||
svc.initializeStorage();
|
||||
return svc;
|
||||
}
|
||||
@@ -155,6 +175,43 @@ class CodeDeployServiceTest {
|
||||
assertEquals("Succeeded", deployment.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ec2TagFilterTargetsRegisteredInstanceAndRunsHook() {
|
||||
Ec2Service ec2Service = mock(Ec2Service.class);
|
||||
CodeDeployService ec2TargetService = new CodeDeployService(lambdaService, mock(EcsService.class),
|
||||
mock(ElbV2Service.class), ssmCommandService, ec2Service, new ObjectMapper(),
|
||||
new RegionResolver(REGION, ACCOUNT_ID), null, Duration.ofHours(1), Clock.systemUTC(), null,
|
||||
Thread::sleep);
|
||||
ec2TargetService.initializeStorage();
|
||||
|
||||
Instance instance = new Instance();
|
||||
instance.setInstanceId("i-tagged");
|
||||
Reservation reservation = new Reservation();
|
||||
reservation.setInstances(List.of(instance));
|
||||
when(ec2Service.describeInstances(eq(REGION), eq(List.of()), any())).thenReturn(List.of(reservation));
|
||||
when(ssmCommandService.isInstanceRegistered("i-tagged", REGION)).thenReturn(true);
|
||||
when(ssmCommandService.sendCommandToInstance(eq("i-tagged"), eq("AWS-RunShellScript"), any(), eq(30), eq(REGION)))
|
||||
.thenReturn("cmd-tagged");
|
||||
when(ssmCommandService.getCommandInvocationStatus("cmd-tagged", "i-tagged", REGION)).thenReturn("Success");
|
||||
|
||||
ec2TargetService.createApplication(REGION, "ec2-tag-app", "Server", null);
|
||||
ec2TargetService.createDeploymentGroup(REGION, "ec2-tag-app", "ec2-tag-group",
|
||||
"CodeDeployDefault.AllAtOnce", ROLE_ARN,
|
||||
Map.of("ec2TagFilters", List.of(Map.of("Key", "Environment", "Value", "test", "Type", "KEY_AND_VALUE"))));
|
||||
|
||||
String deploymentId = ec2TargetService.createDeployment(REGION, "ec2-tag-app", "ec2-tag-group", null,
|
||||
serverRevision(30), "EC2 tag target");
|
||||
Deployment deployment = awaitTerminal(ec2TargetService, deploymentId, Duration.ofSeconds(5));
|
||||
|
||||
assertEquals("Succeeded", deployment.getStatus());
|
||||
assertEquals("i-tagged", ec2TargetService.listDeploymentTargets(REGION, deploymentId).getFirst());
|
||||
ArgumentCaptor<Map<String, List<String>>> filters = ArgumentCaptor.forClass(Map.class);
|
||||
verify(ec2Service).describeInstances(eq(REGION), eq(List.of()), filters.capture());
|
||||
assertEquals(Map.of("tag:Environment", List.of("test")), filters.getValue());
|
||||
verify(ssmCommandService).sendCommandToInstance(eq("i-tagged"), eq("AWS-RunShellScript"),
|
||||
eq(Map.of("commands", List.of("sh scripts/start_server.sh"))), eq(30), eq(REGION));
|
||||
}
|
||||
|
||||
// ---- Lambda platform hooks ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@@ -205,7 +262,7 @@ class CodeDeployServiceTest {
|
||||
when(lambdaService.invoke(eq(REGION), eq("beforeHook"), any(byte[].class), eq(InvocationType.RequestResponse)))
|
||||
.thenAnswer(invocation -> {
|
||||
byte[] payload = invocation.getArgument(2);
|
||||
service.putLifecycleEventHookExecutionStatus("ignored", executionIdFrom(payload), "Succeeded");
|
||||
service.putLifecycleEventHookExecutionStatus(deploymentIdFrom(payload), executionIdFrom(payload), "Succeeded");
|
||||
return cleanInvoke;
|
||||
});
|
||||
|
||||
@@ -270,7 +327,7 @@ class CodeDeployServiceTest {
|
||||
when(lambdaService.invoke(eq(REGION), eq("beforeHook"), any(byte[].class), eq(InvocationType.RequestResponse)))
|
||||
.thenAnswer(invocation -> {
|
||||
byte[] payload = invocation.getArgument(2);
|
||||
service.putLifecycleEventHookExecutionStatus("ignored", executionIdFrom(payload), "Failed");
|
||||
service.putLifecycleEventHookExecutionStatus(deploymentIdFrom(payload), executionIdFrom(payload), "Failed");
|
||||
return cleanInvoke;
|
||||
});
|
||||
|
||||
@@ -304,7 +361,7 @@ class CodeDeployServiceTest {
|
||||
byte[] payload = invocation.getArgument(2);
|
||||
String executionId = executionIdFrom(payload);
|
||||
capturedExecutionId.set(executionId);
|
||||
service.putLifecycleEventHookExecutionStatus("ignored", executionId, "Succeeded");
|
||||
service.putLifecycleEventHookExecutionStatus(deploymentIdFrom(payload), executionId, "Succeeded");
|
||||
return cleanInvoke;
|
||||
});
|
||||
|
||||
@@ -312,11 +369,131 @@ class CodeDeployServiceTest {
|
||||
Deployment deployment = awaitTerminal(deploymentId, Duration.ofSeconds(5));
|
||||
assertEquals("Succeeded", deployment.getStatus());
|
||||
|
||||
service.putLifecycleEventHookExecutionStatus("ignored", capturedExecutionId.get(), "Failed");
|
||||
org.junit.jupiter.api.Assertions.assertThrows(io.github.hectorvent.floci.core.common.AwsException.class,
|
||||
() -> service.putLifecycleEventHookExecutionStatus(deploymentId, capturedExecutionId.get(), "Failed"));
|
||||
|
||||
assertEquals("Succeeded", service.getDeployment(REGION, deploymentId).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPostTrafficHookRestoresExactAliasRoutingWhenRollbackIsEnabled() {
|
||||
service.createApplication(REGION, "app-rollback", "Lambda", null);
|
||||
service.createDeploymentGroup(REGION, "app-rollback", "group-rollback",
|
||||
"CodeDeployDefault.LambdaAllAtOnce", ROLE_ARN,
|
||||
Map.of("autoRollbackConfiguration", Map.of("enabled", true,
|
||||
"events", List.of("DEPLOYMENT_FAILURE"))));
|
||||
LambdaAlias alias = new LambdaAlias();
|
||||
alias.setFunctionVersion("1");
|
||||
alias.setRoutingConfig(Map.of("3", 0.2));
|
||||
when(lambdaService.getAlias(eq(REGION), anyString(), anyString())).thenReturn(alias);
|
||||
InvokeResult cleanInvoke = new InvokeResult(200, null, new byte[0], null, "req-rollback");
|
||||
when(lambdaService.invoke(eq(REGION), eq("afterHook"), any(byte[].class), eq(InvocationType.RequestResponse)))
|
||||
.thenAnswer(invocation -> {
|
||||
byte[] payload = invocation.getArgument(2);
|
||||
service.putLifecycleEventHookExecutionStatus(deploymentIdFrom(payload), executionIdFrom(payload), "Failed");
|
||||
return cleanInvoke;
|
||||
});
|
||||
|
||||
String deploymentId = createLambdaDeployment("app-rollback", "group-rollback", null, "afterHook");
|
||||
|
||||
assertEquals("Failed", awaitTerminal(deploymentId, Duration.ofSeconds(5)).getStatus());
|
||||
ArgumentCaptor<Map<String, Double>> routing = ArgumentCaptor.forClass(Map.class);
|
||||
ArgumentCaptor<String> version = ArgumentCaptor.forClass(String.class);
|
||||
verify(lambdaService, times(2)).updateAlias(eq(REGION), eq("myFunc"), eq("live"), version.capture(),
|
||||
any(), routing.capture());
|
||||
assertEquals("2", version.getAllValues().get(0));
|
||||
assertEquals("1", version.getAllValues().get(1));
|
||||
assertEquals(Map.of("3", 0.2), routing.getAllValues().get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lambdaAppSpecRequiresCurrentVersionAndMatchingAlias() {
|
||||
createLambdaAppAndGroup("app-invalid-appspec", "group-invalid-appspec");
|
||||
Map<String, Object> revision = Map.of("revisionType", "AppSpecContent", "appSpecContent", Map.of("content", """
|
||||
version: '0.0'
|
||||
Resources:
|
||||
- myFunc:
|
||||
Type: AWS::Lambda::Function
|
||||
Properties:
|
||||
Name: myFunc
|
||||
Alias: live
|
||||
TargetVersion: '2'
|
||||
"""));
|
||||
|
||||
io.github.hectorvent.floci.core.common.AwsException error = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
io.github.hectorvent.floci.core.common.AwsException.class,
|
||||
() -> service.createDeployment(REGION, "app-invalid-appspec", "group-invalid-appspec", null, revision, null));
|
||||
|
||||
assertEquals("InvalidRevisionException", error.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lambdaCanaryUsesFullNamedConfigurationInterval() {
|
||||
AtomicLong sleptMillis = new AtomicLong();
|
||||
CodeDeployService noWaitService = newService(Duration.ofHours(1), Clock.systemUTC(), sleptMillis::set);
|
||||
noWaitService.createApplication(REGION, "app-canary", "Lambda", null);
|
||||
noWaitService.createDeploymentGroup(REGION, "app-canary", "group-canary",
|
||||
"CodeDeployDefault.LambdaCanary10Percent5Minutes", ROLE_ARN, null);
|
||||
|
||||
String deploymentId = noWaitService.createDeployment(REGION, "app-canary", "group-canary", null,
|
||||
lambdaRevision(null, null), null);
|
||||
|
||||
assertEquals("Succeeded", awaitTerminal(noWaitService, deploymentId, Duration.ofSeconds(5)).getStatus());
|
||||
assertEquals(Duration.ofMinutes(5).toMillis(), sleptMillis.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lambdaLinearUsesFullNamedConfigurationInterval() {
|
||||
AtomicLong sleptMillis = new AtomicLong();
|
||||
CodeDeployService noWaitService = newService(Duration.ofHours(1), Clock.systemUTC(), sleptMillis::addAndGet);
|
||||
noWaitService.createApplication(REGION, "app-linear", "Lambda", null);
|
||||
noWaitService.createDeploymentGroup(REGION, "app-linear", "group-linear",
|
||||
"CodeDeployDefault.LambdaLinear10PercentEvery1Minute", ROLE_ARN, null);
|
||||
|
||||
String deploymentId = noWaitService.createDeployment(REGION, "app-linear", "group-linear", null,
|
||||
lambdaRevision(null, null), null);
|
||||
|
||||
assertEquals("Succeeded", awaitTerminal(noWaitService, deploymentId, Duration.ofSeconds(5)).getStatus());
|
||||
assertEquals(Duration.ofMinutes(9).toMillis(), sleptMillis.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ecsCanaryUsesFullNamedConfigurationInterval() throws InterruptedException {
|
||||
AtomicLong sleptMillis = new AtomicLong();
|
||||
ElbV2Service elbV2Service = mock(ElbV2Service.class);
|
||||
CodeDeployService noWaitService = new CodeDeployService(lambdaService, mock(EcsService.class),
|
||||
elbV2Service, ssmCommandService, mock(Ec2Service.class), new ObjectMapper(),
|
||||
new RegionResolver(REGION, ACCOUNT_ID), null, Duration.ofHours(1), Clock.systemUTC(), null,
|
||||
sleptMillis::set);
|
||||
noWaitService.initializeStorage();
|
||||
|
||||
noWaitService.executeEcsAllowTraffic(REGION, new Deployment(),
|
||||
"CodeDeployDefault.ECSCanary10Percent5Minutes", new ConcurrentHashMap<>(), List.of("listener"),
|
||||
"blue", "green", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
|
||||
assertEquals(Duration.ofMinutes(5).toMillis(), sleptMillis.get());
|
||||
verify(elbV2Service).shiftListenerForward(REGION, "listener", "blue", "green", 10);
|
||||
verify(elbV2Service).shiftListenerForward(REGION, "listener", "blue", "green", 100);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ecsLinearUsesFullNamedConfigurationInterval() throws InterruptedException {
|
||||
AtomicLong sleptMillis = new AtomicLong();
|
||||
ElbV2Service elbV2Service = mock(ElbV2Service.class);
|
||||
CodeDeployService noWaitService = new CodeDeployService(lambdaService, mock(EcsService.class),
|
||||
elbV2Service, ssmCommandService, mock(Ec2Service.class), new ObjectMapper(),
|
||||
new RegionResolver(REGION, ACCOUNT_ID), null, Duration.ofHours(1), Clock.systemUTC(), null,
|
||||
sleptMillis::addAndGet);
|
||||
noWaitService.initializeStorage();
|
||||
|
||||
noWaitService.executeEcsAllowTraffic(REGION, new Deployment(),
|
||||
"CodeDeployDefault.ECSLinear10PercentEvery1Minutes", new ConcurrentHashMap<>(), List.of("listener"),
|
||||
"blue", "green", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
|
||||
assertEquals(Duration.ofMinutes(9).toMillis(), sleptMillis.get());
|
||||
verify(elbV2Service).shiftListenerForward(REGION, "listener", "blue", "green", 100);
|
||||
}
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------------------
|
||||
|
||||
private String createServerDeployment(String appName, String groupName, int timeoutSeconds) {
|
||||
@@ -324,19 +501,22 @@ class CodeDeployServiceTest {
|
||||
}
|
||||
|
||||
private String createServerDeployment(CodeDeployService target, String appName, String groupName,
|
||||
int timeoutSeconds) {
|
||||
int timeoutSeconds) {
|
||||
target.createApplication(REGION, appName, "Server", null);
|
||||
target.createDeploymentGroup(REGION, appName, groupName, "CodeDeployDefault.AllAtOnce", ROLE_ARN, null);
|
||||
return target.createDeployment(REGION, appName, groupName, null, serverRevision(timeoutSeconds), "server hook test");
|
||||
}
|
||||
|
||||
private Map<String, Object> serverRevision(int timeoutSeconds) {
|
||||
String appSpec = """
|
||||
os: linux
|
||||
hooks:
|
||||
ApplicationStart:
|
||||
- location: scripts/start_server.sh
|
||||
timeout: %d
|
||||
""".formatted(timeoutSeconds);
|
||||
Map<String, Object> revision = Map.of("revisionType", "AppSpecContent",
|
||||
""".formatted(timeoutSeconds);
|
||||
return Map.of("revisionType", "AppSpecContent",
|
||||
"appSpecContent", Map.of("content", appSpec));
|
||||
return target.createDeployment(REGION, appName, groupName, null, revision, "server hook test");
|
||||
}
|
||||
|
||||
private void createLambdaAppAndGroup(String appName, String groupName) {
|
||||
@@ -385,6 +565,14 @@ class CodeDeployServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static String deploymentIdFrom(byte[] payload) {
|
||||
try {
|
||||
return JSON.readTree(payload).get("DeploymentId").asText();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Could not parse hook payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Deployment awaitTerminal(String deploymentId, Duration atMost) {
|
||||
return awaitTerminal(service, deploymentId, atMost);
|
||||
}
|
||||
|
||||
+526
-4
@@ -8,29 +8,45 @@ import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
|
||||
import io.github.hectorvent.floci.core.storage.StorageFactory;
|
||||
import io.github.hectorvent.floci.services.codebuild.CodeBuildService;
|
||||
import io.github.hectorvent.floci.services.codebuild.model.Build;
|
||||
import io.github.hectorvent.floci.services.cloudformation.CloudFormationService;
|
||||
import io.github.hectorvent.floci.services.cloudformation.model.Stack;
|
||||
import io.github.hectorvent.floci.services.codedeploy.CodeDeployService;
|
||||
import io.github.hectorvent.floci.services.codedeploy.model.Deployment;
|
||||
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelinePipeline;
|
||||
import io.github.hectorvent.floci.services.ecs.EcsService;
|
||||
import io.github.hectorvent.floci.services.ecs.model.RegisterTaskDefinitionRequest;
|
||||
import io.github.hectorvent.floci.services.ecs.model.TaskDefinition;
|
||||
import io.github.hectorvent.floci.services.lambda.LambdaService;
|
||||
import io.github.hectorvent.floci.services.s3.S3Service;
|
||||
import io.github.hectorvent.floci.services.s3.model.S3Object;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -46,7 +62,8 @@ class CodePipelineServiceTest {
|
||||
mock(CodeBuildService.class),
|
||||
mock(CodeDeployService.class),
|
||||
mock(LambdaService.class),
|
||||
mock(S3Service.class));
|
||||
mock(S3Service.class),
|
||||
mock(CloudFormationService.class));
|
||||
|
||||
@Test
|
||||
void getPipelineTreatsMissingStoredVersionAsVersionOne() throws Exception {
|
||||
@@ -57,7 +74,8 @@ class CodePipelineServiceTest {
|
||||
mock(CodeBuildService.class),
|
||||
mock(CodeDeployService.class),
|
||||
mock(LambdaService.class),
|
||||
mock(S3Service.class));
|
||||
mock(S3Service.class),
|
||||
mock(CloudFormationService.class));
|
||||
CodePipelinePipeline pipeline = mapper.readValue("""
|
||||
{
|
||||
"accountId": "000000000000",
|
||||
@@ -117,7 +135,8 @@ class CodePipelineServiceTest {
|
||||
mock(CodeBuildService.class),
|
||||
mock(CodeDeployService.class),
|
||||
mock(LambdaService.class),
|
||||
s3Service);
|
||||
s3Service,
|
||||
mock(CloudFormationService.class));
|
||||
String pipelineRequest = """
|
||||
{
|
||||
"pipeline": {
|
||||
@@ -224,6 +243,443 @@ class CodePipelineServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void codeBuildOutputArtifactIsMaterializedForCodeDeploy() throws Exception {
|
||||
CodeBuildService codeBuildService = mock(CodeBuildService.class);
|
||||
CodeDeployService codeDeployService = mock(CodeDeployService.class);
|
||||
S3Service s3Service = mock(S3Service.class);
|
||||
byte[] sourceBytes = "source artifact".getBytes();
|
||||
byte[] buildOutput = "CodeBuild-produced artifact".getBytes();
|
||||
S3Object source = new S3Object();
|
||||
source.setData(sourceBytes);
|
||||
source.setETag("source-etag");
|
||||
source.setVersionId("source-version");
|
||||
source.setLastModified(Instant.now());
|
||||
Build build = build("SUCCEEDED", true);
|
||||
build.setId("build-42");
|
||||
when(s3Service.headObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(s3Service.getObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(codeBuildService.startBuild(anyString(), anyString(), anyString(),
|
||||
any(), any(), any(), any(), any(), any(), any())).thenReturn(build);
|
||||
when(codeBuildService.getBuildOutputArtifact(REGION, ACCOUNT, "build-42")).thenReturn(buildOutput);
|
||||
when(codeDeployService.createDeployment(eq(REGION), eq("lambda-application"), eq("lambda-group"),
|
||||
any(), any(), anyString())).thenReturn("deployment-42");
|
||||
when(codeDeployService.getDeployment(REGION, "deployment-42")).thenReturn(deployment("Succeeded"));
|
||||
CodePipelineService pipelineService = new CodePipelineService(
|
||||
new InMemoryStorageFactory(), mapper, codeBuildService, codeDeployService,
|
||||
mock(LambdaService.class), s3Service, mock(CloudFormationService.class));
|
||||
|
||||
try {
|
||||
handle(pipelineService, "CreatePipeline", """
|
||||
{
|
||||
"pipeline": {
|
||||
"name": "source-build-deploy",
|
||||
"roleArn": "arn:aws:iam::000000000000:role/codepipeline-role",
|
||||
"artifactStore": {"type": "S3", "location": "codepipeline-artifacts"},
|
||||
"stages": [{
|
||||
"name": "Source", "actions": [{
|
||||
"name": "SourceAction",
|
||||
"actionTypeId": {"category": "Source", "owner": "AWS", "provider": "S3", "version": "1"},
|
||||
"configuration": {"S3Bucket": "source-bucket", "S3ObjectKey": "source.zip"},
|
||||
"outputArtifacts": [{"name": "SourceOutput"}]
|
||||
}]
|
||||
}, {
|
||||
"name": "Build", "actions": [{
|
||||
"name": "BuildAction",
|
||||
"actionTypeId": {"category": "Build", "owner": "AWS", "provider": "CodeBuild", "version": "1"},
|
||||
"configuration": {"ProjectName": "build-project"},
|
||||
"inputArtifacts": [{"name": "SourceOutput"}],
|
||||
"outputArtifacts": [{"name": "BuildOutput"}]
|
||||
}]
|
||||
}, {
|
||||
"name": "Deploy", "actions": [{
|
||||
"name": "DeployAction",
|
||||
"actionTypeId": {"category": "Deploy", "owner": "AWS", "provider": "CodeDeploy", "version": "1"},
|
||||
"configuration": {"ApplicationName": "lambda-application", "DeploymentGroupName": "lambda-group"},
|
||||
"inputArtifacts": [{"name": "BuildOutput"}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
""");
|
||||
String executionId = startExecution(pipelineService, "source-build-deploy");
|
||||
|
||||
waitForExecutionStatus(pipelineService, "source-build-deploy", executionId, "Succeeded");
|
||||
ArgumentCaptor<byte[]> deployed = ArgumentCaptor.forClass(byte[].class);
|
||||
verify(s3Service).putObject(eq("codepipeline-artifacts"), anyString(), deployed.capture(),
|
||||
eq("application/zip"), any());
|
||||
assertArrayEquals(buildOutput, deployed.getValue());
|
||||
ArgumentCaptor<Map<String, Object>> revision = ArgumentCaptor.forClass(Map.class);
|
||||
verify(codeDeployService).createDeployment(eq(REGION), eq("lambda-application"), eq("lambda-group"),
|
||||
any(), revision.capture(), anyString());
|
||||
assertEquals("codepipeline-artifacts", ((Map<?, ?>) revision.getValue().get("s3Location")).get("bucket"));
|
||||
verify(codeBuildService, times(1)).getBuildOutputArtifact(REGION, ACCOUNT, "build-42");
|
||||
} finally {
|
||||
pipelineService.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFormationCreateUpdateMaterializesArtifactAndWaitsForStack() throws Exception {
|
||||
S3Service s3Service = mock(S3Service.class);
|
||||
CloudFormationService cloudFormationService = mock(CloudFormationService.class);
|
||||
S3Object source = new S3Object();
|
||||
source.setData(zipArtifact("templates/application.yaml", """
|
||||
AWSTemplateFormatVersion: '2010-09-09'
|
||||
Resources: {}
|
||||
"""));
|
||||
source.setETag("source-etag");
|
||||
source.setLastModified(Instant.now());
|
||||
Stack stack = new Stack();
|
||||
stack.setStackId("arn:aws:cloudformation:us-east-1:000000000000:stack/application/id");
|
||||
stack.setStatus("CREATE_COMPLETE");
|
||||
when(s3Service.headObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(s3Service.getObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(cloudFormationService.describeStacks("application", REGION, ACCOUNT))
|
||||
.thenThrow(new AwsException("ValidationError", "Stack does not exist", 400))
|
||||
.thenReturn(List.of(stack));
|
||||
when(cloudFormationService.executeChangeSet(eq("application"), anyString(), eq(REGION), eq(ACCOUNT)))
|
||||
.thenReturn(CompletableFuture.completedFuture(null));
|
||||
CodePipelineService pipelineService = new CodePipelineService(
|
||||
new InMemoryStorageFactory(), mapper, mock(CodeBuildService.class), mock(CodeDeployService.class),
|
||||
mock(LambdaService.class), s3Service, cloudFormationService);
|
||||
|
||||
try {
|
||||
handle(pipelineService, "CreatePipeline", """
|
||||
{
|
||||
"pipeline": {
|
||||
"name": "cloudformation-pipeline",
|
||||
"roleArn": "arn:aws:iam::000000000000:role/codepipeline-role",
|
||||
"artifactStore": {"type": "S3", "location": "codepipeline-artifacts"},
|
||||
"stages": [{
|
||||
"name": "Source", "actions": [{
|
||||
"name": "SourceAction",
|
||||
"actionTypeId": {"category": "Source", "owner": "AWS", "provider": "S3", "version": "1"},
|
||||
"configuration": {"S3Bucket": "source-bucket", "S3ObjectKey": "source.zip"},
|
||||
"outputArtifacts": [{"name": "SourceOutput"}]
|
||||
}]
|
||||
}, {
|
||||
"name": "Deploy", "actions": [{
|
||||
"name": "CloudFormationAction",
|
||||
"actionTypeId": {"category": "Deploy", "owner": "AWS", "provider": "CloudFormation", "version": "1"},
|
||||
"configuration": {"StackName": "application", "ActionMode": "CREATE_UPDATE", "TemplatePath": "SourceOutput::templates/application.yaml"},
|
||||
"inputArtifacts": [{"name": "SourceOutput"}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
""");
|
||||
String executionId = startExecution(pipelineService, "cloudformation-pipeline");
|
||||
|
||||
waitForExecutionStatus(pipelineService, "cloudformation-pipeline", executionId, "Succeeded");
|
||||
ArgumentCaptor<byte[]> artifact = ArgumentCaptor.forClass(byte[].class);
|
||||
verify(s3Service).putObject(eq("codepipeline-artifacts"), anyString(), artifact.capture(),
|
||||
eq("application/zip"), any());
|
||||
assertArrayEquals(source.getData(), artifact.getValue());
|
||||
ArgumentCaptor<String> template = ArgumentCaptor.forClass(String.class);
|
||||
verify(cloudFormationService).createChangeSet(eq("application"), anyString(), eq("CREATE"),
|
||||
template.capture(), eq(null), eq(Map.of()), eq(List.of()), eq(Map.of()),
|
||||
eq(REGION), eq(ACCOUNT));
|
||||
assertEquals("AWSTemplateFormatVersion: '2010-09-09'\nResources: {}\n", template.getValue());
|
||||
verify(cloudFormationService).executeChangeSet(eq("application"), anyString(), eq(REGION), eq(ACCOUNT));
|
||||
|
||||
stack.setStatus("UPDATE_COMPLETE");
|
||||
String updateExecutionId = startExecution(pipelineService, "cloudformation-pipeline");
|
||||
waitForExecutionStatus(pipelineService, "cloudformation-pipeline", updateExecutionId, "Succeeded");
|
||||
verify(cloudFormationService).createChangeSet(eq("application"), anyString(), eq("UPDATE"),
|
||||
anyString(), eq(null), eq(Map.of()), eq(List.of()), eq(Map.of()), eq(REGION), eq(ACCOUNT));
|
||||
} finally {
|
||||
pipelineService.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void codeDeployToEcsRegistersTaskDefinitionAndSubstitutesAppSpec() throws Exception {
|
||||
S3Service s3Service = mock(S3Service.class);
|
||||
CodeDeployService codeDeployService = mock(CodeDeployService.class);
|
||||
EcsService ecsService = mock(EcsService.class);
|
||||
S3Object source = new S3Object();
|
||||
source.setData(zipArtifact(Map.of(
|
||||
"taskdef.json", """
|
||||
{"family":"pipeline-task","containerDefinitions":[{"name":"app","image":"nginx"}]}
|
||||
""",
|
||||
"appspec.json", """
|
||||
{"version":0.0,"Resources":[{"TargetService":{"Type":"AWS::ECS::Service","Properties":{"TaskDefinition":"<TASK_DEFINITION>","LoadBalancerInfo":{"ContainerName":"app","ContainerPort":80}}}}]}
|
||||
""")));
|
||||
source.setETag("source-etag");
|
||||
source.setLastModified(Instant.now());
|
||||
TaskDefinition taskDefinition = new TaskDefinition();
|
||||
taskDefinition.setTaskDefinitionArn("arn:aws:ecs:us-east-1:000000000000:task-definition/pipeline-task:2");
|
||||
when(s3Service.headObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(s3Service.getObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(ecsService.registerTaskDefinition(any(RegisterTaskDefinitionRequest.class), eq(REGION)))
|
||||
.thenReturn(taskDefinition);
|
||||
when(codeDeployService.createDeployment(eq(REGION), eq("ecs-app"), eq("ecs-group"),
|
||||
any(), any(), anyString())).thenReturn("deployment-ecs");
|
||||
when(codeDeployService.getDeployment(REGION, "deployment-ecs")).thenReturn(deployment("Succeeded"));
|
||||
CodePipelineService pipelineService = new CodePipelineService(
|
||||
new InMemoryStorageFactory(), mapper, mock(CodeBuildService.class), codeDeployService,
|
||||
mock(LambdaService.class), s3Service, mock(CloudFormationService.class), ecsService);
|
||||
|
||||
try {
|
||||
createCodeDeployToEcsPipeline(pipelineService, "ecs-success-pipeline");
|
||||
String executionId = startExecution(pipelineService, "ecs-success-pipeline");
|
||||
|
||||
waitForExecutionStatus(pipelineService, "ecs-success-pipeline", executionId, "Succeeded");
|
||||
ArgumentCaptor<RegisterTaskDefinitionRequest> request = ArgumentCaptor.forClass(RegisterTaskDefinitionRequest.class);
|
||||
verify(ecsService).registerTaskDefinition(request.capture(), eq(REGION));
|
||||
assertEquals("pipeline-task", request.getValue().getFamily());
|
||||
ArgumentCaptor<Map<String, Object>> revision = ArgumentCaptor.forClass(Map.class);
|
||||
verify(codeDeployService).createDeployment(eq(REGION), eq("ecs-app"), eq("ecs-group"),
|
||||
eq(null), revision.capture(), anyString());
|
||||
String appSpec = (String) ((Map<?, ?>) revision.getValue().get("appSpecContent")).get("content");
|
||||
assertTrue(appSpec.contains(taskDefinition.getTaskDefinitionArn()));
|
||||
assertFalse(appSpec.contains("<TASK_DEFINITION>"));
|
||||
} finally {
|
||||
pipelineService.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void codeDeployToEcsPropagatesDeploymentFailure() throws Exception {
|
||||
S3Service s3Service = mock(S3Service.class);
|
||||
CodeDeployService codeDeployService = mock(CodeDeployService.class);
|
||||
EcsService ecsService = mock(EcsService.class);
|
||||
S3Object source = new S3Object();
|
||||
source.setData(zipArtifact(Map.of(
|
||||
"taskdef.json", "{\"family\":\"pipeline-task\",\"containerDefinitions\":[]}",
|
||||
"appspec.json", "{\"version\":0.0,\"Resources\":[{\"TargetService\":{\"Properties\":{\"TaskDefinition\":\"<TASK_DEFINITION>\"}}}]}")));
|
||||
source.setETag("source-etag");
|
||||
source.setLastModified(Instant.now());
|
||||
TaskDefinition taskDefinition = new TaskDefinition();
|
||||
taskDefinition.setTaskDefinitionArn("arn:aws:ecs:us-east-1:000000000000:task-definition/pipeline-task:1");
|
||||
when(s3Service.headObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(s3Service.getObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(ecsService.registerTaskDefinition(any(RegisterTaskDefinitionRequest.class), eq(REGION)))
|
||||
.thenReturn(taskDefinition);
|
||||
when(codeDeployService.createDeployment(anyString(), anyString(), anyString(), any(), any(), anyString()))
|
||||
.thenReturn("deployment-ecs");
|
||||
when(codeDeployService.getDeployment(REGION, "deployment-ecs")).thenReturn(deployment("Failed"));
|
||||
CodePipelineService pipelineService = new CodePipelineService(
|
||||
new InMemoryStorageFactory(), mapper, mock(CodeBuildService.class), codeDeployService,
|
||||
mock(LambdaService.class), s3Service, mock(CloudFormationService.class), ecsService);
|
||||
|
||||
try {
|
||||
createCodeDeployToEcsPipeline(pipelineService, "ecs-failure-pipeline");
|
||||
String executionId = startExecution(pipelineService, "ecs-failure-pipeline");
|
||||
|
||||
waitForExecutionStatus(pipelineService, "ecs-failure-pipeline", executionId, "Failed");
|
||||
JsonNode state = pipelineState(pipelineService, "ecs-failure-pipeline");
|
||||
assertEquals("Failed", state.path("stageStates").path(1).path("actionStates").path(0)
|
||||
.path("latestExecution").path("status").asText());
|
||||
assertTrue(state.path("stageStates").path(1).path("actionStates").path(0)
|
||||
.path("latestExecution").path("summary").asText().contains("Failed"));
|
||||
} finally {
|
||||
pipelineService.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFormationExtractsCodeBuildZipAndResolvesArtifactParameterOverrides() throws Exception {
|
||||
CodeBuildService codeBuildService = mock(CodeBuildService.class);
|
||||
S3Service s3Service = mock(S3Service.class);
|
||||
CloudFormationService cloudFormationService = mock(CloudFormationService.class);
|
||||
S3Object source = new S3Object();
|
||||
source.setData(zipArtifact("template.yaml", """
|
||||
Parameters:
|
||||
CodeS3Bucket: {Type: String}
|
||||
CodeS3Key: {Type: String}
|
||||
Resources: {}
|
||||
"""));
|
||||
source.setETag("source-etag");
|
||||
source.setLastModified(Instant.now());
|
||||
byte[] functionZip = "function bytes".getBytes(StandardCharsets.UTF_8);
|
||||
Build build = build("SUCCEEDED", true);
|
||||
build.setId("build-function");
|
||||
Stack stack = new Stack();
|
||||
stack.setStackId("arn:aws:cloudformation:us-east-1:000000000000:stack/application/id");
|
||||
stack.setStatus("CREATE_COMPLETE");
|
||||
when(s3Service.headObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(s3Service.getObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(codeBuildService.startBuild(anyString(), anyString(), anyString(),
|
||||
any(), any(), any(), any(), any(), any(), any())).thenReturn(build);
|
||||
when(codeBuildService.getBuildOutputArtifact(REGION, ACCOUNT, "build-function"))
|
||||
.thenReturn(zipArtifact("function.zip", functionZip));
|
||||
when(cloudFormationService.describeStacks("application", REGION, ACCOUNT))
|
||||
.thenThrow(new AwsException("ValidationError", "Stack does not exist", 400))
|
||||
.thenReturn(List.of(stack));
|
||||
when(cloudFormationService.executeChangeSet(eq("application"), anyString(), eq(REGION), eq(ACCOUNT)))
|
||||
.thenReturn(CompletableFuture.completedFuture(null));
|
||||
CodePipelineService pipelineService = new CodePipelineService(
|
||||
new InMemoryStorageFactory(), mapper, codeBuildService, mock(CodeDeployService.class),
|
||||
mock(LambdaService.class), s3Service, cloudFormationService);
|
||||
|
||||
try {
|
||||
handle(pipelineService, "CreatePipeline", """
|
||||
{"pipeline":{"name":"build-cloudformation","roleArn":"arn:aws:iam::000000000000:role/codepipeline-role",
|
||||
"artifactStore":{"type":"S3","location":"codepipeline-artifacts"},"stages":[
|
||||
{"name":"Source","actions":[{"name":"SourceAction","actionTypeId":{"category":"Source","owner":"AWS","provider":"S3","version":"1"},
|
||||
"configuration":{"S3Bucket":"source-bucket","S3ObjectKey":"source.zip"},"outputArtifacts":[{"name":"SourceOutput"}]}]},
|
||||
{"name":"Build","actions":[{"name":"BuildAction","actionTypeId":{"category":"Build","owner":"AWS","provider":"CodeBuild","version":"1"},
|
||||
"configuration":{"ProjectName":"build-project"},"inputArtifacts":[{"name":"SourceOutput"}],"outputArtifacts":[{"name":"BuildOutput"}]}]},
|
||||
{"name":"Deploy","actions":[{"name":"CloudFormationAction","actionTypeId":{"category":"Deploy","owner":"AWS","provider":"CloudFormation","version":"1"},
|
||||
"configuration":{"StackName":"application","ActionMode":"CREATE_UPDATE","TemplatePath":"SourceOutput::template.yaml",
|
||||
"ParameterOverrides":"{\\"CodeS3Bucket\\":{\\"Fn::GetArtifactAtt\\":[\\"BuildOutput\\",\\"BucketName\\"]},\\"CodeS3Key\\":{\\"Fn::GetArtifactAtt\\":[\\"BuildOutput\\",\\"ObjectKey\\"]}}"},
|
||||
"inputArtifacts":[{"name":"SourceOutput"},{"name":"BuildOutput"}]}]}]}}
|
||||
""");
|
||||
String executionId = startExecution(pipelineService, "build-cloudformation");
|
||||
|
||||
waitForExecutionStatus(pipelineService, "build-cloudformation", executionId, "Succeeded");
|
||||
ArgumentCaptor<String> keys = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<byte[]> contents = ArgumentCaptor.forClass(byte[].class);
|
||||
verify(s3Service, times(4)).putObject(eq("codepipeline-artifacts"), keys.capture(), contents.capture(),
|
||||
anyString(), any());
|
||||
int functionIndex = keys.getAllValues().indexOf(executionId + "/Deploy/CloudFormationAction/BuildOutput/function.zip");
|
||||
assertTrue(functionIndex >= 0);
|
||||
assertArrayEquals(functionZip, contents.getAllValues().get(functionIndex));
|
||||
ArgumentCaptor<Map<String, String>> parameters = ArgumentCaptor.forClass(Map.class);
|
||||
verify(cloudFormationService).createChangeSet(eq("application"), anyString(), eq("CREATE"),
|
||||
anyString(), eq(null), parameters.capture(), eq(List.of()), eq(Map.of()), eq(REGION), eq(ACCOUNT));
|
||||
assertEquals(Map.of("CodeS3Bucket", "codepipeline-artifacts",
|
||||
"CodeS3Key", executionId + "/Deploy/CloudFormationAction/BuildOutput/function.zip"),
|
||||
parameters.getValue());
|
||||
} finally {
|
||||
pipelineService.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFormationRejectsUnsafeTemplatePath() throws Exception {
|
||||
S3Service s3Service = mock(S3Service.class);
|
||||
S3Object source = new S3Object();
|
||||
source.setData(zipArtifact("template.yaml", "Resources: {}"));
|
||||
source.setETag("source-etag");
|
||||
source.setLastModified(Instant.now());
|
||||
when(s3Service.headObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(s3Service.getObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
CodePipelineService pipelineService = new CodePipelineService(
|
||||
new InMemoryStorageFactory(), mapper, mock(CodeBuildService.class), mock(CodeDeployService.class),
|
||||
mock(LambdaService.class), s3Service, mock(CloudFormationService.class));
|
||||
|
||||
try {
|
||||
handle(pipelineService, "CreatePipeline", """
|
||||
{"pipeline":{"name":"unsafe-template-path","roleArn":"arn:aws:iam::000000000000:role/codepipeline-role",
|
||||
"artifactStore":{"type":"S3","location":"codepipeline-artifacts"},"stages":[
|
||||
{"name":"Source","actions":[{"name":"SourceAction","actionTypeId":{"category":"Source","owner":"AWS","provider":"S3","version":"1"},
|
||||
"configuration":{"S3Bucket":"source-bucket","S3ObjectKey":"source.zip"},"outputArtifacts":[{"name":"SourceOutput"}]}]},
|
||||
{"name":"Deploy","actions":[{"name":"CloudFormationAction","actionTypeId":{"category":"Deploy","owner":"AWS","provider":"CloudFormation","version":"1"},
|
||||
"configuration":{"StackName":"application","ActionMode":"CREATE_UPDATE","TemplatePath":"SourceOutput::../template.yaml"},"inputArtifacts":[{"name":"SourceOutput"}]}]}]}}
|
||||
""");
|
||||
String executionId = startExecution(pipelineService, "unsafe-template-path");
|
||||
|
||||
JsonNode execution = waitForExecutionStatus(pipelineService, "unsafe-template-path", executionId, "Failed");
|
||||
assertEquals("Action CloudFormationAction failed: TemplatePath is not safe",
|
||||
execution.path("statusSummary").asText());
|
||||
} finally {
|
||||
pipelineService.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryFailedActionsRerunsOnlyFailedStageActionsAndKeepsUpstreamArtifacts() throws Exception {
|
||||
retryStageExecutionRerunsRequestedActionsOnly("FAILED_ACTIONS", 1, 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryAllActionsRerunsEveryRequestedStageActionAndKeepsUpstreamArtifacts() throws Exception {
|
||||
retryStageExecutionRerunsRequestedActionsOnly("ALL_ACTIONS", 2, 2);
|
||||
}
|
||||
|
||||
private void retryStageExecutionRerunsRequestedActionsOnly(String retryMode, int expectedGoodDeploys,
|
||||
int expectedFailingDeploys) throws Exception {
|
||||
CodeBuildService codeBuildService = mock(CodeBuildService.class);
|
||||
S3Service s3Service = mock(S3Service.class);
|
||||
S3Object source = new S3Object();
|
||||
source.setData("source artifact".getBytes());
|
||||
source.setETag("source-etag");
|
||||
source.setLastModified(Instant.now());
|
||||
Build build = build("SUCCEEDED", true);
|
||||
build.setId("build-retry");
|
||||
AtomicInteger failingDeploys = new AtomicInteger();
|
||||
when(s3Service.headObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(s3Service.getObject("source-bucket", "source.zip")).thenReturn(source);
|
||||
when(codeBuildService.startBuild(anyString(), anyString(), anyString(),
|
||||
any(), any(), any(), any(), any(), any(), any())).thenReturn(build);
|
||||
when(codeBuildService.getBuildOutputArtifact(REGION, ACCOUNT, "build-retry"))
|
||||
.thenReturn("build artifact".getBytes());
|
||||
doAnswer(invocation -> {
|
||||
if ("fail.zip".equals(invocation.getArgument(1)) && failingDeploys.incrementAndGet() == 1) {
|
||||
throw new AwsException("ActionExecutionFailed", "first deploy fails", 400);
|
||||
}
|
||||
return null;
|
||||
}).when(s3Service).putObject(anyString(), anyString(), any(), anyString(), any());
|
||||
CodePipelineService pipelineService = new CodePipelineService(
|
||||
new InMemoryStorageFactory(), mapper, codeBuildService, mock(CodeDeployService.class),
|
||||
mock(LambdaService.class), s3Service, mock(CloudFormationService.class));
|
||||
|
||||
try {
|
||||
handle(pipelineService, "CreatePipeline", """
|
||||
{
|
||||
"pipeline": {
|
||||
"name": "retry-stage-pipeline",
|
||||
"roleArn": "arn:aws:iam::000000000000:role/codepipeline-role",
|
||||
"artifactStore": {"type": "S3", "location": "codepipeline-artifacts"},
|
||||
"stages": [{
|
||||
"name": "Source", "actions": [{
|
||||
"name": "SourceAction",
|
||||
"actionTypeId": {"category": "Source", "owner": "AWS", "provider": "S3", "version": "1"},
|
||||
"configuration": {"S3Bucket": "source-bucket", "S3ObjectKey": "source.zip"},
|
||||
"outputArtifacts": [{"name": "SourceOutput"}]
|
||||
}]
|
||||
}, {
|
||||
"name": "Build", "actions": [{
|
||||
"name": "BuildAction",
|
||||
"actionTypeId": {"category": "Build", "owner": "AWS", "provider": "CodeBuild", "version": "1"},
|
||||
"configuration": {"ProjectName": "build-project"},
|
||||
"inputArtifacts": [{"name": "SourceOutput"}],
|
||||
"outputArtifacts": [{"name": "BuildOutput"}]
|
||||
}]
|
||||
}, {
|
||||
"name": "Deploy", "actions": [{
|
||||
"name": "GoodDeploy",
|
||||
"actionTypeId": {"category": "Deploy", "owner": "AWS", "provider": "S3", "version": "1"},
|
||||
"configuration": {"BucketName": "deploy-bucket", "ObjectKey": "good.zip"},
|
||||
"inputArtifacts": [{"name": "BuildOutput"}]
|
||||
}, {
|
||||
"name": "FailingDeploy",
|
||||
"actionTypeId": {"category": "Deploy", "owner": "AWS", "provider": "S3", "version": "1"},
|
||||
"configuration": {"BucketName": "deploy-bucket", "ObjectKey": "fail.zip"},
|
||||
"inputArtifacts": [{"name": "BuildOutput"}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
""");
|
||||
String executionId = startExecution(pipelineService, "retry-stage-pipeline");
|
||||
waitForExecutionStatus(pipelineService, "retry-stage-pipeline", executionId, "Failed");
|
||||
|
||||
String retryExecutionId = handle(pipelineService, "RetryStageExecution", """
|
||||
{
|
||||
"pipelineName": "retry-stage-pipeline",
|
||||
"pipelineExecutionId": "%s",
|
||||
"stageName": "Deploy",
|
||||
"retryMode": "%s"
|
||||
}
|
||||
""".formatted(executionId, retryMode)).path("pipelineExecutionId").asText();
|
||||
waitForExecutionStatus(pipelineService, "retry-stage-pipeline", retryExecutionId, "Succeeded");
|
||||
|
||||
verify(s3Service, times(1)).getObject("source-bucket", "source.zip");
|
||||
verify(codeBuildService, times(1)).startBuild(anyString(), anyString(), anyString(),
|
||||
any(), any(), any(), any(), any(), any(), any());
|
||||
verify(s3Service, times(expectedGoodDeploys)).putObject(eq("deploy-bucket"), eq("good.zip"),
|
||||
any(), eq("application/zip"), any());
|
||||
verify(s3Service, times(expectedFailingDeploys)).putObject(eq("deploy-bucket"), eq("fail.zip"),
|
||||
any(), eq("application/zip"), any());
|
||||
} finally {
|
||||
pipelineService.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void stoppingCodeDeployWaitsForFailureWithoutFailingThePipeline() throws Exception {
|
||||
CodeDeployService codeDeployService = mock(CodeDeployService.class);
|
||||
@@ -395,7 +851,7 @@ class CodePipelineServiceTest {
|
||||
CodeDeployService codeDeployService) {
|
||||
return new CodePipelineService(
|
||||
new InMemoryStorageFactory(), mapper, codeBuildService, codeDeployService,
|
||||
mock(LambdaService.class), mock(S3Service.class));
|
||||
mock(LambdaService.class), mock(S3Service.class), mock(CloudFormationService.class));
|
||||
}
|
||||
|
||||
private void createExternalActionPipeline(CodePipelineService pipelineService,
|
||||
@@ -485,6 +941,72 @@ class CodePipelineServiceTest {
|
||||
return deployment;
|
||||
}
|
||||
|
||||
private void createCodeDeployToEcsPipeline(CodePipelineService pipelineService, String pipelineName) throws Exception {
|
||||
handle(pipelineService, "CreatePipeline", """
|
||||
{
|
||||
"pipeline": {
|
||||
"name": "%s",
|
||||
"roleArn": "arn:aws:iam::000000000000:role/codepipeline-role",
|
||||
"artifactStore": {"type": "S3", "location": "codepipeline-artifacts"},
|
||||
"stages": [{
|
||||
"name": "Source",
|
||||
"actions": [{
|
||||
"name": "SourceAction",
|
||||
"actionTypeId": {"category": "Source", "owner": "AWS", "provider": "S3", "version": "1"},
|
||||
"configuration": {"S3Bucket": "source-bucket", "S3ObjectKey": "source.zip"},
|
||||
"outputArtifacts": [{"name": "SourceOutput"}]
|
||||
}]
|
||||
}, {
|
||||
"name": "Deploy",
|
||||
"actions": [{
|
||||
"name": "DeployToEcs",
|
||||
"actionTypeId": {"category": "Deploy", "owner": "AWS", "provider": "CodeDeployToECS", "version": "1"},
|
||||
"configuration": {
|
||||
"ApplicationName": "ecs-app",
|
||||
"DeploymentGroupName": "ecs-group",
|
||||
"TaskDefinitionTemplatePath": "SourceOutput::taskdef.json",
|
||||
"AppSpecTemplatePath": "SourceOutput::appspec.json"
|
||||
},
|
||||
"inputArtifacts": [{"name": "SourceOutput"}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
""".formatted(pipelineName));
|
||||
}
|
||||
|
||||
private byte[] zipArtifact(String entryName, String contents) throws Exception {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(output)) {
|
||||
zip.putNextEntry(new ZipEntry(entryName));
|
||||
zip.write(contents.getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private byte[] zipArtifact(String entryName, byte[] contents) throws Exception {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(output)) {
|
||||
zip.putNextEntry(new ZipEntry(entryName));
|
||||
zip.write(contents);
|
||||
zip.closeEntry();
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private byte[] zipArtifact(Map<String, String> entries) throws Exception {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(output)) {
|
||||
for (Map.Entry<String, String> entry : entries.entrySet()) {
|
||||
zip.putNextEntry(new ZipEntry(entry.getKey()));
|
||||
zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private static final class InMemoryStorageFactory extends StorageFactory {
|
||||
private InMemoryStorageFactory() {
|
||||
super(null, null);
|
||||
|
||||
+4
-4
@@ -21,17 +21,17 @@ class EcsEc2BridgeIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void runTaskAndCreateServiceRejectFargateAndNonBridgeTasks() {
|
||||
void runTaskAndCreateServiceKeepEc2BridgeRestrictions() {
|
||||
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"));
|
||||
.body("message", containsString("awsvpc"));
|
||||
call("RunTask", "{\"taskDefinition\":\"ec2-bridge\",\"capacityProviderStrategy\":["
|
||||
+ "{\"capacityProvider\":\"FARGATE\",\"weight\":1}]}", 400)
|
||||
.body("message", containsString("FARGATE capacity providers"));
|
||||
.body("message", containsString("awsvpc"));
|
||||
call("CreateService", "{\"serviceName\":\"ec2-bridge\",\"taskDefinition\":\"ec2-bridge\","
|
||||
+ "\"desiredCount\":0,\"launchType\":\"FARGATE\"}", 400)
|
||||
.body("message", containsString("EC2 launch type"));
|
||||
.body("message", containsString("awsvpc"));
|
||||
|
||||
call("RegisterTaskDefinition", "{\"family\":\"not-bridge\",\"networkMode\":\"awsvpc\","
|
||||
+ "\"containerDefinitions\":[{\"name\":\"app\",\"image\":\"nginx\"}]}", 200);
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package io.github.hectorvent.floci.services.ecs;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import io.github.hectorvent.floci.config.EmulatorConfig;
|
||||
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
|
||||
import io.github.hectorvent.floci.core.storage.StorageBackend;
|
||||
import io.github.hectorvent.floci.core.storage.StorageFactory;
|
||||
import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager;
|
||||
import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition;
|
||||
import io.github.hectorvent.floci.services.ecs.model.CreateTaskSetRequest;
|
||||
import io.github.hectorvent.floci.services.ecs.model.EcsTask;
|
||||
import io.github.hectorvent.floci.services.ecs.model.LaunchType;
|
||||
import io.github.hectorvent.floci.services.ecs.model.TaskDefinition;
|
||||
import io.github.hectorvent.floci.services.ecs.model.TaskSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class EcsEc2ServiceReconciliationTest {
|
||||
|
||||
private static final String REGION = "us-east-1";
|
||||
|
||||
@Test
|
||||
void taskDefinitionUpdateReplacesAndDrainsEc2ServiceTasks() {
|
||||
EcsService service = newMockModeService();
|
||||
service.createCluster("roll-cluster", REGION);
|
||||
TaskDefinition revisionOne = registerTaskDefinition(service, "roll", "app:1");
|
||||
service.createService("roll-cluster", "web", revisionOne.getTaskDefinitionArn(), 2,
|
||||
LaunchType.EC2, List.of(), null, REGION);
|
||||
service.reconcileServices();
|
||||
|
||||
TaskDefinition revisionTwo = registerTaskDefinition(service, "roll", "app:2");
|
||||
service.updateService("roll-cluster", "web", revisionTwo.getTaskDefinitionArn(), null, null, REGION);
|
||||
|
||||
service.reconcileServices();
|
||||
assertEquals(4, runningTasks(service).size(), "replacements start before old tasks drain");
|
||||
service.reconcileServices();
|
||||
|
||||
List<EcsTask> running = runningTasks(service);
|
||||
assertEquals(2, running.size());
|
||||
assertTrue(running.stream().allMatch(task -> revisionTwo.getTaskDefinitionArn()
|
||||
.equals(task.getTaskDefinitionArn())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forceDeleteDrainsAllEc2ServiceTasks() {
|
||||
EcsService service = newMockModeService();
|
||||
service.createCluster("delete-cluster", REGION);
|
||||
TaskDefinition taskDefinition = registerTaskDefinition(service, "delete", "app:1");
|
||||
service.createService("delete-cluster", "worker", taskDefinition.getTaskDefinitionArn(), 2,
|
||||
LaunchType.EC2, List.of(), null, REGION);
|
||||
service.reconcileServices();
|
||||
|
||||
service.deleteService("delete-cluster", "worker", true, REGION);
|
||||
|
||||
assertEquals(0, runningTasks(service).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void codeDeployTaskSetsAreNotSupplementedByNormalEc2ServiceTasks() {
|
||||
EcsService service = newMockModeService();
|
||||
service.createCluster("blue-green-cluster", REGION);
|
||||
TaskDefinition taskDefinition = registerTaskDefinition(service, "blue-green", "app:1");
|
||||
service.createService("blue-green-cluster", "web", taskDefinition.getTaskDefinitionArn(), 2,
|
||||
LaunchType.EC2, List.of(), null, null, null, "CODE_DEPLOY", null, REGION);
|
||||
CreateTaskSetRequest request = new CreateTaskSetRequest();
|
||||
request.setCluster("blue-green-cluster");
|
||||
request.setService("web");
|
||||
request.setTaskDefinition(taskDefinition.getTaskDefinitionArn());
|
||||
request.setLaunchType(LaunchType.EC2);
|
||||
TaskSet taskSet = service.createTaskSet(request, REGION);
|
||||
|
||||
service.reconcileServices();
|
||||
|
||||
assertEquals(2, taskSet.getRunningCount());
|
||||
assertEquals(2, runningTasks(service).size());
|
||||
|
||||
service.deleteService("blue-green-cluster", "web", true, REGION);
|
||||
|
||||
assertEquals(0, runningTasks(service).size(), "force delete drains CodeDeploy task-set tasks");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recreatingAnEc2CodeDeployServiceDoesNotReuseDeletedTaskSets() {
|
||||
EcsService service = newMockModeService();
|
||||
service.createCluster("ec2-recreate-cluster", REGION);
|
||||
TaskDefinition taskDefinition = registerTaskDefinition(service, "ec2-recreate", "app:1");
|
||||
service.createService("ec2-recreate-cluster", "blue", taskDefinition.getTaskDefinitionArn(), 1,
|
||||
LaunchType.EC2, List.of(), null, null, null, "CODE_DEPLOY", null, REGION);
|
||||
CreateTaskSetRequest request = new CreateTaskSetRequest();
|
||||
request.setCluster("ec2-recreate-cluster");
|
||||
request.setService("blue");
|
||||
request.setTaskDefinition(taskDefinition.getTaskDefinitionArn());
|
||||
request.setLaunchType(LaunchType.EC2);
|
||||
service.createTaskSet(request, REGION);
|
||||
|
||||
service.deleteService("ec2-recreate-cluster", "blue", true, REGION);
|
||||
service.createService("ec2-recreate-cluster", "blue", taskDefinition.getTaskDefinitionArn(), 1,
|
||||
LaunchType.EC2, List.of(), null, null, null, "CODE_DEPLOY", null, REGION);
|
||||
service.reconcileServices();
|
||||
|
||||
assertEquals(List.of(), service.describeTaskSets("ec2-recreate-cluster", "blue", null, REGION));
|
||||
assertEquals(List.of(), runningTasks(service));
|
||||
}
|
||||
|
||||
private static List<EcsTask> runningTasks(EcsService service) {
|
||||
return service.describeTasks(null, service.listTasks(null, null, null, null, REGION), REGION).stream()
|
||||
.filter(task -> "RUNNING".equals(task.getLastStatus()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static TaskDefinition registerTaskDefinition(EcsService service, String family, String image) {
|
||||
ContainerDefinition container = new ContainerDefinition();
|
||||
container.setName("app");
|
||||
container.setImage(image);
|
||||
return service.registerTaskDefinition(family, List.of(container), null, null, null,
|
||||
null, null, List.of(), REGION);
|
||||
}
|
||||
|
||||
private static EcsService newMockModeService() {
|
||||
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
|
||||
when(config.services().ecs().mock()).thenReturn(true);
|
||||
when(config.effectiveBaseUrl()).thenReturn("http://localhost:4566");
|
||||
EcsService service = new EcsService(
|
||||
new RegionResolver(REGION, "000000000000"),
|
||||
mock(EcsContainerManager.class),
|
||||
config,
|
||||
mock(EcsLoadBalancerRegistrar.class),
|
||||
new InMemoryStorageFactory(),
|
||||
null);
|
||||
service.initializeStorage();
|
||||
return service;
|
||||
}
|
||||
|
||||
private static final class InMemoryStorageFactory extends StorageFactory {
|
||||
private final Map<String, StorageBackend<String, ?>> stores = new HashMap<>();
|
||||
|
||||
private InMemoryStorageFactory() {
|
||||
super(null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <V> AccountAwareStorageBackend<V> create(String serviceName, String fileName,
|
||||
TypeReference<Map<String, V>> typeReference) {
|
||||
return (AccountAwareStorageBackend<V>) stores.computeIfAbsent(fileName,
|
||||
ignored -> AccountAwareStorageBackend.inMemory("000000000000"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -56,6 +56,13 @@ class EcsLoadBalancersIntegrationTest {
|
||||
return targetGroup.getTargetGroupArn();
|
||||
}
|
||||
|
||||
private String createInstanceTargetGroup(String name) {
|
||||
TargetGroup targetGroup = elbV2Service.createTargetGroup("us-east-1", name, "HTTP", "HTTP1",
|
||||
8080, "vpc-default", "instance", null, null, false, null, null, null, null, null, null,
|
||||
null, Map.of());
|
||||
return targetGroup.getTargetGroupArn();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createServiceParsesAndEchoesLoadBalancers() {
|
||||
seedClusterAndTaskDef("lb-cluster-1", "lb-td-1");
|
||||
@@ -147,6 +154,29 @@ class EcsLoadBalancersIntegrationTest {
|
||||
.body("message", org.hamcrest.Matchers.containsString("does not expose port 9999"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fargateServiceRequiresAnIpTargetGroup() {
|
||||
String cluster = "fargate-lb-cluster";
|
||||
String family = "fargate-lb-task";
|
||||
call("CreateCluster", "{\"clusterName\":\"" + cluster + "\"}");
|
||||
call("RegisterTaskDefinition", "{\"family\":\"" + family + "\","
|
||||
+ "\"requiresCompatibilities\":[\"FARGATE\"],\"networkMode\":\"awsvpc\","
|
||||
+ "\"cpu\":\"256\",\"memory\":\"512\",\"containerDefinitions\":[{"
|
||||
+ "\"name\":\"web\",\"image\":\"nginx\",\"portMappings\":[{\"containerPort\":8080}]}]}");
|
||||
String tgArn = createInstanceTargetGroup("fargate-instance-tg");
|
||||
|
||||
given().contentType(CT).header("X-Amz-Target", TARGET + "CreateService")
|
||||
.body("{\"cluster\":\"" + cluster + "\",\"serviceName\":\"fargate-lb\","
|
||||
+ "\"taskDefinition\":\"" + family + "\",\"desiredCount\":0,"
|
||||
+ "\"launchType\":\"FARGATE\",\"networkConfiguration\":{\"awsvpcConfiguration\":{"
|
||||
+ "\"subnets\":[\"subnet-default-us-east-1-a\"]}},\"loadBalancers\":[{"
|
||||
+ "\"targetGroupArn\":\"" + tgArn + "\",\"containerName\":\"web\","
|
||||
+ "\"containerPort\":8080}]}")
|
||||
.when().post("/")
|
||||
.then().statusCode(400)
|
||||
.body("message", org.hamcrest.Matchers.containsString("target type ip"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceWithLoadBalancersIsStableInMockMode() {
|
||||
// Mock mode launches no real containers; the registrar must no-op cleanly.
|
||||
|
||||
@@ -7,11 +7,18 @@ import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
|
||||
import io.github.hectorvent.floci.core.storage.StorageBackend;
|
||||
import io.github.hectorvent.floci.core.storage.StorageFactory;
|
||||
import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager;
|
||||
import io.github.hectorvent.floci.services.ecs.container.EcsTaskHandle;
|
||||
import io.github.hectorvent.floci.services.ecs.model.AwsVpcConfiguration;
|
||||
import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition;
|
||||
import io.github.hectorvent.floci.services.ecs.model.CreateTaskSetRequest;
|
||||
import io.github.hectorvent.floci.services.ecs.model.EcsLoadBalancer;
|
||||
import io.github.hectorvent.floci.services.ecs.model.EcsTask;
|
||||
import io.github.hectorvent.floci.services.ecs.model.LaunchType;
|
||||
import io.github.hectorvent.floci.services.ecs.model.NetworkConfiguration;
|
||||
import io.github.hectorvent.floci.services.ecs.model.NetworkMode;
|
||||
import io.github.hectorvent.floci.services.ecs.model.TaskDefinition;
|
||||
import io.github.hectorvent.floci.services.ecs.model.TaskStatus;
|
||||
import io.github.hectorvent.floci.services.ecs.model.TaskSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
@@ -23,9 +30,12 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -92,6 +102,206 @@ class EcsServiceRolloutTest {
|
||||
assertEquals(rev1.getTaskDefinitionArn(), svc.getTaskDefinition());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ec2TaskSetPlacesTasksRegistersGreenTargetsAndDrainsThem() {
|
||||
EcsLoadBalancerRegistrar registrar = mock(EcsLoadBalancerRegistrar.class);
|
||||
EcsService service = newDockerModeService(registrar);
|
||||
service.createCluster("blue-green-cluster", REGION);
|
||||
TaskDefinition taskDefinition = registerTaskDef(service, "blue-green-task", "app:1");
|
||||
service.createService("blue-green-cluster", "blue-green-service", taskDefinition.getTaskDefinitionArn(),
|
||||
2, LaunchType.EC2, List.of(), null, null, null, "CODE_DEPLOY", null, REGION);
|
||||
|
||||
EcsLoadBalancer loadBalancer = new EcsLoadBalancer();
|
||||
loadBalancer.setTargetGroupArn("arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/green/id");
|
||||
loadBalancer.setContainerName("app");
|
||||
loadBalancer.setContainerPort(80);
|
||||
CreateTaskSetRequest request = new CreateTaskSetRequest();
|
||||
request.setCluster("blue-green-cluster");
|
||||
request.setService("blue-green-service");
|
||||
request.setTaskDefinition(taskDefinition.getTaskDefinitionArn());
|
||||
request.setLaunchType(LaunchType.EC2);
|
||||
request.setScaleValue(100.0);
|
||||
request.setLoadBalancers(List.of(loadBalancer));
|
||||
|
||||
TaskSet taskSet = service.createTaskSet(request, REGION);
|
||||
|
||||
assertEquals(2, taskSet.getRunningCount());
|
||||
assertEquals("STEADY_STATE", taskSet.getStabilityStatus());
|
||||
verify(registrar, times(2)).registerTaskSetTask(any(EcsTask.class), anyList(), eq(REGION));
|
||||
|
||||
TaskSet scaled = service.updateTaskSet("blue-green-cluster", "blue-green-service",
|
||||
taskSet.getTaskSetArn(), 0.0, "PERCENT", REGION);
|
||||
assertEquals(0, scaled.getRunningCount());
|
||||
verify(registrar, times(2)).deregisterTaskSetTask(any(EcsTask.class), anyList(), eq(REGION));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fargateTaskSetRunsRegistersGreenTargetsAndReconcilesStoppedTasks() {
|
||||
EcsLoadBalancerRegistrar registrar = mock(EcsLoadBalancerRegistrar.class);
|
||||
EcsService service = newDockerModeService(registrar);
|
||||
service.createCluster("fargate-blue-green-cluster", REGION);
|
||||
TaskDefinition taskDefinition = registerFargateTaskDef(service, "fargate-blue-green-task", "app:1");
|
||||
NetworkConfiguration networkConfiguration = fargateNetworkConfiguration();
|
||||
service.createService("fargate-blue-green-cluster", "fargate-blue-green-service",
|
||||
taskDefinition.getTaskDefinitionArn(), 2, LaunchType.FARGATE, List.of(), networkConfiguration,
|
||||
null, null, "CODE_DEPLOY", null, REGION);
|
||||
|
||||
EcsLoadBalancer loadBalancer = new EcsLoadBalancer();
|
||||
loadBalancer.setTargetGroupArn("arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/green/id");
|
||||
loadBalancer.setContainerName("app");
|
||||
loadBalancer.setContainerPort(80);
|
||||
CreateTaskSetRequest request = new CreateTaskSetRequest();
|
||||
request.setCluster("fargate-blue-green-cluster");
|
||||
request.setService("fargate-blue-green-service");
|
||||
request.setTaskDefinition(taskDefinition.getTaskDefinitionArn());
|
||||
request.setLaunchType(LaunchType.FARGATE);
|
||||
request.setScaleValue(100.0);
|
||||
request.setLoadBalancers(List.of(loadBalancer));
|
||||
|
||||
TaskSet taskSet = service.createTaskSet(request, REGION);
|
||||
|
||||
assertEquals(2, taskSet.getRunningCount());
|
||||
assertEquals("STEADY_STATE", taskSet.getStabilityStatus());
|
||||
verify(registrar, times(4)).registerTaskSetTask(any(EcsTask.class), anyList(), eq(REGION));
|
||||
|
||||
EcsTask firstTask = runningTasks(service).getFirst();
|
||||
service.stopTask("fargate-blue-green-cluster", firstTask.getTaskArn(), "test stop", REGION);
|
||||
service.reconcileServices();
|
||||
|
||||
assertEquals(2, taskSet.getRunningCount());
|
||||
verify(registrar, times(5)).registerTaskSetTask(any(EcsTask.class), anyList(), eq(REGION));
|
||||
verify(registrar).deregisterTaskSetTask(any(EcsTask.class), anyList(), eq(REGION));
|
||||
|
||||
service.deleteTaskSet("fargate-blue-green-cluster", "fargate-blue-green-service",
|
||||
taskSet.getTaskSetArn(), true, REGION);
|
||||
assertEquals(2, runningTasks(service).size());
|
||||
verify(registrar, times(3)).deregisterTaskSetTask(any(EcsTask.class), anyList(), eq(REGION));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFargateCodeDeployServiceBootstrapsPrimaryBlueTaskSet() {
|
||||
EcsLoadBalancerRegistrar registrar = mock(EcsLoadBalancerRegistrar.class);
|
||||
EcsService service = newDockerModeService(registrar);
|
||||
service.createCluster("fargate-bootstrap-cluster", REGION);
|
||||
TaskDefinition taskDefinition = registerFargateTaskDef(service, "fargate-bootstrap-task", "app:1");
|
||||
EcsLoadBalancer loadBalancer = new EcsLoadBalancer();
|
||||
loadBalancer.setTargetGroupArn("arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/blue/id");
|
||||
loadBalancer.setContainerName("app");
|
||||
loadBalancer.setContainerPort(80);
|
||||
|
||||
service.createService("fargate-bootstrap-cluster", "fargate-bootstrap-service",
|
||||
taskDefinition.getTaskDefinitionArn(), 2, LaunchType.FARGATE, List.of(loadBalancer),
|
||||
fargateNetworkConfiguration(), null, null, "CODE_DEPLOY", null, REGION);
|
||||
|
||||
List<TaskSet> taskSets = service.describeTaskSets("fargate-bootstrap-cluster",
|
||||
"fargate-bootstrap-service", null, REGION);
|
||||
assertEquals(1, taskSets.size());
|
||||
TaskSet taskSet = taskSets.getFirst();
|
||||
assertEquals("PRIMARY", taskSet.getStatus());
|
||||
assertEquals(2, taskSet.getRunningCount());
|
||||
assertEquals("STEADY_STATE", taskSet.getStabilityStatus());
|
||||
assertEquals(List.of(loadBalancer), taskSet.getLoadBalancers());
|
||||
verify(registrar, times(2)).registerTaskSetTask(any(EcsTask.class), anyList(), eq(REGION));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recreatingAFargateCodeDeployServiceDoesNotReuseDeletedTaskSets() {
|
||||
EcsService service = newDockerModeService(mock(EcsLoadBalancerRegistrar.class));
|
||||
service.createCluster("fargate-recreate-cluster", REGION);
|
||||
TaskDefinition taskDefinition = registerFargateTaskDef(service, "fargate-recreate-task", "app:1");
|
||||
NetworkConfiguration networkConfiguration = fargateNetworkConfiguration();
|
||||
service.createService("fargate-recreate-cluster", "blue", taskDefinition.getTaskDefinitionArn(), 1,
|
||||
LaunchType.FARGATE, List.of(), networkConfiguration, null, null, "CODE_DEPLOY", null, REGION);
|
||||
|
||||
TaskSet oldPrimary = service.describeTaskSets("fargate-recreate-cluster", "blue", null, REGION).getFirst();
|
||||
EcsTask oldTask = runningTasks(service).getFirst();
|
||||
service.deleteService("fargate-recreate-cluster", "blue", true, REGION);
|
||||
service.createService("fargate-recreate-cluster", "blue", taskDefinition.getTaskDefinitionArn(), 1,
|
||||
LaunchType.FARGATE, List.of(), networkConfiguration, null, null, "CODE_DEPLOY", null, REGION);
|
||||
|
||||
List<TaskSet> taskSets = service.describeTaskSets("fargate-recreate-cluster", "blue", null, REGION);
|
||||
assertEquals(1, taskSets.size());
|
||||
assertNotEquals(oldPrimary.getTaskSetArn(), taskSets.getFirst().getTaskSetArn());
|
||||
assertEquals("PRIMARY", taskSets.getFirst().getStatus());
|
||||
assertEquals(1, taskSets.getFirst().getRunningCount());
|
||||
assertEquals(1, runningTasks(service).size());
|
||||
assertEquals("STOPPED", service.describeTasks("fargate-recreate-cluster",
|
||||
List.of(oldTask.getTaskArn()), REGION).getFirst().getLastStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forceDeletingCodeDeployServiceStopsAllTaskSetContainersAndDeregistersTheirTargetsBeforeRecreate() {
|
||||
EcsLoadBalancerRegistrar registrar = mock(EcsLoadBalancerRegistrar.class);
|
||||
EcsContainerManager containerManager = mock(EcsContainerManager.class);
|
||||
EcsTaskHandle blueHandle = mock(EcsTaskHandle.class);
|
||||
EcsTaskHandle greenFirstHandle = mock(EcsTaskHandle.class);
|
||||
EcsTaskHandle greenSecondHandle = mock(EcsTaskHandle.class);
|
||||
EcsTaskHandle otherServiceHandle = mock(EcsTaskHandle.class);
|
||||
EcsTaskHandle recreatedHandle = mock(EcsTaskHandle.class);
|
||||
when(containerManager.startTask(any(), any(), any(), eq(REGION))).thenReturn(
|
||||
blueHandle, greenFirstHandle, greenSecondHandle, otherServiceHandle, recreatedHandle);
|
||||
EcsService service = newDockerModeService(registrar, containerManager);
|
||||
service.createCluster("delete-recreate-cluster", REGION);
|
||||
TaskDefinition taskDefinition = registerFargateTaskDef(service, "delete-recreate-task", "app:1");
|
||||
NetworkConfiguration networkConfiguration = fargateNetworkConfiguration();
|
||||
|
||||
EcsLoadBalancer blueLoadBalancer = loadBalancer("blue");
|
||||
EcsLoadBalancer greenLoadBalancer = loadBalancer("green");
|
||||
EcsLoadBalancer otherLoadBalancer = loadBalancer("other");
|
||||
service.createService("delete-recreate-cluster", "blue", taskDefinition.getTaskDefinitionArn(), 1,
|
||||
LaunchType.FARGATE, List.of(blueLoadBalancer), networkConfiguration, null, null,
|
||||
"CODE_DEPLOY", null, REGION);
|
||||
CreateTaskSetRequest greenTaskSetRequest = new CreateTaskSetRequest();
|
||||
greenTaskSetRequest.setCluster("delete-recreate-cluster");
|
||||
greenTaskSetRequest.setService("blue");
|
||||
greenTaskSetRequest.setTaskDefinition(taskDefinition.getTaskDefinitionArn());
|
||||
greenTaskSetRequest.setLaunchType(LaunchType.FARGATE);
|
||||
greenTaskSetRequest.setNetworkConfiguration(networkConfiguration);
|
||||
greenTaskSetRequest.setLoadBalancers(List.of(greenLoadBalancer));
|
||||
greenTaskSetRequest.setScaleValue(200.0);
|
||||
service.createTaskSet(greenTaskSetRequest, REGION);
|
||||
service.createService("delete-recreate-cluster", "unaffected", taskDefinition.getTaskDefinitionArn(), 1,
|
||||
LaunchType.FARGATE, List.of(otherLoadBalancer), networkConfiguration, null, null,
|
||||
"CODE_DEPLOY", null, REGION);
|
||||
|
||||
service.deleteService("delete-recreate-cluster", "blue", true, REGION);
|
||||
service.createService("delete-recreate-cluster", "blue", taskDefinition.getTaskDefinitionArn(), 1,
|
||||
LaunchType.FARGATE, List.of(blueLoadBalancer), networkConfiguration, null, null,
|
||||
"CODE_DEPLOY", null, REGION);
|
||||
|
||||
verify(containerManager).stopTaskAndCollectExitCodes(blueHandle);
|
||||
verify(containerManager).stopTaskAndCollectExitCodes(greenFirstHandle);
|
||||
verify(containerManager).stopTaskAndCollectExitCodes(greenSecondHandle);
|
||||
verify(containerManager, never()).stopTaskAndCollectExitCodes(otherServiceHandle);
|
||||
verify(containerManager, never()).stopTaskAndCollectExitCodes(recreatedHandle);
|
||||
verify(registrar).deregisterTaskSetTask(any(EcsTask.class),
|
||||
argThat(loadBalancers -> targets(loadBalancers, "blue")), eq(REGION));
|
||||
verify(registrar, times(2)).deregisterTaskSetTask(any(EcsTask.class),
|
||||
argThat(loadBalancers -> targets(loadBalancers, "green")), eq(REGION));
|
||||
verify(registrar, never()).deregisterTask(any(EcsTask.class), any(), eq(REGION));
|
||||
assertEquals(1, service.describeTaskSets("delete-recreate-cluster", "blue", null, REGION).size(),
|
||||
"only the recreated service's primary task set remains");
|
||||
assertEquals(2, runningTasks(service).size(),
|
||||
"the recreated service and the unrelated service retain one task each");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEc2CodeDeployServiceDoesNotBootstrapATaskSet() {
|
||||
EcsLoadBalancerRegistrar registrar = mock(EcsLoadBalancerRegistrar.class);
|
||||
EcsService service = newDockerModeService(registrar);
|
||||
service.createCluster("ec2-bootstrap-cluster", REGION);
|
||||
TaskDefinition taskDefinition = registerTaskDef(service, "ec2-bootstrap-task", "app:1");
|
||||
|
||||
service.createService("ec2-bootstrap-cluster", "ec2-bootstrap-service",
|
||||
taskDefinition.getTaskDefinitionArn(), 2, LaunchType.EC2, List.of(), null,
|
||||
null, null, "CODE_DEPLOY", null, REGION);
|
||||
|
||||
assertEquals(List.of(), service.describeTaskSets("ec2-bootstrap-cluster",
|
||||
"ec2-bootstrap-service", null, REGION));
|
||||
assertEquals(List.of(), runningTasks(service));
|
||||
verify(registrar, times(0)).registerTaskSetTask(any(EcsTask.class), anyList(), eq(REGION));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceHoldingARawReferenceIsPinnedOnItsFirstTickInsteadOfRelaunchingForever() {
|
||||
EcsService service = newMockModeService();
|
||||
@@ -219,6 +429,36 @@ class EcsServiceRolloutTest {
|
||||
null, null, List.of(), REGION);
|
||||
}
|
||||
|
||||
private static TaskDefinition registerFargateTaskDef(EcsService service, String family, String image) {
|
||||
ContainerDefinition cd = new ContainerDefinition();
|
||||
cd.setName("app");
|
||||
cd.setImage(image);
|
||||
return service.registerTaskDefinition(family, List.of(cd), NetworkMode.awsvpc, "256", "512",
|
||||
null, null, List.of("FARGATE"), REGION);
|
||||
}
|
||||
|
||||
private static NetworkConfiguration fargateNetworkConfiguration() {
|
||||
AwsVpcConfiguration awsvpc = new AwsVpcConfiguration();
|
||||
awsvpc.setSubnets(List.of("subnet-12345678"));
|
||||
NetworkConfiguration networkConfiguration = new NetworkConfiguration();
|
||||
networkConfiguration.setAwsvpcConfiguration(awsvpc);
|
||||
return networkConfiguration;
|
||||
}
|
||||
|
||||
private static EcsLoadBalancer loadBalancer(String name) {
|
||||
EcsLoadBalancer loadBalancer = new EcsLoadBalancer();
|
||||
loadBalancer.setTargetGroupArn("arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/"
|
||||
+ name + "/id");
|
||||
loadBalancer.setContainerName("app");
|
||||
loadBalancer.setContainerPort(80);
|
||||
return loadBalancer;
|
||||
}
|
||||
|
||||
private static boolean targets(List<EcsLoadBalancer> loadBalancers, String name) {
|
||||
return loadBalancers.size() == 1
|
||||
&& loadBalancers.getFirst().getTargetGroupArn().endsWith("/" + name + "/id");
|
||||
}
|
||||
|
||||
private static EcsService newMockModeService() {
|
||||
return newMockModeService(null);
|
||||
}
|
||||
@@ -238,6 +478,29 @@ class EcsServiceRolloutTest {
|
||||
return service;
|
||||
}
|
||||
|
||||
private static EcsService newDockerModeService(EcsLoadBalancerRegistrar registrar) {
|
||||
EcsContainerManager containerManager = mock(EcsContainerManager.class);
|
||||
EcsTaskHandle taskHandle = mock(EcsTaskHandle.class);
|
||||
when(containerManager.startTask(any(), any(), any(), eq(REGION))).thenReturn(taskHandle);
|
||||
return newDockerModeService(registrar, containerManager);
|
||||
}
|
||||
|
||||
private static EcsService newDockerModeService(EcsLoadBalancerRegistrar registrar,
|
||||
EcsContainerManager containerManager) {
|
||||
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
|
||||
when(config.services().ecs().mock()).thenReturn(false);
|
||||
when(config.effectiveBaseUrl()).thenReturn("http://localhost:4566");
|
||||
EcsService service = new EcsService(
|
||||
new RegionResolver(REGION, "000000000000"),
|
||||
containerManager,
|
||||
config,
|
||||
registrar,
|
||||
new InMemoryStorageFactory(),
|
||||
null);
|
||||
service.initializeStorage();
|
||||
return service;
|
||||
}
|
||||
|
||||
private static final class InMemoryStorageFactory extends StorageFactory {
|
||||
private final Map<String, StorageBackend<String, ?>> stores = new HashMap<>();
|
||||
|
||||
|
||||
+42
@@ -7,10 +7,14 @@ import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
|
||||
import io.github.hectorvent.floci.core.storage.StorageBackend;
|
||||
import io.github.hectorvent.floci.core.storage.StorageFactory;
|
||||
import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager;
|
||||
import io.github.hectorvent.floci.services.ecs.model.AwsVpcConfiguration;
|
||||
import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition;
|
||||
import io.github.hectorvent.floci.services.ecs.model.ContainerInstance;
|
||||
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.LaunchType;
|
||||
import io.github.hectorvent.floci.services.ecs.model.NetworkMode;
|
||||
import io.github.hectorvent.floci.services.ecs.model.NetworkConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
@@ -130,6 +134,28 @@ class EcsServiceTaskOwnershipTest {
|
||||
"service teardown stops only the service's own tasks");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forceDeletingAFargateServiceClearsInactiveServiceTaskCounts() {
|
||||
EcsService service = newMockModeService();
|
||||
service.createCluster("fargate-delete-cluster", REGION);
|
||||
registerFargateTaskDef(service);
|
||||
service.createService("fargate-delete-cluster", "worker", "own-fam", 1, LaunchType.FARGATE,
|
||||
List.of(), fargateNetworkConfiguration(), REGION);
|
||||
service.reconcileServices();
|
||||
service.reconcileServices();
|
||||
EcsTask task = allTasks(service).getFirst();
|
||||
|
||||
service.deleteService("fargate-delete-cluster", "worker", true, REGION);
|
||||
|
||||
assertEquals("STOPPED", reload(service, task).getLastStatus());
|
||||
EcsServiceModel deleted = service.describeServices("fargate-delete-cluster", List.of("worker"), REGION)
|
||||
.getFirst();
|
||||
assertEquals("INACTIVE", deleted.getStatus());
|
||||
assertEquals(0, deleted.getDesiredCount());
|
||||
assertEquals(0, deleted.getRunningCount());
|
||||
assertEquals(0, deleted.getPendingCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listTasksByServiceNameReturnsOnlyTasksTheServiceLaunched() {
|
||||
EcsService service = newMockModeService();
|
||||
@@ -216,6 +242,22 @@ class EcsServiceTaskOwnershipTest {
|
||||
service.registerTaskDefinition("own-fam", List.of(cd), null, null, null, null, null, List.of(), region);
|
||||
}
|
||||
|
||||
private static void registerFargateTaskDef(EcsService service) {
|
||||
ContainerDefinition cd = new ContainerDefinition();
|
||||
cd.setName("app");
|
||||
cd.setImage("app:1");
|
||||
service.registerTaskDefinition("own-fam", List.of(cd), NetworkMode.awsvpc, "256", "512",
|
||||
null, null, List.of("FARGATE"), REGION);
|
||||
}
|
||||
|
||||
private static NetworkConfiguration fargateNetworkConfiguration() {
|
||||
AwsVpcConfiguration awsvpc = new AwsVpcConfiguration();
|
||||
awsvpc.setSubnets(List.of("subnet-12345678"));
|
||||
NetworkConfiguration network = new NetworkConfiguration();
|
||||
network.setAwsvpcConfiguration(awsvpc);
|
||||
return network;
|
||||
}
|
||||
|
||||
private static EcsService newMockModeService() {
|
||||
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
|
||||
when(config.services().ecs().mock()).thenReturn(true);
|
||||
|
||||
@@ -23,6 +23,7 @@ class ElbV2IntegrationTest {
|
||||
"AWS4-HMAC-SHA256 Credential=test/20260427/us-east-1/ec2/aws4_request";
|
||||
private static final String ELB_V2_XMLNS =
|
||||
"https://elasticloadbalancing.amazonaws.com/doc/2015-12-01/";
|
||||
private static final String ELB_V2_API_VERSION = "2015-12-01";
|
||||
|
||||
private static String lbArn;
|
||||
private static String tgArn;
|
||||
@@ -37,6 +38,7 @@ class ElbV2IntegrationTest {
|
||||
void createLoadBalancer() {
|
||||
lbArn = given()
|
||||
.formParam("Action", "CreateLoadBalancer")
|
||||
.formParam("Version", ELB_V2_API_VERSION)
|
||||
.formParam("Name", "my-test-lb")
|
||||
.formParam("Type", "application")
|
||||
.formParam("Scheme", "internet-facing")
|
||||
@@ -60,7 +62,7 @@ class ElbV2IntegrationTest {
|
||||
.body("CreateLoadBalancerResponse.CreateLoadBalancerResult.LoadBalancers.member.AvailabilityZones.member.SubnetId",
|
||||
hasItems(Ec2Service.defaultSubnetId("us-east-1", "a"), Ec2Service.defaultSubnetId("us-east-1", "b")))
|
||||
.body("CreateLoadBalancerResponse.CreateLoadBalancerResult.LoadBalancers.member.DNSName",
|
||||
containsString(".elb.localhost.floci.io"))
|
||||
containsString(".elb.localhost"))
|
||||
.extract()
|
||||
.path("CreateLoadBalancerResponse.CreateLoadBalancerResult.LoadBalancers.member.LoadBalancerArn");
|
||||
}
|
||||
@@ -70,6 +72,7 @@ class ElbV2IntegrationTest {
|
||||
void describeLoadBalancerByArn() {
|
||||
given()
|
||||
.formParam("Action", "DescribeLoadBalancers")
|
||||
.formParam("Version", ELB_V2_API_VERSION)
|
||||
.formParam("LoadBalancerArns.member.1", lbArn)
|
||||
.header("Authorization", AUTH)
|
||||
.when()
|
||||
@@ -82,6 +85,18 @@ class ElbV2IntegrationTest {
|
||||
equalTo("active"))
|
||||
.body("DescribeLoadBalancersResponse.DescribeLoadBalancersResult.LoadBalancers.member.AvailabilityZones.member.SubnetId",
|
||||
hasItems(Ec2Service.defaultSubnetId("us-east-1", "a"), Ec2Service.defaultSubnetId("us-east-1", "b")));
|
||||
|
||||
given()
|
||||
.formParam("Action", "DescribeLoadBalancers")
|
||||
.formParam("Version", ELB_V2_API_VERSION)
|
||||
.formParam("LoadBalancerArns.member.1", lbArn)
|
||||
.header("Authorization", AUTH)
|
||||
.when()
|
||||
.post("/")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("DescribeLoadBalancersResponse.DescribeLoadBalancersResult.LoadBalancers.member.LoadBalancerArn",
|
||||
equalTo(lbArn));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,6 +104,7 @@ class ElbV2IntegrationTest {
|
||||
void describeLoadBalancerByName() {
|
||||
given()
|
||||
.formParam("Action", "DescribeLoadBalancers")
|
||||
.formParam("Version", ELB_V2_API_VERSION)
|
||||
.formParam("Names.member.1", "my-test-lb")
|
||||
.header("Authorization", AUTH)
|
||||
.when()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.github.hectorvent.floci.services.rds;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import io.github.hectorvent.floci.config.EmulatorConfig;
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||
@@ -7,6 +8,7 @@ import io.github.hectorvent.floci.core.common.docker.CurrentContainerNetworkReso
|
||||
import io.github.hectorvent.floci.core.common.docker.DockerHostResolver;
|
||||
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
|
||||
import io.github.hectorvent.floci.core.storage.InMemoryStorage;
|
||||
import io.github.hectorvent.floci.core.storage.PersistentStorage;
|
||||
import io.github.hectorvent.floci.core.storage.StorageBackend;
|
||||
import io.github.hectorvent.floci.services.ec2.Ec2Service;
|
||||
import io.github.hectorvent.floci.services.ec2.model.Subnet;
|
||||
@@ -43,11 +45,13 @@ import io.github.hectorvent.floci.services.secretsmanager.SecretsManagerService;
|
||||
import io.github.hectorvent.floci.services.secretsmanager.model.Secret;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
@@ -231,7 +235,7 @@ class RdsServiceTest {
|
||||
|
||||
@Test
|
||||
void createDbInstanceRemovesVisibleRecordWhenProxyStartupFails() {
|
||||
doThrow(new IllegalStateException("proxy down"))
|
||||
doThrow(new IllegalStateException("proxy down")).doNothing()
|
||||
.when(proxyManager).startProxy(any(), any(), anyBoolean(), anyInt(), any(), anyInt(),
|
||||
any(), any(), any(), any(), any());
|
||||
|
||||
@@ -240,6 +244,58 @@ class RdsServiceTest {
|
||||
"admin", "secret123", null, "db.t3.micro",
|
||||
20, false, null, null, null, null, false));
|
||||
assertThrows(AwsException.class, () -> rdsService.getDbInstance("mypostgres"));
|
||||
|
||||
DbInstance replacement = rdsService.createDbInstance("replacement", "postgres", "13",
|
||||
"admin", "secret123", null, "db.t3.micro",
|
||||
20, false, null, null, null, null, false);
|
||||
assertEquals(7000, replacement.getProxyPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistedInstancesPreventProxyPortReuseAfterRestart(@TempDir Path directory) {
|
||||
when(config.services().rds().mock()).thenReturn(true);
|
||||
Path instancesFile = directory.resolve("rds-instances.json");
|
||||
PersistentStorage<String, DbInstance> initialInstances = new PersistentStorage<>(
|
||||
instancesFile, new TypeReference<Map<String, DbInstance>>() {});
|
||||
RdsService initialService = newService(containerManager, proxyManager,
|
||||
initialInstances, new InMemoryStorage<>(), new InMemoryStorage<>(),
|
||||
new InMemoryStorage<>(), new InMemoryStorage<>());
|
||||
DbInstance first = initialService.createDbInstance("first", "postgres", "16.3",
|
||||
"admin", "secret", "app", "db.t3.micro", 20, false,
|
||||
null, null, null);
|
||||
|
||||
PersistentStorage<String, DbInstance> restoredInstances = new PersistentStorage<>(
|
||||
instancesFile, new TypeReference<Map<String, DbInstance>>() {});
|
||||
restoredInstances.load();
|
||||
RdsService restartedService = newService(containerManager, proxyManager,
|
||||
restoredInstances, new InMemoryStorage<>(), new InMemoryStorage<>(),
|
||||
new InMemoryStorage<>(), new InMemoryStorage<>());
|
||||
DbInstance second = restartedService.createDbInstance("second", "postgres", "16.3",
|
||||
"admin", "secret", "app", "db.t3.micro", 20, false,
|
||||
null, null, null);
|
||||
|
||||
assertEquals(7000, first.getProxyPort());
|
||||
assertEquals(7001, second.getProxyPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletingOneInstancePreservesOtherInstances() {
|
||||
when(config.services().rds().mock()).thenReturn(true);
|
||||
InMemoryStorage<String, DbInstance> instances = new InMemoryStorage<>();
|
||||
RdsService service = newService(containerManager, proxyManager, instances,
|
||||
new InMemoryStorage<>(), new InMemoryStorage<>(), new InMemoryStorage<>(),
|
||||
new InMemoryStorage<>());
|
||||
service.createDbInstance("first", "postgres", "16.3", "admin", "secret", "app",
|
||||
"db.t3.micro", 20, false, null, null, null);
|
||||
DbInstance second = service.createDbInstance(
|
||||
"second", "postgres", "16.3", "admin", "secret", "app",
|
||||
"db.t3.micro", 20, false, null, null, null);
|
||||
|
||||
service.deleteDbInstance("first");
|
||||
|
||||
assertThrows(AwsException.class, () -> service.getDbInstance("first"));
|
||||
assertEquals(second.getDbInstanceArn(), service.getDbInstance("second").getDbInstanceArn());
|
||||
assertTrue(instances.get("us-east-1::second").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user