aws / aws-cdk

The AWS Cloud Development Kit is a framework for defining cloud infrastructure in code
https://aws.amazon.com/cdk
Apache License 2.0
11.66k stars 3.92k forks source link

[Codepipelines] Passing CodeBuild generated parameters into Codepipelines ApplicationStage with CDK #15638

Open branthebuidler opened 3 years ago

branthebuidler commented 3 years ago

I have a Codepipelines based project (v. 1.114.0).

Use case:

I need to deploy an application stack to multiple environments, and want to input a build number that is the output of the build stage in the pipeline. The build number references the docker build in ECR that I want the application stack to use. To this end, I am attempting to pass the build number parameter generated at build time in a CodeBuild stage to the ApplicationStage deployment in Cloudformation.

Code:

    // Pipeline
    const pipeline = new pipelines.CdkPipeline(this, 'Pipeline', {...});

    // Build stage
    const buildStage = pipeline.addStage('Build')
    const buildOutput = new codepipeline.Artifact();
    const buildAction = new actions.CodeBuildAction({
            ...
            buildSpec: codebuild.BuildSpec.fromSourceFilename("buildspec.yml")
        }),
        // I create a JSON artifact containing the data I want in the artifact
        outputs: [buildOutput]
    });
    buildStage.addActions(buildAction);

    // Application stage
    const stage = new ApplicationStage(this, "ApplicationStage", {
        env: {
            account: <id>,
            region: <region>
        },
        ...
        outputData: buildOutput.getParam("output_file", "output_key")
    });
    pipeline.addApplicationStage(stage);

    // My stage class which is instantiated above
    class ApplicationStage extends cdk.Stage {
        constructor(scope: cdk.Construct, id: string, props: ApplicationStageProps) {
            super(scope, id, props);
            // Create a stack which uses the data
            new MyStack(this, "MyStack", {data: props.outputData});
        };
    }

I am seeing errors in CFN when attempting to deploy the application stack:

Template Error: Encountered unsupported function: Fn::GetArtifactAtt Supported functions are: [Fn::Base64, Fn::GetAtt, 
Fn::GetAZs, Fn::ImportValue, Fn::Join, Fn::Split, Fn::FindInMap, Fn::Select, Ref, Fn::Equals, Fn::If, Fn::Not, Condition, 
Fn::And, Fn::Or, Fn::Contains, Fn::EachMemberEquals, Fn::EachMemberIn, Fn::ValueOf, Fn::ValueOfAll, Fn::RefAll, Fn::Sub, 
Fn::Cidr] (Service: AmazonCloudFormation; Status Code: 400; Error Code: ValidationError; Request ID: <id>; Proxy: null)

I have already tried to pass this information using Codepipelines variables that I exported from CodeBuild, but with no luck (CFN is unable to resolve variable references cross stage it seems). Additionally, the cdk.Stage construct does not support parameter overrides as far as I can tell.

Any recommendations for how to pull this off? It seems like there is not support for this use case.

P.S. Would prefer not to use Parameter Store as I have multiple deployment stages and want to ensure the specific build output is passed to the correct stage at the correct time. While this is likely doable with the Parameter Store, it introduces unnecessary complexity.

skinny85 commented 3 years ago

Hey @avivsugarman,

unfortunately, I'm not sure what you're trying to do here is supported by CDK Pipelines. The Fn::GetArtifactAtt expression can only be used inside the parameterOverrides property of a CloudFormation Stack deployment CodePipeline Action, but CDK Pipelines does not allow you to set the parameters on the Stack level - it operates on the Stage level.

Paging in @rix0rrr to answer the question, as he knows much more about CDK Pipelines than I do.

peterwoodworth commented 3 years ago

Relabeling this as a feature request because this doesn't look possible to me either. Let's see what @rix0rrr says

rix0rrr commented 3 years ago

Since you know the build number you need in the build stage, couldn't you just use the build number directly in your CDK app? The generated templates will then contain that value at deploy time.

branthebuidler commented 3 years ago

@rix0rrr Yes, that is the workaround we went with, and it is a reasonable solution for this specific use case even though ideally we would base our build ID off of the commit hash and not an arbitrary build time ID.

I think this should still be kept as an open feature request, as the ability to share pipeline data with an application stage (either via env vars or parameter overrides or some other way) will probably prove useful in other use cases.

github-actions[bot] commented 2 years ago

This issue has not received any attention in 1 year. If you want to keep this issue open, please leave a comment below and auto-close will be canceled.

vinibartling commented 1 year ago

Hello, is there any update on this issue? This seems to be a common problem - wanting to export variable generated in CodeBuild Action to the next stage. If this feature request is not in the works, could someone please clearly post the workaround in the AWS docs or here?

In my case, I generate an artifact in CodeBuild Action and I want to access that artifact's name during the Cloudformation stack deployment, which is part of next stage in the pipeline. What would be the workaround for that? Static naming of the artifact doesn't work because Cloudformation doesn't detect any changes and doesn't produce a change-set. Any guidance would be much appreciated.

vinibartling commented 1 year ago

Found a workaround - used AwsCustomResource to fetch artifact information i needed in the Cloudformation stack and used it to update the lambda function. CDK+Codepipeline really needs to solve this issue to help the engineers.

pradoz commented 8 months ago

Found a workaround - used AwsCustomResource to fetch artifact information i needed in the Cloudformation stack and used it to update the lambda function. CDK+Codepipeline really needs to solve this issue to help the engineers.

@vinibartling mind sharing the CR you used as a workaround? I agree its not ideal, but it may provide a good starting point for a contributor who wants to take on this issue

vinibartling commented 8 months ago

Sure @pradoz, here's the workaround I have in place.

It is a 3 step process, with some conventions in place:

  1. During Source build stage in the pipeline, when I package my lambda, I upload it to a common artifacts S3 versioned bucket with the same name everytime. For example: foo-service --> foo-service.zip. Since the bucket is versioned, every new upload updates the version of this file. In my CDK codebase, the "FOO" stack that needs this artifact is aware of this artifact-filename (convention) and S3 bucket name.

  2. In DEV stage, I have a custom resource to pull the versionId of foo-service.zip lambda package from S3 bucket and also upload this versionId to SSM, so I can use the same version in PROD stage. I use static fromBucket(bucket: s3.IBucket, key: string, objectVersion?: string): S3Code from Code ('aws-cdk-lib/aws-lambda') for generating LambdaFunction.

const lambdaAttr = {
    functionNameSuffix: 'foo-service',
    customResourceName: 'foo-service-get-version-id',
    artifactFilename: 'foo-service.zip'
};

getVersionId(lambdaAttr: LambdaAttributes, ssmRole: Role) {
    const paramName = `${lambdaAttr.functionNameSuffix}-version`;
    if (this.stage == Stage.DEV) {
        const customResource = this.generateCustomResourceToReadS3(
            lambdaAttr.customResourceName,
            lambdaAttr.artifactFilename,
        );
        const versionId = customResource.getResponseField('VersionId');

        // store versionId in ssm
        const ssmParameter = new StringParameter(
            this.stack,
            `${lambdaAttr.functionNameSuffix}-version`,
            {
                parameterName: paramName,
                stringValue: versionId,
            },
        );
        ssmParameter.grantRead(ssmRole);
        return versionId;
    }

    // read from parameter from ssm to ensure version was written correctly
    return this.generateCustomResourceToReadSSM(
        `${lambdaAttr.functionNameSuffix}-read-version`,
        paramName,
    );
}

generateCustomResourceToReadS3(id: string, key: string) {
    return new cr.AwsCustomResource(this.stack, id, {
        onUpdate: {
            // will also be called for a CREATE event
            service: 'S3',
            action: 'headObject',
            parameters: {
                Bucket: ARTIFACT_BUCKET_NAME,
                Key: key,
            },
            physicalResourceId: cr.PhysicalResourceId.of(Date.now().toString()), // Update physical id to always fetch the latest version
        },
        policy: cr.AwsCustomResourcePolicy.fromStatements([
            new PolicyStatement({
                effect: Effect.ALLOW,
                actions: ['s3:*'],
                resources: ['*'],
            }),
        ]),
        removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
}
  1. In PROD stage, another custom resource pulls this versionId from SSM in DEV account and uses it for deploying the lambda.
// Access for PROD to read From DEV account's SSM
public grantReadAccessToSSM() {
    return new Role(this.stack, `${this.appPrefix}-ssmReadAccessToProd`, {
        assumedBy: new AccountPrincipal(PROD),
        roleName: PROD_ASSUMED_ROLENAME_TO_READ_SSM(this.appPrefix),
    });
}

generateCustomResourceToReadSSM(id: string, paramName: string) {
    const resource = new cr.AwsCustomResource(this.stack, id, {
        onUpdate: {
            service: 'SSM',
            action: 'getParameter',
            parameters: {
                Name: paramName,
            },
            physicalResourceId: cr.PhysicalResourceId.of(Date.now().toString()), // Update physical id to always fetch the latest version
            assumedRoleArn: PROD_ASSUMED_ROLEARN(this.appPrefix),
        },
        policy: cr.AwsCustomResourcePolicy.fromStatements([
            new PolicyStatement({
                effect: Effect.ALLOW,
                actions: ['ssm:*'],
                resources: ['*'],
            }),
            new PolicyStatement({
                effect: Effect.ALLOW,
                actions: ['sts:*'],
                resources: ['*'],
            }),
        ]),
    });
    return resource.getResponseField('Parameter.Value');
}

Hope this helps. Please feel free to reach out with anything else I can help.