feat: Fargate, ECS, ALB,Codebuild, CodePipeline

This commit is contained in:
Zakaria
2026-10-02 09:54:55 -04:00
parent 283fcc7ab8
commit 7135c81050
26 changed files with 2675 additions and 209 deletions
@@ -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()
@@ -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");
@@ -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;
}
}
@@ -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);
}
@@ -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);
@@ -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);
@@ -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"));
}
}
}
@@ -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<>();
@@ -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