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
+2 -2
View File
@@ -109,8 +109,8 @@ For `computePlatform: ECS`, `CreateDeployment` performs a full blue/green traffi
3. Runs lifecycle hook Lambdas in order: `BeforeInstall` → (install) → `AfterInstall` → `BeforeAllowTraffic` → (traffic shift) → `AfterAllowTraffic` 3. Runs lifecycle hook Lambdas in order: `BeforeInstall` → (install) → `AfterInstall` → `BeforeAllowTraffic` → (traffic shift) → `AfterAllowTraffic`
4. **Traffic shift** — atomically updates the ELB v2 listener's default forward rule: 4. **Traffic shift** — atomically updates the ELB v2 listener's default forward rule:
- `ECSAllAtOnce`: immediately shifts 100% of traffic to the green target group - `ECSAllAtOnce`: immediately shifts 100% of traffic to the green target group
- `ECSCanary*`: shifts the canary percentage first, waits a short interval (capped at 5 s in emulator), then shifts to 100% - `ECSCanary*`: shifts the canary percentage first, waits the configured interval, then shifts to 100%
- `ECSLinear*`: shifts traffic in equal increments (capped at 2 s per step in emulator) - `ECSLinear*`: shifts traffic in equal increments at the configured interval
5. Promotes the green task set to **PRIMARY** on the ECS service and deletes the original blue task set 5. Promotes the green task set to **PRIMARY** on the ECS service and deletes the original blue task set
6. Marks the deployment `Succeeded`; if any lifecycle hook reports `Failed`, the deployment is marked `Failed` 6. Marks the deployment `Succeeded`; if any lifecycle hook reports `Failed`, the deployment is marked `Failed`
@@ -111,7 +111,7 @@ public class LambdaCfnProvisioner implements CfnResourceProvisioner {
"Cannot replace Lambda function " + r.getPhysicalId() "Cannot replace Lambda function " + r.getPhysicalId()
+ " without a new FunctionName", 400); + " without a new FunctionName", 400);
} }
func = createLambdaFunction(region, desired, !replacement); func = createLambdaFunction(region, desired, !replacement, r);
if (replacement && r.getPhysicalId() != null) { if (replacement && r.getPhysicalId() != null) {
deleteReplacedLambda(region, r.getPhysicalId()); deleteReplacedLambda(region, r.getPhysicalId());
} }
@@ -343,13 +343,15 @@ public class LambdaCfnProvisioner implements CfnResourceProvisioner {
return !Objects.equals(existingPackageType, desired.packageType()); return !Objects.equals(existingPackageType, desired.packageType());
} }
private LambdaFunction createLambdaFunction(String region, LambdaDesiredState desired, boolean allowAdopt) { private LambdaFunction createLambdaFunction(String region, LambdaDesiredState desired, boolean allowAdopt,
StackResource resource) {
try { try {
return lambdaService.createFunction(region, desired.createRequest()); return lambdaService.createFunction(region, desired.createRequest());
} catch (AwsException e) { } catch (AwsException e) {
if (allowAdopt && ("ResourceConflictException".equals(e.getErrorCode()) if (allowAdopt && ("ResourceConflictException".equals(e.getErrorCode())
|| (e.getMessage() != null && e.getMessage().contains("Function already exist")))) { || (e.getMessage() != null && e.getMessage().contains("Function already exist")))) {
return lambdaService.getFunction(region, desired.functionName()); LambdaFunction existing = lambdaService.getFunction(region, desired.functionName());
return updateLambdaFunction(region, existing, desired, resource);
} }
throw e; throw e;
} }
@@ -85,16 +85,9 @@ public class LambdaVersionAliasCfnProvisioner implements CfnResourceProvisioner
} }
String description = ctx.resolveOptional(props, "Description"); String description = ctx.resolveOptional(props, "Description");
// Version resources are immutable in CloudFormation. An update re-invokes provision with // Lambda deduplicates an unchanged publish by $LATEST revision. Calling it here is
// the prior physical id, and publishing again would mint a new version and repoint every // essential on UpdateStack: after a dependent function updates its code, the Version
// Ref to it, so the version this resource already published is kept while it still exists. // must snapshot that new revision rather than retaining its prior physical id.
String previous = r.getPhysicalId();
if (previous != null && existingVersionIsCurrent(ctx.region(), functionName, previous)) {
r.getAttributes().put("Version", previous.substring(previous.lastIndexOf(':') + 1));
r.getAttributes().put("FunctionArn", previous);
return;
}
LambdaFunction version = lambdaService.publishVersion(ctx.region(), functionName, description); LambdaFunction version = lambdaService.publishVersion(ctx.region(), functionName, description);
// Matches real CloudFormation: Ref returns the qualified ARN, GetAtt Version the version // Matches real CloudFormation: Ref returns the qualified ARN, GetAtt Version the version
@@ -104,26 +97,6 @@ public class LambdaVersionAliasCfnProvisioner implements CfnResourceProvisioner
r.getAttributes().put("FunctionArn", version.getFunctionArn()); r.getAttributes().put("FunctionArn", version.getFunctionArn());
} }
/** True when {@code physicalId} names a still-published version of the function in the template. */
private boolean existingVersionIsCurrent(String region, String functionName, String physicalId) {
int sep = physicalId.lastIndexOf(':');
if (sep <= 0) {
return false;
}
String version = physicalId.substring(sep + 1);
String baseArn = physicalId.substring(0, sep);
try {
LambdaFunction fn = lambdaService.getFunction(region, functionName);
if (!baseArn.equals(fn.getFunctionArn().replace(":$LATEST", ""))) {
return false; // the FunctionName property now points at a different function
}
return lambdaService.listVersionsByFunction(region, functionName).stream()
.anyMatch(v -> version.equals(v.getVersion()));
} catch (AwsException e) {
return false;
}
}
private void provisionAlias(StackResource r, JsonNode props, ProvisionContext ctx) { private void provisionAlias(StackResource r, JsonNode props, ProvisionContext ctx) {
String functionName = ctx.resolveOptional(props, "FunctionName"); String functionName = ctx.resolveOptional(props, "FunctionName");
String aliasName = ctx.resolveOptional(props, "Name"); String aliasName = ctx.resolveOptional(props, "Name");
@@ -15,6 +15,7 @@ import io.github.hectorvent.floci.core.common.docker.ContainerLifecycleManager;
import io.github.hectorvent.floci.core.common.docker.ContainerLogStreamer; import io.github.hectorvent.floci.core.common.docker.ContainerLogStreamer;
import io.github.hectorvent.floci.core.common.docker.ContainerSpec; import io.github.hectorvent.floci.core.common.docker.ContainerSpec;
import io.github.hectorvent.floci.core.common.docker.ContainerStorageHelper; import io.github.hectorvent.floci.core.common.docker.ContainerStorageHelper;
import io.github.hectorvent.floci.core.common.docker.DockerClientProducer;
import io.github.hectorvent.floci.services.codebuild.BuildspecParser.ParsedArtifacts; import io.github.hectorvent.floci.services.codebuild.BuildspecParser.ParsedArtifacts;
import io.github.hectorvent.floci.services.codebuild.BuildspecParser.ParsedBuildspec; import io.github.hectorvent.floci.services.codebuild.BuildspecParser.ParsedBuildspec;
import io.github.hectorvent.floci.services.codebuild.model.Build; import io.github.hectorvent.floci.services.codebuild.model.Build;
@@ -38,6 +39,7 @@ import java.io.ByteArrayOutputStream;
import java.io.Closeable; import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -195,9 +197,10 @@ public class CodeBuildRunner implements ContainerTeardown {
String logGroup = "/aws/codebuild/" + project.getName(); String logGroup = "/aws/codebuild/" + project.getName();
String logStream = logStreamer.generateLogStreamName(buildId.replace(":", "/")); String logStream = logStreamer.generateLogStreamName(buildId.replace(":", "/"));
String image = build.getEnvironment() != null && build.getEnvironment().getImage() != null String requestedImage = build.getEnvironment() != null && build.getEnvironment().getImage() != null
? build.getEnvironment().getImage() ? build.getEnvironment().getImage()
: project.getEnvironment().getImage(); : project.getEnvironment().getImage();
String image = resolveBuildImage(requestedImage);
boolean privileged = (project.getEnvironment() != null boolean privileged = (project.getEnvironment() != null
&& Boolean.TRUE.equals(project.getEnvironment().getPrivilegedMode())) && Boolean.TRUE.equals(project.getEnvironment().getPrivilegedMode()))
@@ -214,8 +217,11 @@ public class CodeBuildRunner implements ContainerTeardown {
// Keep the container alive so each phase can be run with docker exec. // Keep the container alive so each phase can be run with docker exec.
// No bind mount needed — source and artifacts are transferred with docker cp. // No bind mount needed — source and artifacts are transferred with docker cp.
ContainerSpec spec = containerBuilder.newContainer(image) String startupCommand = "aws/codebuild/standard:7.0".equals(requestedImage)
.withCmd(List.of("sh", "-c", "tail -f /dev/null")) ? "apt-get update && apt-get install -y --no-install-recommends zip && tail -f /dev/null"
: "tail -f /dev/null";
ContainerBuilder.Builder container = containerBuilder.newContainer(image)
.withCmd(List.of("sh", "-c", startupCommand))
.withEnv(envList) .withEnv(envList)
.withDockerNetwork(config.services().codebuild().dockerNetwork()) .withDockerNetwork(config.services().codebuild().dockerNetwork())
.withEmbeddedDns() .withEmbeddedDns()
@@ -223,8 +229,9 @@ public class CodeBuildRunner implements ContainerTeardown {
.withPrivileged(privileged) .withPrivileged(privileged)
.withLogRotation() .withLogRotation()
.withLabels(ContainerStorageHelper.resourceIdentityLabels( .withLabels(ContainerStorageHelper.resourceIdentityLabels(
"codebuild", buildId, regionResolver.getAccountId(), region)) "codebuild", buildId, regionResolver.getAccountId(), region));
.build(); exposeDockerSocketIfPrivileged(container, privileged, config.docker());
ContainerSpec spec = container.build();
ContainerLifecycleManager.ContainerInfo info = lifecycleManager.createAndStart(spec); ContainerLifecycleManager.ContainerInfo info = lifecycleManager.createAndStart(spec);
containerId = info.containerId(); containerId = info.containerId();
@@ -261,7 +268,8 @@ public class CodeBuildRunner implements ContainerTeardown {
if (installResult.stopped()) { finishStopped(build); return; } if (installResult.stopped()) { finishStopped(build); return; }
if (installResult.failed()) { if (installResult.failed()) {
completePhaseWithError(build, "INSTALL", "FAILED", installResult.errorMessage()); completePhaseWithError(build, "INSTALL", "FAILED", installResult.errorMessage());
buildFailed = true; finishFailed(build);
return;
} else { } else {
completePhase(build, "INSTALL", "SUCCEEDED"); completePhase(build, "INSTALL", "SUCCEEDED");
} }
@@ -276,7 +284,8 @@ public class CodeBuildRunner implements ContainerTeardown {
if (preBuildResult.stopped()) { finishStopped(build); return; } if (preBuildResult.stopped()) { finishStopped(build); return; }
if (preBuildResult.failed()) { if (preBuildResult.failed()) {
completePhaseWithError(build, "PRE_BUILD", "FAILED", preBuildResult.errorMessage()); completePhaseWithError(build, "PRE_BUILD", "FAILED", preBuildResult.errorMessage());
buildFailed = true; finishFailed(build);
return;
} else { } else {
completePhase(build, "PRE_BUILD", "SUCCEEDED"); completePhase(build, "PRE_BUILD", "SUCCEEDED");
} }
@@ -294,7 +303,8 @@ public class CodeBuildRunner implements ContainerTeardown {
if (buildResult.stopped()) { finishStopped(build); return; } if (buildResult.stopped()) { finishStopped(build); return; }
if (buildResult.failed()) { if (buildResult.failed()) {
completePhaseWithError(build, "BUILD", "FAILED", buildResult.errorMessage()); completePhaseWithError(build, "BUILD", "FAILED", buildResult.errorMessage());
buildFailed = true; finishFailed(build);
return;
} else { } else {
completePhase(build, "BUILD", "SUCCEEDED"); completePhase(build, "BUILD", "SUCCEEDED");
} }
@@ -311,7 +321,8 @@ public class CodeBuildRunner implements ContainerTeardown {
if (postBuildResult.stopped()) { finishStopped(build); return; } if (postBuildResult.stopped()) { finishStopped(build); return; }
if (postBuildResult.failed()) { if (postBuildResult.failed()) {
completePhaseWithError(build, "POST_BUILD", "FAILED", postBuildResult.errorMessage()); completePhaseWithError(build, "POST_BUILD", "FAILED", postBuildResult.errorMessage());
buildFailed = true; finishFailed(build);
return;
} else { } else {
completePhase(build, "POST_BUILD", "SUCCEEDED"); completePhase(build, "POST_BUILD", "SUCCEEDED");
} }
@@ -384,6 +395,27 @@ public class CodeBuildRunner implements ContainerTeardown {
} }
} }
private String resolveBuildImage(String image) {
if ("aws/codebuild/standard:7.0".equals(image)) {
return "golang:1.23-bookworm";
}
return image;
}
static void exposeDockerSocketIfPrivileged(ContainerBuilder.Builder container, boolean privileged,
EmulatorConfig.DockerConfig dockerConfig) {
if (!privileged) {
return;
}
String dockerHost = DockerClientProducer.resolveDockerConnection(dockerConfig, System::getenv).host();
URI dockerUri = URI.create(dockerHost);
if (!"unix".equals(dockerUri.getScheme()) || dockerUri.getPath() == null || dockerUri.getPath().isBlank()) {
LOG.debugv("CodeBuild privileged mode cannot mount non-Unix Docker endpoint {0}", dockerHost);
return;
}
container.withBind(dockerUri.getPath(), "/var/run/docker.sock");
}
private String resolveAndAcquireSource(String region, Build build, Project project, private String resolveAndAcquireSource(String region, Build build, Project project,
String buildspecOverride, Path workspace) throws IOException { String buildspecOverride, Path workspace) throws IOException {
String sourceType = project.getSource() != null ? project.getSource().getType() : "NO_SOURCE"; String sourceType = project.getSource() != null ? project.getSource().getType() : "NO_SOURCE";
@@ -660,25 +692,20 @@ public class CodeBuildRunner implements ContainerTeardown {
private void uploadArtifacts(String region, Build build, Project project, private void uploadArtifacts(String region, Build build, Project project,
ParsedArtifacts artifacts, Path workspace) throws IOException { ParsedArtifacts artifacts, Path workspace) throws IOException {
String type = artifacts.type(); String type = artifacts.type();
if ("NO_ARTIFACTS".equals(type) && project.getArtifacts() != null) { if ("NO_ARTIFACTS".equals(type) && build.getArtifacts() != null) {
type = project.getArtifacts().getType(); type = build.getArtifacts().getType();
} }
if (type == null || "NO_ARTIFACTS".equals(type) || "CODEPIPELINE".equals(type)) { if (type == null || "NO_ARTIFACTS".equals(type)) {
return; return;
} }
if (!"S3".equals(type)) { if (!"S3".equals(type) && !"CODEPIPELINE".equals(type)) {
return; return;
} }
String packaging = artifacts.packaging(); String packaging = artifacts.packaging();
if ("ZIP".equals(packaging) && project.getArtifacts() != null if ("ZIP".equals(packaging) && build.getArtifacts() != null
&& project.getArtifacts().getPackaging() != null) { && build.getArtifacts().getPackaging() != null) {
packaging = project.getArtifacts().getPackaging(); packaging = build.getArtifacts().getPackaging();
}
String location = project.getArtifacts() != null ? project.getArtifacts().getLocation() : null;
if (location == null || location.isBlank()) {
return;
} }
List<String> filePatterns = artifacts.files(); List<String> filePatterns = artifacts.files();
@@ -698,6 +725,16 @@ public class CodeBuildRunner implements ContainerTeardown {
return; return;
} }
if ("CODEPIPELINE".equals(type)) {
build.setOutputArtifact(zipFiles(baseDir, matchedFiles, artifacts.discardPaths()));
return;
}
String location = build.getArtifacts() != null ? build.getArtifacts().getLocation() : null;
if (location == null || location.isBlank()) {
return;
}
int slash = location.indexOf('/'); int slash = location.indexOf('/');
String bucket = slash > 0 ? location.substring(0, slash) : location; String bucket = slash > 0 ? location.substring(0, slash) : location;
String prefix = slash > 0 ? location.substring(slash + 1) : ""; String prefix = slash > 0 ? location.substring(slash + 1) : "";
@@ -841,6 +878,13 @@ public class CodeBuildRunner implements ContainerTeardown {
build.setBuildComplete(true); build.setBuildComplete(true);
build.setBuildStatus("FAILED"); build.setBuildStatus("FAILED");
build.setCurrentPhase("COMPLETED"); build.setCurrentPhase("COMPLETED");
BuildPhase completed = new BuildPhase();
completed.setPhaseType("COMPLETED");
completed.setPhaseStatus("FAILED");
completed.setStartTime(System.currentTimeMillis() / 1000.0);
completed.setEndTime(System.currentTimeMillis() / 1000.0);
completed.setDurationInSeconds(0L);
build.getPhases().add(completed);
} }
private Optional<BuildPhase> findPhase(Build build, String phaseType) { private Optional<BuildPhase> findPhase(Build build, String phaseType) {
@@ -489,6 +489,14 @@ public class CodeBuildService {
return build; return build;
} }
/**
* Returns the CODEPIPELINE output produced by one completed build. This is runtime state,
* like the build itself, and must not be inferred from a project-wide artifact location.
*/
public byte[] getBuildOutputArtifact(String region, String account, String buildId) {
return getBuild(region, account, buildId).getOutputArtifact();
}
public List<Build> batchGetBuilds(String region, String account, List<String> buildIds) { public List<Build> batchGetBuilds(String region, String account, List<String> buildIds) {
Map<String, Build> store = buildsFor(account, region); Map<String, Build> store = buildsFor(account, region);
return buildIds.stream() return buildIds.stream()
@@ -1,5 +1,6 @@
package io.github.hectorvent.floci.services.codebuild.model; package io.github.hectorvent.floci.services.codebuild.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
@@ -29,6 +30,7 @@ public class Build {
private Integer timeoutInMinutes; private Integer timeoutInMinutes;
private Integer queuedTimeoutInMinutes; private Integer queuedTimeoutInMinutes;
private String encryptionKey; private String encryptionKey;
private byte[] outputArtifact;
public String getId() { return id; } public String getId() { return id; }
public void setId(String id) { this.id = id; } public void setId(String id) { this.id = id; }
@@ -83,4 +85,11 @@ public class Build {
public String getEncryptionKey() { return encryptionKey; } public String getEncryptionKey() { return encryptionKey; }
public void setEncryptionKey(String encryptionKey) { this.encryptionKey = encryptionKey; } public void setEncryptionKey(String encryptionKey) { this.encryptionKey = encryptionKey; }
@JsonIgnore
public byte[] getOutputArtifact() { return outputArtifact != null ? outputArtifact.clone() : null; }
public void setOutputArtifact(byte[] outputArtifact) {
this.outputArtifact = outputArtifact != null ? outputArtifact.clone() : null;
}
} }
@@ -17,10 +17,16 @@ import io.github.hectorvent.floci.services.codedeploy.model.OnPremisesInstance;
import io.github.hectorvent.floci.services.ec2.Ec2Service; import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ecs.EcsService; import io.github.hectorvent.floci.services.ecs.EcsService;
import io.github.hectorvent.floci.services.ecs.model.CreateTaskSetRequest; 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.EcsServiceModel;
import io.github.hectorvent.floci.services.ecs.model.TaskSet; import io.github.hectorvent.floci.services.ecs.model.TaskSet;
import io.github.hectorvent.floci.services.elbv2.ElbV2Service; import io.github.hectorvent.floci.services.elbv2.ElbV2Service;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup; import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
import io.github.hectorvent.floci.services.elbv2.model.TargetHealth;
import io.github.hectorvent.floci.services.lambda.LambdaService; 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.s3.S3Service;
import io.github.hectorvent.floci.services.ssm.SsmCommandService; import io.github.hectorvent.floci.services.ssm.SsmCommandService;
import io.github.hectorvent.floci.services.lambda.model.InvocationType; import io.github.hectorvent.floci.services.lambda.model.InvocationType;
import io.github.hectorvent.floci.services.lambda.model.InvokeResult; import io.github.hectorvent.floci.services.lambda.model.InvokeResult;
@@ -29,6 +35,8 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
@@ -36,6 +44,8 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
@@ -54,7 +64,13 @@ public class CodeDeployService {
private static final Duration DEFAULT_HOOK_CALLBACK_TIMEOUT = Duration.ofHours(1); private static final Duration DEFAULT_HOOK_CALLBACK_TIMEOUT = Duration.ofHours(1);
private static final Duration STOP_POLL_INTERVAL = Duration.ofMillis(200); private static final Duration STOP_POLL_INTERVAL = Duration.ofMillis(200);
@FunctionalInterface
interface Sleeper {
void sleep(long millis) throws InterruptedException;
}
private final LambdaService lambdaService; private final LambdaService lambdaService;
private final S3Service s3Service;
private final EcsService ecsService; private final EcsService ecsService;
private final ElbV2Service elbV2Service; private final ElbV2Service elbV2Service;
private final SsmCommandService ssmCommandService; private final SsmCommandService ssmCommandService;
@@ -65,21 +81,48 @@ public class CodeDeployService {
private final StorageFactory storageFactory; private final StorageFactory storageFactory;
private final Duration hookCallbackTimeout; private final Duration hookCallbackTimeout;
private final Clock clock; private final Clock clock;
private final Sleeper sleeper;
@Inject @Inject
public CodeDeployService(LambdaService lambdaService, EcsService ecsService, public CodeDeployService(LambdaService lambdaService, EcsService ecsService,
ElbV2Service elbV2Service, SsmCommandService ssmCommandService, ElbV2Service elbV2Service, SsmCommandService ssmCommandService,
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver, Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
StorageFactory storageFactory) { StorageFactory storageFactory, S3Service s3Service) {
this(lambdaService, ecsService, elbV2Service, ssmCommandService, ec2Service, mapper, this(lambdaService, ecsService, elbV2Service, ssmCommandService, ec2Service, mapper,
regionResolver, storageFactory, DEFAULT_HOOK_CALLBACK_TIMEOUT, Clock.systemUTC()); regionResolver, storageFactory, DEFAULT_HOOK_CALLBACK_TIMEOUT, Clock.systemUTC(), s3Service, Thread::sleep);
} }
CodeDeployService(LambdaService lambdaService, EcsService ecsService, CodeDeployService(LambdaService lambdaService, EcsService ecsService,
ElbV2Service elbV2Service, SsmCommandService ssmCommandService, ElbV2Service elbV2Service, SsmCommandService ssmCommandService,
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver, Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
StorageFactory storageFactory, Duration hookCallbackTimeout, Clock clock) { StorageFactory storageFactory) {
this(lambdaService, ecsService, elbV2Service, ssmCommandService, ec2Service, mapper,
regionResolver, storageFactory, DEFAULT_HOOK_CALLBACK_TIMEOUT, Clock.systemUTC(), null, Thread::sleep);
}
CodeDeployService(LambdaService lambdaService, EcsService ecsService,
ElbV2Service elbV2Service, SsmCommandService ssmCommandService,
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
StorageFactory storageFactory, Duration hookCallbackTimeout, Clock clock) {
this(lambdaService, ecsService, elbV2Service, ssmCommandService, ec2Service, mapper,
regionResolver, storageFactory, hookCallbackTimeout, clock, null, Thread::sleep);
}
CodeDeployService(LambdaService lambdaService, EcsService ecsService,
ElbV2Service elbV2Service, SsmCommandService ssmCommandService,
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
StorageFactory storageFactory, Duration hookCallbackTimeout, Clock clock, S3Service s3Service) {
this(lambdaService, ecsService, elbV2Service, ssmCommandService, ec2Service, mapper,
regionResolver, storageFactory, hookCallbackTimeout, clock, s3Service, Thread::sleep);
}
CodeDeployService(LambdaService lambdaService, EcsService ecsService,
ElbV2Service elbV2Service, SsmCommandService ssmCommandService,
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
StorageFactory storageFactory, Duration hookCallbackTimeout, Clock clock, S3Service s3Service,
Sleeper sleeper) {
this.lambdaService = lambdaService; this.lambdaService = lambdaService;
this.s3Service = s3Service;
this.ecsService = ecsService; this.ecsService = ecsService;
this.elbV2Service = elbV2Service; this.elbV2Service = elbV2Service;
this.ssmCommandService = ssmCommandService; this.ssmCommandService = ssmCommandService;
@@ -90,6 +133,7 @@ public class CodeDeployService {
this.storageFactory = storageFactory; this.storageFactory = storageFactory;
this.hookCallbackTimeout = hookCallbackTimeout; this.hookCallbackTimeout = hookCallbackTimeout;
this.clock = clock; this.clock = clock;
this.sleeper = sleeper;
} }
// ---- Durable (persisted) ---- // ---- Durable (persisted) ----
@@ -109,8 +153,8 @@ public class CodeDeployService {
private final ConcurrentHashMap<String, ConcurrentHashMap<String, Deployment>> deployments = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, ConcurrentHashMap<String, Deployment>> deployments = new ConcurrentHashMap<>();
// key: region -> deploymentId -> targetId -> DeploymentTarget wrapper // key: region -> deploymentId -> targetId -> DeploymentTarget wrapper
private final ConcurrentHashMap<String, ConcurrentHashMap<String, ConcurrentHashMap<String, Map<String, Object>>>> deploymentTargets = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, ConcurrentHashMap<String, ConcurrentHashMap<String, Map<String, Object>>>> deploymentTargets = new ConcurrentHashMap<>();
// key: lifecycleEventHookExecutionId -> CompletableFuture<status> // key: lifecycleEventHookExecutionId -> the deployment event that owns the callback
private final ConcurrentHashMap<String, CompletableFuture<String>> hookFutures = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, HookExecution> hookExecutions = new ConcurrentHashMap<>();
// key: deploymentId -> stop flag // key: deploymentId -> stop flag
private final ConcurrentHashMap<String, AtomicBoolean> stopFlags = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, AtomicBoolean> stopFlags = new ConcurrentHashMap<>();
@@ -203,6 +247,28 @@ public class CodeDeployService {
String afterAllowTraffic; String afterAllowTraffic;
} }
private static final class HookExecution {
final String deploymentId;
final String lifecycleEventName;
final CompletableFuture<String> future;
HookExecution(String deploymentId, String lifecycleEventName, CompletableFuture<String> future) {
this.deploymentId = deploymentId;
this.lifecycleEventName = lifecycleEventName;
this.future = future;
}
}
private static final class AliasRoutingSnapshot {
final String functionVersion;
final Map<String, Double> routingConfig;
AliasRoutingSnapshot(LambdaAlias alias) {
this.functionVersion = alias.getFunctionVersion();
this.routingConfig = alias.getRoutingConfig() == null ? Map.of() : Map.copyOf(alias.getRoutingConfig());
}
}
private static final class ServerAppSpecInfo { private static final class ServerAppSpecInfo {
String os; String os;
Map<String, List<Map<String, Object>>> hooks; // hookName → [{location, timeout, runas}] Map<String, List<Map<String, Object>>> hooks; // hookName → [{location, timeout, runas}]
@@ -578,8 +644,10 @@ public class CodeDeployService {
return createServerDeployment(region, appName, groupName, group, configName, revision, description); return createServerDeployment(region, appName, groupName, group, configName, revision, description);
} }
AppSpecInfo appSpec = parseAppSpec(revision);
String effectiveConfig = configName != null ? configName : group.getDeploymentConfigName(); String effectiveConfig = configName != null ? configName : group.getDeploymentConfigName();
validateLambdaDeployment(region, app, group, effectiveConfig);
AppSpecInfo appSpec = parseAppSpec(revision);
validateLambdaTarget(region, appSpec);
String deploymentId = generateDeploymentId(); String deploymentId = generateDeploymentId();
double now = Instant.now().toEpochMilli() / 1000.0; double now = Instant.now().toEpochMilli() / 1000.0;
@@ -695,10 +763,16 @@ public class CodeDeployService {
} }
public String putLifecycleEventHookExecutionStatus(String deploymentId, String executionId, String status) { public String putLifecycleEventHookExecutionStatus(String deploymentId, String executionId, String status) {
CompletableFuture<String> future = hookFutures.get(executionId); if (!"Succeeded".equals(status) && !"Failed".equals(status)) {
if (future != null && !future.isDone()) { throw new AwsException("InvalidLifecycleEventHookExecutionStatusException",
future.complete(status); "Lifecycle event hook execution status must be Succeeded or Failed", 400);
} }
HookExecution execution = hookExecutions.get(executionId);
if (execution == null || !execution.deploymentId.equals(deploymentId) || execution.future.isDone()) {
throw new AwsException("InvalidLifecycleEventHookExecutionIdException",
"Lifecycle event hook execution ID is not valid for this deployment", 400);
}
execution.future.complete(status);
return executionId; return executionId;
} }
@@ -1063,12 +1137,17 @@ public class CodeDeployService {
for (Map<String, String> filter : ec2TagFilters) { for (Map<String, String> filter : ec2TagFilters) {
String key = filter.get("Key"); String key = filter.get("Key");
String value = filter.get("Value"); String value = filter.get("Value");
if (key != null && value != null) { String type = filter.get("Type");
if ("KEY_ONLY".equals(type) && key != null) {
filters.computeIfAbsent("tag-key", k -> new ArrayList<>()).add(key);
} else if ("VALUE_ONLY".equals(type) && value != null) {
filters.computeIfAbsent("tag-value", k -> new ArrayList<>()).add(value);
} else if (key != null && value != null) {
filters.computeIfAbsent("tag:" + key, k -> new ArrayList<>()).add(value); filters.computeIfAbsent("tag:" + key, k -> new ArrayList<>()).add(value);
} }
} }
try { try {
ec2Service.describeInstances(region, null, filters).stream() ec2Service.describeInstances(region, List.of(), filters).stream()
.flatMap(r -> r.getInstances().stream()) .flatMap(r -> r.getInstances().stream())
.map(inst -> inst.getInstanceId()) .map(inst -> inst.getInstanceId())
.filter(id -> id != null) .filter(id -> id != null)
@@ -1118,38 +1197,53 @@ public class CodeDeployService {
if (revision == null) { if (revision == null) {
throw new AwsException("InvalidRevisionException", "Revision is required", 400); throw new AwsException("InvalidRevisionException", "Revision is required", 400);
} }
Object appSpecContent = revision.get("appSpecContent"); String content = appSpecContent(revision);
if (!(appSpecContent instanceof Map)) {
throw new AwsException("InvalidRevisionException", "Missing appSpecContent in revision", 400);
}
String content = (String) ((Map<String, Object>) appSpecContent).get("content");
if (content == null || content.isBlank()) { if (content == null || content.isBlank()) {
throw new AwsException("InvalidRevisionException", "Missing content in appSpecContent", 400); throw new AwsException("InvalidRevisionException", "Missing content in appSpecContent", 400);
} }
AppSpecInfo info = new AppSpecInfo(); AppSpecInfo info = new AppSpecInfo();
try { try {
JsonNode root = mapper.readTree(content); JsonNode root = yamlMapper.readTree(content);
JsonNode resources = root.get("Resources"); if (!root.isObject() || !"0.0".equals(root.path("version").asText())) {
if (resources != null && resources.isArray() && !resources.isEmpty()) { throw new IllegalArgumentException("AppSpec version must be 0.0");
JsonNode firstResource = resources.get(0); }
if (firstResource.isObject()) { JsonNode resources = root.path("Resources");
JsonNode resourceNode = firstResource.fields().next().getValue(); if (!resources.isArray() || resources.size() != 1 || !resources.get(0).isObject()
JsonNode props = resourceNode.path("Properties"); || resources.get(0).size() != 1) {
info.functionName = props.path("Name").asText(null); throw new IllegalArgumentException("Lambda AppSpec must contain exactly one resource");
info.aliasName = props.path("Alias").asText(null); }
info.currentVersion = props.path("CurrentVersion").asText(null); JsonNode resourceNode = resources.get(0).elements().next();
info.targetVersion = props.path("TargetVersion").asText(null); if (!"AWS::Lambda::Function".equals(resourceNode.path("Type").asText())) {
} throw new IllegalArgumentException("Lambda AppSpec resource Type must be AWS::Lambda::Function");
}
JsonNode props = resourceNode.path("Properties");
info.functionName = requiredAppSpecText(props, "Name");
info.aliasName = requiredAppSpecText(props, "Alias");
info.currentVersion = requiredAppSpecText(props, "CurrentVersion");
info.targetVersion = requiredAppSpecText(props, "TargetVersion");
if (info.currentVersion.equals(info.targetVersion)) {
throw new IllegalArgumentException("CurrentVersion and TargetVersion must differ");
} }
JsonNode hooks = root.get("Hooks"); JsonNode hooks = root.get("Hooks");
if (hooks != null && hooks.isArray()) { if (hooks != null) {
if (!hooks.isArray()) {
throw new IllegalArgumentException("Hooks must be an array");
}
for (JsonNode hook : hooks) { for (JsonNode hook : hooks) {
if (hook.has("BeforeAllowTraffic")) { if (!hook.isObject() || hook.size() != 1) {
info.beforeAllowTraffic = hook.get("BeforeAllowTraffic").asText(null); throw new IllegalArgumentException("Each Hook must contain exactly one lifecycle event");
} }
if (hook.has("AfterAllowTraffic")) { Map.Entry<String, JsonNode> entry = hook.fields().next();
info.afterAllowTraffic = hook.get("AfterAllowTraffic").asText(null); String hookFunction = requiredAppSpecText(hook, entry.getKey());
if ("BeforeAllowTraffic".equals(entry.getKey())) {
if (info.beforeAllowTraffic != null) { throw new IllegalArgumentException("Duplicate BeforeAllowTraffic hook"); }
info.beforeAllowTraffic = hookFunction;
} else if ("AfterAllowTraffic".equals(entry.getKey())) {
if (info.afterAllowTraffic != null) { throw new IllegalArgumentException("Duplicate AfterAllowTraffic hook"); }
info.afterAllowTraffic = hookFunction;
} else {
throw new IllegalArgumentException("Unsupported Lambda lifecycle event: " + entry.getKey());
} }
} }
} }
@@ -1157,13 +1251,73 @@ public class CodeDeployService {
throw new AwsException("InvalidRevisionException", "Failed to parse AppSpec content: " + e.getMessage(), 400); throw new AwsException("InvalidRevisionException", "Failed to parse AppSpec content: " + e.getMessage(), 400);
} }
if (info.functionName == null || info.aliasName == null || info.targetVersion == null) {
throw new AwsException("InvalidRevisionException",
"AppSpec must specify Name, Alias, and TargetVersion", 400);
}
return info; return info;
} }
@SuppressWarnings("unchecked")
private String appSpecContent(Map<String, Object> revision) {
Object appSpecContent = revision.get("appSpecContent");
if (appSpecContent instanceof Map<?, ?> content) {
Object value = ((Map<String, Object>) content).get("content");
if (value instanceof String text && !text.isBlank()) {
return text;
}
}
Object s3Location = revision.get("s3Location");
if (!(s3Location instanceof Map<?, ?> location) || s3Service == null) {
throw new AwsException("InvalidRevisionException", "Missing AppSpec content in revision", 400);
}
String bucket = String.valueOf(location.get("bucket"));
String key = String.valueOf(location.get("key"));
byte[] artifact = s3Service.getObject(bucket, key).getData();
InputStream input = artifact == null ? InputStream.nullInputStream() : new ByteArrayInputStream(artifact);
try (ZipInputStream zip = new ZipInputStream(input)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
String name = entry.getName();
if (!entry.isDirectory() && ("appspec.yml".equalsIgnoreCase(name) || "appspec.yaml".equalsIgnoreCase(name))) {
return new String(zip.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
}
} catch (Exception e) {
throw new AwsException("InvalidRevisionException", "Could not read AppSpec from S3 revision: " + e.getMessage(), 400);
}
throw new AwsException("InvalidRevisionException", "S3 revision does not contain appspec.yml or appspec.yaml", 400);
}
private String requiredAppSpecText(JsonNode node, String field) {
JsonNode value = node.get(field);
if (value == null || !value.isTextual() || value.asText().isBlank()) {
throw new IllegalArgumentException("AppSpec must specify a non-empty " + field);
}
return value.asText();
}
private void validateLambdaDeployment(String region, Application app, DeploymentGroup group, String configName) {
if (!"Lambda".equals(app.getComputePlatform())) {
throw new AwsException("InvalidDeploymentConfigException", "Application compute platform must be Lambda", 400);
}
if (group.getComputePlatform() != null && !"Lambda".equals(group.getComputePlatform())) {
throw new AwsException("InvalidDeploymentConfigException", "Deployment group compute platform must be Lambda", 400);
}
DeploymentConfig config = getDeploymentConfig(region, configName);
if (!"Lambda".equals(config.getComputePlatform())) {
throw new AwsException("InvalidDeploymentConfigException", "Deployment configuration compute platform must be Lambda", 400);
}
}
private void validateLambdaTarget(String region, AppSpecInfo appSpec) {
LambdaAlias alias = lambdaService.getAlias(region, appSpec.functionName, appSpec.aliasName);
if (!appSpec.currentVersion.equals(alias.getFunctionVersion())) {
throw new AwsException("InvalidRevisionException", "AppSpec CurrentVersion does not match the alias version", 400);
}
List<String> versions = lambdaService.listVersionsByFunction(region, appSpec.functionName).stream()
.map(LambdaFunction::getVersion).toList();
if (!versions.contains(appSpec.currentVersion) || !versions.contains(appSpec.targetVersion)) {
throw new AwsException("InvalidRevisionException", "AppSpec version does not exist", 400);
}
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private EcsAppSpecInfo parseEcsAppSpec(Map<String, Object> revision) { private EcsAppSpecInfo parseEcsAppSpec(Map<String, Object> revision) {
if (revision == null) { if (revision == null) {
@@ -1180,7 +1334,13 @@ public class CodeDeployService {
EcsAppSpecInfo info = new EcsAppSpecInfo(); EcsAppSpecInfo info = new EcsAppSpecInfo();
try { try {
JsonNode root = mapper.readTree(content); JsonNode root;
try {
root = mapper.readTree(content);
} catch (Exception ignored) {
// CodePipeline's AppSpecTemplatePath conventionally points to YAML.
root = yamlMapper.readTree(content);
}
JsonNode resources = root.get("Resources"); JsonNode resources = root.get("Resources");
if (resources != null && resources.isArray() && !resources.isEmpty()) { if (resources != null && resources.isArray() && !resources.isEmpty()) {
JsonNode firstResource = resources.get(0); JsonNode firstResource = resources.get(0);
@@ -1367,6 +1527,21 @@ public class CodeDeployService {
// its startedBy, which is how a client tells a CodeDeploy set from an external one. // its startedBy, which is how a client tells a CodeDeploy set from an external one.
createTaskSet.setExternalId(deploymentId); createTaskSet.setExternalId(deploymentId);
createTaskSet.setStartedBy("CODE_DEPLOY"); createTaskSet.setStartedBy("CODE_DEPLOY");
EcsServiceModel targetService = ecsService.describeServices(clusterName, List.of(serviceName), region)
.getFirst();
if (targetService.getCapacityProviderStrategy() != null
&& !targetService.getCapacityProviderStrategy().isEmpty()) {
createTaskSet.setCapacityProviderStrategy(targetService.getCapacityProviderStrategy());
} else {
createTaskSet.setLaunchType(targetService.getLaunchType());
}
if (greenTgArn != null) {
EcsLoadBalancer loadBalancer = new EcsLoadBalancer();
loadBalancer.setTargetGroupArn(greenTgArn);
loadBalancer.setContainerName(appSpec.containerName);
loadBalancer.setContainerPort(appSpec.containerPort);
createTaskSet.setLoadBalancers(List.of(loadBalancer));
}
greenTaskSet = ecsService.createTaskSet(createTaskSet, region); greenTaskSet = ecsService.createTaskSet(createTaskSet, region);
appendTaskSetInfo(ecsTargetMap, greenTaskSet, greenTgArn, 0.0); appendTaskSetInfo(ecsTargetMap, greenTaskSet, greenTgArn, 0.0);
finishLifecycleEvent(installEvent, "Succeeded"); finishLifecycleEvent(installEvent, "Succeeded");
@@ -1406,6 +1581,8 @@ public class CodeDeployService {
// AllowTraffic: shift ELB traffic blue → green // AllowTraffic: shift ELB traffic blue → green
if (!listenerArns.isEmpty() && blueTgArn != null && greenTgArn != null) { if (!listenerArns.isEmpty() && blueTgArn != null && greenTgArn != null) {
waitForGreenTargets(region, greenTgArn, stopFlag);
if (stopFlag.get()) { finishEcsStopped(deployment, ecsTargetMap); return; }
executeEcsAllowTraffic(region, deployment, configName, ecsTargetMap, executeEcsAllowTraffic(region, deployment, configName, ecsTargetMap,
listenerArns, blueTgArn, greenTgArn, stopFlag); listenerArns, blueTgArn, greenTgArn, stopFlag);
} else { } else {
@@ -1460,10 +1637,24 @@ public class CodeDeployService {
} }
} }
private void executeEcsAllowTraffic(String region, Deployment deployment, String configName, /** A real target group is switched only once every registered green task is routable. */
Map<String, Object> ecsTargetMap, List<String> listenerArns, private void waitForGreenTargets(String region, String greenTgArn, AtomicBoolean stopFlag)
String blueTgArn, String greenTgArn, throws InterruptedException {
AtomicBoolean stopFlag) throws InterruptedException { while (!stopFlag.get()) {
List<TargetHealth> targets = elbV2Service.describeTargetHealth(region, greenTgArn, null);
// Mock ECS does not start containers or register target addresses. Preserve that control-plane
// behavior while Docker-backed deployments wait for the ELB health checker.
if (targets.isEmpty() || targets.stream().allMatch(target -> "healthy".equals(target.getState()))) {
return;
}
sleeper.sleep(STOP_POLL_INTERVAL.toMillis());
}
}
void executeEcsAllowTraffic(String region, Deployment deployment, String configName,
Map<String, Object> ecsTargetMap, List<String> listenerArns,
String blueTgArn, String greenTgArn,
AtomicBoolean stopFlag) throws InterruptedException {
TrafficRoutingInfo trc = getTrafficRoutingInfo(region, configName); TrafficRoutingInfo trc = getTrafficRoutingInfo(region, configName);
Map<String, Object> event = addLifecycleEvent(ecsTargetMap, "AllowTraffic"); Map<String, Object> event = addLifecycleEvent(ecsTargetMap, "AllowTraffic");
try { try {
@@ -1473,9 +1664,9 @@ public class CodeDeployService {
elbV2Service.shiftListenerForward(region, listenerArn, elbV2Service.shiftListenerForward(region, listenerArn,
blueTgArn, greenTgArn, trc.percentage); blueTgArn, greenTgArn, trc.percentage);
} }
long waitMs = Math.min(trc.intervalSeconds * 1000L, 5000L); long waitMs = trc.intervalSeconds * 1000L;
if (waitMs > 0 && !stopFlag.get()) { if (waitMs > 0 && !stopFlag.get()) {
Thread.sleep(waitMs); sleeper.sleep(waitMs);
} }
if (!stopFlag.get()) { if (!stopFlag.get()) {
for (String listenerArn : listenerArns) { for (String listenerArn : listenerArns) {
@@ -1493,9 +1684,9 @@ public class CodeDeployService {
blueTgArn, greenTgArn, pct); blueTgArn, greenTgArn, pct);
} }
if (pct < 100) { if (pct < 100) {
long waitMs = Math.min(trc.intervalSeconds * 1000L, 2000L); long waitMs = trc.intervalSeconds * 1000L;
if (waitMs > 0) { if (waitMs > 0) {
Thread.sleep(waitMs); sleeper.sleep(waitMs);
} }
} }
} }
@@ -1588,6 +1779,9 @@ public class CodeDeployService {
private void runStateMachine(String region, Deployment deployment, AppSpecInfo appSpec, private void runStateMachine(String region, Deployment deployment, AppSpecInfo appSpec,
Map<String, Object> lambdaTargetMap, AtomicBoolean stopFlag, String configName) { Map<String, Object> lambdaTargetMap, AtomicBoolean stopFlag, String configName) {
String deploymentId = deployment.getDeploymentId(); String deploymentId = deployment.getDeploymentId();
AliasRoutingSnapshot aliasSnapshot = new AliasRoutingSnapshot(
lambdaService.getAlias(region, appSpec.functionName, appSpec.aliasName));
boolean trafficShifted = false;
try { try {
deployment.setStatus("InProgress"); deployment.setStatus("InProgress");
deployment.setStartTime(Instant.now().toEpochMilli() / 1000.0); deployment.setStartTime(Instant.now().toEpochMilli() / 1000.0);
@@ -1605,16 +1799,30 @@ public class CodeDeployService {
if (stopFlag.get()) { finishStopped(deployment, lambdaTargetMap); return; } if (stopFlag.get()) { finishStopped(deployment, lambdaTargetMap); return; }
// Once AllowTraffic starts, a failed partial shift must be restored from the captured snapshot.
trafficShifted = true;
executeAllowTraffic(region, deployment, appSpec, configName, lambdaTargetMap, stopFlag); executeAllowTraffic(region, deployment, appSpec, configName, lambdaTargetMap, stopFlag);
if (stopFlag.get()) { finishStopped(deployment, lambdaTargetMap); return; } if (stopFlag.get()) {
rollbackAliasIfEnabled(region, deployment, appSpec, aliasSnapshot, "DEPLOYMENT_STOP_ON_REQUEST");
finishStopped(deployment, lambdaTargetMap);
return;
}
if (appSpec.afterAllowTraffic != null) { if (appSpec.afterAllowTraffic != null) {
boolean ok = invokeHook(region, deployment, appSpec.afterAllowTraffic, boolean ok = invokeHook(region, deployment, appSpec.afterAllowTraffic,
"AfterAllowTraffic", lambdaTargetMap, stopFlag); "AfterAllowTraffic", lambdaTargetMap, stopFlag);
if (stopFlag.get()) { finishStopped(deployment, lambdaTargetMap); return; } if (stopFlag.get()) {
if (!ok) { finishFailed(deployment, lambdaTargetMap, "HOOK_EXECUTION_FAILURE", rollbackAliasIfEnabled(region, deployment, appSpec, aliasSnapshot, "DEPLOYMENT_STOP_ON_REQUEST");
"Hook function reported failure: AfterAllowTraffic"); return; } finishStopped(deployment, lambdaTargetMap);
return;
}
if (!ok) {
rollbackAliasIfEnabled(region, deployment, appSpec, aliasSnapshot, "DEPLOYMENT_FAILURE");
finishFailed(deployment, lambdaTargetMap, "HOOK_EXECUTION_FAILURE",
"Hook function reported failure: AfterAllowTraffic");
return;
}
} }
updateTargetStatus(lambdaTargetMap, "Succeeded"); updateTargetStatus(lambdaTargetMap, "Succeeded");
@@ -1623,18 +1831,24 @@ public class CodeDeployService {
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
if (trafficShifted) {
rollbackAliasIfEnabled(region, deployment, appSpec, aliasSnapshot, "DEPLOYMENT_STOP_ON_REQUEST");
}
finishStopped(deployment, lambdaTargetMap); finishStopped(deployment, lambdaTargetMap);
} catch (Exception e) { } catch (Exception e) {
LOG.warnv("Deployment {0} failed: {1}", deploymentId, e.getMessage()); LOG.warnv("Deployment {0} failed: {1}", deploymentId, e.getMessage());
if (trafficShifted) {
rollbackAliasIfEnabled(region, deployment, appSpec, aliasSnapshot, "DEPLOYMENT_FAILURE");
}
finishFailed(deployment, lambdaTargetMap, "DeploymentFailed", e.getMessage()); finishFailed(deployment, lambdaTargetMap, "DeploymentFailed", e.getMessage());
} finally { } finally {
stopFlags.remove(deploymentId); stopFlags.remove(deploymentId);
} }
} }
private void executeAllowTraffic(String region, Deployment deployment, AppSpecInfo appSpec, private boolean executeAllowTraffic(String region, Deployment deployment, AppSpecInfo appSpec,
String configName, Map<String, Object> lambdaTargetMap, String configName, Map<String, Object> lambdaTargetMap,
AtomicBoolean stopFlag) throws InterruptedException { AtomicBoolean stopFlag) throws InterruptedException {
TrafficRoutingInfo trc = getTrafficRoutingInfo(region, configName); TrafficRoutingInfo trc = getTrafficRoutingInfo(region, configName);
Map<String, Object> event = addLifecycleEvent(lambdaTargetMap, "AllowTraffic"); Map<String, Object> event = addLifecycleEvent(lambdaTargetMap, "AllowTraffic");
@@ -1647,12 +1861,11 @@ public class CodeDeployService {
lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName, lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName,
appSpec.currentVersion, null, routing); appSpec.currentVersion, null, routing);
// Wait the canary interval (capped at 5s for emulator speed) long waitMs = trc.intervalSeconds * 1000L;
long waitMs = Math.min(trc.intervalSeconds * 1000L, 5000L);
if (waitMs > 0 && !stopFlag.get()) { if (waitMs > 0 && !stopFlag.get()) {
Thread.sleep(waitMs); sleeper.sleep(waitMs);
} }
if (stopFlag.get()) { return; } if (stopFlag.get()) { return true; }
// Step 2: flip 100% to targetVersion // Step 2: flip 100% to targetVersion
lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName, lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName,
@@ -1669,9 +1882,9 @@ public class CodeDeployService {
double weight = pct / 100.0; double weight = pct / 100.0;
lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName, lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName,
appSpec.currentVersion, null, Map.of(appSpec.targetVersion, weight)); appSpec.currentVersion, null, Map.of(appSpec.targetVersion, weight));
long waitMs = Math.min(trc.intervalSeconds * 1000L, 2000L); long waitMs = trc.intervalSeconds * 1000L;
if (waitMs > 0) { if (waitMs > 0) {
Thread.sleep(waitMs); sleeper.sleep(waitMs);
} }
} }
} }
@@ -1683,18 +1896,35 @@ public class CodeDeployService {
} }
} }
finishLifecycleEvent(event, "Succeeded"); finishLifecycleEvent(event, "Succeeded");
return true;
} catch (Exception e) { } catch (Exception e) {
finishLifecycleEvent(event, "Failed"); finishLifecycleEvent(event, "Failed");
throw e; throw e;
} }
} }
@SuppressWarnings("unchecked")
private void rollbackAliasIfEnabled(String region, Deployment deployment, AppSpecInfo appSpec,
AliasRoutingSnapshot snapshot, String event) {
DeploymentGroup group = getDeploymentGroup(region, deployment.getApplicationName(), deployment.getDeploymentGroupName());
Map<String, Object> config = group.getAutoRollbackConfiguration();
if (config == null || !Boolean.TRUE.equals(config.get("enabled"))) {
return;
}
Object events = config.get("events");
if (!(events instanceof List<?> list) || !list.contains(event)) {
return;
}
lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName,
snapshot.functionVersion, null, snapshot.routingConfig);
}
private boolean invokeHook(String region, Deployment deployment, String hookFunctionName, private boolean invokeHook(String region, Deployment deployment, String hookFunctionName,
String lifecycleEventName, Map<String, Object> lambdaTargetMap, String lifecycleEventName, Map<String, Object> lambdaTargetMap,
AtomicBoolean stopFlag) throws InterruptedException { AtomicBoolean stopFlag) throws InterruptedException {
String executionId = UUID.randomUUID().toString(); String executionId = UUID.randomUUID().toString();
CompletableFuture<String> future = new CompletableFuture<>(); CompletableFuture<String> future = new CompletableFuture<>();
hookFutures.put(executionId, future); hookExecutions.put(executionId, new HookExecution(deployment.getDeploymentId(), lifecycleEventName, future));
Map<String, Object> event = addLifecycleEvent(lambdaTargetMap, lifecycleEventName); Map<String, Object> event = addLifecycleEvent(lambdaTargetMap, lifecycleEventName);
@@ -1757,7 +1987,7 @@ public class CodeDeployService {
} }
return "Succeeded".equals(status); return "Succeeded".equals(status);
} finally { } finally {
hookFutures.remove(executionId); hookExecutions.remove(executionId);
} }
} }
@@ -12,6 +12,9 @@ import io.github.hectorvent.floci.core.storage.StorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.codebuild.CodeBuildService; import io.github.hectorvent.floci.services.codebuild.CodeBuildService;
import io.github.hectorvent.floci.services.codebuild.model.Build; import io.github.hectorvent.floci.services.codebuild.model.Build;
import io.github.hectorvent.floci.services.codebuild.model.ProjectArtifacts;
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.CodeDeployService;
import io.github.hectorvent.floci.services.codedeploy.model.Deployment; import io.github.hectorvent.floci.services.codedeploy.model.Deployment;
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelineExecution; import io.github.hectorvent.floci.services.codepipeline.model.CodePipelineExecution;
@@ -19,6 +22,9 @@ import io.github.hectorvent.floci.services.codepipeline.model.CodePipelineExecut
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelinePipeline; import io.github.hectorvent.floci.services.codepipeline.model.CodePipelinePipeline;
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelinePipeline.TransitionState; import io.github.hectorvent.floci.services.codepipeline.model.CodePipelinePipeline.TransitionState;
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelineStoredItem; import io.github.hectorvent.floci.services.codepipeline.model.CodePipelineStoredItem;
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.lambda.LambdaService;
import io.github.hectorvent.floci.services.lambda.model.InvocationType; import io.github.hectorvent.floci.services.lambda.model.InvocationType;
import io.github.hectorvent.floci.services.lambda.model.InvokeResult; import io.github.hectorvent.floci.services.lambda.model.InvokeResult;
@@ -30,6 +36,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
@@ -42,12 +49,15 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@ApplicationScoped @ApplicationScoped
public class CodePipelineService { public class CodePipelineService {
@@ -59,6 +69,8 @@ public class CodePipelineService {
private static final long SOURCE_POLL_INTERVAL_MS = 500L; private static final long SOURCE_POLL_INTERVAL_MS = 500L;
private static final String SOURCE_POLL_TYPE = "source-poll"; private static final String SOURCE_POLL_TYPE = "source-poll";
private static final String MISSING_SOURCE_REVISION = "missing"; private static final String MISSING_SOURCE_REVISION = "missing";
private static final int MAX_ARTIFACT_ENTRY_SIZE = 256 * 1024 * 1024;
private static final int MAX_EXTRACTED_ARTIFACT_SIZE = 256 * 1024 * 1024;
private final AccountAwareStorageBackend<CodePipelinePipeline> pipelineStore; private final AccountAwareStorageBackend<CodePipelinePipeline> pipelineStore;
private final AccountAwareStorageBackend<CodePipelineExecution> executionStore; private final AccountAwareStorageBackend<CodePipelineExecution> executionStore;
@@ -66,6 +78,8 @@ public class CodePipelineService {
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final CodeBuildService codeBuildService; private final CodeBuildService codeBuildService;
private final CodeDeployService codeDeployService; private final CodeDeployService codeDeployService;
private final EcsService ecsService;
private final CloudFormationService cloudFormationService;
private final LambdaService lambdaService; private final LambdaService lambdaService;
private final S3Service s3Service; private final S3Service s3Service;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
@@ -76,13 +90,13 @@ public class CodePipelineService {
// monitor for its whole run, so counting under that monitor would block StartPipelineExecution // monitor for its whole run, so counting under that monitor would block StartPipelineExecution
// until the running execution finished. // until the running execution finished.
private final KeyedLockPool startLocks = new KeyedLockPool(); private final KeyedLockPool startLocks = new KeyedLockPool();
private final Map<String, byte[]> runtimeArtifacts = new ConcurrentHashMap<>();
@Inject @Inject
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public CodePipelineService(StorageFactory storageFactory, ObjectMapper mapper, public CodePipelineService(StorageFactory storageFactory, ObjectMapper mapper,
CodeBuildService codeBuildService, CodeDeployService codeDeployService, CodeBuildService codeBuildService, CodeDeployService codeDeployService,
LambdaService lambdaService, S3Service s3Service) { LambdaService lambdaService, S3Service s3Service,
CloudFormationService cloudFormationService, EcsService ecsService) {
this.pipelineStore = storageFactory.create( this.pipelineStore = storageFactory.create(
"codepipeline", "codepipeline-pipelines.json", new TypeReference<Map<String, CodePipelinePipeline>>() {}); "codepipeline", "codepipeline-pipelines.json", new TypeReference<Map<String, CodePipelinePipeline>>() {});
this.executionStore = storageFactory.create( this.executionStore = storageFactory.create(
@@ -92,10 +106,20 @@ public class CodePipelineService {
this.mapper = mapper; this.mapper = mapper;
this.codeBuildService = codeBuildService; this.codeBuildService = codeBuildService;
this.codeDeployService = codeDeployService; this.codeDeployService = codeDeployService;
this.ecsService = ecsService;
this.cloudFormationService = cloudFormationService;
this.lambdaService = lambdaService; this.lambdaService = lambdaService;
this.s3Service = s3Service; this.s3Service = s3Service;
} }
CodePipelineService(StorageFactory storageFactory, ObjectMapper mapper,
CodeBuildService codeBuildService, CodeDeployService codeDeployService,
LambdaService lambdaService, S3Service s3Service,
CloudFormationService cloudFormationService) {
this(storageFactory, mapper, codeBuildService, codeDeployService, lambdaService, s3Service,
cloudFormationService, null);
}
public JsonNode handle(String action, JsonNode request, String region, String account) { public JsonNode handle(String action, JsonNode request, String region, String account) {
return switch (action) { return switch (action) {
case "CreatePipeline" -> createPipeline(request, region, account); case "CreatePipeline" -> createPipeline(request, region, account);
@@ -745,22 +769,82 @@ public class CodePipelineService {
String pipelineName = text(request, "pipelineName"); String pipelineName = text(request, "pipelineName");
CodePipelineExecution source = requireExecution( CodePipelineExecution source = requireExecution(
account, region, pipelineName, text(request, "pipelineExecutionId")); account, region, pipelineName, text(request, "pipelineExecutionId"));
ObjectNode start = mapper.createObjectNode(); CodePipelinePipeline pipeline = requirePipeline(account, region, pipelineName);
start.put("name", pipelineName); String stageName = text(request, "stageName");
start.set("sourceRevisions", mapper.valueToTree(source.getSourceRevisions())); String retryMode = text(request, "retryMode");
ArrayNode vars = start.putArray("variables"); requireStage(pipeline, stageName);
source.getVariables().forEach(v -> vars.addObject() if (!List.of("FAILED_ACTIONS", "ALL_ACTIONS").contains(retryMode)) {
.put("name", v.get("name")) throw new AwsException("ValidationException", "retryMode must be FAILED_ACTIONS or ALL_ACTIONS", 400);
.put("value", v.get("resolvedValue"))); }
return startPipelineExecution(start, region, account); if (!"Failed".equals(stageExecutionStatus(source, stageName))) {
throw new AwsException("PipelineExecutionNotRetryableException",
"Stage execution is not failed: " + stageName, 400);
}
List<String> actionNames = new ArrayList<>();
for (JsonNode stage : pipeline.getDeclaration().path("stages")) {
if (!stageName.equals(stage.path("name").asText())) {
continue;
}
for (JsonNode action : stage.path("actions")) {
String actionName = action.path("name").asText();
if ("ALL_ACTIONS".equals(retryMode) || source.getActionExecutions().stream()
.anyMatch(a -> stageName.equals(a.getStageName()) && actionName.equals(a.getActionName())
&& "Failed".equals(a.getStatus()))) {
actionNames.add(actionName);
}
}
}
if (actionNames.isEmpty()) {
throw new AwsException("PipelineExecutionNotRetryableException",
"Stage execution has no failed actions: " + stageName, 400);
}
CodePipelineExecution retry = new CodePipelineExecution();
retry.setAccountId(account);
retry.setRegion(region);
retry.setPipelineExecutionId(UUID.randomUUID().toString());
retry.setPipelineName(pipelineName);
retry.setPipelineVersion(pipeline.getVersion());
retry.setExecutionMode(pipeline.getDeclaration().path("executionMode").asText(DEFAULT_EXECUTION_MODE));
retry.setExecutionType("STANDARD");
retry.setStatus("InProgress");
retry.setStatusSummary("Stage execution retry started.");
retry.setStartTime(now());
retry.setLastUpdateTime(retry.getStartTime());
retry.setSourceRevisions(new ArrayList<>(source.getSourceRevisions()));
retry.setVariables(new ArrayList<>(source.getVariables()));
retry.setArtifacts(new LinkedHashMap<>(source.getArtifacts()));
retry.setRetryStageName(stageName);
retry.setRetryActionNames(actionNames);
retry.setTrigger(Map.of("triggerType", "RetryStageExecution", "triggerDetail", stageName));
if (!persistExecutionIfSlotAvailable(retry)) {
throw new AwsException("ConcurrentPipelineExecutionsLimitExceededException",
"The pipeline has reached the limit for concurrent pipeline executions", 400);
}
applyExecutionMode(retry);
try {
executor.submit(() -> runExecution(pipeline, retry));
} catch (RejectedExecutionException exception) {
retry.setStatus("Failed");
retry.setStatusSummary("Pipeline execution could not be scheduled.");
retry.setLastUpdateTime(now());
putExecution(retry);
throw new AwsException("ConflictException", "Pipeline execution could not be scheduled.", 400);
}
return mapper.createObjectNode().put("pipelineExecutionId", retry.getPipelineExecutionId());
} }
private ObjectNode rollbackStage(JsonNode request, String region, String account) { private ObjectNode rollbackStage(JsonNode request, String region, String account) {
CodePipelineExecution target = requireExecution( CodePipelineExecution target = requireExecution(
account, region, text(request, "pipelineName"), text(request, "targetPipelineExecutionId")); account, region, text(request, "pipelineName"), text(request, "targetPipelineExecutionId"));
ObjectNode started = retryStageExecution(mapper.createObjectNode() ObjectNode start = mapper.createObjectNode().put("name", target.getPipelineName());
.put("pipelineName", target.getPipelineName()) start.set("sourceRevisions", mapper.valueToTree(target.getSourceRevisions()));
.put("pipelineExecutionId", target.getPipelineExecutionId()), region, account); ArrayNode vars = start.putArray("variables");
target.getVariables().forEach(v -> vars.addObject()
.put("name", v.get("name"))
.put("value", v.get("resolvedValue")));
ObjectNode started = startPipelineExecution(start, region, account);
CodePipelineExecution rollback = requireExecution( CodePipelineExecution rollback = requireExecution(
account, region, target.getPipelineName(), started.path("pipelineExecutionId").asText()); account, region, target.getPipelineName(), started.path("pipelineExecutionId").asText());
rollback.setExecutionType("ROLLBACK"); rollback.setExecutionType("ROLLBACK");
@@ -977,6 +1061,10 @@ public class CodePipelineService {
Runnable work = () -> { Runnable work = () -> {
try { try {
for (JsonNode stage : pipeline.getDeclaration().path("stages")) { for (JsonNode stage : pipeline.getDeclaration().path("stages")) {
if (execution.getRetryStageName() != null
&& !execution.getRetryStageName().equals(stage.path("name").asText())) {
continue;
}
waitForTransition(pipeline, execution, stage.path("name").asText()); waitForTransition(pipeline, execution, stage.path("name").asText());
if (finishIfStopped(execution)) { if (finishIfStopped(execution)) {
return; return;
@@ -986,7 +1074,7 @@ public class CodePipelineService {
execution.getStageExecutionStatuses().put(stageName, "InProgress"); execution.getStageExecutionStatuses().put(stageName, "InProgress");
execution.setLastUpdateTime(now()); execution.setLastUpdateTime(now());
putExecution(execution); putExecution(execution);
runStage(pipeline, execution, stage); runStage(pipeline, execution, stage, execution.getRetryActionNames());
if ("Failed".equals(execution.getStatus())) { if ("Failed".equals(execution.getStatus())) {
execution.getStageExecutionStatuses().put(stageName, "Failed"); execution.getStageExecutionStatuses().put(stageName, "Failed");
return; return;
@@ -1012,7 +1100,6 @@ public class CodePipelineService {
execution.setCurrentStage(null); execution.setCurrentStage(null);
execution.setLastUpdateTime(now()); execution.setLastUpdateTime(now());
putExecution(execution); putExecution(execution);
clearRuntimeArtifacts(execution);
} }
}; };
if ("QUEUED".equals(execution.getExecutionMode())) { if ("QUEUED".equals(execution.getExecutionMode())) {
@@ -1022,9 +1109,14 @@ public class CodePipelineService {
} }
} }
private void runStage(CodePipelinePipeline pipeline, CodePipelineExecution execution, JsonNode stage) { private void runStage(CodePipelinePipeline pipeline, CodePipelineExecution execution, JsonNode stage,
List<String> retryActionNames) {
Map<Integer, List<JsonNode>> groups = new LinkedHashMap<>(); Map<Integer, List<JsonNode>> groups = new LinkedHashMap<>();
for (JsonNode action : stage.path("actions")) { for (JsonNode action : stage.path("actions")) {
if (execution.getRetryStageName() != null
&& !retryActionNames.contains(action.path("name").asText())) {
continue;
}
groups.computeIfAbsent(action.path("runOrder").asInt(1), ignored -> new ArrayList<>()).add(action); groups.computeIfAbsent(action.path("runOrder").asInt(1), ignored -> new ArrayList<>()).add(action);
} }
groups.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> { groups.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
@@ -1098,7 +1190,9 @@ public class CodePipelineService {
switch (provider) { switch (provider) {
case "S3" -> executeS3(execution, action, state); case "S3" -> executeS3(execution, action, state);
case "CodeBuild" -> executeCodeBuild(execution, action, state); case "CodeBuild" -> executeCodeBuild(execution, action, state);
case "CodeDeploy" -> executeCodeDeploy(execution, action, state); case "CodeDeploy" -> executeCodeDeploy(pipeline, execution, action, state);
case "CodeDeployToECS" -> executeCodeDeployToEcs(execution, action, state);
case "CloudFormation" -> executeCloudFormation(pipeline, execution, action, state);
case "Lambda" -> executeLambda(execution, action, state); case "Lambda" -> executeLambda(execution, action, state);
case "CodePipeline" -> executeNestedPipeline(execution, action, state); case "CodePipeline" -> executeNestedPipeline(execution, action, state);
default -> throw new AwsException("InvalidActionDeclarationException", default -> throw new AwsException("InvalidActionDeclarationException",
@@ -1113,7 +1207,7 @@ public class CodePipelineService {
String key = config.path("S3ObjectKey").asText(config.path("ObjectKey").asText(null)); String key = config.path("S3ObjectKey").asText(config.path("ObjectKey").asText(null));
S3Object object = s3Service.getObject(bucket, key); S3Object object = s3Service.getObject(bucket, key);
for (JsonNode artifact : action.path("outputArtifacts")) { for (JsonNode artifact : action.path("outputArtifacts")) {
runtimeArtifacts.put(artifactKey(execution, artifact.path("name").asText()), object.getData()); execution.getArtifacts().put(artifact.path("name").asText(), object.getData());
} }
Map<String, Object> revision = new LinkedHashMap<>(); Map<String, Object> revision = new LinkedHashMap<>();
revision.put("name", state.getActionName()); revision.put("name", state.getActionName());
@@ -1129,7 +1223,7 @@ public class CodePipelineService {
String bucket = config.path("BucketName").asText(config.path("S3Bucket").asText(null)); String bucket = config.path("BucketName").asText(config.path("S3Bucket").asText(null));
String objectKey = config.path("ObjectKey").asText(state.getActionName() + ".zip"); String objectKey = config.path("ObjectKey").asText(state.getActionName() + ".zip");
JsonNode input = action.path("inputArtifacts").path(0); JsonNode input = action.path("inputArtifacts").path(0);
byte[] data = runtimeArtifacts.get(artifactKey(execution, input.path("name").asText())); byte[] data = execution.getArtifacts().get(input.path("name").asText());
if (data == null) { if (data == null) {
throw new AwsException("InvalidJobStateException", "Input artifact is not available", 400); throw new AwsException("InvalidJobStateException", "Input artifact is not available", 400);
} }
@@ -1139,9 +1233,14 @@ public class CodePipelineService {
private void executeCodeBuild(CodePipelineExecution execution, JsonNode action, private void executeCodeBuild(CodePipelineExecution execution, JsonNode action,
ActionExecution state) throws InterruptedException { ActionExecution state) throws InterruptedException {
for (JsonNode artifact : action.path("outputArtifacts")) {
execution.getArtifacts().remove(artifact.path("name").asText());
}
String projectName = action.path("configuration").path("ProjectName").asText(null); String projectName = action.path("configuration").path("ProjectName").asText(null);
ProjectArtifacts artifactsOverride = new ProjectArtifacts();
artifactsOverride.setType("CODEPIPELINE");
Build build = codeBuildService.startBuild(execution.getRegion(), execution.getAccountId(), projectName, Build build = codeBuildService.startBuild(execution.getRegion(), execution.getAccountId(), projectName,
null, null, null, null, null, null, null); null, null, artifactsOverride, null, null, null, null);
state.setExternalExecutionId(build.getId()); state.setExternalExecutionId(build.getId());
while (!Boolean.TRUE.equals(build.getBuildComplete())) { while (!Boolean.TRUE.equals(build.getBuildComplete())) {
if (abandonExternalActionIfRequested(execution, state)) { if (abandonExternalActionIfRequested(execution, state)) {
@@ -1160,16 +1259,38 @@ public class CodePipelineService {
} }
throw new AwsException("ActionExecutionFailed", message, 400); throw new AwsException("ActionExecutionFailed", message, 400);
} }
if (!action.path("outputArtifacts").isEmpty()) {
byte[] output = codeBuildService.getBuildOutputArtifact(
execution.getRegion(), execution.getAccountId(), build.getId());
if (output == null) {
throw new AwsException("ActionExecutionFailed",
"CodeBuild build " + build.getId() + " did not produce an output artifact", 400);
}
for (JsonNode artifact : action.path("outputArtifacts")) {
execution.getArtifacts().put(artifact.path("name").asText(), output);
}
}
} }
private void executeCodeDeploy(CodePipelineExecution execution, JsonNode action, private void executeCodeDeploy(CodePipelinePipeline pipeline, CodePipelineExecution execution, JsonNode action,
ActionExecution state) throws InterruptedException { ActionExecution state) throws InterruptedException {
JsonNode config = action.path("configuration"); JsonNode config = action.path("configuration");
Map<String, Object> revision = null; Map<String, Object> revision = null;
JsonNode input = action.path("inputArtifacts").path(0); JsonNode input = action.path("inputArtifacts").path(0);
if (!input.isMissingNode()) { if (!input.isMissingNode()) {
revision = Map.of("revisionType", "S3", String artifactName = input.path("name").asText();
"s3Location", Map.of("bucket", artifactBucket(action), "key", input.path("name").asText())); byte[] artifact = execution.getArtifacts().get(artifactName);
if (artifact == null) {
throw new AwsException("InvalidJobStateException", "Input artifact is not available", 400);
}
String bucket = pipeline.getDeclaration().path("artifactStore").path("location").asText(null);
if (bucket == null || bucket.isBlank()) {
throw new AwsException("InvalidActionDeclarationException", "Pipeline artifact store is required", 400);
}
String key = execution.getPipelineExecutionId() + "/" + state.getStageName() + "/"
+ state.getActionName() + "/" + artifactName + ".zip";
s3Service.putObject(bucket, key, artifact, "application/zip", Map.of());
revision = Map.of("revisionType", "S3", "s3Location", Map.of("bucket", bucket, "key", key));
} }
String deploymentId = codeDeployService.createDeployment( String deploymentId = codeDeployService.createDeployment(
execution.getRegion(), config.path("ApplicationName").asText(), execution.getRegion(), config.path("ApplicationName").asText(),
@@ -1195,6 +1316,370 @@ public class CodePipelineService {
} }
} }
private void executeCodeDeployToEcs(CodePipelineExecution execution, JsonNode action,
ActionExecution state) throws InterruptedException {
if (ecsService == null) {
throw new AwsException("InvalidActionDeclarationException",
"CodeDeployToECS is not available", 400);
}
JsonNode config = action.path("configuration");
String applicationName = requiredCodeDeployToEcsConfiguration(config, "ApplicationName");
String deploymentGroupName = requiredCodeDeployToEcsConfiguration(config, "DeploymentGroupName");
String taskDefinitionPath = requiredCodeDeployToEcsConfiguration(config, "TaskDefinitionTemplatePath");
String appSpecPath = requiredCodeDeployToEcsConfiguration(config, "AppSpecTemplatePath");
String taskDefinitionTemplate = readCodeDeployToEcsTemplate(execution, action, taskDefinitionPath);
String appSpecTemplate = readCodeDeployToEcsTemplate(execution, action, appSpecPath);
RegisterTaskDefinitionRequest taskDefinitionRequest;
try {
taskDefinitionRequest = mapper.readValue(taskDefinitionTemplate, RegisterTaskDefinitionRequest.class);
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"TaskDefinitionTemplatePath must reference a valid ECS task definition JSON template", 400);
}
if (taskDefinitionRequest.getFamily() == null || taskDefinitionRequest.getFamily().isBlank()) {
throw new AwsException("InvalidActionDeclarationException",
"Task definition template must specify family", 400);
}
TaskDefinition taskDefinition = ecsService.registerTaskDefinition(taskDefinitionRequest, execution.getRegion());
String appSpec = appSpecTemplate.replace("<TASK_DEFINITION>", taskDefinition.getTaskDefinitionArn());
Map<String, Object> revision = Map.of("revisionType", "AppSpecContent",
"appSpecContent", Map.of("content", appSpec));
String deploymentId = codeDeployService.createDeployment(execution.getRegion(), applicationName,
deploymentGroupName, null, revision, "CodePipeline execution");
state.setExternalExecutionId(deploymentId);
waitForCodeDeployDeployment(execution, state, deploymentId);
}
private String readCodeDeployToEcsTemplate(CodePipelineExecution execution, JsonNode action, String reference) {
int separator = reference.indexOf("::");
if (separator <= 0 || separator == reference.length() - 2 || reference.indexOf("::", separator + 2) >= 0) {
throw new AwsException("InvalidActionDeclarationException",
"CodeDeployToECS template paths must use ArtifactName::TemplateFilePath", 400);
}
String artifactName = reference.substring(0, separator);
boolean declaredInput = false;
for (JsonNode input : action.path("inputArtifacts")) {
if (artifactName.equals(input.path("name").asText())) {
declaredInput = true;
break;
}
}
if (!declaredInput) {
throw new AwsException("InvalidActionDeclarationException",
"CodeDeployToECS template references an unavailable input artifact: " + artifactName, 400);
}
byte[] artifact = execution.getArtifacts().get(artifactName);
if (artifact == null) {
throw new AwsException("InvalidJobStateException", "Input artifact is not available: " + artifactName, 400);
}
return readTemplateFromArtifact(artifact, reference.substring(separator + 2));
}
private static String requiredCodeDeployToEcsConfiguration(JsonNode config, String field) {
String value = config.path(field).asText(null);
if (value == null || value.isBlank()) {
throw new AwsException("InvalidActionDeclarationException",
"CodeDeployToECS " + field + " is required", 400);
}
return value;
}
private void waitForCodeDeployDeployment(CodePipelineExecution execution, ActionExecution state, String deploymentId)
throws InterruptedException {
Deployment deployment = codeDeployService.getDeployment(execution.getRegion(), deploymentId);
while (!List.of("Succeeded", "Failed", "Stopped").contains(deployment.getStatus())) {
if (abandonExternalActionIfRequested(execution, state)) {
return;
}
TimeUnit.MILLISECONDS.sleep(POLL_INTERVAL_MS);
deployment = codeDeployService.getDeployment(execution.getRegion(), deploymentId);
}
if (abandonExternalActionIfRequested(execution, state)) {
return;
}
if (!"Succeeded".equals(deployment.getStatus())) {
String message = "CodeDeploy deployment finished with " + deployment.getStatus();
if (recordExternalActionFailureWhileStopping(execution, state, message)) {
return;
}
throw new AwsException("ActionExecutionFailed", message, 400);
}
}
private void executeCloudFormation(CodePipelinePipeline pipeline, CodePipelineExecution execution,
JsonNode action, ActionExecution state) throws InterruptedException {
JsonNode config = action.path("configuration");
String stackName = requiredActionConfiguration(config, "StackName");
String actionMode = requiredActionConfiguration(config, "ActionMode");
if (!"CREATE_UPDATE".equals(actionMode)) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation ActionMode must be CREATE_UPDATE", 400);
}
String templatePath = requiredActionConfiguration(config, "TemplatePath");
int separator = templatePath.indexOf("::");
if (separator <= 0 || separator == templatePath.length() - 2
|| templatePath.indexOf("::", separator + 2) >= 0) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation TemplatePath must use ArtifactName::TemplateFilePath", 400);
}
String artifactName = templatePath.substring(0, separator);
byte[] artifact = execution.getArtifacts().get(artifactName);
if (artifact == null) {
throw new AwsException("InvalidJobStateException", "Input artifact is not available: " + artifactName, 400);
}
String bucket = pipeline.getDeclaration().path("artifactStore").path("location").asText(null);
if (bucket == null || bucket.isBlank()) {
throw new AwsException("InvalidActionDeclarationException", "Pipeline artifact store is required", 400);
}
Map<String, ArtifactLocation> artifactLocations = materializeCloudFormationArtifacts(
execution, action, state, bucket);
String templateBody = readTemplateFromArtifact(artifact, templatePath.substring(separator + 2));
boolean exists;
try {
cloudFormationService.describeStacks(stackName, execution.getRegion(), execution.getAccountId());
exists = true;
} catch (AwsException exception) {
if (!"ValidationError".equals(exception.getErrorCode())) {
throw exception;
}
exists = false;
}
String changeSetName = "codepipeline-" + UUID.randomUUID().toString().substring(0, 8);
String changeSetType = exists ? "UPDATE" : "CREATE";
cloudFormationService.createChangeSet(stackName, changeSetName, changeSetType, templateBody, null,
resolveParameterOverrides(config, execution, action, artifactLocations), List.of(), Map.of(),
execution.getRegion(), execution.getAccountId());
Future<?> deployment = cloudFormationService.executeChangeSet(
stackName, changeSetName, execution.getRegion(), execution.getAccountId());
try {
deployment.get();
} catch (ExecutionException exception) {
Throwable cause = exception.getCause();
if (cause instanceof AwsException awsException) {
throw awsException;
}
throw new AwsException("ActionExecutionFailed", "CloudFormation deployment failed: "
+ Objects.toString(cause.getMessage(), cause.getClass().getSimpleName()), 400);
}
Stack stack = cloudFormationService.describeStacks(stackName, execution.getRegion(), execution.getAccountId()).get(0);
if (!List.of("CREATE_COMPLETE", "UPDATE_COMPLETE").contains(stack.getStatus())) {
throw new AwsException("ActionExecutionFailed", "CloudFormation stack " + stackName
+ " finished with " + stack.getStatus(), 400);
}
state.setExternalExecutionId(stack.getStackId());
}
private static String requiredActionConfiguration(JsonNode config, String field) {
String value = config.path(field).asText(null);
if (value == null || value.isBlank()) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation " + field + " is required", 400);
}
return value;
}
private Map<String, ArtifactLocation> materializeCloudFormationArtifacts(CodePipelineExecution execution,
JsonNode action,
ActionExecution state,
String bucket) {
Map<String, ArtifactLocation> locations = new LinkedHashMap<>();
for (JsonNode input : action.path("inputArtifacts")) {
String artifactName = input.path("name").asText();
byte[] artifact = execution.getArtifacts().get(artifactName);
if (artifact == null) {
throw new AwsException("InvalidJobStateException",
"Input artifact is not available: " + artifactName, 400);
}
String prefix = execution.getPipelineExecutionId() + "/" + state.getStageName() + "/"
+ state.getActionName() + "/" + artifactName;
String archiveKey = prefix + ".zip";
s3Service.putObject(bucket, archiveKey, artifact, "application/zip", Map.of());
List<ArtifactLocation> nestedArtifacts = extractArtifactEntries(bucket, prefix, artifact);
ArtifactLocation location = nestedArtifacts.size() == 1 && nestedArtifacts.getFirst().key().endsWith(".zip")
? nestedArtifacts.getFirst() : new ArtifactLocation(bucket, archiveKey);
locations.put(artifactName, location);
}
return locations;
}
private List<ArtifactLocation> extractArtifactEntries(String bucket, String prefix, byte[] artifact) {
List<ArtifactLocation> nestedArtifacts = new ArrayList<>();
int extractedSize = 0;
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(artifact))) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
String entryName = entry.getName();
validateArtifactEntryName(entryName);
byte[] data = zip.readNBytes(MAX_ARTIFACT_ENTRY_SIZE + 1);
if (data.length > MAX_ARTIFACT_ENTRY_SIZE) {
throw new AwsException("InvalidActionDeclarationException",
"Artifact entry exceeds 256 MB: " + entryName, 400);
}
if (data.length > MAX_EXTRACTED_ARTIFACT_SIZE - extractedSize) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation input artifact expands beyond 256 MB", 400);
}
extractedSize += data.length;
String key = prefix + "/" + entryName;
s3Service.putObject(bucket, key, data, "application/octet-stream", Map.of());
nestedArtifacts.add(new ArtifactLocation(bucket, key));
}
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"Could not read CloudFormation input artifact", 400);
}
return nestedArtifacts;
}
private static void validateArtifactEntryName(String entryName) {
if (entryName.startsWith("/") || entryName.startsWith("\\") || entryName.contains("\\")) {
throw new AwsException("InvalidActionDeclarationException", "Artifact entry path is not safe", 400);
}
for (String segment : entryName.split("/")) {
if (segment.isBlank() || ".".equals(segment) || "..".equals(segment)) {
throw new AwsException("InvalidActionDeclarationException", "Artifact entry path is not safe", 400);
}
}
}
private Map<String, String> resolveParameterOverrides(JsonNode config, CodePipelineExecution execution,
JsonNode action,
Map<String, ArtifactLocation> artifactLocations) {
String overrides = config.path("ParameterOverrides").asText(null);
if (overrides == null || overrides.isBlank()) {
return Map.of();
}
try {
JsonNode values = mapper.readTree(overrides);
if (!values.isObject()) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation ParameterOverrides must be a JSON object", 400);
}
Map<String, String> resolved = new LinkedHashMap<>();
values.fields().forEachRemaining(entry -> resolved.put(entry.getKey(),
resolveParameterOverride(entry.getValue(), execution, action, artifactLocations)));
return resolved;
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation ParameterOverrides must be valid JSON", 400);
}
}
private String resolveParameterOverride(JsonNode value, CodePipelineExecution execution, JsonNode action,
Map<String, ArtifactLocation> artifactLocations) {
if (value.isTextual() || value.isNumber() || value.isBoolean()) {
return value.asText();
}
if (!value.isObject()) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation parameter override must be a scalar or supported function", 400);
}
JsonNode artifactAtt = value.path("Fn::GetArtifactAtt");
if (!artifactAtt.isMissingNode()) {
List<String> arguments = functionArguments(artifactAtt, 2, "Fn::GetArtifactAtt");
ArtifactLocation location = requireArtifactLocation(arguments.getFirst(), action, artifactLocations);
return switch (arguments.get(1)) {
case "BucketName" -> location.bucket();
case "ObjectKey" -> location.key();
case "URL" -> "https://s3." + execution.getRegion() + ".amazonaws.com/"
+ location.bucket() + "/" + location.key();
default -> throw new AwsException("InvalidActionDeclarationException",
"Fn::GetArtifactAtt attribute is not supported: " + arguments.get(1), 400);
};
}
JsonNode getParam = value.path("Fn::GetParam");
if (!getParam.isMissingNode()) {
List<String> arguments = functionArguments(getParam, 3, "Fn::GetParam");
requireArtifactLocation(arguments.getFirst(), action, artifactLocations);
byte[] artifact = execution.getArtifacts().get(arguments.getFirst());
String contents = readTemplateFromArtifact(artifact, arguments.get(1));
try {
JsonNode parameters = mapper.readTree(contents);
JsonNode parameter = parameters.path(arguments.get(2));
if (!parameters.isObject() || parameter.isMissingNode() || !parameter.isValueNode()) {
throw new AwsException("InvalidActionDeclarationException",
"Fn::GetParam could not resolve " + arguments.get(2), 400);
}
return parameter.asText();
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"Fn::GetParam file must contain a JSON object", 400);
}
}
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation parameter override uses an unsupported function", 400);
}
private static List<String> functionArguments(JsonNode arguments, int expectedCount, String functionName) {
if (!arguments.isArray() || arguments.size() != expectedCount) {
throw new AwsException("InvalidActionDeclarationException",
functionName + " requires " + expectedCount + " string arguments", 400);
}
List<String> values = new ArrayList<>();
for (JsonNode argument : arguments) {
if (!argument.isTextual() || argument.asText().isBlank()) {
throw new AwsException("InvalidActionDeclarationException",
functionName + " requires " + expectedCount + " string arguments", 400);
}
values.add(argument.asText());
}
return values;
}
private static ArtifactLocation requireArtifactLocation(String artifactName, JsonNode action,
Map<String, ArtifactLocation> artifactLocations) {
boolean isInputArtifact = false;
for (JsonNode input : action.path("inputArtifacts")) {
if (artifactName.equals(input.path("name").asText())) {
isInputArtifact = true;
break;
}
}
ArtifactLocation location = artifactLocations.get(artifactName);
if (!isInputArtifact || location == null) {
throw new AwsException("InvalidActionDeclarationException",
"Parameter override references an unavailable input artifact: " + artifactName, 400);
}
return location;
}
private static String readTemplateFromArtifact(byte[] artifact, String templatePath) {
if (templatePath.startsWith("/") || templatePath.startsWith("\\") || templatePath.contains("\\")) {
throw new AwsException("InvalidActionDeclarationException", "TemplatePath is not safe", 400);
}
for (String segment : templatePath.split("/")) {
if (segment.isBlank() || ".".equals(segment) || "..".equals(segment)) {
throw new AwsException("InvalidActionDeclarationException", "TemplatePath is not safe", 400);
}
}
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(artifact))) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (!entry.isDirectory() && templatePath.equals(entry.getName())) {
byte[] template = zip.readNBytes(51_201);
if (template.length > 51_200) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation template exceeds 51200 bytes", 400);
}
return new String(template, StandardCharsets.UTF_8);
}
}
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"Could not read CloudFormation template from input artifact", 400);
}
throw new AwsException("InvalidActionDeclarationException",
"TemplatePath does not exist in the input artifact", 400);
}
private record ArtifactLocation(String bucket, String key) {
}
private boolean abandonExternalActionIfRequested(CodePipelineExecution execution, ActionExecution state) { private boolean abandonExternalActionIfRequested(CodePipelineExecution execution, ActionExecution state) {
if (!execution.isStopRequested() || !execution.isAbandon()) { if (!execution.isStopRequested() || !execution.isAbandon()) {
return false; return false;
@@ -1518,9 +2003,9 @@ public class CodePipelineService {
private ObjectNode executionNode(CodePipelineExecution execution) { private ObjectNode executionNode(CodePipelineExecution execution) {
ObjectNode node = mapper.valueToTree(execution); ObjectNode node = mapper.valueToTree(execution);
node.remove(List.of("accountId", "region", "startTime", "lastUpdateTime", node.remove(List.of("accountId", "region", "startTime", "lastUpdateTime", "artifacts",
"sourceRevisions", "actionExecutions", "currentStage", "stopRequested", "abandon", "sourceRevisions", "actionExecutions", "currentStage", "stopRequested", "abandon",
"rollbackTargetPipelineExecutionId")); "rollbackTargetPipelineExecutionId", "retryStageName", "retryActionNames"));
if (execution.getRollbackTargetPipelineExecutionId() != null) { if (execution.getRollbackTargetPipelineExecutionId() != null) {
node.putObject("rollbackMetadata").put( node.putObject("rollbackMetadata").put(
"rollbackTargetPipelineExecutionId", execution.getRollbackTargetPipelineExecutionId()); "rollbackTargetPipelineExecutionId", execution.getRollbackTargetPipelineExecutionId());
@@ -1728,19 +2213,10 @@ public class CodePipelineService {
return account + ":" + region + ":" + pipelineName; return account + ":" + region + ":" + pipelineName;
} }
private static String artifactKey(CodePipelineExecution execution, String artifactName) {
return execution.getAccountId() + ":" + execution.getPipelineExecutionId() + ":" + artifactName;
}
private static String artifactBucket(JsonNode action) { private static String artifactBucket(JsonNode action) {
return action.path("configuration").path("BucketName").asText("codepipeline-artifacts"); return action.path("configuration").path("BucketName").asText("codepipeline-artifacts");
} }
private void clearRuntimeArtifacts(CodePipelineExecution execution) {
String prefix = execution.getAccountId() + ":" + execution.getPipelineExecutionId() + ":";
runtimeArtifacts.keySet().removeIf(key -> key.startsWith(prefix));
}
private static boolean isTerminal(String status) { private static boolean isTerminal(String status) {
return List.of("Succeeded", "Failed", "Stopped", "Superseded", "Cancelled").contains(status); return List.of("Succeeded", "Failed", "Stopped", "Superseded", "Cancelled").contains(status);
} }
@@ -26,6 +26,7 @@ public class CodePipelineExecution {
private Double startTime; private Double startTime;
private Double lastUpdateTime; private Double lastUpdateTime;
private List<Map<String, Object>> artifactRevisions = new ArrayList<>(); private List<Map<String, Object>> artifactRevisions = new ArrayList<>();
private Map<String, byte[]> artifacts = new LinkedHashMap<>();
private List<Map<String, Object>> sourceRevisions = new ArrayList<>(); private List<Map<String, Object>> sourceRevisions = new ArrayList<>();
private List<Map<String, String>> variables = new ArrayList<>(); private List<Map<String, String>> variables = new ArrayList<>();
private Map<String, String> trigger = new LinkedHashMap<>(); private Map<String, String> trigger = new LinkedHashMap<>();
@@ -35,6 +36,8 @@ public class CodePipelineExecution {
private volatile boolean stopRequested; private volatile boolean stopRequested;
private volatile boolean abandon; private volatile boolean abandon;
private String rollbackTargetPipelineExecutionId; private String rollbackTargetPipelineExecutionId;
private String retryStageName;
private List<String> retryActionNames = new ArrayList<>();
public String getAccountId() { public String getAccountId() {
return accountId; return accountId;
@@ -132,6 +135,14 @@ public class CodePipelineExecution {
this.artifactRevisions = artifactRevisions; this.artifactRevisions = artifactRevisions;
} }
public Map<String, byte[]> getArtifacts() {
return artifacts;
}
public void setArtifacts(Map<String, byte[]> artifacts) {
this.artifacts = artifacts == null ? new LinkedHashMap<>() : artifacts;
}
public List<Map<String, Object>> getSourceRevisions() { public List<Map<String, Object>> getSourceRevisions() {
return sourceRevisions; return sourceRevisions;
} }
@@ -205,6 +216,22 @@ public class CodePipelineExecution {
this.rollbackTargetPipelineExecutionId = rollbackTargetPipelineExecutionId; this.rollbackTargetPipelineExecutionId = rollbackTargetPipelineExecutionId;
} }
public String getRetryStageName() {
return retryStageName;
}
public void setRetryStageName(String retryStageName) {
this.retryStageName = retryStageName;
}
public List<String> getRetryActionNames() {
return retryActionNames;
}
public void setRetryActionNames(List<String> retryActionNames) {
this.retryActionNames = retryActionNames == null ? new ArrayList<>() : retryActionNames;
}
@RegisterForReflection @RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public static class ActionExecution { public static class ActionExecution {
@@ -61,6 +61,11 @@ public class EcsLoadBalancerRegistrar {
throw new AwsException("TargetGroupNotFound", throw new AwsException("TargetGroupNotFound",
"Target group " + loadBalancer.getTargetGroupArn() + " was not found.", 400); "Target group " + loadBalancer.getTargetGroupArn() + " was not found.", 400);
} }
if (taskDefinition.isFargateCompatible() && !"ip".equals(targetGroup.getTargetType())) {
throw new AwsException("InvalidParameterException",
"The target group " + loadBalancer.getTargetGroupArn()
+ " must have target type ip for a Fargate service.", 400);
}
boolean declared = taskDefinition.getContainerDefinitions().stream() boolean declared = taskDefinition.getContainerDefinitions().stream()
.filter(container -> loadBalancer.getContainerName().equals(container.getName())) .filter(container -> loadBalancer.getContainerName().equals(container.getName()))
.flatMap(container -> container.getPortMappings() == null .flatMap(container -> container.getPortMappings() == null
@@ -78,7 +83,16 @@ public class EcsLoadBalancerRegistrar {
/** Registers the task's load-balanced containers as ELBv2 targets. */ /** Registers the task's load-balanced containers as ELBv2 targets. */
public void registerTask(EcsTask task, EcsServiceModel svc, String region) { public void registerTask(EcsTask task, EcsServiceModel svc, String region) {
forEachTarget(task, svc, (tgArn, td) -> { register(task, svc.getLoadBalancers(), region);
}
/** Registers a task-set task against the load balancers declared by that task set. */
public void registerTaskSetTask(EcsTask task, List<EcsLoadBalancer> loadBalancers, String region) {
register(task, loadBalancers, region);
}
private void register(EcsTask task, List<EcsLoadBalancer> loadBalancers, String region) {
forEachTarget(task, loadBalancers, (tgArn, td) -> {
try { try {
elbV2Service.registerTargets(region, tgArn, List.of(td)); elbV2Service.registerTargets(region, tgArn, List.of(td));
LOG.infov("Registered ECS task target {0}:{1} into target group {2}", LOG.infov("Registered ECS task target {0}:{1} into target group {2}",
@@ -91,7 +105,16 @@ public class EcsLoadBalancerRegistrar {
/** Deregisters the task's load-balanced containers from their ELBv2 target groups. */ /** Deregisters the task's load-balanced containers from their ELBv2 target groups. */
public void deregisterTask(EcsTask task, EcsServiceModel svc, String region) { public void deregisterTask(EcsTask task, EcsServiceModel svc, String region) {
forEachTarget(task, svc, (tgArn, td) -> { deregister(task, svc.getLoadBalancers(), region);
}
/** Deregisters a task-set task from the load balancers declared by that task set. */
public void deregisterTaskSetTask(EcsTask task, List<EcsLoadBalancer> loadBalancers, String region) {
deregister(task, loadBalancers, region);
}
private void deregister(EcsTask task, List<EcsLoadBalancer> loadBalancers, String region) {
forEachTarget(task, loadBalancers, (tgArn, td) -> {
try { try {
elbV2Service.deregisterTargets(region, tgArn, List.of(td)); elbV2Service.deregisterTargets(region, tgArn, List.of(td));
LOG.infov("Deregistered ECS task target {0}:{1} from target group {2}", LOG.infov("Deregistered ECS task target {0}:{1} from target group {2}",
@@ -102,15 +125,15 @@ public class EcsLoadBalancerRegistrar {
}); });
} }
private void forEachTarget(EcsTask task, EcsServiceModel svc, private void forEachTarget(EcsTask task, List<EcsLoadBalancer> loadBalancers,
BiConsumer<String, TargetDescription> action) { BiConsumer<String, TargetDescription> action) {
if (svc == null || svc.getLoadBalancers() == null || svc.getLoadBalancers().isEmpty()) { if (loadBalancers == null || loadBalancers.isEmpty()) {
return; return;
} }
if (task.getContainers() == null || task.getContainers().isEmpty()) { if (task.getContainers() == null || task.getContainers().isEmpty()) {
return; return;
} }
for (EcsLoadBalancer lb : svc.getLoadBalancers()) { for (EcsLoadBalancer lb : loadBalancers) {
if (lb.getTargetGroupArn() == null || lb.getTargetGroupArn().isBlank()) { if (lb.getTargetGroupArn() == null || lb.getTargetGroupArn().isBlank()) {
continue; continue;
} }
@@ -1166,8 +1166,8 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
} }
EcsCluster cluster = resolveClusterOrDefault(request.getCluster(), region); EcsCluster cluster = resolveClusterOrDefault(request.getCluster(), region);
TaskDefinition taskDef = resolveLaunchableTaskDefinition(request.getTaskDefinition(), region); TaskDefinition taskDef = resolveLaunchableTaskDefinition(request.getTaskDefinition(), region);
validateEc2BridgeLaunch(cluster, taskDef, request); validateRuntimeLaunch(cluster, taskDef, request);
return launchTasks(cluster, taskDef, request, null, null, region); return launchTasks(cluster, taskDef, request, null, null, null, region);
} }
public List<EcsTask> startTask(String clusterRef, List<String> containerInstanceRefs, public List<EcsTask> startTask(String clusterRef, List<String> containerInstanceRefs,
@@ -1204,7 +1204,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
for (String instanceRef : instanceRefs) { for (String instanceRef : instanceRefs) {
ContainerInstance instance = resolveContainerInstanceOrThrow(cluster.getClusterArn(), instanceRef); ContainerInstance instance = resolveContainerInstanceOrThrow(cluster.getClusterArn(), instanceRef);
result.addAll(launchTasks(cluster, taskDef, request, result.addAll(launchTasks(cluster, taskDef, request,
instance.getContainerInstanceArn(), null, region)); instance.getContainerInstanceArn(), null, null, region));
} }
return result; return result;
} }
@@ -1218,7 +1218,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
*/ */
private List<EcsTask> launchTasks(EcsCluster cluster, TaskDefinition taskDef, RunTaskRequest request, private List<EcsTask> launchTasks(EcsCluster cluster, TaskDefinition taskDef, RunTaskRequest request,
String containerInstanceArn, String containerInstanceArn,
String owningServiceArn, String region) { String owningServiceArn, String taskSetArn, String region) {
// Fail loudly instead of silently launching zero containers and leaving // Fail loudly instead of silently launching zero containers and leaving
// a task that looks RUNNING with nothing behind it. // a task that looks RUNNING with nothing behind it.
if (taskDef.getContainerDefinitions() == null || taskDef.getContainerDefinitions().isEmpty()) { if (taskDef.getContainerDefinitions() == null || taskDef.getContainerDefinitions().isEmpty()) {
@@ -1248,6 +1248,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
task.setGroup(request.getGroup() != null ? request.getGroup() : "family:" + taskDef.getFamily()); task.setGroup(request.getGroup() != null ? request.getGroup() : "family:" + taskDef.getFamily());
task.setStartedBy(request.getStartedBy()); task.setStartedBy(request.getStartedBy());
task.setOwningServiceArn(owningServiceArn); task.setOwningServiceArn(owningServiceArn);
task.setTaskSetArn(taskSetArn);
task.setLastStatus(TaskStatus.PENDING.name()); task.setLastStatus(TaskStatus.PENDING.name());
task.setDesiredStatus(TaskStatus.RUNNING.name()); task.setDesiredStatus(TaskStatus.RUNNING.name());
task.setCpu(effectiveCpu(taskDef, request)); task.setCpu(effectiveCpu(taskDef, request));
@@ -1399,7 +1400,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
request.setPropagateTags(PROPAGATE_TAGS_TASK_DEFINITION); request.setPropagateTags(PROPAGATE_TAGS_TASK_DEFINITION);
} }
EcsTask task = launchTasks(cluster, taskDef, request, containerInstanceArn, EcsTask task = launchTasks(cluster, taskDef, request, containerInstanceArn,
svc.getServiceArn(), region).getFirst(); svc.getServiceArn(), null, region).getFirst();
task.setDeploymentId(deploymentId(svc)); task.setDeploymentId(deploymentId(svc));
return task; return task;
} }
@@ -1553,26 +1554,34 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
return new Placement(launchType, capacityProvider); return new Placement(launchType, capacityProvider);
} }
/** Floci's Docker-backed service implementation supports EC2 bridge tasks only. */ /**
private void validateEc2BridgeLaunch(EcsCluster cluster, TaskDefinition taskDef, RunTaskRequest request) { * Keeps the established EC2 bridge restriction while allowing Docker-backed Fargate tasks to
if (taskDef.getNetworkMode() != NetworkMode.bridge) { * use their awsvpc task ENI. A capacity-provider strategy determines the effective launch
* type, so validate every placement rather than only the explicit launchType member.
*/
private void validateRuntimeLaunch(EcsCluster cluster, TaskDefinition taskDef, RunTaskRequest request) {
List<Placement> placements = resolvePlacements(cluster, request, 1);
boolean fargate = placements.stream().anyMatch(placement -> placement.launchType() == LaunchType.FARGATE);
boolean ec2 = placements.stream().anyMatch(placement -> placement.launchType() == LaunchType.EC2);
if (fargate && ec2) {
throw new AwsException("ClientException",
"A task definition cannot be placed on both EC2 and FARGATE capacity providers.", 400);
}
if (fargate) {
if (taskDef.getNetworkMode() != NetworkMode.awsvpc) {
throw new AwsException("ClientException",
"Fargate tasks require the awsvpc network mode.", 400);
}
if (!taskDef.isFargateCompatible()) {
throw new AwsException("ClientException",
"The task definition is not compatible with the FARGATE launch type.", 400);
}
return;
}
if (ec2 && taskDef.getNetworkMode() != NetworkMode.bridge) {
throw new AwsException("ClientException", throw new AwsException("ClientException",
"Only bridge network mode is supported for ECS EC2 tasks.", 400); "Only bridge network mode is supported for ECS EC2 tasks.", 400);
} }
LaunchType launchType = request.getLaunchType();
if (launchType != null && launchType != LaunchType.EC2) {
throw new AwsException("ClientException",
"Only the EC2 launch type is supported; FARGATE is not available.", 400);
}
List<CapacityProviderStrategyItem> strategy = request.getCapacityProviderStrategy();
if (strategy == null || strategy.isEmpty()) {
strategy = clusterDefaultStrategy(cluster);
}
if (strategy != null && strategy.stream()
.anyMatch(item -> FARGATE_CAPACITY_PROVIDERS.contains(item.capacityProvider()))) {
throw new AwsException("ClientException",
"FARGATE capacity providers are not supported; use an EC2 capacity provider.", 400);
}
} }
/** Accepts the two built-in Fargate providers as well as anything CreateCapacityProvider made. */ /** Accepts the two built-in Fargate providers as well as anything CreateCapacityProvider made. */
@@ -1730,6 +1739,13 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
/** Registers a freshly-started task's containers as ELBv2 targets if its service is load-balanced. */ /** Registers a freshly-started task's containers as ELBv2 targets if its service is load-balanced. */
private void registerTaskWithLoadBalancers(EcsTask task, EcsCluster cluster, String region) { private void registerTaskWithLoadBalancers(EcsTask task, EcsCluster cluster, String region) {
if (task.getTaskSetArn() != null) {
TaskSet taskSet = taskSets.get(task.getTaskSetArn());
if (taskSet != null) {
lbRegistrar.registerTaskSetTask(task, taskSet.getLoadBalancers(), region);
}
return;
}
EcsServiceModel svc = owningService(task, cluster); EcsServiceModel svc = owningService(task, cluster);
if (svc != null && !svc.getLoadBalancers().isEmpty()) { if (svc != null && !svc.getLoadBalancers().isEmpty()) {
lbRegistrar.registerTask(task, svc, region); lbRegistrar.registerTask(task, svc, region);
@@ -1764,7 +1780,12 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
return; return;
} }
EcsServiceModel svc = owningService(task, cluster); EcsServiceModel svc = owningService(task, cluster);
if (svc != null && !svc.getLoadBalancers().isEmpty()) { if (task.getTaskSetArn() != null) {
TaskSet taskSet = taskSets.get(task.getTaskSetArn());
if (taskSet != null) {
lbRegistrar.deregisterTaskSetTask(task, taskSet.getLoadBalancers(), region);
}
} else if (svc != null && !svc.getLoadBalancers().isEmpty()) {
lbRegistrar.deregisterTask(task, svc, region); lbRegistrar.deregisterTask(task, svc, region);
} }
} }
@@ -2052,7 +2073,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
RunTaskRequest launchValidation = new RunTaskRequest(); RunTaskRequest launchValidation = new RunTaskRequest();
launchValidation.setLaunchType(request.getLaunchType()); launchValidation.setLaunchType(request.getLaunchType());
launchValidation.setCapacityProviderStrategy(request.getCapacityProviderStrategy()); launchValidation.setCapacityProviderStrategy(request.getCapacityProviderStrategy());
validateEc2BridgeLaunch(cluster, taskDef, launchValidation); validateRuntimeLaunch(cluster, taskDef, launchValidation);
lbRegistrar.validateServiceLoadBalancers(request.getLoadBalancers(), taskDef, region); lbRegistrar.validateServiceLoadBalancers(request.getLoadBalancers(), taskDef, region);
String taskDefinition = taskDef.getTaskDefinitionArn(); String taskDefinition = taskDef.getTaskDefinitionArn();
String serviceName = request.getServiceName(); String serviceName = request.getServiceName();
@@ -2132,6 +2153,9 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
cluster.setActiveServicesCount(cluster.getActiveServicesCount() + 1); cluster.setActiveServicesCount(cluster.getActiveServicesCount() + 1);
persistCluster(region, cluster); persistCluster(region, cluster);
recordServiceDeployment(svc, taskDefinition, region); recordServiceDeployment(svc, taskDefinition, region);
if (svc.getLaunchType() == LaunchType.FARGATE && "CODE_DEPLOY".equals(svc.getDeploymentController())) {
createInitialCodeDeployTaskSet(cluster, svc, region);
}
if (eventPublisher != null) { if (eventPublisher != null) {
eventPublisher.emitDeploymentStateChange(svc, "SERVICE_DEPLOYMENT_STARTED", eventPublisher.emitDeploymentStateChange(svc, "SERVICE_DEPLOYMENT_STARTED",
"ECS deployment " + svc.getDeploymentId() + " started.", region); "ECS deployment " + svc.getDeploymentId() + " started.", region);
@@ -2140,6 +2164,31 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
return svc; return svc;
} }
/**
* A Fargate CodeDeploy service starts with its blue deployment already placed. EC2 services
* retain their existing controller-owned task-set lifecycle.
*/
private void createInitialCodeDeployTaskSet(EcsCluster cluster, EcsServiceModel svc, String region) {
CreateTaskSetRequest request = new CreateTaskSetRequest();
request.setCluster(cluster.getClusterName());
request.setService(svc.getServiceName());
request.setTaskDefinition(svc.getTaskDefinition());
if (svc.getCapacityProviderStrategy() != null && !svc.getCapacityProviderStrategy().isEmpty()) {
request.setCapacityProviderStrategy(svc.getCapacityProviderStrategy());
} else {
request.setLaunchType(LaunchType.FARGATE);
}
request.setPlatformVersion(svc.getPlatformVersion());
request.setScaleValue(100.0);
request.setNetworkConfiguration(svc.getNetworkConfiguration());
request.setLoadBalancers(svc.getLoadBalancers());
request.setServiceRegistries(svc.getServiceRegistries());
TaskSet taskSet = createTaskSet(request, region);
taskSet.setStatus("PRIMARY");
taskSet.setUpdatedAt(Instant.now());
}
public EcsServiceModel updateService(String clusterRef, String serviceName, String taskDefinition, public EcsServiceModel updateService(String clusterRef, String serviceName, String taskDefinition,
Integer desiredCount, NetworkConfiguration networkConfiguration, Integer desiredCount, NetworkConfiguration networkConfiguration,
String region) { String region) {
@@ -2436,6 +2485,20 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
t.getTaskArn(), e.getMessage()); t.getTaskArn(), e.getMessage());
} }
}); });
// Service ARNs are reused when a same-named service is recreated. Retaining its task
// sets would make the new service reconcile and relaunch the deleted deployment.
taskSets.entrySet().removeIf(entry -> svc.getServiceArn().equals(entry.getValue().getServiceArn()));
// Inactive services are not reconciled, so retain the final observed task state rather
// than leaving the count that was recorded before the service drain.
svc.setRunningCount((int) tasks.values().stream()
.filter(t -> ownedBy(t, svc, cluster))
.filter(t -> TaskStatus.RUNNING.name().equals(t.getLastStatus()))
.count());
svc.setPendingCount((int) tasks.values().stream()
.filter(t -> ownedBy(t, svc, cluster))
.filter(t -> TaskStatus.PENDING.name().equals(t.getLastStatus()))
.count());
services.put(key, svc);
return svc; return svc;
} }
@@ -3285,7 +3348,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
private static final Set<String> TASK_SET_CONTROLLERS = Set.of("EXTERNAL", "CODE_DEPLOY"); private static final Set<String> TASK_SET_CONTROLLERS = Set.of("EXTERNAL", "CODE_DEPLOY");
/** The only unit {@code Scale} accepts, so the computed count is always a percentage. */ /** The only unit {@code Scale} accepts, so the computed count is always a percentage. */
private static final String SCALE_UNIT_PERCENT = "PERCENT"; private static final String SCALE_UNIT_PERCENT = "PERCENT";
/** The stability a task set reports while Floci places no tasks of its own for one. */ /** Task sets report steady state when their running task count reaches the computed desired count. */
private static final String STABILITY_STATUS_STEADY_STATE = "STEADY_STATE"; private static final String STABILITY_STATUS_STEADY_STATE = "STEADY_STATE";
public TaskSet createTaskSet(String clusterRef, String serviceRef, String taskDefinitionRef, public TaskSet createTaskSet(String clusterRef, String serviceRef, String taskDefinitionRef,
@@ -3363,7 +3426,9 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
ts.setUpdatedAt(Instant.now()); ts.setUpdatedAt(Instant.now());
applyTaskSetScale(ts, svc); applyTaskSetScale(ts, svc);
lbRegistrar.validateServiceLoadBalancers(ts.getLoadBalancers(), taskDef, region);
taskSets.put(taskSetArn, ts); taskSets.put(taskSetArn, ts);
reconcileTaskSet(cluster, svc, ts, region);
return ts; return ts;
} }
@@ -3391,15 +3456,54 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
* Stamps {@code computedDesiredCount}, which AWS derives from the service's desired count and * Stamps {@code computedDesiredCount}, which AWS derives from the service's desired count and
* the task set's scale percentage, always rounding up: a computed 1.2 becomes 2 tasks. * the task set's scale percentage, always rounding up: a computed 1.2 becomes 2 tasks.
* *
* <p>Floci places no tasks for a task set, so its running and pending counts stay at zero. A
* stability status derived from them would leave every set on a service with a desired count
* above zero in {@code STABILIZING} for ever, hanging anything that waits for the set to
* stabilise, so the set is reported {@code STEADY_STATE} until Floci tracks tasks for it.
*/ */
private void applyTaskSetScale(TaskSet ts, EcsServiceModel svc) { private void applyTaskSetScale(TaskSet ts, EcsServiceModel svc) {
ts.setComputedDesiredCount( ts.setComputedDesiredCount(
(int) Math.ceil(svc.getDesiredCount() * ts.getScaleValue() / 100.0)); (int) Math.ceil(svc.getDesiredCount() * ts.getScaleValue() / 100.0));
ts.setStabilityStatus(STABILITY_STATUS_STEADY_STATE); }
/** Places and drains task-set tasks with the same runtime launcher used by the service scheduler. */
private void reconcileTaskSet(EcsCluster cluster, EcsServiceModel svc, TaskSet ts, String region) {
List<EcsTask> running = tasks.values().stream()
.filter(task -> ts.getTaskSetArn().equals(task.getTaskSetArn()))
.filter(task -> TaskStatus.RUNNING.name().equals(task.getLastStatus()))
.toList();
int toStart = ts.getComputedDesiredCount() - running.size();
if (toStart > 0) {
TaskDefinition taskDef = resolveTaskDefinitionOrThrow(ts.getTaskDefinition(), region);
for (int i = 0; i < toStart; i++) {
RunTaskRequest request = new RunTaskRequest();
request.setCount(1);
request.setGroup("service:" + svc.getServiceName());
request.setStartedBy(ts.getStartedBy());
request.setNetworkConfiguration(ts.getNetworkConfiguration());
request.setCapacityProviderStrategy(ts.getCapacityProviderStrategy());
if (ts.getCapacityProviderStrategy() == null || ts.getCapacityProviderStrategy().isEmpty()) {
request.setLaunchType(ts.getLaunchType());
}
validateRuntimeLaunch(cluster, taskDef, request);
launchTasks(cluster, taskDef, request, null, svc.getServiceArn(), ts.getTaskSetArn(), region);
}
} else if (toStart < 0) {
running.stream().limit(-toStart).forEach(task -> stopTask(cluster.getClusterName(), task.getTaskArn(),
"Task set scale-in", STOP_CODE_SERVICE_SCHEDULER_INITIATED, region));
}
updateTaskSetRuntime(ts);
}
private void updateTaskSetRuntime(TaskSet ts) {
int running = (int) tasks.values().stream()
.filter(task -> ts.getTaskSetArn().equals(task.getTaskSetArn()))
.filter(task -> TaskStatus.RUNNING.name().equals(task.getLastStatus()))
.count();
int pending = (int) tasks.values().stream()
.filter(task -> ts.getTaskSetArn().equals(task.getTaskSetArn()))
.filter(task -> TaskStatus.PENDING.name().equals(task.getLastStatus()))
.count();
ts.setRunningCount(running);
ts.setPendingCount(pending);
ts.setStabilityStatus(running == ts.getComputedDesiredCount() && pending == 0
? STABILITY_STATUS_STEADY_STATE : "STABILIZING");
ts.setStabilityStatusAt(Instant.now()); ts.setStabilityStatusAt(Instant.now());
} }
@@ -3416,6 +3520,7 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
ts.setScaleUnit(SCALE_UNIT_PERCENT); ts.setScaleUnit(SCALE_UNIT_PERCENT);
ts.setUpdatedAt(Instant.now()); ts.setUpdatedAt(Instant.now());
applyTaskSetScale(ts, svc); applyTaskSetScale(ts, svc);
reconcileTaskSet(cluster, svc, ts, region);
return ts; return ts;
} }
@@ -3436,6 +3541,11 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
} }
ts.setStatus("DRAINING"); ts.setStatus("DRAINING");
ts.setUpdatedAt(Instant.now()); ts.setUpdatedAt(Instant.now());
tasks.values().stream()
.filter(task -> ts.getTaskSetArn().equals(task.getTaskSetArn()))
.filter(task -> !TaskStatus.STOPPED.name().equals(task.getLastStatus()))
.forEach(task -> stopTask(cluster.getClusterName(), task.getTaskArn(),
"Task set deleted", STOP_CODE_SERVICE_SCHEDULER_INITIATED, region));
taskSets.remove(ts.getTaskSetArn()); taskSets.remove(ts.getTaskSetArn());
return ts; return ts;
} }
@@ -4128,9 +4238,20 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
if (!"ACTIVE".equals(svc.getStatus())) { if (!"ACTIVE".equals(svc.getStatus())) {
return; return;
} }
String deploymentController = svc.getDeploymentController() != null
? svc.getDeploymentController() : DEFAULT_DEPLOYMENT_CONTROLLER;
String region = extractRegionFromServiceKey(key); String region = extractRegionFromServiceKey(key);
String clusterName = extractClusterNameFromServiceKey(key); String clusterName = extractClusterNameFromServiceKey(key);
if (!DEFAULT_DEPLOYMENT_CONTROLLER.equals(deploymentController)) {
EcsCluster cluster = resolveClusterOrDefault(clusterName, region);
for (TaskSet taskSet : taskSetsFor(svc)) {
reconcileTaskSet(cluster, svc, taskSet, region);
}
// Task sets are scheduled by their CodeDeploy or external controller, not by the
// normal service reconciler. Otherwise green tasks would be supplemented by a second
// desiredCount-worth of regular service tasks on every tick.
return;
}
if (SCHEDULING_DAEMON.equals(svc.getSchedulingStrategy())) { if (SCHEDULING_DAEMON.equals(svc.getSchedulingStrategy())) {
reconcileDaemonService(key, svc, clusterName, region); reconcileDaemonService(key, svc, clusterName, region);
@@ -4140,6 +4261,9 @@ public class EcsService implements ContainerTeardown, ResourceProvider, Resettab
EcsCluster cluster = resolveClusterOrDefault(clusterName, region); EcsCluster cluster = resolveClusterOrDefault(clusterName, region);
List<EcsTask> runningTasks = tasks.values().stream() List<EcsTask> runningTasks = tasks.values().stream()
.filter(t -> ownedBy(t, svc, cluster)) .filter(t -> ownedBy(t, svc, cluster))
// Task sets own their desired count. Including them here lets the rolling-service
// reconciler replace a CodeDeploy green task before traffic is switched.
.filter(t -> t.getTaskSetArn() == null)
.filter(t -> TaskStatus.RUNNING.name().equals(t.getLastStatus())) .filter(t -> TaskStatus.RUNNING.name().equals(t.getLastStatus()))
.toList(); .toList();
long running = runningTasks.size(); long running = runningTasks.size();
@@ -21,6 +21,8 @@ public class EcsTask {
* caller-supplied label and therefore cannot be trusted to establish ownership. * caller-supplied label and therefore cannot be trusted to establish ownership.
*/ */
private String owningServiceArn; private String owningServiceArn;
/** Task set that launched this service-owned task, or null for a rolling-service task. */
private String taskSetArn;
private LaunchType launchType; private LaunchType launchType;
private String lastStatus; private String lastStatus;
private String desiredStatus; private String desiredStatus;
@@ -81,6 +83,7 @@ public class EcsTask {
this.taskDefinitionArn = other.taskDefinitionArn; this.taskDefinitionArn = other.taskDefinitionArn;
this.group = other.group; this.group = other.group;
this.owningServiceArn = other.owningServiceArn; this.owningServiceArn = other.owningServiceArn;
this.taskSetArn = other.taskSetArn;
this.launchType = other.launchType; this.launchType = other.launchType;
this.lastStatus = other.lastStatus; this.lastStatus = other.lastStatus;
this.desiredStatus = other.desiredStatus; this.desiredStatus = other.desiredStatus;
@@ -138,6 +141,9 @@ public class EcsTask {
public String getOwningServiceArn() { return owningServiceArn; } public String getOwningServiceArn() { return owningServiceArn; }
public void setOwningServiceArn(String owningServiceArn) { this.owningServiceArn = owningServiceArn; } public void setOwningServiceArn(String owningServiceArn) { this.owningServiceArn = owningServiceArn; }
public String getTaskSetArn() { return taskSetArn; }
public void setTaskSetArn(String taskSetArn) { this.taskSetArn = taskSetArn; }
public LaunchType getLaunchType() { return launchType; } public LaunchType getLaunchType() { return launchType; }
public void setLaunchType(LaunchType launchType) { this.launchType = launchType; } public void setLaunchType(LaunchType launchType) { this.launchType = launchType; }
@@ -764,6 +764,9 @@ public class RdsService implements Resettable, ResourceProvider {
} catch (RuntimeException rollbackFailure) { } catch (RuntimeException rollbackFailure) {
e.addSuppressed(rollbackFailure); e.addSuppressed(rollbackFailure);
} }
if (!persistedResourceUsesProxyPort(proxyPort)) {
releaseProxyPort(proxyPort);
}
throw e; throw e;
} }
} }
@@ -3,8 +3,16 @@ package io.github.hectorvent.floci.services.cloudformation;
import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test; 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 io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -259,6 +267,92 @@ class CloudFormationLambdaAddressingIntegrationTest {
awaitStackGone(stackName); 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 { private static void awaitStackGone(String stackName) throws InterruptedException {
for (int i = 0; i < 200; i++) { for (int i = 0; i < 200; i++) {
var response = given() var response = given()
@@ -90,6 +90,21 @@ class LambdaVersionAliasCfnProvisionerTest {
r.getAttributes().get("FunctionArn")); 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 @Test
void versionWithoutFunctionNameFails() { void versionWithoutFunctionNameFails() {
StackResource r = resource("AWS::Lambda::Version", "MyFuncVersion"); 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.core.common.RegionResolver;
import io.github.hectorvent.floci.services.codedeploy.model.Deployment; import io.github.hectorvent.floci.services.codedeploy.model.Deployment;
import io.github.hectorvent.floci.services.ec2.Ec2Service; import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ec2.model.Instance;
import io.github.hectorvent.floci.services.ec2.model.Reservation;
import io.github.hectorvent.floci.services.ecs.EcsService; import io.github.hectorvent.floci.services.ecs.EcsService;
import io.github.hectorvent.floci.services.elbv2.ElbV2Service; import io.github.hectorvent.floci.services.elbv2.ElbV2Service;
import io.github.hectorvent.floci.services.lambda.LambdaService; 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.InvocationType;
import io.github.hectorvent.floci.services.lambda.model.InvokeResult; import io.github.hectorvent.floci.services.lambda.model.InvokeResult;
import io.github.hectorvent.floci.services.ssm.SsmCommandService; import io.github.hectorvent.floci.services.ssm.SsmCommandService;
import io.github.hectorvent.floci.testing.MutableClock; import io.github.hectorvent.floci.testing.MutableClock;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static org.awaitility.Awaitility.await; 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.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -48,6 +56,14 @@ class CodeDeployServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
lambdaService = mock(LambdaService.class); 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); ssmCommandService = mock(SsmCommandService.class);
service = newService(Duration.ofHours(1)); service = newService(Duration.ofHours(1));
} }
@@ -57,9 +73,13 @@ class CodeDeployServiceTest {
} }
private CodeDeployService newService(Duration hookCallbackTimeout, Clock clock) { 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), CodeDeployService svc = new CodeDeployService(lambdaService, mock(EcsService.class),
mock(ElbV2Service.class), ssmCommandService, mock(Ec2Service.class), new ObjectMapper(), 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(); svc.initializeStorage();
return svc; return svc;
} }
@@ -155,6 +175,43 @@ class CodeDeployServiceTest {
assertEquals("Succeeded", deployment.getStatus()); 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 ---------------------------------------------------------- // ---- Lambda platform hooks ----------------------------------------------------------
@Test @Test
@@ -205,7 +262,7 @@ class CodeDeployServiceTest {
when(lambdaService.invoke(eq(REGION), eq("beforeHook"), any(byte[].class), eq(InvocationType.RequestResponse))) when(lambdaService.invoke(eq(REGION), eq("beforeHook"), any(byte[].class), eq(InvocationType.RequestResponse)))
.thenAnswer(invocation -> { .thenAnswer(invocation -> {
byte[] payload = invocation.getArgument(2); byte[] payload = invocation.getArgument(2);
service.putLifecycleEventHookExecutionStatus("ignored", executionIdFrom(payload), "Succeeded"); service.putLifecycleEventHookExecutionStatus(deploymentIdFrom(payload), executionIdFrom(payload), "Succeeded");
return cleanInvoke; return cleanInvoke;
}); });
@@ -270,7 +327,7 @@ class CodeDeployServiceTest {
when(lambdaService.invoke(eq(REGION), eq("beforeHook"), any(byte[].class), eq(InvocationType.RequestResponse))) when(lambdaService.invoke(eq(REGION), eq("beforeHook"), any(byte[].class), eq(InvocationType.RequestResponse)))
.thenAnswer(invocation -> { .thenAnswer(invocation -> {
byte[] payload = invocation.getArgument(2); byte[] payload = invocation.getArgument(2);
service.putLifecycleEventHookExecutionStatus("ignored", executionIdFrom(payload), "Failed"); service.putLifecycleEventHookExecutionStatus(deploymentIdFrom(payload), executionIdFrom(payload), "Failed");
return cleanInvoke; return cleanInvoke;
}); });
@@ -304,7 +361,7 @@ class CodeDeployServiceTest {
byte[] payload = invocation.getArgument(2); byte[] payload = invocation.getArgument(2);
String executionId = executionIdFrom(payload); String executionId = executionIdFrom(payload);
capturedExecutionId.set(executionId); capturedExecutionId.set(executionId);
service.putLifecycleEventHookExecutionStatus("ignored", executionId, "Succeeded"); service.putLifecycleEventHookExecutionStatus(deploymentIdFrom(payload), executionId, "Succeeded");
return cleanInvoke; return cleanInvoke;
}); });
@@ -312,11 +369,131 @@ class CodeDeployServiceTest {
Deployment deployment = awaitTerminal(deploymentId, Duration.ofSeconds(5)); Deployment deployment = awaitTerminal(deploymentId, Duration.ofSeconds(5));
assertEquals("Succeeded", deployment.getStatus()); 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()); 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 -------------------------------------------------------------------------- // ---- Helpers --------------------------------------------------------------------------
private String createServerDeployment(String appName, String groupName, int timeoutSeconds) { private String createServerDeployment(String appName, String groupName, int timeoutSeconds) {
@@ -324,19 +501,22 @@ class CodeDeployServiceTest {
} }
private String createServerDeployment(CodeDeployService target, String appName, String groupName, private String createServerDeployment(CodeDeployService target, String appName, String groupName,
int timeoutSeconds) { int timeoutSeconds) {
target.createApplication(REGION, appName, "Server", null); target.createApplication(REGION, appName, "Server", null);
target.createDeploymentGroup(REGION, appName, groupName, "CodeDeployDefault.AllAtOnce", ROLE_ARN, 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 = """ String appSpec = """
os: linux os: linux
hooks: hooks:
ApplicationStart: ApplicationStart:
- location: scripts/start_server.sh - location: scripts/start_server.sh
timeout: %d timeout: %d
""".formatted(timeoutSeconds); """.formatted(timeoutSeconds);
Map<String, Object> revision = Map.of("revisionType", "AppSpecContent", return Map.of("revisionType", "AppSpecContent",
"appSpecContent", Map.of("content", appSpec)); "appSpecContent", Map.of("content", appSpec));
return target.createDeployment(REGION, appName, groupName, null, revision, "server hook test");
} }
private void createLambdaAppAndGroup(String appName, String groupName) { 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) { private Deployment awaitTerminal(String deploymentId, Duration atMost) {
return awaitTerminal(service, deploymentId, 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.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.codebuild.CodeBuildService; import io.github.hectorvent.floci.services.codebuild.CodeBuildService;
import io.github.hectorvent.floci.services.codebuild.model.Build; 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.CodeDeployService;
import io.github.hectorvent.floci.services.codedeploy.model.Deployment; import io.github.hectorvent.floci.services.codedeploy.model.Deployment;
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelinePipeline; 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.lambda.LambdaService;
import io.github.hectorvent.floci.services.s3.S3Service; import io.github.hectorvent.floci.services.s3.S3Service;
import io.github.hectorvent.floci.services.s3.model.S3Object; import io.github.hectorvent.floci.services.s3.model.S3Object;
import org.junit.jupiter.api.Test; 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.Map;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; 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.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -46,7 +62,8 @@ class CodePipelineServiceTest {
mock(CodeBuildService.class), mock(CodeBuildService.class),
mock(CodeDeployService.class), mock(CodeDeployService.class),
mock(LambdaService.class), mock(LambdaService.class),
mock(S3Service.class)); mock(S3Service.class),
mock(CloudFormationService.class));
@Test @Test
void getPipelineTreatsMissingStoredVersionAsVersionOne() throws Exception { void getPipelineTreatsMissingStoredVersionAsVersionOne() throws Exception {
@@ -57,7 +74,8 @@ class CodePipelineServiceTest {
mock(CodeBuildService.class), mock(CodeBuildService.class),
mock(CodeDeployService.class), mock(CodeDeployService.class),
mock(LambdaService.class), mock(LambdaService.class),
mock(S3Service.class)); mock(S3Service.class),
mock(CloudFormationService.class));
CodePipelinePipeline pipeline = mapper.readValue(""" CodePipelinePipeline pipeline = mapper.readValue("""
{ {
"accountId": "000000000000", "accountId": "000000000000",
@@ -117,7 +135,8 @@ class CodePipelineServiceTest {
mock(CodeBuildService.class), mock(CodeBuildService.class),
mock(CodeDeployService.class), mock(CodeDeployService.class),
mock(LambdaService.class), mock(LambdaService.class),
s3Service); s3Service,
mock(CloudFormationService.class));
String pipelineRequest = """ String pipelineRequest = """
{ {
"pipeline": { "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 @Test
void stoppingCodeDeployWaitsForFailureWithoutFailingThePipeline() throws Exception { void stoppingCodeDeployWaitsForFailureWithoutFailingThePipeline() throws Exception {
CodeDeployService codeDeployService = mock(CodeDeployService.class); CodeDeployService codeDeployService = mock(CodeDeployService.class);
@@ -395,7 +851,7 @@ class CodePipelineServiceTest {
CodeDeployService codeDeployService) { CodeDeployService codeDeployService) {
return new CodePipelineService( return new CodePipelineService(
new InMemoryStorageFactory(), mapper, codeBuildService, codeDeployService, 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, private void createExternalActionPipeline(CodePipelineService pipelineService,
@@ -485,6 +941,72 @@ class CodePipelineServiceTest {
return deployment; 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 static final class InMemoryStorageFactory extends StorageFactory {
private InMemoryStorageFactory() { private InMemoryStorageFactory() {
super(null, null); super(null, null);
@@ -21,17 +21,17 @@ class EcsEc2BridgeIntegrationTest {
} }
@Test @Test
void runTaskAndCreateServiceRejectFargateAndNonBridgeTasks() { void runTaskAndCreateServiceKeepEc2BridgeRestrictions() {
call("RegisterTaskDefinition", "{\"family\":\"ec2-bridge\",\"networkMode\":\"bridge\"," call("RegisterTaskDefinition", "{\"family\":\"ec2-bridge\",\"networkMode\":\"bridge\","
+ "\"containerDefinitions\":[{\"name\":\"app\",\"image\":\"nginx\"}]}", 200); + "\"containerDefinitions\":[{\"name\":\"app\",\"image\":\"nginx\"}]}", 200);
call("RunTask", "{\"taskDefinition\":\"ec2-bridge\",\"launchType\":\"FARGATE\"}", 400) call("RunTask", "{\"taskDefinition\":\"ec2-bridge\",\"launchType\":\"FARGATE\"}", 400)
.body("message", containsString("EC2 launch type")); .body("message", containsString("awsvpc"));
call("RunTask", "{\"taskDefinition\":\"ec2-bridge\",\"capacityProviderStrategy\":[" call("RunTask", "{\"taskDefinition\":\"ec2-bridge\",\"capacityProviderStrategy\":["
+ "{\"capacityProvider\":\"FARGATE\",\"weight\":1}]}", 400) + "{\"capacityProvider\":\"FARGATE\",\"weight\":1}]}", 400)
.body("message", containsString("FARGATE capacity providers")); .body("message", containsString("awsvpc"));
call("CreateService", "{\"serviceName\":\"ec2-bridge\",\"taskDefinition\":\"ec2-bridge\"," call("CreateService", "{\"serviceName\":\"ec2-bridge\",\"taskDefinition\":\"ec2-bridge\","
+ "\"desiredCount\":0,\"launchType\":\"FARGATE\"}", 400) + "\"desiredCount\":0,\"launchType\":\"FARGATE\"}", 400)
.body("message", containsString("EC2 launch type")); .body("message", containsString("awsvpc"));
call("RegisterTaskDefinition", "{\"family\":\"not-bridge\",\"networkMode\":\"awsvpc\"," call("RegisterTaskDefinition", "{\"family\":\"not-bridge\",\"networkMode\":\"awsvpc\","
+ "\"containerDefinitions\":[{\"name\":\"app\",\"image\":\"nginx\"}]}", 200); + "\"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(); 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 @Test
void createServiceParsesAndEchoesLoadBalancers() { void createServiceParsesAndEchoesLoadBalancers() {
seedClusterAndTaskDef("lb-cluster-1", "lb-td-1"); seedClusterAndTaskDef("lb-cluster-1", "lb-td-1");
@@ -147,6 +154,29 @@ class EcsLoadBalancersIntegrationTest {
.body("message", org.hamcrest.Matchers.containsString("does not expose port 9999")); .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 @Test
void serviceWithLoadBalancersIsStableInMockMode() { void serviceWithLoadBalancersIsStableInMockMode() {
// Mock mode launches no real containers; the registrar must no-op cleanly. // Mock mode launches no real containers; the registrar must no-op cleanly.
@@ -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.StorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager; import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager;
import io.github.hectorvent.floci.services.ecs.container.EcsTaskHandle;
import io.github.hectorvent.floci.services.ecs.model.AwsVpcConfiguration;
import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition; import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition;
import io.github.hectorvent.floci.services.ecs.model.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.EcsTask;
import io.github.hectorvent.floci.services.ecs.model.LaunchType; 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.TaskDefinition;
import io.github.hectorvent.floci.services.ecs.model.TaskStatus; 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 org.junit.jupiter.api.Test;
import java.util.HashMap; 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; 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.ArgumentMatchers.eq;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -92,6 +102,206 @@ class EcsServiceRolloutTest {
assertEquals(rev1.getTaskDefinitionArn(), svc.getTaskDefinition()); 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 @Test
void serviceHoldingARawReferenceIsPinnedOnItsFirstTickInsteadOfRelaunchingForever() { void serviceHoldingARawReferenceIsPinnedOnItsFirstTickInsteadOfRelaunchingForever() {
EcsService service = newMockModeService(); EcsService service = newMockModeService();
@@ -219,6 +429,36 @@ class EcsServiceRolloutTest {
null, null, List.of(), REGION); 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() { private static EcsService newMockModeService() {
return newMockModeService(null); return newMockModeService(null);
} }
@@ -238,6 +478,29 @@ class EcsServiceRolloutTest {
return service; 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 static final class InMemoryStorageFactory extends StorageFactory {
private final Map<String, StorageBackend<String, ?>> stores = new HashMap<>(); 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.StorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager; import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager;
import io.github.hectorvent.floci.services.ecs.model.AwsVpcConfiguration;
import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition; import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition;
import io.github.hectorvent.floci.services.ecs.model.ContainerInstance; 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.EcsTask;
import io.github.hectorvent.floci.services.ecs.model.LaunchType; 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 org.junit.jupiter.api.Test;
import java.util.HashMap; import java.util.HashMap;
@@ -130,6 +134,28 @@ class EcsServiceTaskOwnershipTest {
"service teardown stops only the service's own tasks"); "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 @Test
void listTasksByServiceNameReturnsOnlyTasksTheServiceLaunched() { void listTasksByServiceNameReturnsOnlyTasksTheServiceLaunched() {
EcsService service = newMockModeService(); EcsService service = newMockModeService();
@@ -216,6 +242,22 @@ class EcsServiceTaskOwnershipTest {
service.registerTaskDefinition("own-fam", List.of(cd), null, null, null, null, null, List.of(), region); 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() { private static EcsService newMockModeService() {
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS); EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
when(config.services().ecs().mock()).thenReturn(true); 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"; "AWS4-HMAC-SHA256 Credential=test/20260427/us-east-1/ec2/aws4_request";
private static final String ELB_V2_XMLNS = private static final String ELB_V2_XMLNS =
"https://elasticloadbalancing.amazonaws.com/doc/2015-12-01/"; "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 lbArn;
private static String tgArn; private static String tgArn;
@@ -37,6 +38,7 @@ class ElbV2IntegrationTest {
void createLoadBalancer() { void createLoadBalancer() {
lbArn = given() lbArn = given()
.formParam("Action", "CreateLoadBalancer") .formParam("Action", "CreateLoadBalancer")
.formParam("Version", ELB_V2_API_VERSION)
.formParam("Name", "my-test-lb") .formParam("Name", "my-test-lb")
.formParam("Type", "application") .formParam("Type", "application")
.formParam("Scheme", "internet-facing") .formParam("Scheme", "internet-facing")
@@ -60,7 +62,7 @@ class ElbV2IntegrationTest {
.body("CreateLoadBalancerResponse.CreateLoadBalancerResult.LoadBalancers.member.AvailabilityZones.member.SubnetId", .body("CreateLoadBalancerResponse.CreateLoadBalancerResult.LoadBalancers.member.AvailabilityZones.member.SubnetId",
hasItems(Ec2Service.defaultSubnetId("us-east-1", "a"), Ec2Service.defaultSubnetId("us-east-1", "b"))) hasItems(Ec2Service.defaultSubnetId("us-east-1", "a"), Ec2Service.defaultSubnetId("us-east-1", "b")))
.body("CreateLoadBalancerResponse.CreateLoadBalancerResult.LoadBalancers.member.DNSName", .body("CreateLoadBalancerResponse.CreateLoadBalancerResult.LoadBalancers.member.DNSName",
containsString(".elb.localhost.floci.io")) containsString(".elb.localhost"))
.extract() .extract()
.path("CreateLoadBalancerResponse.CreateLoadBalancerResult.LoadBalancers.member.LoadBalancerArn"); .path("CreateLoadBalancerResponse.CreateLoadBalancerResult.LoadBalancers.member.LoadBalancerArn");
} }
@@ -70,6 +72,7 @@ class ElbV2IntegrationTest {
void describeLoadBalancerByArn() { void describeLoadBalancerByArn() {
given() given()
.formParam("Action", "DescribeLoadBalancers") .formParam("Action", "DescribeLoadBalancers")
.formParam("Version", ELB_V2_API_VERSION)
.formParam("LoadBalancerArns.member.1", lbArn) .formParam("LoadBalancerArns.member.1", lbArn)
.header("Authorization", AUTH) .header("Authorization", AUTH)
.when() .when()
@@ -82,6 +85,18 @@ class ElbV2IntegrationTest {
equalTo("active")) equalTo("active"))
.body("DescribeLoadBalancersResponse.DescribeLoadBalancersResult.LoadBalancers.member.AvailabilityZones.member.SubnetId", .body("DescribeLoadBalancersResponse.DescribeLoadBalancersResult.LoadBalancers.member.AvailabilityZones.member.SubnetId",
hasItems(Ec2Service.defaultSubnetId("us-east-1", "a"), Ec2Service.defaultSubnetId("us-east-1", "b"))); 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 @Test
@@ -89,6 +104,7 @@ class ElbV2IntegrationTest {
void describeLoadBalancerByName() { void describeLoadBalancerByName() {
given() given()
.formParam("Action", "DescribeLoadBalancers") .formParam("Action", "DescribeLoadBalancers")
.formParam("Version", ELB_V2_API_VERSION)
.formParam("Names.member.1", "my-test-lb") .formParam("Names.member.1", "my-test-lb")
.header("Authorization", AUTH) .header("Authorization", AUTH)
.when() .when()
@@ -1,5 +1,6 @@
package io.github.hectorvent.floci.services.rds; 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.config.EmulatorConfig;
import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.RegionResolver; import io.github.hectorvent.floci.core.common.RegionResolver;
@@ -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.common.docker.DockerHostResolver;
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend; import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
import io.github.hectorvent.floci.core.storage.InMemoryStorage; 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.core.storage.StorageBackend;
import io.github.hectorvent.floci.services.ec2.Ec2Service; import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ec2.model.Subnet; 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 io.github.hectorvent.floci.services.secretsmanager.model.Secret;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Collection; import java.util.Collection;
@@ -231,7 +235,7 @@ class RdsServiceTest {
@Test @Test
void createDbInstanceRemovesVisibleRecordWhenProxyStartupFails() { void createDbInstanceRemovesVisibleRecordWhenProxyStartupFails() {
doThrow(new IllegalStateException("proxy down")) doThrow(new IllegalStateException("proxy down")).doNothing()
.when(proxyManager).startProxy(any(), any(), anyBoolean(), anyInt(), any(), anyInt(), .when(proxyManager).startProxy(any(), any(), anyBoolean(), anyInt(), any(), anyInt(),
any(), any(), any(), any(), any()); any(), any(), any(), any(), any());
@@ -240,6 +244,58 @@ class RdsServiceTest {
"admin", "secret123", null, "db.t3.micro", "admin", "secret123", null, "db.t3.micro",
20, false, null, null, null, null, false)); 20, false, null, null, null, null, false));
assertThrows(AwsException.class, () -> rdsService.getDbInstance("mypostgres")); 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 @Test