io.linguarobot.aws.cdk.maven.StackDeployer Maven / Gradle / Ivy
package io.linguarobot.aws.cdk.maven;
import com.google.common.hash.Hashing;
import io.linguarobot.aws.cdk.AssetMetadata;
import io.linguarobot.aws.cdk.ContainerAssetData;
import io.linguarobot.aws.cdk.ContainerImageAssetMetadata;
import io.linguarobot.aws.cdk.FileAssetData;
import io.linguarobot.aws.cdk.FileAssetMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.CloudFormationException;
import software.amazon.awssdk.services.cloudformation.model.Output;
import software.amazon.awssdk.services.cloudformation.model.Stack;
import software.amazon.awssdk.services.cloudformation.model.StackStatus;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
public class StackDeployer {
private static final Logger logger = LoggerFactory.getLogger(StackDeployer.class);
private static final String BOOTSTRAP_VERSION_OUTPUT = "BootstrapVersion";
private static final String BUCKET_NAME_OUTPUT = "BucketName";
private static final String BUCKET_DOMAIN_NAME_OUTPUT = "BucketDomainName";
private static final String ASSET_PREFIX_SEPARATOR = "||";
private static final String ZIP_PACKAGING = "zip";
private static final String FILE_PACKAGING = "file";
private static final String IMAGE_PACKAGING = "container-image";
private static final int MAX_TEMPLATE_SIZE = 50 * 1024;
private final CloudFormationClient client;
private final Path cloudAssemblyDirectory;
private final ResolvedEnvironment environment;
private final ToolkitConfiguration toolkitConfiguration;
private final FileAssetPublisher fileAssetPublisher;
private final DockerImageAssetPublisher dockerImagePublisher;
public StackDeployer(Path cloudAssemblyDirectory,
ResolvedEnvironment environment,
ToolkitConfiguration toolkitConfiguration,
FileAssetPublisher fileAssetPublisher,
DockerImageAssetPublisher dockerImagePublisher) {
this.cloudAssemblyDirectory = cloudAssemblyDirectory;
this.environment = environment;
this.toolkitConfiguration = toolkitConfiguration;
this.fileAssetPublisher = fileAssetPublisher;
this.dockerImagePublisher = dockerImagePublisher;
this.client = CloudFormationClient.builder()
.region(environment.getRegion())
.credentialsProvider(StaticCredentialsProvider.create(environment.getCredentials()))
.build();
}
public Stack deploy(StackDefinition stackDefinition) {
String stackName = stackDefinition.getStackName();
logger.info("Deploying '{}' stack", stackName);
Map parameters = new HashMap<>();
Stack deployedStack = Stacks.findStack(client, stackName).orElse(null);
if (deployedStack != null) {
if (Stacks.isInProgress(deployedStack)) {
logger.info("Waiting until stack '{}' reaches stable state", deployedStack.stackName());
deployedStack = awaitCompletion(deployedStack);
}
if (deployedStack.stackStatus() == StackStatus.ROLLBACK_COMPLETE || deployedStack.stackStatus() == StackStatus.ROLLBACK_FAILED) {
logger.warn("The stack '{}' is in {} state after unsuccessful creation. The stack will be deleted " +
"and re-created.", stackName, deployedStack.stackStatus());
deployedStack = Stacks.awaitCompletion(client, Stacks.deleteStack(client, deployedStack.stackName()));
}
if (Stacks.isFailed(deployedStack)) {
throw StackDeploymentException.builder(stackName, environment)
.withCause("The stack '" + stackName + "' is in the failed state " + deployedStack.stackStatus())
.build();
}
if (deployedStack.stackStatus() != StackStatus.DELETE_COMPLETE) {
deployedStack.parameters().forEach(p -> parameters.put(p.parameterKey(), ParameterValue.unchanged()));
}
}
stackDefinition.getParameterValues().forEach((name, value) -> parameters.put(name, ParameterValue.value(value)));
Toolkit toolkit = null;
List publishmentTasks = new ArrayList<>();
for (AssetMetadata asset : stackDefinition.getAssets()) {
switch (asset.getPackaging()) {
case FILE_PACKAGING:
case ZIP_PACKAGING:
if (toolkit == null) {
toolkit = getToolkit(stackDefinition);
}
FileAssetMetadata fileAsset = (FileAssetMetadata) asset;
String bucketName = toolkit.getBucketName();
String prefix = generatePrefix(fileAsset);
String filename = generateFilename(fileAsset);
FileAssetData fileData = fileAsset.getData();
parameters.put(fileData.getS3BucketParameter(), ParameterValue.value(toolkit.getBucketName()));
parameters.put(fileData.getS3KeyParameter(), ParameterValue.value(String.join(ASSET_PREFIX_SEPARATOR, prefix, filename)));
parameters.put(fileData.getArtifactHashParameter(), ParameterValue.value(fileAsset.getSourceHash()));
publishmentTasks.add(() -> {
Path file = cloudAssemblyDirectory.resolve(fileAsset.getPath());
try {
fileAssetPublisher.publish(file, prefix + filename, bucketName);
} catch (IOException e) {
throw StackDeploymentException.builder(stackName, environment)
.withCause("An error occurred while publishing the file asset " + file)
.withCause(e)
.build();
}
});
break;
case IMAGE_PACKAGING:
ContainerImageAssetMetadata imageAsset = (ContainerImageAssetMetadata) asset;
publishmentTasks.add(createImagePublishmentTask(stackName, imageAsset));
break;
default:
throw StackDeploymentException.builder(stackName, environment)
.withCause("The asset packaging " + asset.getPackaging() + " is not supported. You might " +
"need to update the plugin in order to support the assets of this type")
.build();
}
}
Map effectiveParameters = parameters.entrySet().stream()
.filter(parameter -> stackDefinition.getParameters().containsKey(parameter.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
TemplateRef templateRef = getTemplateRef(stackDefinition, publishmentTasks);
List missingParameters = stackDefinition.getParameters().values().stream()
.filter(parameterDefinition -> parameterDefinition.getDefaultValue() == null)
.filter(parameterDefinition -> !effectiveParameters.containsKey(parameterDefinition.getName()))
.map(ParameterDefinition::getName)
.collect(Collectors.toList());
if (!missingParameters.isEmpty()) {
throw StackDeploymentException.builder(stackName, environment)
.withCause("The values for the following template parameters are missing: " + String.join(", ", missingParameters))
.build();
}
try {
publishmentTasks.forEach(Runnable::run);
} catch (CdkPluginException e) {
throw StackDeploymentException.builder(stackName, environment)
.withCause(e.getMessage())
.withCause(e.getCause())
.build();
} catch (Exception e) {
throw StackDeploymentException.builder(stackName, environment)
.withCause(e)
.build();
}
boolean updated = true;
Stack stack;
if (deployedStack != null && deployedStack.stackStatus() != StackStatus.DELETE_COMPLETE) {
try {
stack = Stacks.updateStack(client, stackName, templateRef, effectiveParameters);
} catch (CloudFormationException e) {
AwsErrorDetails errorDetails = e.awsErrorDetails();
if (!errorDetails.errorCode().equals("ValidationError") ||
!errorDetails.errorMessage().startsWith("No updates are to be performed")) {
throw e;
}
logger.info("No changes of the '{}' stack are detected. The deployment will be skipped", stackName);
stack = deployedStack;
updated = false;
}
} else {
stack = Stacks.createStack(client, stackName, templateRef, effectiveParameters);
}
if (updated) {
if (!Stacks.isCompleted(stack)) {
logger.info("Waiting until '{}' reaches stable state", stackName);
stack = awaitCompletion(stack);
}
if (Stacks.isFailed(stack)) {
throw StackDeploymentException.builder(stackName, environment)
.withCause("The deployment has failed: " + stack.stackStatus())
.build();
}
if (Stacks.isRolledBack(stack)) {
throw StackDeploymentException.builder(stackName, environment)
.withCause("The deployment has been unsuccessful, the stack has been rolled back to its previous state")
.build();
}
logger.info("The stack '{}' has been successfully deployed", stackName);
}
return stack;
}
private Runnable createImagePublishmentTask(String stackName, ContainerImageAssetMetadata imageAsset) {
Path contextDirectory = cloudAssemblyDirectory.resolve(imageAsset.getPath());
if (!Files.exists(contextDirectory)) {
throw StackDeploymentException.builder(stackName, environment)
.withCause("The Docker context directory doesn't exist: " + contextDirectory)
.build();
}
Path dockerfilePath;
ContainerAssetData imageData = imageAsset.getData();
if (imageData.getFile() != null) {
dockerfilePath = contextDirectory.resolve(imageData.getFile());
if (!Files.exists(dockerfilePath)) {
throw StackDeploymentException.builder(stackName, environment)
.withCause("The Dockerfile doesn't exist: " + dockerfilePath)
.build();
}
} else {
dockerfilePath = findDockerfile(contextDirectory)
.orElseThrow(() -> StackDeploymentException.builder(stackName, environment)
.withCause("Unable to find Dockerfile in the context directory " + contextDirectory)
.build());
}
return () -> {
String localTag = String.join("-", "cdkasset", imageAsset.getId().toLowerCase());
ImageBuild imageBuild = ImageBuild.builder()
.withContextDirectory(contextDirectory)
.withDockerfile(dockerfilePath)
.withImageTag(localTag)
.withArguments(imageData.getBuildArguments())
.withTarget(imageData.getTarget())
.build();
dockerImagePublisher.publish(imageData.getRepositoryName(), imageData.getImageTag(), imageBuild);
};
}
private Optional findDockerfile(Path contextDirectory) {
Path dockerfile = contextDirectory.resolve("Dockerfile");
if (!Files.exists(dockerfile)) {
dockerfile = contextDirectory.resolve("dockerfile");
}
return Optional.of(dockerfile).filter(Files::exists);
}
private TemplateRef getTemplateRef(StackDefinition stackDefinition, List deploymentTasks) {
Path templateFile = cloudAssemblyDirectory.resolve(stackDefinition.getTemplateFile());
TemplateRef templateRef;
try {
templateRef = readTemplateBody(templateFile, MAX_TEMPLATE_SIZE)
.map(TemplateRef::fromString)
.orElse(null);
} catch (IOException e) {
throw StackDeploymentException.builder(stackDefinition.getStackName(), environment)
.withCause("Unable to read the template file: " + templateFile)
.withCause(e)
.build();
}
if (templateRef == null) {
Toolkit toolkit = getToolkit(stackDefinition);
String contentHash;
try {
contentHash = hash(templateFile.toFile());
} catch (IOException e) {
throw StackDeploymentException.builder(stackDefinition.getStackName(), environment)
.withCause("Unable to read the template file: " + templateFile)
.withCause(e)
.build();
}
String objectName = "cdk/" + stackDefinition.getStackName() + "/" + contentHash + ".json";
deploymentTasks.add(() -> {
try {
fileAssetPublisher.publish(templateFile, objectName, toolkit.getBucketName());
} catch (IOException e) {
throw StackDeploymentException.builder(stackDefinition.getStackName(), environment)
.withCause("An error occurred while uploading the template file to the deployment bucket")
.withCause(e)
.build();
}
});
templateRef = TemplateRef.fromUrl("https://" + toolkit.getBucketDomainName() + "/" + objectName);
}
return templateRef;
}
public Optional destroy(StackDefinition stackDefinition) {
Stack stack = Stacks.findStack(client, stackDefinition.getStackName()).orElse(null);
if (stack != null && stack.stackStatus() != StackStatus.DELETE_COMPLETE) {
logger.info("Destroying '{}' stack", stackDefinition.getStackName());
stack = Stacks.deleteStack(client, stack.stackId());
} else {
logger.warn("The generated template for the stack '{}' doesn't have any resources defined. The deployment " +
"will be skipped", stackDefinition.getStackName());
}
return Optional.ofNullable(stack);
}
private String hash(File file) throws IOException {
return com.google.common.io.Files.asByteSource(file).hash(Hashing.sha256()).toString();
}
private String generateFilename(FileAssetMetadata fileAsset) {
StringBuilder fileName = new StringBuilder();
fileName.append(fileAsset.getSourceHash());
if (fileAsset.getPackaging().equals(ZIP_PACKAGING)) {
fileName.append('.').append(ZIP_PACKAGING);
} else {
int extensionDelimiter = fileAsset.getPath().lastIndexOf('.');
if (extensionDelimiter > 0) {
fileName.append(fileAsset.getPath().substring(extensionDelimiter));
}
}
return fileName.toString();
}
private String generatePrefix(FileAssetMetadata fileAsset) {
StringBuilder prefix = new StringBuilder();
prefix.append("assets").append('/');
if (!fileAsset.getId().equals(fileAsset.getSourceHash())) {
prefix.append(fileAsset.getId()).append('/');
}
return prefix.toString();
}
private Optional readTemplateBody(Path template, long limit) throws IOException {
byte[] buffer = new byte[1024 * 8];
ByteArrayOutputStream templateBody = new ByteArrayOutputStream();
int bytesRead;
try (InputStream inputStream = new BufferedInputStream(new FileInputStream(template.toFile()))) {
while (templateBody.size() <= limit && (bytesRead = inputStream.read(buffer)) != -1) {
templateBody.write(buffer, 0, bytesRead);
}
}
if (templateBody.size() > limit) {
return Optional.empty();
}
return Optional.of(templateBody.toString(StandardCharsets.UTF_8.name()));
}
private Toolkit getToolkit(StackDefinition stack) {
Stack toolkitStack = Stacks.findStack(client, toolkitConfiguration.getStackName()).orElse(null);
if (toolkitStack != null && Stacks.isInProgress(toolkitStack)) {
logger.info("Waiting until toolkit stack reaches stable state, environment={}, stackName={}",
environment, toolkitConfiguration.getStackName());
toolkitStack = awaitCompletion(toolkitStack);
}
if (toolkitStack == null || toolkitStack.stackStatus() == StackStatus.DELETE_COMPLETE ||
toolkitStack.stackStatus() == StackStatus.ROLLBACK_COMPLETE) {
throw StackDeploymentException.builder(stack.getStackName(), environment)
.withCause("The stack " + stack.getStackName() + " requires a bootstrap. Did you forged to " +
"add 'bootstrap' goal to the execution")
.build();
}
if (Stacks.isFailed(toolkitStack)) {
throw StackDeploymentException.builder(stack.getStackName(), environment)
.withCause("The toolkit stack is in failed state. Please make sure that the toolkit stack is " +
"stable before the deployment")
.build();
}
Map outputs = toolkitStack.outputs().stream()
.collect(Collectors.toMap(Output::outputKey, Output::outputValue));
if (stack.getRequiredToolkitStackVersion() != null) {
Integer toolkitStackVersion = Optional.ofNullable(outputs.get(BOOTSTRAP_VERSION_OUTPUT))
.map(Integer::parseInt)
.orElse(0);
if (toolkitStackVersion < stack.getRequiredToolkitStackVersion()) {
throw StackDeploymentException.builder(stack.getStackName(), environment)
.withCause("The toolkit stack version is lower than the minimum version required by the " +
"stack. Please update the toolkit stack or add 'bootstrap' goal to the plugin " +
"execution if you want the plugin to automatically create or update toolkit stack")
.build();
}
}
String bucketName = outputs.get(BUCKET_NAME_OUTPUT);
if (bucketName == null) {
throw StackDeploymentException.builder(stack.getStackName(), environment)
.withCause("The toolkit stack " + toolkitConfiguration.getStackName() + " doesn't have a " +
"required output '" + BUCKET_NAME_OUTPUT + "'")
.build();
}
String bucketDomainName = outputs.get(BUCKET_DOMAIN_NAME_OUTPUT);
if (bucketDomainName == null) {
throw StackDeploymentException.builder(stack.getStackName(), environment)
.withCause("The toolkit stack " + toolkitConfiguration.getStackName() + " doesn't have a " +
"required output '" + BUCKET_DOMAIN_NAME_OUTPUT + "'")
.build();
}
return new Toolkit(bucketName, bucketDomainName);
}
private Stack awaitCompletion(Stack stack) {
Stack completedStack;
if (logger.isInfoEnabled()) {
completedStack = Stacks.awaitCompletion(client, stack, new LoggingStackEventListener());
} else {
completedStack = Stacks.awaitCompletion(client, stack);
}
return completedStack;
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy