Skip to content

Commit

Permalink
feat(core+cli): support tagging of stacks (#2185)
Browse files Browse the repository at this point in the history
Adding tags parameter option to cdk deploy command to allow tagging full stacks and their associated resources. 

Now it will be possible to:

```
const app = new App();

const stack1 = new Stack(app, 'stack1', { tags: { foo: 'bar' } });
const stack2 = new Stacl(app, 'stack2');

stack1.node.apply(new Tag('fii', 'bug'));
stack2.node.apply(new Tag('boo', 'bug'));
```
That will produce 
* stack1 with tags `foo bar` and `fii bug`
* stack2 with tags `boo bug`

It is possible also to override constructor tags with the stack.node.apply. 

So doing:
```
stack1.node.apply(new Tag('foo', 'newBar');
```
stack1 will have tags `foo newBar` and `fii bug`

Last, but not least, it is also possible to pass it via arguments (using yargs) as in the following example:

```
cdk deploy --tags foo=bar --tags myTag=myValue
```
That will produce a stack with tags `foo bar`and `myTag myValue`

**Important**
That will ignore tags provided by the constructor and/or aspects. 

Fixes #932
  • Loading branch information
IsmaelMartinez authored and Elad Ben-Israel committed Jun 3, 2019
1 parent 0b1bbf7 commit d0e19d5
Show file tree
Hide file tree
Showing 14 changed files with 209 additions and 47 deletions.
18 changes: 3 additions & 15 deletions packages/@aws-cdk/cdk/lib/cfn-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import cxapi = require('@aws-cdk/cx-api');
import { CfnCondition } from './cfn-condition';
import { Construct, IConstruct } from './construct';
import { CreationPolicy, DeletionPolicy, UpdatePolicy } from './resource-policy';
import { TagManager } from './tag-manager';
import { capitalizePropertyNames, ignoreEmpty, PostResolveToken } from './util';
// import required to be here, otherwise causes a cycle when running the generated JavaScript
// tslint:disable-next-line:ordered-imports
import { CfnRefElement } from './cfn-element';
import { CfnReference } from './cfn-reference';
import { TagManager } from './tag-manager';

export interface CfnResourceProps {
/**
Expand All @@ -23,12 +23,6 @@ export interface CfnResourceProps {
readonly properties?: any;
}

export interface ITaggable {
/**
* TagManager to set, remove and format tags
*/
readonly tags: TagManager;
}
/**
* Represents a CloudFormation resource.
*/
Expand Down Expand Up @@ -56,13 +50,6 @@ export class CfnResource extends CfnRefElement {
return (construct as any).resourceType !== undefined;
}

/**
* Check whether the given construct is Taggable
*/
public static isTaggable(construct: any): construct is ITaggable {
return (construct as any).tags !== undefined;
}

/**
* Options for this resource, such as condition, update policy etc.
*/
Expand Down Expand Up @@ -212,7 +199,7 @@ export class CfnResource extends CfnRefElement {
public _toCloudFormation(): object {
try {
// merge property overrides onto properties and then render (and validate).
const tags = CfnResource.isTaggable(this) ? this.tags.renderTags() : undefined;
const tags = TagManager.isTaggable(this) ? this.tags.renderTags() : undefined;
const properties = deepMerge(
this.properties || {},
{ tags },
Expand Down Expand Up @@ -281,6 +268,7 @@ export enum TagType {
Standard = 'StandardTag',
AutoScalingGroup = 'AutoScalingGroupTag',
Map = 'StringToStringMap',
KeyValue = 'KeyValue',
NotTaggable = 'NotTaggable',
}

Expand Down
3 changes: 2 additions & 1 deletion packages/@aws-cdk/cdk/lib/construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,4 +719,5 @@ export interface OutgoingReference {
}

// Import this _after_ everything else to help node work the classes out in the correct order...
import { Reference } from './reference';

import { Reference } from './reference';
26 changes: 23 additions & 3 deletions packages/@aws-cdk/cdk/lib/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Construct, IConstruct, PATH_SEP } from './construct';
import { Environment } from './environment';
import { HashedAddressingScheme, IAddressingScheme, LogicalIDs } from './logical-id';
import { makeUniqueId } from './uniqueid';

export interface StackProps {
/**
* The AWS environment (account/region) where this stack will be deployed.
Expand Down Expand Up @@ -41,14 +40,22 @@ export interface StackProps {
* @default true
*/
readonly autoDeploy?: boolean;

/**
* Stack tags that will be applied to all the taggable resources and the stack itself.
*
* @default {}
*/
readonly tags?: { [key: string]: string };
}

const STACK_SYMBOL = Symbol.for('@aws-cdk/cdk.Stack');

/**
* A root construct which represents a single CloudFormation stack.
*/
export class Stack extends Construct {
export class Stack extends Construct implements ITaggable {

/**
* Adds a metadata annotation "aws:cdk:physical-name" to the construct if physicalName
* is non-null. This can be used later by tools and aspects to determine if resources
Expand All @@ -73,6 +80,11 @@ export class Stack extends Construct {

private static readonly VALID_STACK_NAME_REGEX = /^[A-Za-z][A-Za-z0-9-]*$/;

/**
* Tags to be applied to the stack.
*/
public readonly tags: TagManager;

/**
* Lists all missing contextual information.
* This is returned when the stack is synthesized under the 'missing' attribute
Expand Down Expand Up @@ -150,6 +162,7 @@ export class Stack extends Construct {
this.logicalIds = new LogicalIDs(props && props.namingScheme ? props.namingScheme : new HashedAddressingScheme());
this.name = props.stackName !== undefined ? props.stackName : this.calculateStackName();
this.autoDeploy = props && props.autoDeploy === false ? false : true;
this.tags = new TagManager(TagType.KeyValue, "aws:cdk:stack", props.tags);

if (!Stack.VALID_STACK_NAME_REGEX.test(this.name)) {
throw new Error(`Stack name must match the regular expression: ${Stack.VALID_STACK_NAME_REGEX.toString()}, got '${name}'`);
Expand Down Expand Up @@ -490,6 +503,10 @@ export class Stack extends Construct {
}
}
}

if (this.tags.hasTags()) {
this.node.addMetadata(cxapi.STACK_TAGS_METADATA_KEY, this.tags.renderTags());
}
}

protected synthesize(builder: cxapi.CloudAssemblyBuilder): void {
Expand Down Expand Up @@ -552,13 +569,15 @@ export class Stack extends Construct {
visit(this);

const app = this.parentApp();

if (app && app.node.metadata.length > 0) {
output[PATH_SEP] = app.node.metadata;
}

return output;

function visit(node: IConstruct) {

if (node.node.metadata.length > 0) {
// Make the path absolute
output[PATH_SEP + node.node.path] = node.node.metadata.map(md => node.node.resolve(md) as cxapi.MetadataEntry);
Expand Down Expand Up @@ -664,8 +683,9 @@ function cfnElements(node: IConstruct, into: CfnElement[] = []): CfnElement[] {
import { ArnComponents, arnFromComponents, parseArn } from './arn';
import { CfnElement } from './cfn-element';
import { CfnReference } from './cfn-reference';
import { CfnResource } from './cfn-resource';
import { CfnResource, TagType } from './cfn-resource';
import { Aws, ScopedAws } from './pseudo';
import { ITaggable, TagManager } from './tag-manager';

/**
* Find all resources in a set of constructs
Expand Down
11 changes: 4 additions & 7 deletions packages/@aws-cdk/cdk/lib/tag-aspect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// import cxapi = require('@aws-cdk/cx-api');
import { IAspect } from './aspect';
import { CfnResource, ITaggable } from './cfn-resource';
import { IConstruct } from './construct';
import { ITaggable, TagManager } from './tag-manager';

/**
* Properties for a tag
Expand Down Expand Up @@ -71,12 +72,8 @@ export abstract class TagBase implements IAspect {
}

public visit(construct: IConstruct): void {
if (!CfnResource.isCfnResource(construct)) {
return;
}
const resource = construct as CfnResource;
if (CfnResource.isTaggable(resource)) {
this.applyTag(resource);
if (TagManager.isTaggable(construct)) {
this.applyTag(construct);
}
}

Expand Down
60 changes: 60 additions & 0 deletions packages/@aws-cdk/cdk/lib/tag-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ interface CfnAsgTag {
propagateAtLaunch: boolean;
}

interface StackTag {
Key: string;
Value: string;
}
/**
* Interface for converter between CloudFormation and internal tag representations
*/
Expand Down Expand Up @@ -142,6 +146,36 @@ class MapFormatter implements ITagFormatter {
}
}

/**
* StackTags are of the format { Key: key, Value: value }
*/
class KeyValueFormatter implements ITagFormatter {
public parseTags(keyValueTags: any, priority: number): Tag[] {
const tags: Tag[] = [];
for (const key in keyValueTags) {
if (keyValueTags.hasOwnProperty(key)) {
const value = keyValueTags[key];
tags.push({
key,
value,
priority
});
}
}
return tags;
}
public formatTags(unformattedTags: Tag[]): any {
const tags: StackTag[] = [];
unformattedTags.forEach(tag => {
tags.push({
Key: tag.key,
Value: tag.value
});
});
return tags;
}
}

class NoFormat implements ITagFormatter {
public parseTags(_cfnPropertyTags: any): Tag[] {
return [];
Expand All @@ -155,13 +189,32 @@ const TAG_FORMATTERS: {[key: string]: ITagFormatter} = {
[TagType.AutoScalingGroup]: new AsgFormatter(),
[TagType.Standard]: new StandardFormatter(),
[TagType.Map]: new MapFormatter(),
[TagType.KeyValue]: new KeyValueFormatter(),
[TagType.NotTaggable]: new NoFormat(),
};

/**
* Interface to implement tags.
*/
export interface ITaggable {
/**
* TagManager to set, remove and format tags
*/
readonly tags: TagManager;
}

/**
* TagManager facilitates a common implementation of tagging for Constructs.
*/
export class TagManager {

/**
* Check whether the given construct is Taggable
*/
public static isTaggable(construct: any): construct is ITaggable {
return (construct as any).tags !== undefined;
}

private readonly tags = new Map<string, Tag>();
private readonly priorities = new Map<string, number>();
private readonly tagFormatter: ITagFormatter;
Expand Down Expand Up @@ -217,6 +270,13 @@ export class TagManager {
return true;
}

/**
* Returns true if there are any tags defined
*/
public hasTags(): boolean {
return this.tags.size > 0;
}

private _setTag(...tags: Tag[]) {
for (const tag of tags) {
if (tag.priority >= (this.priorities.get(tag.key) || 0)) {
Expand Down
22 changes: 20 additions & 2 deletions packages/@aws-cdk/cdk/test/test.tag-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,24 @@ export = {
'when there are no tags': {
'#renderTags() returns undefined'(test: Test) {
const mgr = new TagManager(TagType.Standard, 'AWS::Resource::Type');
test.deepEqual(mgr.renderTags(), undefined );
test.deepEqual(mgr.renderTags(), undefined);
test.done();
},
'#hasTags() returns false'(test: Test) {
const mgr = new TagManager(TagType.Standard, 'AWS::Resource::Type');
test.equal(mgr.hasTags(), false);
test.done();
}
},
'#renderTags() handles standard, map, and ASG tag formats'(test: Test) {
'#renderTags() handles standard, map, keyValue, and ASG tag formats'(test: Test) {
const tagged: TagManager[] = [];
const standard = new TagManager(TagType.Standard, 'AWS::Resource::Type');
const asg = new TagManager(TagType.AutoScalingGroup, 'AWS::Resource::Type');
const keyValue = new TagManager(TagType.KeyValue, 'AWS::Resource::Type');
const mapper = new TagManager(TagType.Map, 'AWS::Resource::Type');
tagged.push(standard);
tagged.push(asg);
tagged.push(keyValue);
tagged.push(mapper);
for (const res of tagged) {
res.setTag('foo', 'bar');
Expand All @@ -65,12 +72,23 @@ export = {
{key: 'foo', value: 'bar', propagateAtLaunch: true},
{key: 'asg', value: 'only', propagateAtLaunch: false},
]);
test.deepEqual(keyValue.renderTags(), [
{ Key: 'foo', Value : 'bar' },
{ Key: 'asg', Value : 'only' }
]);
test.deepEqual(mapper.renderTags(), {
foo: 'bar',
asg: 'only',
});
test.done();
},
'when there are tags it hasTags returns true'(test: Test) {
const mgr = new TagManager(TagType.Standard, 'AWS::Resource::Type');
mgr.setTag('key', 'myVal', 2);
mgr.setTag('key', 'newVal', 1);
test.equal(mgr.hasTags(), true);
test.done();
},
'tags with higher or equal priority always take precedence'(test: Test) {
const mgr = new TagManager(TagType.Standard, 'AWS::Resource::Type');
mgr.setTag('key', 'myVal', 2);
Expand Down
2 changes: 2 additions & 0 deletions packages/@aws-cdk/cx-api/lib/cxapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const DISABLE_VERSION_REPORTING = 'aws:cdk:disable-version-reporting';
export const DISABLE_ASSET_STAGING_CONTEXT = 'aws:cdk:disable-asset-staging';

/**
* If this context key is set, the CDK will stage assets under the specified
* directory. Otherwise, assets will not be staged.
* Omits stack traces from construct metadata entries.
*/
export const DISABLE_METADATA_STACK_TRACE = 'aws:cdk:disable-stack-trace';
5 changes: 5 additions & 0 deletions packages/@aws-cdk/cx-api/lib/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export const ERROR_METADATA_KEY = 'aws:cdk:error';
*/
export const PATH_METADATA_KEY = 'aws:cdk:path';

/**
* Tag metadata key.
*/
export const STACK_TAGS_METADATA_KEY = 'aws:cdk:stack-tags';

export enum SynthesisMessageLevel {
INFO = 'info',
WARNING = 'warning',
Expand Down
5 changes: 3 additions & 2 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ async function parseCommandLineArguments() {
.option('exclusively', { type: 'boolean', alias: 'e', desc: 'only deploy requested stacks, don\'t include dependencies' })
.option('require-approval', { type: 'string', choices: [RequireApproval.Never, RequireApproval.AnyChange, RequireApproval.Broadening], desc: 'what security-sensitive changes need manual approval' }))
.option('ci', { type: 'boolean', desc: 'Force CI detection. Use --no-ci to disable CI autodetection.', default: process.env.CI !== undefined })
.option('tags', { type: 'array', alias: 't', desc: 'tags to add to the stack (KEY=VALUE)', nargs: 1, requiresArg: true })
.command('destroy [STACKS..]', 'Destroy the stack(s) named STACKS', yargs => yargs
.option('exclusively', { type: 'boolean', alias: 'x', desc: 'only deploy requested stacks, don\'t include dependees' })
.option('force', { type: 'boolean', alias: 'f', desc: 'Do not ask for confirmation before destroying the stacks' }))
Expand Down Expand Up @@ -99,7 +100,6 @@ async function initCommandLine() {
proxyAddress: argv.proxy,
ec2creds: argv.ec2creds,
});

const configuration = new Configuration(argv);
await configuration.load();

Expand Down Expand Up @@ -198,7 +198,8 @@ async function initCommandLine() {
roleArn: args.roleArn,
requireApproval: configuration.settings.get(['requireApproval']),
ci: args.ci,
reuseAssets: args['build-exclude']
reuseAssets: args['build-exclude'],
tags: configuration.settings.get(['tags'])
});

case 'destroy':
Expand Down
Loading

0 comments on commit d0e19d5

Please sign in to comment.