StackSetManager.java

/*
 * This file is part of the pl.wrzasq.lambda.
 *
 * @license http://mit-license.org/ The MIT license
 * @copyright 2019 © by Rafał Wrzeszcz - Wrzasq.pl.
 */

package pl.wrzasq.lambda.cform.stackset.service;

import java.util.Collection;
import java.util.List;

import com.amazonaws.services.cloudformation.AmazonCloudFormation;
import com.amazonaws.services.cloudformation.model.Capability;
import com.amazonaws.services.cloudformation.model.CreateStackSetRequest;
import com.amazonaws.services.cloudformation.model.DeleteStackSetRequest;
import com.amazonaws.services.cloudformation.model.DescribeStackSetRequest;
import com.amazonaws.services.cloudformation.model.OperationIdAlreadyExistsException;
import com.amazonaws.services.cloudformation.model.Parameter;
import com.amazonaws.services.cloudformation.model.StackSetNotFoundException;
import com.amazonaws.services.cloudformation.model.Tag;
import com.amazonaws.services.cloudformation.model.UpdateStackSetRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import pl.wrzasq.commons.aws.cloudformation.CustomResourceResponse;
import pl.wrzasq.commons.aws.cloudformation.StackSetHandler;
import pl.wrzasq.commons.aws.cloudformation.StackUtils;
import pl.wrzasq.lambda.cform.stackset.model.StackSetRequest;
import pl.wrzasq.lambda.cform.stackset.model.StackSetResponse;

/**
 * CloudFormation API implementation.
 */
public class StackSetManager {
    /**
     * Message pattern for drift case.
     */
    private static final String DRIFT_LOG_MESSAGE_PATTERN
        = "Stack set ID {} differs from CloudFormation-provided physical resource ID {}.";

    /**
     * Cast array.
     */
    private static final Capability[] CAPABILITY_CAST = new Capability[0];

    /**
     * Logger.
     */
    private Logger logger = LoggerFactory.getLogger(StackSetManager.class);

    /**
     * AWS CloudFormation API client.
     */
    private AmazonCloudFormation cloudFormation;

    /**
     * Stack set operations helper.
     */
    private StackSetHandler stackSetHandler;

    /**
     * Initializes object with given CloudFormation client.
     *
     * @param cloudFormation AWS CloudFormation client.
     * @param stackSetHandler Stack set operations helper.
     */
    public StackSetManager(AmazonCloudFormation cloudFormation, StackSetHandler stackSetHandler) {
        this.cloudFormation = cloudFormation;
        this.stackSetHandler = stackSetHandler;
    }

    /**
     * Handles stack set deployment.
     *
     * @param input Resource deployment request.
     * @param physicalResourceId Physical ID of existing resource (if present).
     * @return Data about published version.
     */
    public CustomResourceResponse<StackSetResponse> deployStackSet(StackSetRequest input, String physicalResourceId) {
        var response = new StackSetResponse();

        try {
            var stackSet = this.cloudFormation.describeStackSet(
                new DescribeStackSetRequest()
                    .withStackSetName(input.getStackSetName())
            )
                .getStackSet();
            this.logger.info("Stack set already exists (ARN {}).", stackSet.getStackSetARN());

            this.updateStackSet(input);

            // don't do anything here - in worst case it will fail in Delete call in UPDATE_COMPLETE_CLEANUP phase
            // it will not hurt us (even if fails, as it's post-update phase) and allows to simplify logic of this
            // method to avoid handling all combinations, which could lead to unhandled edge cases
            if (!stackSet.getStackSetId().equals(physicalResourceId)) {
                this.logger.warn(
                    StackSetManager.DRIFT_LOG_MESSAGE_PATTERN,
                    stackSet.getStackSetId(),
                    physicalResourceId
                );
            }

            response.setId(stackSet.getStackSetId());
        } catch (StackSetNotFoundException error) {
            response.setId(this.createStackSet(input));
        }

        response.setStackSetName(input.getStackSetName());
        return new CustomResourceResponse<>(response, response.getId());
    }

    /**
     * Handles stack set deletion.
     *
     * @param input Resource delete request.
     * @param physicalResourceId Physical ID of existing resource (if present).
     * @return Empty response.
     */
    public CustomResourceResponse<StackSetResponse> deleteStackSet(StackSetRequest input, String physicalResourceId) {
        var stackSet = this.cloudFormation.describeStackSet(
            new DescribeStackSetRequest()
                .withStackSetName(input.getStackSetName())
        )
            .getStackSet();

        // avoid removing unknown data
        if (!stackSet.getStackSetId().equals(physicalResourceId)) {
            this.logger.error(
                StackSetManager.DRIFT_LOG_MESSAGE_PATTERN,
                stackSet.getStackSetId(),
                physicalResourceId
            );
            throw new IllegalStateException(
                String.format(
                    "Can not delete Stack set - ID %s doesn't match CloudFormation-provided resource ID %s.",
                    stackSet.getStackSetId(),
                    physicalResourceId
                )
            );
        }

        this.cloudFormation.deleteStackSet(
            new DeleteStackSetRequest()
                .withStackSetName(input.getStackSetName())
        );

        this.logger.info("Stack set deleted.");

        return new CustomResourceResponse<>(null, physicalResourceId);
    }

    /**
     * Creates new stack set.
     *
     * @param input Stack set specification.
     * @return Created stack set ID.
     */
    private String createStackSet(StackSetRequest input) {
        var result = this.cloudFormation.createStackSet(
            new CreateStackSetRequest()
                .withStackSetName(input.getStackSetName())
                .withTemplateURL(input.getTemplateUrl())
                .withDescription(input.getDescription())
                .withAdministrationRoleARN(input.getAdministrationRoleArn())
                .withExecutionRoleName(input.getExecutionRoleName())
                .withCapabilities(input.getCapabilities().toArray(StackSetManager.CAPABILITY_CAST))
                .withParameters(StackSetManager.buildSdkParameters(input))
                .withTags(StackSetManager.buildSdkTags(input))
        );

        this.logger.info("Created new stack set, ID {}.", result.getStackSetId());

        return result.getStackSetId();
    }

    /**
     * Updates existing stack set.
     *
     * @param input Stack set specification.
     */
    private void updateStackSet(StackSetRequest input) {
        // operation ID seed
        var attempt = 0;
        var hash = String.format(
            "hash-%d",
            List.of(
                input.getTemplateUrl(),
                input.getDescription(),
                input.getAdministrationRoleArn(),
                input.getExecutionRoleName(),
                input.getCapabilities(),
                input.getParameters(),
                input.getTags()
            )
                .hashCode()
        );

        do {
            try {
                // if the operation ID will match latest one it will be immediate success, but the ID could have been
                // used in past already - in such case we need to make sure to make new ID
                var result = this.cloudFormation.updateStackSet(
                    new UpdateStackSetRequest()
                        .withStackSetName(input.getStackSetName())
                        .withOperationId(String.format("%s-%d", hash, attempt))
                        .withTemplateURL(input.getTemplateUrl())
                        .withDescription(input.getDescription())
                        .withAdministrationRoleARN(input.getAdministrationRoleArn())
                        .withExecutionRoleName(input.getExecutionRoleName())
                        .withCapabilities(input.getCapabilities().toArray(StackSetManager.CAPABILITY_CAST))
                        .withParameters(StackSetManager.buildSdkParameters(input))
                        .withTags(StackSetManager.buildSdkTags(input))
                );

                this.stackSetHandler.waitForStackSetOperation(input.getStackSetName(), result.getOperationId());

                this.logger.info("Updated stack set (operation ID: {}).", result.getOperationId());
                return;
            } catch (OperationIdAlreadyExistsException error) {
                this.logger.info("ID already used, trying next attempt.");
                ++attempt;
            }
        } while (true);
    }

    /**
     * Converts key-value mapping into AWS SDK structure.
     *
     * @param input Request data.
     * @return Collection of AWS SDK DTOs.
     */
    private static Collection<Parameter> buildSdkParameters(StackSetRequest input) {
        return StackUtils.buildSdkList(
            input.getParameters(),
            (key, value) ->
                new Parameter()
                    .withParameterKey(key)
                    .withParameterValue(value)
        );
    }

    /**
     * Converts key-value mapping into AWS SDK structure.
     *
     * @param input Request data.
     * @return Collection of AWS SDK DTOs.
     */
    private static Collection<Tag> buildSdkTags(StackSetRequest input) {
        return StackUtils.buildSdkList(
            input.getTags(),
            (key, value) ->
                new Tag()
                    .withKey(key)
                    .withValue(value)
        );
    }
}