Skip to content

Commit

Permalink
fix(pipelines): stack tags
Browse files Browse the repository at this point in the history
Apply stack tags to the stacks deployed using CDK Pipelines.

Fixes #9260.
  • Loading branch information
rix0rrr committed Sep 25, 2020
1 parent e349004 commit 901857b
Show file tree
Hide file tree
Showing 11 changed files with 156 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export interface AwsCloudFormationStackProperties {
*/
readonly parameters?: { [id: string]: string };

/**
* Values for CloudFormation stack tags that should be passed when the stack is deployed.
*
* @default - No tags
*/
readonly tags?: { [id: string]: string };

/**
* The name to use for the CloudFormation stack.
* @default - name derived from artifact ID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,13 @@
"type": "string"
}
},
"tags": {
"description": "Values for CloudFormation stack tags that should be passed when the stack is deployed. (Default - No tags)",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"stackName": {
"description": "The name to use for the CloudFormation stack. (Default - name derived from artifact ID)",
"type": "string"
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":"5.0.0"}
{"version":"6.0.0"}
2 changes: 1 addition & 1 deletion packages/@aws-cdk/core/lib/stack-synthesizers/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export function addStackArtifactToAssembly(

// nested stack tags are applied at the AWS::CloudFormation::Stack resource
// level and are not needed in the cloud assembly.
// TODO: move these to the cloud assembly artifact properties instead of metadata
if (stack.tags.hasTags()) {
stack.node.addMetadata(cxschema.ArtifactMetadataEntryType.STACK_TAGS, stack.tags.renderTags());
}
Expand All @@ -46,6 +45,7 @@ export function addStackArtifactToAssembly(
const properties: cxschema.AwsCloudFormationStackProperties = {
templateFile: stack.templateFile,
terminationProtection: stack.terminationProtection,
tags: stack.tags.tagValues(),
...stackProps,
...stackNameProperty,
};
Expand Down
18 changes: 16 additions & 2 deletions packages/@aws-cdk/core/lib/tag-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,18 @@ export class TagManager {
* Renders tags into the proper format based on TagType
*/
public renderTags(): any {
const sortedTags = Array.from(this.tags.values()).sort((a, b) => a.key.localeCompare(b.key));
return this.tagFormatter.formatTags(sortedTags);
return this.tagFormatter.formatTags(this.sortedTags);
}

/**
* Render the tags in a readable format
*/
public tagValues(): Record<string, string> {
const ret: Record<string, string> = {};
for (const tag of this.sortedTags) {
ret[tag.key] = tag.value;
}
return ret;
}

/**
Expand Down Expand Up @@ -315,4 +325,8 @@ export class TagManager {
}
}
}

private get sortedTags() {
return Array.from(this.tags.values()).sort((a, b) => a.key.localeCompare(b.key));
}
}
20 changes: 19 additions & 1 deletion packages/@aws-cdk/core/test/test.stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ export = {
test.done();
},

'stack tags are reflected in the stack cloud assembly artifact'(test: Test) {
'stack tags are reflected in the stack cloud assembly artifact metadata'(test: Test) {
// GIVEN
const app = new App({ stackTraces: false });
const stack1 = new Stack(app, 'stack1');
Expand All @@ -920,6 +920,24 @@ export = {
test.done();
},

'stack tags are reflected in the stack artifact properties'(test: Test) {
// GIVEN
const app = new App({ stackTraces: false });
const stack1 = new Stack(app, 'stack1');
const stack2 = new Stack(stack1, 'stack2');

// WHEN
Tags.of(app).add('foo', 'bar');

// THEN
const asm = app.synth();
const expected = { foo: 'bar' };

test.deepEqual(asm.getStackArtifact(stack1.artifactId).tags, expected);
test.deepEqual(asm.getStackArtifact(stack2.artifactId).tags, expected);
test.done();
},

'Termination Protection is reflected in Cloud Assembly artifact'(test: Test) {
// if the root is an app, invoke "synth" to avoid double synthesis
const app = new App();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export class CloudFormationStackArtifact extends CloudArtifact {
*/
public readonly parameters: { [id: string]: string };

/**
* CloudFormation tags to pass to the stack.
*/
public readonly tags: { [id: string]: string };

/**
* The physical name of this stack.
*/
Expand Down Expand Up @@ -96,7 +101,8 @@ export class CloudFormationStackArtifact extends CloudArtifact {
}
this.environment = EnvironmentUtils.parse(artifact.environment);
this.templateFile = properties.templateFile;
this.parameters = properties.parameters || { };
this.parameters = properties.parameters ?? {};
this.tags = properties.tags ?? {};
this.assumeRoleArn = properties.assumeRoleArn;
this.cloudFormationExecutionRoleArn = properties.cloudFormationExecutionRoleArn;
this.stackTemplateAssetObjectUrl = properties.stackTemplateAssetObjectUrl;
Expand Down
32 changes: 32 additions & 0 deletions packages/@aws-cdk/cx-api/test/stack-artifact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
import * as cxapi from '../lib';
import { rimraf } from './util';

let builder: cxapi.CloudAssemblyBuilder;
beforeEach(() => {
builder = new cxapi.CloudAssemblyBuilder();
});

afterEach(() => {
rimraf(builder.outdir);
});

test('read tags', () => {
// GIVEN
builder.addArtifact('Stack', {
type: cxschema.ArtifactType.AWS_CLOUDFORMATION_STACK,
environment: 'aws://1222344/us-east-1',
properties: {
templateFile: 'bla.json',
tags: {
foo: 'bar',
},
},
});

// WHEN
const assembly = builder.buildAssembly();

// THEN
expect(assembly.getStackByName('Stack').tags).toEqual({ foo: 'bar' });
});
23 changes: 23 additions & 0 deletions packages/@aws-cdk/cx-api/test/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as fs from 'fs';
import * as path from 'path';

/**
* rm -rf reimplementation, don't want to depend on an NPM package for this
*/
export function rimraf(fsPath: string) {
try {
const isDir = fs.lstatSync(fsPath).isDirectory();

if (isDir) {
for (const file of fs.readdirSync(fsPath)) {
rimraf(path.join(fsPath, file));
}
fs.rmdirSync(fsPath);
} else {
fs.unlinkSync(fsPath);
}
} catch (e) {
// We will survive ENOENT
if (e.code !== 'ENOENT') { throw e; }
}
}
44 changes: 42 additions & 2 deletions packages/@aws-cdk/pipelines/lib/actions/deploy-cdk-stack-action.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as fs from 'fs';
import * as path from 'path';
import * as cfn from '@aws-cdk/aws-cloudformation';
import * as codepipeline from '@aws-cdk/aws-codepipeline';
Expand Down Expand Up @@ -112,6 +113,11 @@ export interface DeployCdkStackActionProps extends DeployCdkStackActionOptions {
* @default - No dependencies
*/
readonly dependencyStackArtifactIds?: string[];

/**
* Template configuration path relative to the input artifact
*/
readonly templateConfigurationPath?: string;
}

/**
Expand Down Expand Up @@ -156,12 +162,25 @@ export class DeployCdkStackAction implements codepipeline.IAction {
// It should be easier to get this, but for now it is what it is.
const appAsmRoot = assemblyBuilderOf(appOf(scope)).outdir;
const fullTemplatePath = path.join(artifact.assembly.directory, artifact.templateFile);
const templatePath = path.relative(appAsmRoot, fullTemplatePath);

let fullConfigPath;
if (Object.keys(artifact.tags).length > 0) {
fullConfigPath = `${fullTemplatePath}.config.json`;

// Write the template configuration file (for parameters into CreateChangeSet call that
// cannot be configured any other way). They must come from a file, and there's unfortunately
// no better hook to write this file (`construct.onSynthesize()` would have been the prime candidate
// but that is being deprecated--and DeployCdkStackAction isn't even a construct).
writeTemplateConfiguration(fullConfigPath, {
Tags: artifact.tags,
});
}

return new DeployCdkStackAction({
actionRole,
cloudFormationExecutionRole,
templatePath,
templatePath: path.relative(appAsmRoot, fullTemplatePath),
templateConfigurationPath: fullConfigPath ? path.relative(appAsmRoot, fullConfigPath) : undefined,
region,
stackArtifactId: artifact.id,
dependencyStackArtifactIds: artifact.dependencies.filter(isStackArtifact).map(s => s.id),
Expand Down Expand Up @@ -223,6 +242,7 @@ export class DeployCdkStackAction implements codepipeline.IAction {
deploymentRole: props.cloudFormationExecutionRole,
region: props.region,
capabilities: [cfn.CloudFormationCapabilities.NAMED_IAM, cfn.CloudFormationCapabilities.AUTO_EXPAND],
templateConfiguration: props.templateConfigurationPath ? props.cloudAssemblyInput.atPath(props.templateConfigurationPath) : undefined,
});
this.executeChangeSetAction = new cpactions.CloudFormationExecuteChangeSetAction({
actionName: `${baseActionName}.Deploy`,
Expand Down Expand Up @@ -331,3 +351,23 @@ function isStackArtifact(a: cxapi.CloudArtifact): a is cxapi.CloudFormationStack
// return a instanceof cxapi.CloudFormationStackArtifact;
return a.constructor.name === 'CloudFormationStackArtifact';
}

/**
* Template configuration in a CodePipeline
*
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/continuous-delivery-codepipeline-cfn-artifacts.html#w2ab1c13c17c15
*/
interface TemplateConfiguration {
readonly Parameters?: Record<string, string>;
readonly Tags?: Record<string, string>;
readonly StackPolicy?: {
readonly Statements: Array<Record<string, string>>;
};
}

/**
* Write template configuration to the given file
*/
function writeTemplateConfiguration(filename: string, config: TemplateConfiguration) {
fs.writeFileSync(filename, JSON.stringify(config, undefined, 2), { encoding: 'utf-8' });
}
16 changes: 1 addition & 15 deletions packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,21 +626,7 @@ export interface DestroyOptions {
* @returns an array with the tags available in the stack metadata.
*/
function tagsForStack(stack: cxapi.CloudFormationStackArtifact): Tag[] {
const tagLists = stack.findMetadataByType(cxschema.ArtifactMetadataEntryType.STACK_TAGS).map(
// the tags in the cloud assembly are stored differently
// unfortunately.
x => toCloudFormationTags(x.data as cxschema.Tag[]));
return Array.prototype.concat([], ...tagLists);
}

/**
* Transform tags as they are retrieved from the cloud assembly,
* to the way that CloudFormation expects them. (Different casing).
*/
function toCloudFormationTags(tags: cxschema.Tag[]): Tag[] {
return tags.map(t => {
return { Key: t.key, Value: t.value };
});
return Object.entries(stack.tags).map(([Key, Value]) => ({ Key, Value }));
}

export interface Tag {
Expand Down

0 comments on commit 901857b

Please sign in to comment.