ProcessedTemplate.java

// Generated by delombok at Tue Apr 06 14:12:31 UTC 2021
/*
 * This file is part of the pl.wrzasq.lambda.
 *
 * @license http://mit-license.org/ The MIT license
 * @copyright 2020 © by Rafał Wrzeszcz - Wrzasq.pl.
 */
package pl.wrzasq.lambda.macro.pipeline.multistagecd.template;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import pl.wrzasq.commons.aws.cloudformation.macro.TemplateDefinition;
import pl.wrzasq.commons.aws.cloudformation.macro.TemplateUtils;
import pl.wrzasq.commons.json.ObjectMapperFactory;
import pl.wrzasq.lambda.macro.pipeline.multistagecd.model.PipelineAction;
import pl.wrzasq.lambda.macro.pipeline.multistagecd.model.PipelineArtifact;
import pl.wrzasq.lambda.macro.pipeline.multistagecd.model.PipelineConfiguraiton;
import pl.wrzasq.lambda.macro.pipeline.multistagecd.model.PipelineDefinition;
import pl.wrzasq.lambda.macro.pipeline.multistagecd.model.PipelineStage;
import pl.wrzasq.lambda.macro.pipeline.multistagecd.model.codepipeline.ActionTypeId;

/**
 * Contains template structure after macro logic transformation.
 */
public class ProcessedTemplate implements TemplateDefinition {
    /**
     * Artificial "Pipeline" section.
     */
    private static final String SECTION_PIPELINE = "Pipeline";
    /**
     * !Ref "AWS::NoValue" expression.
     */
    private static final Object NO_VALUE = TemplateUtils.ref("AWS::NoValue");
    /**
     * CloudFormation artifact path separator.
     */
    private static final String ARTIFACT_SEPARATOR = "::";
    /**
     * Values converter.
     */
    private static ObjectMapper objectMapper = ObjectMapperFactory.createObjectMapper();
    /**
     * Logger.
     */
    private static Logger logger = LoggerFactory.getLogger(ProcessedTemplate.class);
    /**
     * Template structure.
     */
    private Map<String, Object> template;

    /**
     * Template initializer.
     *
     * @param input Initial template structure.
     */
    public ProcessedTemplate(Map<String, Object> input) {
        this.template = input.containsKey(ProcessedTemplate.SECTION_PIPELINE) ? this.processTemplate(input) : 
        // nothing to process
        input;
    }

    /**
     * Builds pipeline resource.
     *
     * @param input Initial template structure.
     * @return Template state after processing.
     */
    private Map<String, Object> processTemplate(Map<String, Object> input) {
        logger.info("Found pipeline definition.");
        // default values are set in the classes
        var pipeline = ProcessedTemplate.objectMapper.convertValue(input.get(ProcessedTemplate.SECTION_PIPELINE), PipelineDefinition.class);
        input.remove(ProcessedTemplate.SECTION_PIPELINE);
        var output = new HashMap<>(input);
        // pipeline metadata setup
        output.put(TemplateUtils.SECTION_CONDITIONS, this.buildConditions(TemplateUtils.asMap(input.computeIfAbsent(TemplateUtils.SECTION_CONDITIONS, key -> new HashMap<>())), pipeline.getConfig()));
        output.put(TemplateUtils.SECTION_RESOURCES, this.buildResources(TemplateUtils.asMap(input.computeIfAbsent(TemplateUtils.SECTION_RESOURCES, key -> new HashMap<>())), pipeline));
        return output;
    }

    /**
     * Builds conditions used by pipeline.
     *
     * @param conditions Initial conditions.
     * @param config Pipeline config.
     * @return Template conditions.
     */
    private Map<String, Object> buildConditions(Map<String, Object> conditions, PipelineConfiguraiton config) {
        conditions.put(config.getHasCheckoutStepConditionName(), ProcessedTemplate.buildBooleanCondition(config.getHasCheckoutStepParameterName()));
        conditions.put(config.getHasNextStageConditionName(), ProcessedTemplate.buildBooleanCondition(config.getHasNextStageParameterName()));
        conditions.put(config.getRequiresManualApprovalConditionName(), Collections.singletonMap("Fn::And", Arrays.asList(Collections.singletonMap("Condition", config.getHasNextStageConditionName()), ProcessedTemplate.buildBooleanCondition(config.getRequiresManualApprovalParameterName()))));
        return conditions;
    }

    /**
     * Builds resources used by pipeline.
     *
     * @param resources Resources container.
     * @param pipeline Pipeline definition.
     * @return Template conditions.
     */
    private Map<String, Object> buildResources(Map<String, Object> resources, PipelineDefinition pipeline) {
        var config = pipeline.getConfig();
        resources.put(config.getResourceName(), this.buildPipelineDefinition(pipeline));
        // GitHub webhook
        if (config.getWebhookAuthenticationType() != null && config.getWebhookSecretToken() != null) {
            resources.put(String.format("%sWebhook", config.getResourceName()), this.buildWebhookDefinition(config, pipeline.getSources().keySet().iterator().next()));
        }
        return resources;
    }

    /**
     * Build pipeline definition.
     *
     * @param pipeline Pipeline definition.
     * @return Resource definition.
     */
    private Map<String, Object> buildPipelineDefinition(PipelineDefinition pipeline) {
        var config = pipeline.getConfig();
        var artifacts = pipeline.getArtifacts();
        var stages = new ArrayList<>();
        stages.add(this.buildSourceStage(config, pipeline.getSources(), artifacts));
        stages.addAll(this.buildStages(pipeline.getStages()));
        stages.add(this.buildManualApprovalStage(config));
        stages.add(this.buildPromoteStage(config, artifacts));
        // finalize properties
        var properties = new HashMap<>(pipeline.getProperties());
        properties.put("Stages", stages);
        return TemplateUtils.generateResource("CodePipeline::Pipeline", properties, null);
    }

    /**
     * Builds sources stage.
     *
     * @param config Pipeline configuration.
     * @param sources Source locations.
     * @param artifacts Previous stage artifacts.
     * @return Stage definition.
     */
    private Map<String, Object> buildSourceStage(PipelineConfiguraiton config, Map<String, Map<String, Object>> sources, Map<String, PipelineArtifact> artifacts) {
        return CodePipelineUtils.buildStage("Source", ProcessedTemplate.fnIf(config.getHasCheckoutStepConditionName(), this.buildCheckoutSources(sources), this.buildPromotedSources(artifacts)));
    }

    /**
     * Builds sources action steps.
     *
     * @param sources Source locations.
     * @return Stage actions.
     */
    private Object buildCheckoutSources(Map<String, Map<String, Object>> sources) {
        var steps = new ArrayList<>(sources.size());
        // keeping order to avoid structure changes in CloudFormation
        for (var id : new TreeSet<>(sources.keySet())) {
            steps.add(ProcessedTemplate.buildActionDefinition(sources.get(id), PipelineAction.builder().name(id).outputs(Collections.singletonList(id)).build()));
        }
        return steps;
    }

    /**
     * Builds sources action steps.
     *
     * @param artifacts Previous stage artifacts.
     * @return Stage actions.
     */
    private Object buildPromotedSources(Map<String, PipelineArtifact> artifacts) {
        var steps = new ArrayList<>(artifacts.size());
        var runOrder = 0;
        var type = ActionTypeId.s3Source();
        /*
        having sources in reverse direction than in promote step, makes us sure that each next stage pipeline is
        launched only when all previous stage artifacts are properly uploaded
         */
        for (var id : new TreeSet<>(artifacts.keySet()).descendingSet()) {
            var artifact = artifacts.get(id);
            var actionConfig = new HashMap<String, Object>();
            actionConfig.put("S3Bucket", artifact.getSourceBucketName());
            actionConfig.put("S3ObjectKey", artifact.getObjectKey());
            steps.add(ProcessedTemplate.buildActionDefinition(PipelineAction.builder().name(id).type(type).configuration(actionConfig).outputs(Collections.singletonList(id)).runOrder(++runOrder).build()));
        }
        return steps;
    }

    /**
     * Builds pipelines stage definition.
     *
     * @param stages Stages definitions.
     * @return Stages definition.
     */
    private List<Object> buildStages(List<PipelineStage> stages) {
        return stages.stream().map(stage -> stage.getCondition() == null ? this.buildStage(stage) : ProcessedTemplate.fnIf(stage.getCondition(), this.buildStage(stage), ProcessedTemplate.NO_VALUE)).collect(Collectors.toList());
    }

    /**
     * Builds manual approval stage definition.
     *
     * @param config Pipeline configuration.
     * @return Stage definition.
     */
    private Object buildManualApprovalStage(PipelineConfiguraiton config) {
        return ProcessedTemplate.fnIf(config.getRequiresManualApprovalConditionName(), CodePipelineUtils.buildStage("Review", Collections.singletonList(ProcessedTemplate.buildActionDefinition(PipelineAction.builder().name("Approval").type(ActionTypeId.manualApproval()).build()))), ProcessedTemplate.NO_VALUE);
    }

    /**
     * Builds artifacts promotion stage definition.
     *
     * @param config Pipeline configuration.
     * @param artifacts Artifacts configuration.
     * @return Stage definition.
     */
    private Object buildPromoteStage(PipelineConfiguraiton config, Map<String, PipelineArtifact> artifacts) {
        var steps = new ArrayList<>(artifacts.size());
        var runOrder = 0;
        for (var id : new TreeSet<>(artifacts.keySet())) {
            var artifact = artifacts.get(id);
            var type = ActionTypeId.s3Deploy();
            var actionConfig = new HashMap<String, Object>();
            actionConfig.put("BucketName", artifact.getNextBucketName());
            actionConfig.put("ObjectKey", artifact.getObjectKey());
            actionConfig.put("Extract", false);
            // this shifts ownership to target account in case (usual scenario) of cross-account deployment
            actionConfig.put("CannedACL", "bucket-owner-full-control");
            steps.add(ProcessedTemplate.buildActionDefinition(PipelineAction.builder().name(id).type(type).configuration(actionConfig).inputs(Collections.singletonList(id)).runOrder(++runOrder).build()));
        }
        return ProcessedTemplate.fnIf(config.getHasNextStageConditionName(), CodePipelineUtils.buildStage("Promote", steps), ProcessedTemplate.NO_VALUE);
    }

    /**
     * Builds pipeline stage definition structure.
     *
     * @param stage Stage setup.
     * @return CodePipeline stage definition.
     */
    private Map<String, Object> buildStage(PipelineStage stage) {
        var actions = stage.getActions();
        actions.forEach(ProcessedTemplate::normalizeAction);
        ProcessedTemplate.orderActions(actions);
        return CodePipelineUtils.buildStage(stage.getName(), actions.stream().map(ProcessedTemplate::buildActionDefinition).collect(Collectors.toList()));
    }

    /**
     * Normalizes all actions.
     *
     * @param action Action setup.
     */
    private static void normalizeAction(PipelineAction action) {
        if (action.getType() == null) {
            action.setType(ActionTypeId.cloudFormationDeploy());
        }
        action.setInputs(ProcessedTemplate.detectInputArtifacts(action));
    }

    /**
     * Orders all actions.
     *
     * @param actions Stage actions.
     */
    private static void orderActions(List<PipelineAction> actions) {
        var visited = new HashSet<PipelineAction>();
        var outputs = new HashMap<String, PipelineAction>();
        actions.forEach(action -> action.getOutputs().forEach(input -> outputs.put(input, action)));
        actions.forEach(action -> ProcessedTemplate.calculateActionOrder(action, visited, outputs));
    }

    /**
     * Calculates action order.
     *
     * @param action Current subject action.
     * @param visited Current state markers.
     * @param outputs Output artifacts mapping.
     */
    private static void calculateActionOrder(PipelineAction action, Set<PipelineAction> visited, Map<String, PipelineAction> outputs) {
        // already calculated
        if (action.getRunOrder() != null) {
            return;
        }
        if (visited.contains(action)) {
            throw new IllegalArgumentException(String.format("Circular artifact dependency for %s.", action.getName()));
        }
        visited.add(action);
        
        // dependencies from outside of stage not need to be considered
        action.getInputs().stream().filter(outputs::containsKey).peek(input -> ProcessedTemplate.calculateActionOrder(outputs.get(input), visited, outputs)).map(input -> outputs.get(input).getRunOrder()).mapToInt(order -> (order == null ? 1 : order) + 1).max().ifPresent(action::setRunOrder);
        visited.remove(action);
    }

    /**
     * Builds list of all used input artifacts.
     *
     * @param action Action definition.
     * @return List of input artifacts.
     */
    private static List<String> detectInputArtifacts(PipelineAction action) {
        var inputs = action.getInputs();
        // check TemplatePath source artifact
        var config = action.getConfiguration();
        var value = config.get("TemplatePath");
        if (value instanceof String) {
            inputs.add(((String) value).split(ProcessedTemplate.ARTIFACT_SEPARATOR)[0]);
        }
        // check TemplateConfiguration source artifact
        value = config.get("TemplateConfiguration");
        if (value instanceof String) {
            inputs.add(((String) value).split(ProcessedTemplate.ARTIFACT_SEPARATOR)[0]);
        }
        return inputs;
    }

    /**
     * Populates pipeline action definition structure.
     *
     * @param action Action configuration.
     * @return CodePipeline stage definition.
     */
    private static Map<String, Object> buildActionDefinition(PipelineAction action) {
        return ProcessedTemplate.buildActionDefinition(new HashMap<>(), action);
    }

    /**
     * Populates pipeline action definition structure.
     *
     * @param data Initial definition.
     * @param action Action configuration.
     * @return CodePipeline stage definition.
     */
    private static Map<String, Object> buildActionDefinition(Map<String, Object> data, PipelineAction action) {
        data.put("Name", action.getName());
        data.compute("ActionTypeId", (String key, Object old) -> CodePipelineUtils.convertActionTypeId(action.getType() != null ? action.getType() : old));
        if (action.getRegion() != null) {
            data.put("Region", action.getRegion());
        }
        if (!action.getConfiguration().isEmpty()) {
            data.put("Configuration", action.getConfiguration());
        }
        if (action.getNamespace() != null) {
            data.put("Namespace", action.getNamespace());
        }
        if (!action.getInputs().isEmpty()) {
            data.put("InputArtifacts", ProcessedTemplate.buildArtifactRefs(action.getInputs()));
        }
        if (!action.getOutputs().isEmpty()) {
            data.put("OutputArtifacts", ProcessedTemplate.buildArtifactRefs(action.getOutputs()));
        }
        if (action.getRunOrder() != null) {
            data.put("RunOrder", action.getRunOrder());
        }
        return data;
    }

    /**
     * Builds webhook definition.
     *
     * @param config Pipeline configuration.
     * @param checkoutAction Checkout action name.
     * @return Resource definition.
     */
    private Map<String, Object> buildWebhookDefinition(PipelineConfiguraiton config, String checkoutAction) {
        var filter = new HashMap<String, String>();
        filter.put("JsonPath", "$.ref");
        filter.put("MatchEquals", "refs/heads/{Branch}");
        var properties = new HashMap<String, Object>();
        properties.put("Authentication", config.getWebhookAuthenticationType());
        properties.put("AuthenticationConfiguration", Collections.singletonMap("SecretToken", config.getWebhookSecretToken()));
        properties.put("TargetPipeline", TemplateUtils.ref(config.getResourceName()));
        properties.put("TargetPipelineVersion", TemplateUtils.getAtt(config.getResourceName(), "Version"));
        properties.put("TargetAction", checkoutAction);
        properties.put("Filters", Collections.singletonList(filter));
        properties.put("RegisterWithThirdParty", true);
        return TemplateUtils.generateResource("CodePipeline::Webhook", properties, config.getHasCheckoutStepConditionName());
    }

    /**
     * Builds condition based on Y/N parameter.
     *
     * @param param Parameter name.
     * @return Condition definition structure.
     */
    private static Map<String, Object> buildBooleanCondition(String param) {
        return Collections.singletonMap("Fn::Equals", Arrays.asList(TemplateUtils.ref(param), "true"));
    }

    /**
     * Converts artifact names into list of artifact reference.
     *
     * @param names Artifact names.
     * @return Artifact references.
     */
    private static List<Map<String, String>> buildArtifactRefs(Collection<String> names) {
        return names.stream().distinct().map(CodePipelineUtils::buildArtifactRef).collect(Collectors.toList());
    }

    /**
     * Builds Fn::If call.
     *
     * @param condition Condition name.
     * @param whenTrue Value in case of positive case.
     * @param whenFalse Value in case of negative case.
     * @return Fn::If call.
     */
    private static Map<String, Object> fnIf(String condition, Object whenTrue, Object whenFalse) {
        return Collections.singletonMap("Fn::If", Arrays.asList(condition, whenTrue, whenFalse));
    }

    /**
     * Template structure.
     */
    @SuppressWarnings("all")
    @lombok.Generated
    public Map<String, Object> getTemplate() {
        return this.template;
    }
}