From d0e19d50f5fc1459cea66b309aaa9fe18a3d9618 Mon Sep 17 00:00:00 2001 From: IsmaelMartinez Date: Mon, 3 Jun 2019 15:00:07 +0100 Subject: [PATCH] feat(core+cli): support tagging of stacks (#2185) 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 --- packages/@aws-cdk/cdk/lib/cfn-resource.ts | 18 +----- packages/@aws-cdk/cdk/lib/construct.ts | 3 +- packages/@aws-cdk/cdk/lib/stack.ts | 26 +++++++- packages/@aws-cdk/cdk/lib/tag-aspect.ts | 11 ++-- packages/@aws-cdk/cdk/lib/tag-manager.ts | 60 +++++++++++++++++++ .../@aws-cdk/cdk/test/test.tag-manager.ts | 22 ++++++- packages/@aws-cdk/cx-api/lib/cxapi.ts | 2 + packages/@aws-cdk/cx-api/lib/metadata.ts | 5 ++ packages/aws-cdk/bin/cdk.ts | 5 +- packages/aws-cdk/lib/api/cxapp/stacks.ts | 28 +++++++++ packages/aws-cdk/lib/api/deploy-stack.ts | 5 +- packages/aws-cdk/lib/api/deployment-target.ts | 3 + packages/aws-cdk/lib/cdk-toolkit.ts | 12 +++- packages/aws-cdk/lib/settings.ts | 56 ++++++++++++----- 14 files changed, 209 insertions(+), 47 deletions(-) diff --git a/packages/@aws-cdk/cdk/lib/cfn-resource.ts b/packages/@aws-cdk/cdk/lib/cfn-resource.ts index 9f7e46546f4a1..efdebd222796a 100644 --- a/packages/@aws-cdk/cdk/lib/cfn-resource.ts +++ b/packages/@aws-cdk/cdk/lib/cfn-resource.ts @@ -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 { /** @@ -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. */ @@ -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. */ @@ -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 }, @@ -281,6 +268,7 @@ export enum TagType { Standard = 'StandardTag', AutoScalingGroup = 'AutoScalingGroupTag', Map = 'StringToStringMap', + KeyValue = 'KeyValue', NotTaggable = 'NotTaggable', } diff --git a/packages/@aws-cdk/cdk/lib/construct.ts b/packages/@aws-cdk/cdk/lib/construct.ts index 83088a1ac2748..24735d55f7e98 100644 --- a/packages/@aws-cdk/cdk/lib/construct.ts +++ b/packages/@aws-cdk/cdk/lib/construct.ts @@ -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'; \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/lib/stack.ts b/packages/@aws-cdk/cdk/lib/stack.ts index 572736e9c782f..41d3626c57fe0 100644 --- a/packages/@aws-cdk/cdk/lib/stack.ts +++ b/packages/@aws-cdk/cdk/lib/stack.ts @@ -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. @@ -41,6 +40,13 @@ 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'); @@ -48,7 +54,8 @@ 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 @@ -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 @@ -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}'`); @@ -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 { @@ -552,6 +569,7 @@ export class Stack extends Construct { visit(this); const app = this.parentApp(); + if (app && app.node.metadata.length > 0) { output[PATH_SEP] = app.node.metadata; } @@ -559,6 +577,7 @@ export class Stack extends Construct { 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); @@ -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 diff --git a/packages/@aws-cdk/cdk/lib/tag-aspect.ts b/packages/@aws-cdk/cdk/lib/tag-aspect.ts index e6f373fffea92..d2f50ca7cd1f5 100644 --- a/packages/@aws-cdk/cdk/lib/tag-aspect.ts +++ b/packages/@aws-cdk/cdk/lib/tag-aspect.ts @@ -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 @@ -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); } } diff --git a/packages/@aws-cdk/cdk/lib/tag-manager.ts b/packages/@aws-cdk/cdk/lib/tag-manager.ts index 9a7b24da4ddfc..ed6a2e6504136 100644 --- a/packages/@aws-cdk/cdk/lib/tag-manager.ts +++ b/packages/@aws-cdk/cdk/lib/tag-manager.ts @@ -18,6 +18,10 @@ interface CfnAsgTag { propagateAtLaunch: boolean; } +interface StackTag { + Key: string; + Value: string; +} /** * Interface for converter between CloudFormation and internal tag representations */ @@ -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 []; @@ -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(); private readonly priorities = new Map(); private readonly tagFormatter: ITagFormatter; @@ -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)) { diff --git a/packages/@aws-cdk/cdk/test/test.tag-manager.ts b/packages/@aws-cdk/cdk/test/test.tag-manager.ts index dd9b1cb86e51e..05d4ee7c132f5 100644 --- a/packages/@aws-cdk/cdk/test/test.tag-manager.ts +++ b/packages/@aws-cdk/cdk/test/test.tag-manager.ts @@ -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'); @@ -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); diff --git a/packages/@aws-cdk/cx-api/lib/cxapi.ts b/packages/@aws-cdk/cx-api/lib/cxapi.ts index 2a71c151eb0f3..e918c087d9d17 100644 --- a/packages/@aws-cdk/cx-api/lib/cxapi.ts +++ b/packages/@aws-cdk/cx-api/lib/cxapi.ts @@ -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'; diff --git a/packages/@aws-cdk/cx-api/lib/metadata.ts b/packages/@aws-cdk/cx-api/lib/metadata.ts index 25e0f730105a4..49957c3d1403e 100644 --- a/packages/@aws-cdk/cx-api/lib/metadata.ts +++ b/packages/@aws-cdk/cx-api/lib/metadata.ts @@ -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', diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 0bcedfb031213..dc65005ad76c3 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -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' })) @@ -99,7 +100,6 @@ async function initCommandLine() { proxyAddress: argv.proxy, ec2creds: argv.ec2creds, }); - const configuration = new Configuration(argv); await configuration.load(); @@ -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': diff --git a/packages/aws-cdk/lib/api/cxapp/stacks.ts b/packages/aws-cdk/lib/api/cxapp/stacks.ts index 5117240eff68e..f0691bc85edc9 100644 --- a/packages/aws-cdk/lib/api/cxapp/stacks.ts +++ b/packages/aws-cdk/lib/api/cxapp/stacks.ts @@ -62,6 +62,7 @@ export interface AppStacksProps { * In a class because it shares some global state */ export class AppStacks { + /** * Since app execution basically always synthesizes all the stacks, * we can invoke it once and cache the response for subsequent calls. @@ -233,6 +234,21 @@ export class AppStacks { } } + /** + * Returns and array with the tags available in the stack metadata. + */ + public getTagsFromStackMetadata(stack: cxapi.CloudFormationStackArtifact): Tag[] { + for (const id of Object.keys(stack.metadata)) { + const metadata = stack.metadata[id]; + for (const entry of metadata) { + if (entry.type === cxapi.STACK_TAGS_METADATA_KEY) { + return entry.data; + } + } + } + return []; + } + /** * Extracts 'aws:cdk:warning|info|error' metadata entries from the stack synthesis */ @@ -368,3 +384,15 @@ function includeUpstreamStacks( print('Including dependency stacks: %s', colors.bold(added.join(', '))); } } + +export interface SelectedStack extends cxapi.CloudFormationStackArtifact { + /** + * The original name of the stack before renaming + */ + originalName: string; +} + +export interface Tag { + readonly Key: string; + readonly Value: string; +} diff --git a/packages/aws-cdk/lib/api/deploy-stack.ts b/packages/aws-cdk/lib/api/deploy-stack.ts index b340606641531..c008bab26e036 100644 --- a/packages/aws-cdk/lib/api/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deploy-stack.ts @@ -2,6 +2,7 @@ import cxapi = require('@aws-cdk/cx-api'); import aws = require('aws-sdk'); import colors = require('colors/safe'); import uuid = require('uuid'); +import { Tag } from "../api/cxapp/stacks"; import { prepareAssets } from '../assets'; import { debug, error, print } from '../logging'; import { toYAML } from '../serialize'; @@ -32,6 +33,7 @@ export interface DeployStackOptions { quiet?: boolean; ci?: boolean; reuseAssets?: string[]; + tags?: Tag[]; } const LARGE_TEMPLATE_SIZE_KB = 50; @@ -73,7 +75,8 @@ export async function deployStack(options: DeployStackOptions): Promise {