feat: Fargate, ECS, ALB,Codebuild, CodePipeline
This commit is contained in:
@@ -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`
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-30
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+284
-54
@@ -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,
|
||||||
|
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
|
||||||
|
StorageFactory storageFactory, S3Service s3Service) {
|
||||||
|
this(lambdaService, ecsService, elbV2Service, ssmCommandService, ec2Service, mapper,
|
||||||
|
regionResolver, storageFactory, DEFAULT_HOOK_CALLBACK_TIMEOUT, Clock.systemUTC(), s3Service, Thread::sleep);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
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(), null, 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, 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 resourceNode = firstResource.fields().next().getValue();
|
|
||||||
JsonNode props = resourceNode.path("Properties");
|
|
||||||
info.functionName = props.path("Name").asText(null);
|
|
||||||
info.aliasName = props.path("Alias").asText(null);
|
|
||||||
info.currentVersion = props.path("CurrentVersion").asText(null);
|
|
||||||
info.targetVersion = props.path("TargetVersion").asText(null);
|
|
||||||
}
|
}
|
||||||
|
JsonNode resources = root.path("Resources");
|
||||||
|
if (!resources.isArray() || resources.size() != 1 || !resources.get(0).isObject()
|
||||||
|
|| resources.get(0).size() != 1) {
|
||||||
|
throw new IllegalArgumentException("Lambda AppSpec must contain exactly one resource");
|
||||||
|
}
|
||||||
|
JsonNode resourceNode = resources.get(0).elements().next();
|
||||||
|
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) {
|
||||||
for (JsonNode hook : hooks) {
|
if (!hooks.isArray()) {
|
||||||
if (hook.has("BeforeAllowTraffic")) {
|
throw new IllegalArgumentException("Hooks must be an array");
|
||||||
info.beforeAllowTraffic = hook.get("BeforeAllowTraffic").asText(null);
|
|
||||||
}
|
}
|
||||||
if (hook.has("AfterAllowTraffic")) {
|
for (JsonNode hook : hooks) {
|
||||||
info.afterAllowTraffic = hook.get("AfterAllowTraffic").asText(null);
|
if (!hook.isObject() || hook.size() != 1) {
|
||||||
|
throw new IllegalArgumentException("Each Hook must contain exactly one lifecycle event");
|
||||||
|
}
|
||||||
|
Map.Entry<String, JsonNode> entry = hook.fields().next();
|
||||||
|
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,7 +1637,21 @@ 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. */
|
||||||
|
private void waitForGreenTargets(String region, String greenTgArn, 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,
|
Map<String, Object> ecsTargetMap, List<String> listenerArns,
|
||||||
String blueTgArn, String greenTgArn,
|
String blueTgArn, String greenTgArn,
|
||||||
AtomicBoolean stopFlag) throws InterruptedException {
|
AtomicBoolean stopFlag) throws InterruptedException {
|
||||||
@@ -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,16 +1831,22 @@ 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);
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+511
-35
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -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 {
|
||||||
|
|||||||
+28
-5
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+94
@@ -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()
|
||||||
|
|||||||
+15
@@ -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");
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package io.github.hectorvent.floci.services.codebuild;
|
||||||
|
|
||||||
|
import com.github.dockerjava.api.DockerClient;
|
||||||
|
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import org.junit.jupiter.api.Assumptions;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class CodeBuildDockerIntegrationTest {
|
||||||
|
|
||||||
|
private static final String CONTENT_TYPE = "application/x-amz-json-1.1";
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DockerClient dockerClient;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void configureRestAssured() {
|
||||||
|
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void requireDocker() {
|
||||||
|
try {
|
||||||
|
dockerClient.pingCmd().exec();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Assumptions.assumeTrue(false,
|
||||||
|
"Docker daemon must be available for CodeBuild Docker integration tests");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void privilegedBuildCanUseMountedDockerSocket() throws InterruptedException {
|
||||||
|
String projectName = "codebuild-docker-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
given()
|
||||||
|
.header("X-Amz-Target", "CodeBuild_20161006.CreateProject")
|
||||||
|
.contentType(CONTENT_TYPE)
|
||||||
|
.body("""
|
||||||
|
{
|
||||||
|
"name": "%s",
|
||||||
|
"source": {"type": "NO_SOURCE"},
|
||||||
|
"artifacts": {"type": "NO_ARTIFACTS"},
|
||||||
|
"environment": {
|
||||||
|
"type": "LINUX_CONTAINER",
|
||||||
|
"image": "docker:27-cli",
|
||||||
|
"computeType": "BUILD_GENERAL1_SMALL",
|
||||||
|
"privilegedMode": true
|
||||||
|
},
|
||||||
|
"serviceRole": "arn:aws:iam::000000000000:role/codebuild-role"
|
||||||
|
}
|
||||||
|
""".formatted(projectName))
|
||||||
|
.when()
|
||||||
|
.post("/")
|
||||||
|
.then()
|
||||||
|
.statusCode(200);
|
||||||
|
|
||||||
|
String buildId = given()
|
||||||
|
.header("X-Amz-Target", "CodeBuild_20161006.StartBuild")
|
||||||
|
.contentType(CONTENT_TYPE)
|
||||||
|
.body("""
|
||||||
|
{
|
||||||
|
"projectName": "%s",
|
||||||
|
"buildspecOverride": "version: 0.2\\nphases:\\n build:\\n commands:\\n - docker version\\n - printf 'FROM scratch\\\\n' > Dockerfile\\n - docker build -t floci-codebuild-socket-test .\\n - docker image rm floci-codebuild-socket-test\\n"
|
||||||
|
}
|
||||||
|
""".formatted(projectName))
|
||||||
|
.when()
|
||||||
|
.post("/")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.path("build.id");
|
||||||
|
|
||||||
|
String status = waitForBuild(buildId);
|
||||||
|
assertEquals("SUCCEEDED", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String waitForBuild(String buildId) throws InterruptedException {
|
||||||
|
for (int attempts = 0; attempts < 120; attempts++) {
|
||||||
|
String status = given()
|
||||||
|
.header("X-Amz-Target", "CodeBuild_20161006.BatchGetBuilds")
|
||||||
|
.contentType(CONTENT_TYPE)
|
||||||
|
.body("{\"ids\":[\"" + buildId + "\"]}")
|
||||||
|
.when()
|
||||||
|
.post("/")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.path("builds[0].buildStatus");
|
||||||
|
if (!"IN_PROGRESS".equals(status)) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
Thread.sleep(500);
|
||||||
|
}
|
||||||
|
return "TIMED_OUT";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package io.github.hectorvent.floci.services.codebuild;
|
||||||
|
|
||||||
|
import io.github.hectorvent.floci.config.EmulatorConfig;
|
||||||
|
import io.github.hectorvent.floci.core.common.docker.ContainerBuilder;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class CodeBuildRunnerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void privilegedBuildMountsConfiguredDockerSocket() {
|
||||||
|
ContainerBuilder.Builder container = mock(ContainerBuilder.Builder.class);
|
||||||
|
|
||||||
|
CodeBuildRunner.exposeDockerSocketIfPrivileged(container, true,
|
||||||
|
dockerConfig("unix:///run/user/1000/docker.sock"));
|
||||||
|
|
||||||
|
verify(container).withBind("/run/user/1000/docker.sock", "/var/run/docker.sock");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unprivilegedBuildCannotAccessDockerSocket() {
|
||||||
|
ContainerBuilder.Builder container = mock(ContainerBuilder.Builder.class);
|
||||||
|
|
||||||
|
CodeBuildRunner.exposeDockerSocketIfPrivileged(container, false,
|
||||||
|
dockerConfig("unix:///run/user/1000/docker.sock"));
|
||||||
|
|
||||||
|
verifyNoInteractions(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void privilegedBuildDoesNotInventSocketForTcpDockerHost() {
|
||||||
|
ContainerBuilder.Builder container = mock(ContainerBuilder.Builder.class);
|
||||||
|
|
||||||
|
CodeBuildRunner.exposeDockerSocketIfPrivileged(container, true,
|
||||||
|
dockerConfig("tcp://docker.example.test:2376"));
|
||||||
|
|
||||||
|
verifyNoInteractions(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmulatorConfig.DockerConfig dockerConfig(String dockerHost) {
|
||||||
|
EmulatorConfig.DockerConfig dockerConfig = mock(EmulatorConfig.DockerConfig.class);
|
||||||
|
when(dockerConfig.dockerHost()).thenReturn(dockerHost);
|
||||||
|
when(dockerConfig.dockerConfigPath()).thenReturn(Optional.empty());
|
||||||
|
return dockerConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
-7
@@ -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) {
|
||||||
@@ -327,6 +504,10 @@ class CodeDeployServiceTest {
|
|||||||
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:
|
||||||
@@ -334,9 +515,8 @@ class CodeDeployServiceTest {
|
|||||||
- 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);
|
||||||
}
|
}
|
||||||
|
|||||||
+526
-4
@@ -8,29 +8,45 @@ import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
|
|||||||
import io.github.hectorvent.floci.core.storage.StorageFactory;
|
import io.github.hectorvent.floci.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);
|
||||||
|
|||||||
+4
-4
@@ -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);
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
package io.github.hectorvent.floci.services.ecs;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import io.github.hectorvent.floci.config.EmulatorConfig;
|
||||||
|
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||||
|
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
|
||||||
|
import io.github.hectorvent.floci.core.storage.StorageBackend;
|
||||||
|
import io.github.hectorvent.floci.core.storage.StorageFactory;
|
||||||
|
import io.github.hectorvent.floci.services.ecs.container.EcsContainerManager;
|
||||||
|
import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition;
|
||||||
|
import io.github.hectorvent.floci.services.ecs.model.CreateTaskSetRequest;
|
||||||
|
import io.github.hectorvent.floci.services.ecs.model.EcsTask;
|
||||||
|
import io.github.hectorvent.floci.services.ecs.model.LaunchType;
|
||||||
|
import io.github.hectorvent.floci.services.ecs.model.TaskDefinition;
|
||||||
|
import io.github.hectorvent.floci.services.ecs.model.TaskSet;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class EcsEc2ServiceReconciliationTest {
|
||||||
|
|
||||||
|
private static final String REGION = "us-east-1";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void taskDefinitionUpdateReplacesAndDrainsEc2ServiceTasks() {
|
||||||
|
EcsService service = newMockModeService();
|
||||||
|
service.createCluster("roll-cluster", REGION);
|
||||||
|
TaskDefinition revisionOne = registerTaskDefinition(service, "roll", "app:1");
|
||||||
|
service.createService("roll-cluster", "web", revisionOne.getTaskDefinitionArn(), 2,
|
||||||
|
LaunchType.EC2, List.of(), null, REGION);
|
||||||
|
service.reconcileServices();
|
||||||
|
|
||||||
|
TaskDefinition revisionTwo = registerTaskDefinition(service, "roll", "app:2");
|
||||||
|
service.updateService("roll-cluster", "web", revisionTwo.getTaskDefinitionArn(), null, null, REGION);
|
||||||
|
|
||||||
|
service.reconcileServices();
|
||||||
|
assertEquals(4, runningTasks(service).size(), "replacements start before old tasks drain");
|
||||||
|
service.reconcileServices();
|
||||||
|
|
||||||
|
List<EcsTask> running = runningTasks(service);
|
||||||
|
assertEquals(2, running.size());
|
||||||
|
assertTrue(running.stream().allMatch(task -> revisionTwo.getTaskDefinitionArn()
|
||||||
|
.equals(task.getTaskDefinitionArn())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forceDeleteDrainsAllEc2ServiceTasks() {
|
||||||
|
EcsService service = newMockModeService();
|
||||||
|
service.createCluster("delete-cluster", REGION);
|
||||||
|
TaskDefinition taskDefinition = registerTaskDefinition(service, "delete", "app:1");
|
||||||
|
service.createService("delete-cluster", "worker", taskDefinition.getTaskDefinitionArn(), 2,
|
||||||
|
LaunchType.EC2, List.of(), null, REGION);
|
||||||
|
service.reconcileServices();
|
||||||
|
|
||||||
|
service.deleteService("delete-cluster", "worker", true, REGION);
|
||||||
|
|
||||||
|
assertEquals(0, runningTasks(service).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void codeDeployTaskSetsAreNotSupplementedByNormalEc2ServiceTasks() {
|
||||||
|
EcsService service = newMockModeService();
|
||||||
|
service.createCluster("blue-green-cluster", REGION);
|
||||||
|
TaskDefinition taskDefinition = registerTaskDefinition(service, "blue-green", "app:1");
|
||||||
|
service.createService("blue-green-cluster", "web", taskDefinition.getTaskDefinitionArn(), 2,
|
||||||
|
LaunchType.EC2, List.of(), null, null, null, "CODE_DEPLOY", null, REGION);
|
||||||
|
CreateTaskSetRequest request = new CreateTaskSetRequest();
|
||||||
|
request.setCluster("blue-green-cluster");
|
||||||
|
request.setService("web");
|
||||||
|
request.setTaskDefinition(taskDefinition.getTaskDefinitionArn());
|
||||||
|
request.setLaunchType(LaunchType.EC2);
|
||||||
|
TaskSet taskSet = service.createTaskSet(request, REGION);
|
||||||
|
|
||||||
|
service.reconcileServices();
|
||||||
|
|
||||||
|
assertEquals(2, taskSet.getRunningCount());
|
||||||
|
assertEquals(2, runningTasks(service).size());
|
||||||
|
|
||||||
|
service.deleteService("blue-green-cluster", "web", true, REGION);
|
||||||
|
|
||||||
|
assertEquals(0, runningTasks(service).size(), "force delete drains CodeDeploy task-set tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recreatingAnEc2CodeDeployServiceDoesNotReuseDeletedTaskSets() {
|
||||||
|
EcsService service = newMockModeService();
|
||||||
|
service.createCluster("ec2-recreate-cluster", REGION);
|
||||||
|
TaskDefinition taskDefinition = registerTaskDefinition(service, "ec2-recreate", "app:1");
|
||||||
|
service.createService("ec2-recreate-cluster", "blue", taskDefinition.getTaskDefinitionArn(), 1,
|
||||||
|
LaunchType.EC2, List.of(), null, null, null, "CODE_DEPLOY", null, REGION);
|
||||||
|
CreateTaskSetRequest request = new CreateTaskSetRequest();
|
||||||
|
request.setCluster("ec2-recreate-cluster");
|
||||||
|
request.setService("blue");
|
||||||
|
request.setTaskDefinition(taskDefinition.getTaskDefinitionArn());
|
||||||
|
request.setLaunchType(LaunchType.EC2);
|
||||||
|
service.createTaskSet(request, REGION);
|
||||||
|
|
||||||
|
service.deleteService("ec2-recreate-cluster", "blue", true, REGION);
|
||||||
|
service.createService("ec2-recreate-cluster", "blue", taskDefinition.getTaskDefinitionArn(), 1,
|
||||||
|
LaunchType.EC2, List.of(), null, null, null, "CODE_DEPLOY", null, REGION);
|
||||||
|
service.reconcileServices();
|
||||||
|
|
||||||
|
assertEquals(List.of(), service.describeTaskSets("ec2-recreate-cluster", "blue", null, REGION));
|
||||||
|
assertEquals(List.of(), runningTasks(service));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<EcsTask> runningTasks(EcsService service) {
|
||||||
|
return service.describeTasks(null, service.listTasks(null, null, null, null, REGION), REGION).stream()
|
||||||
|
.filter(task -> "RUNNING".equals(task.getLastStatus()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskDefinition registerTaskDefinition(EcsService service, String family, String image) {
|
||||||
|
ContainerDefinition container = new ContainerDefinition();
|
||||||
|
container.setName("app");
|
||||||
|
container.setImage(image);
|
||||||
|
return service.registerTaskDefinition(family, List.of(container), null, null, null,
|
||||||
|
null, null, List.of(), REGION);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EcsService newMockModeService() {
|
||||||
|
EmulatorConfig config = mock(EmulatorConfig.class, RETURNS_DEEP_STUBS);
|
||||||
|
when(config.services().ecs().mock()).thenReturn(true);
|
||||||
|
when(config.effectiveBaseUrl()).thenReturn("http://localhost:4566");
|
||||||
|
EcsService service = new EcsService(
|
||||||
|
new RegionResolver(REGION, "000000000000"),
|
||||||
|
mock(EcsContainerManager.class),
|
||||||
|
config,
|
||||||
|
mock(EcsLoadBalancerRegistrar.class),
|
||||||
|
new InMemoryStorageFactory(),
|
||||||
|
null);
|
||||||
|
service.initializeStorage();
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class InMemoryStorageFactory extends StorageFactory {
|
||||||
|
private final Map<String, StorageBackend<String, ?>> stores = new HashMap<>();
|
||||||
|
|
||||||
|
private InMemoryStorageFactory() {
|
||||||
|
super(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <V> AccountAwareStorageBackend<V> create(String serviceName, String fileName,
|
||||||
|
TypeReference<Map<String, V>> typeReference) {
|
||||||
|
return (AccountAwareStorageBackend<V>) stores.computeIfAbsent(fileName,
|
||||||
|
ignored -> AccountAwareStorageBackend.inMemory("000000000000"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -56,6 +56,13 @@ class EcsLoadBalancersIntegrationTest {
|
|||||||
return targetGroup.getTargetGroupArn();
|
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<>();
|
||||||
|
|
||||||
|
|||||||
+42
@@ -7,10 +7,14 @@ import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
|
|||||||
import io.github.hectorvent.floci.core.storage.StorageBackend;
|
import io.github.hectorvent.floci.core.storage.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
|
||||||
|
|||||||
Reference in New Issue
Block a user