diff --git a/packages/@aws-cdk/cdk/README.md b/packages/@aws-cdk/cdk/README.md index d8377bc037654..344443c30b1b0 100644 --- a/packages/@aws-cdk/cdk/README.md +++ b/packages/@aws-cdk/cdk/README.md @@ -117,11 +117,19 @@ has a few features that are covered later to explain how this works. ### API -In order to enable additional controls a Tags can specifically include or +In order to enable additional controls a Tag can specifically include or exclude a CloudFormation Resource Type, propagate tags for an autoscaling group, and use priority to override the default precedence. See the `TagProps` interface for more details. +Tags can be configured by using the properties for the AWS CloudFormation layer +resources or by using the tag aspects described here. The aspects will always +take precedence over the AWS CloudFormation layer in the event of a name +collision. The tags will be merged otherwise. For the aspect based tags, the +tags applied closest to the resource will take precedence, given an equal +priority. A higher priority tag will always take precedence over a lower +priority tag. + #### applyToLaunchedInstances This property is a boolean that defaults to `true`. When `true` and the aspect diff --git a/packages/@aws-cdk/cdk/lib/aspects/tag-aspect.ts b/packages/@aws-cdk/cdk/lib/aspects/tag-aspect.ts index 45abee70023ec..ea35af4153e11 100644 --- a/packages/@aws-cdk/cdk/lib/aspects/tag-aspect.ts +++ b/packages/@aws-cdk/cdk/lib/aspects/tag-aspect.ts @@ -1,8 +1,58 @@ import { ITaggable, Resource } from '../cloudformation/resource'; import { IConstruct } from '../core/construct'; -import { TagProps } from '../core/tag-manager'; import { IAspect } from './aspect'; +/** + * Properties for a tag + */ +export interface TagProps { + /** + * Whether the tag should be applied to instances in an AutoScalingGroup + * + * @default true + */ + applyToLaunchedInstances?: boolean; + + /** + * An array of Resource Types that will not receive this tag + * + * An empty array will allow this tag to be applied to all resources. A + * non-empty array will apply this tag only if the Resource type is not in + * this array. + * @default [] + */ + excludeResourceTypes?: string[]; + + /** + * An array of Resource Types that will receive this tag + * + * An empty array will match any Resource. A non-empty array will apply this + * tag only to Resource types that are included in this array. + * @default [] + */ + includeResourceTypes?: string[]; + + /** + * Priority of the tag operation + * + * Higher or equal priority tags will take precedence. + * + * Setting priority will enable the user to control tags when they need to not + * follow the default precedence pattern of last applied and closest to the + * construct in the tree. + * + * @default + * + * Default priorities: + * + * - 100 for {@link SetTag} + * - 200 for {@link RemoveTag} + * - 50 for tags added directly to CloudFormation resources + * + */ + priority?: number; +} + /** * The common functionality for Tag and Remove Tag Aspects */ @@ -43,10 +93,10 @@ export class Tag extends TagBase { */ public readonly value: string; + private readonly defaultPriority = 100; + constructor(key: string, value: string, props: TagProps = {}) { super(key, props); - this.props.applyToLaunchedInstances = props.applyToLaunchedInstances !== false; - this.props.priority = props.priority === undefined ? 0 : props.priority; if (value === undefined) { throw new Error('Tag must have a value'); } @@ -54,7 +104,14 @@ export class Tag extends TagBase { } protected applyTag(resource: ITaggable) { - resource.tags.setTag(this.key, this.value!, this.props); + if (resource.tags.applyTagAspectHere(this.props.includeResourceTypes, this.props.excludeResourceTypes)) { + resource.tags.setTag( + this.key, + this.value, + this.props.priority !== undefined ? this.props.priority : this.defaultPriority, + this.props.applyToLaunchedInstances !== false + ); + } } } @@ -63,13 +120,15 @@ export class Tag extends TagBase { */ export class RemoveTag extends TagBase { + private readonly defaultPriority = 200; + constructor(key: string, props: TagProps = {}) { super(key, props); - this.props.priority = props.priority === undefined ? 1 : props.priority; } protected applyTag(resource: ITaggable): void { - resource.tags.removeTag(this.key, this.props); - return; + if (resource.tags.applyTagAspectHere(this.props.includeResourceTypes, this.props.excludeResourceTypes)) { + resource.tags.removeTag(this.key, this.props.priority !== undefined ? this.props.priority : this.defaultPriority); + } } } diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts index 74ab805926354..1487aa61e7282 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts @@ -206,12 +206,13 @@ export class Resource extends Referenceable { */ public toCloudFormation(): object { try { - if (Resource.isTaggable(this)) { - const tags = this.tags.renderTags(); - this.properties.tags = tags === undefined ? this.properties.tags : tags; - } // merge property overrides onto properties and then render (and validate). - const properties = this.renderProperties(deepMerge(this.properties || { }, this.untypedPropertyOverrides)); + const tags = Resource.isTaggable(this) ? this.tags.renderTags() : undefined; + const properties = this.renderProperties(deepMerge( + this.properties || {}, + { tags }, + this.untypedPropertyOverrides + )); return { Resources: { @@ -254,7 +255,6 @@ export class Resource extends Referenceable { protected renderProperties(properties: any): { [key: string]: any } { return properties; } - } export enum TagType { @@ -312,33 +312,35 @@ export interface ResourceOptions { * Merges `source` into `target`, overriding any existing values. * `null`s will cause a value to be deleted. */ -export function deepMerge(target: any, source: any) { - if (typeof(source) !== 'object' || typeof(target) !== 'object') { - throw new Error(`Invalid usage. Both source (${JSON.stringify(source)}) and target (${JSON.stringify(target)}) must be objects`); - } +export function deepMerge(target: any, ...sources: any[]) { + for (const source of sources) { + if (typeof(source) !== 'object' || typeof(target) !== 'object') { + throw new Error(`Invalid usage. Both source (${JSON.stringify(source)}) and target (${JSON.stringify(target)}) must be objects`); + } - for (const key of Object.keys(source)) { - const value = source[key]; - if (typeof(value) === 'object' && value != null && !Array.isArray(value)) { - // if the value at the target is not an object, override it with an - // object so we can continue the recursion - if (typeof(target[key]) !== 'object') { - target[key] = { }; - } + for (const key of Object.keys(source)) { + const value = source[key]; + if (typeof(value) === 'object' && value != null && !Array.isArray(value)) { + // if the value at the target is not an object, override it with an + // object so we can continue the recursion + if (typeof(target[key]) !== 'object') { + target[key] = { }; + } - deepMerge(target[key], value); + deepMerge(target[key], value); - // if the result of the merge is an empty object, it's because the - // eventual value we assigned is `undefined`, and there are no - // sibling concrete values alongside, so we can delete this tree. - const output = target[key]; - if (typeof(output) === 'object' && Object.keys(output).length === 0) { + // if the result of the merge is an empty object, it's because the + // eventual value we assigned is `undefined`, and there are no + // sibling concrete values alongside, so we can delete this tree. + const output = target[key]; + if (typeof(output) === 'object' && Object.keys(output).length === 0) { + delete target[key]; + } + } else if (value === undefined) { delete target[key]; + } else { + target[key] = value; } - } else if (value === undefined) { - delete target[key]; - } else { - target[key] = value; } } diff --git a/packages/@aws-cdk/cdk/lib/core/tag-manager.ts b/packages/@aws-cdk/cdk/lib/core/tag-manager.ts index 55bcdeb74717e..1739fb5ffa86f 100644 --- a/packages/@aws-cdk/cdk/lib/core/tag-manager.ts +++ b/packages/@aws-cdk/cdk/lib/core/tag-manager.ts @@ -1,153 +1,228 @@ import { TagType } from '../cloudformation/resource'; +import { CfnTag } from '../cloudformation/tag'; -/** - * Properties Tags is a dictionary of tags as strings - */ -type Tags = { [key: string]: {value: string, props: TagProps }}; +interface Tag { + key: string; + value: string; + priority: number; -/** - * Properties for a tag - */ -export interface TagProps { /** - * Handles AutoScalingGroup PropagateAtLaunch property + * @default true */ applyToLaunchedInstances?: boolean; +} - /** - * An array of Resource Types that will not receive this tag - * - * An empty array will allow this tag to be applied to all resources. A - * non-empty array will apply this tag only if the Resource type is not in - * this array. - * @default [] - */ - excludeResourceTypes?: string[]; +interface CfnAsgTag { + key: string; + value: string; + propagateAtLaunch: boolean; +} +/** + * Interface for converter between CloudFormation and internal tag representations + */ +interface ITagFormatter { /** - * An array of Resource Types that will receive this tag - * - * An empty array will match any Resource. A non-empty array will apply this - * tag only to Resource types that are included in this array. - * @default [] + * Format the given tags as CloudFormation tags */ - includeResourceTypes?: string[]; + formatTags(tags: Tag[]): any; /** - * Higher or equal priority tags will take precedence + * Parse the CloudFormation tag representation into internal representation * - * Setting priority will enable the user to control tags when they need to not - * follow the default precedence pattern of last applied and closest to the - * construct in the tree. - * @default 0 for Tag 1 for RemoveTag + * Use the given priority. */ - priority?: number; + parseTags(cfnPropertyTags: any, priority: number): Tag[]; } /** - * TagManager facilitates a common implementation of tagging for Constructs. + * Standard tags are a list of { key, value } objects */ -export class TagManager { +class StandardFormatter implements ITagFormatter { + public parseTags(cfnPropertyTags: any, priority: number): Tag[] { + if (!Array.isArray(cfnPropertyTags)) { + throw new Error(`Invalid tag input expected array of {key, value} have ${JSON.stringify(cfnPropertyTags)}`); + } + + const tags: Tag[] = []; + for (const tag of cfnPropertyTags) { + if (tag.key === undefined || tag.value === undefined) { + throw new Error(`Invalid tag input expected {key, value} have ${JSON.stringify(tag)}`); + } + // using interp to ensure Token is now string + tags.push({ + key: `${tag.key}`, + value: `${tag.value}`, + priority + }); + } + return tags; + } + + public formatTags(tags: Tag[]): any { + const cfnTags: CfnTag[] = []; + for (const tag of tags) { + cfnTags.push({ + key: tag.key, + value: tag.value + }); + } + return cfnTags.length === 0 ? undefined : cfnTags; + } +} + +/** + * ASG tags are a list of { key, value, propagateAtLaunch } objects + */ +class AsgFormatter implements ITagFormatter { + public parseTags(cfnPropertyTags: any, priority: number): Tag[] { + const tags: Tag[] = []; + if (!Array.isArray(cfnPropertyTags)) { + throw new Error(`Invalid tag input expected array of {key, value, propagateAtLaunch} have ${JSON.stringify(cfnPropertyTags)}`); + } - private readonly tags: Tags = {}; + for (const tag of cfnPropertyTags) { + if (tag.key === undefined || + tag.value === undefined || + tag.propagateAtLaunch === undefined) { + throw new Error(`Invalid tag input expected {key, value, propagateAtLaunch} have ${JSON.stringify(tag)}`); + } + // using interp to ensure Token is now string + tags.push({ + key: `${tag.key}`, + value: `${tag.value}`, + priority, + applyToLaunchedInstances: !!tag.propagateAtLaunch + }); + } - private readonly removedTags: {[key: string]: number} = {}; + return tags; + } - constructor(private readonly tagType: TagType, private readonly resourceTypeName: string) { } + public formatTags(tags: Tag[]): any { + const cfnTags: CfnAsgTag[] = []; + for (const tag of tags) { + cfnTags.push({ + key: tag.key, + value: tag.value, + propagateAtLaunch: tag.applyToLaunchedInstances !== false, + }); + } + return cfnTags.length === 0 ? undefined : cfnTags; + } +} + +/** + * Some CloudFormation constructs use a { key: value } map for tags + */ +class MapFormatter implements ITagFormatter { + public parseTags(cfnPropertyTags: any, priority: number): Tag[] { + const tags: Tag[] = []; + if (Array.isArray(cfnPropertyTags) || typeof(cfnPropertyTags) !== 'object') { + throw new Error(`Invalid tag input expected map of {key: value} have ${JSON.stringify(cfnPropertyTags)}`); + } + + for (const [key, value] of Object.entries(cfnPropertyTags)) { + tags.push({ + key, + value: `${value}`, + priority + }); + } + + return tags; + } + + public formatTags(tags: Tag[]): any { + const cfnTags: {[key: string]: string} = {}; + for (const tag of tags) { + cfnTags[`${tag.key}`] = `${tag.value}`; + } + return Object.keys(cfnTags).length === 0 ? undefined : cfnTags; + } +} + +class NoFormat implements ITagFormatter { + public parseTags(_cfnPropertyTags: any): Tag[] { + return []; + } + public formatTags(_tags: Tag[]): any { + return undefined; + } +} + +const TAG_FORMATTERS: {[key: string]: ITagFormatter} = { + [TagType.AutoScalingGroup]: new AsgFormatter(), + [TagType.Standard]: new StandardFormatter(), + [TagType.Map]: new MapFormatter(), + [TagType.NotTaggable]: new NoFormat(), +}; + +/** + * TagManager facilitates a common implementation of tagging for Constructs. + */ +export class TagManager { + private readonly tags = new Map(); + private readonly priorities = new Map(); + private readonly tagFormatter: ITagFormatter; + private readonly resourceTypeName: string; + private readonly initialTagPriority = 50; + + constructor(tagType: TagType, resourceTypeName: string, tagStructure?: any) { + this.resourceTypeName = resourceTypeName; + this.tagFormatter = TAG_FORMATTERS[tagType]; + if (tagStructure !== undefined) { + this._setTag(...this.tagFormatter.parseTags(tagStructure, this.initialTagPriority)); + } + } /** * Adds the specified tag to the array of tags * - * @param key The key value of the tag - * @param value The value value of the tag - * @param props A `TagProps` defaulted to applyToLaunchInstances true */ - public setTag(key: string, value: string, props?: TagProps): void { - const tagProps: TagProps = props || {}; - - if (!this.canApplyTag(key, tagProps)) { - // tag is blocked by a remove - return; - } - tagProps.applyToLaunchedInstances = tagProps.applyToLaunchedInstances !== false; - this.tags[key] = { value, props: tagProps }; - // ensure nothing is left in removeTags - delete this.removedTags[key]; + public setTag(key: string, value: string, priority = 0, applyToLaunchedInstances = true): void { + // This method mostly exists because we don't want to expose the 'Tag' type used (it will be confusing + // to users). + this._setTag({ key, value, priority, applyToLaunchedInstances }); } /** * Removes the specified tag from the array if it exists * - * @param key The key of the tag to remove + * @param key The tag to remove + * @param priority The priority of the remove operation */ - public removeTag(key: string, props?: TagProps): void { - const tagProps = props || {}; - const priority = tagProps.priority === undefined ? 0 : tagProps.priority; - if (!this.canApplyTag(key, tagProps)) { - // tag is blocked by a remove - return; + public removeTag(key: string, priority: number): void { + if (priority >= (this.priorities.get(key) || 0)) { + this.tags.delete(key); + this.priorities.set(key, priority); } - delete this.tags[key]; - this.removedTags[key] = priority; } /** * Renders tags into the proper format based on TagType */ public renderTags(): any { - const keys = Object.keys(this.tags); - switch (this.tagType) { - case TagType.Standard: { - const tags: Array<{key: string, value: string}> = []; - for (const key of keys) { - tags.push({key, value: this.tags[key].value}); - } - return tags.length === 0 ? undefined : tags; - } - case TagType.AutoScalingGroup: { - const tags: Array<{key: string, value: string, propagateAtLaunch: boolean}> = []; - for (const key of keys) { - tags.push({ - key, - value: this.tags[key].value, - propagateAtLaunch: this.tags[key].props.applyToLaunchedInstances !== false} - ); - } - return tags.length === 0 ? undefined : tags; - } - case TagType.Map: { - const tags: {[key: string]: string} = {}; - for (const key of keys) { - tags[key] = this.tags[key].value; - } - return Object.keys(tags).length === 0 ? undefined : tags; - } - case TagType.NotTaggable: { - return undefined; - } - } + return this.tagFormatter.formatTags(Array.from(this.tags.values())); } - private canApplyTag(key: string, props: TagProps): boolean { - const include = props.includeResourceTypes || []; - const exclude = props.excludeResourceTypes || []; - const priority = props.priority === undefined ? 0 : props.priority; - if (exclude.length !== 0 && - exclude.indexOf(this.resourceTypeName) !== -1) { + public applyTagAspectHere(include?: string[], exclude?: string[]) { + if (exclude && exclude.length > 0 && exclude.indexOf(this.resourceTypeName) !== -1) { return false; } - if (include.length !== 0 && - include.indexOf(this.resourceTypeName) === -1) { + if (include && include.length > 0 && include.indexOf(this.resourceTypeName) === -1) { return false; } - if (this.tags[key]) { - if (this.tags[key].props.priority !== undefined) { - return priority >= this.tags[key].props.priority!; + + return true; + } + + private _setTag(...tags: Tag[]) { + for (const tag of tags) { + if (tag.priority >= (this.priorities.get(tag.key) || 0)) { + this.tags.set(tag.key, tag); + this.priorities.set(tag.key, tag.priority); } } - if (this.removedTags[key]) { - return priority >= this.removedTags[key]; - } - return true; } } diff --git a/packages/@aws-cdk/cdk/test/aspects/test.tag-aspect.ts b/packages/@aws-cdk/cdk/test/aspects/test.tag-aspect.ts index fbd58535c1b56..368259e287be1 100644 --- a/packages/@aws-cdk/cdk/test/aspects/test.tag-aspect.ts +++ b/packages/@aws-cdk/cdk/test/aspects/test.tag-aspect.ts @@ -1,19 +1,40 @@ import { Test } from 'nodeunit'; -import { RemoveTag, Resource, Stack, Tag, TagManager, TagType } from '../../lib'; +import { Construct, RemoveTag, Resource, ResourceProps, Stack, Tag, TagManager, TagType } from '../../lib'; class TaggableResource extends Resource { - public readonly tags = new TagManager(TagType.Standard, 'AWS::Fake::Resource'); + public readonly tags: TagManager; + constructor(scope: Construct, id: string, props: ResourceProps) { + super(scope, id, props); + const tags = props.properties === undefined ? undefined : props.properties.tags; + this.tags = new TagManager(TagType.Standard, 'AWS::Fake::Resource', tags); + } public testProperties() { return this.properties; } } -class AsgTaggableResource extends TaggableResource { - public readonly tags = new TagManager(TagType.AutoScalingGroup, 'AWS::Fake::Resource'); +class AsgTaggableResource extends Resource { + public readonly tags: TagManager; + constructor(scope: Construct, id: string, props: ResourceProps) { + super(scope, id, props); + const tags = props.properties === undefined ? undefined : props.properties.tags; + this.tags = new TagManager(TagType.AutoScalingGroup, 'AWS::Fake::Resource', tags); + } + public testProperties() { + return this.properties; + } } -class MapTaggableResource extends TaggableResource { - public readonly tags = new TagManager(TagType.Map, 'AWS::Fake::Resource'); +class MapTaggableResource extends Resource { + public readonly tags: TagManager; + constructor(scope: Construct, id: string, props: ResourceProps) { + super(scope, id, props); + const tags = props.properties === undefined ? undefined : props.properties.tags; + this.tags = new TagManager(TagType.Map, 'AWS::Fake::Resource', tags); + } + public testProperties() { + return this.properties; + } } export = { @@ -130,16 +151,35 @@ export = { test.deepEqual(res2.tags.renderTags(), [{key: 'key', value: 'value'}]); test.done(); }, - 'Aspects are mutually exclusive with tags created by L1 Constructor'(test: Test) { + 'Aspects are merged with tags created by L1 Constructor'(test: Test) { const root = new Stack(); const aspectBranch = new TaggableResource(root, 'FakeBranchA', { type: 'AWS::Fake::Thing', properties: { tags: [ + {key: 'aspects', value: 'overwrite'}, {key: 'cfn', value: 'is cool'}, ], }, }); + const asgResource = new AsgTaggableResource(aspectBranch, 'FakeAsg', { + type: 'AWS::Fake::Thing', + properties: { + tags: [ + {key: 'aspects', value: 'overwrite', propagateAtLaunch: false}, + {key: 'cfn', value: 'is cool', propagateAtLaunch: true}, + ], + }, + }); + const mapTaggable = new MapTaggableResource(aspectBranch, 'FakeSam', { + type: 'AWS::Fake::Thing', + properties: { + tags: { + aspects: 'overwrite', + cfn: 'is cool', + }, + }, + }); const cfnBranch = new TaggableResource(root, 'FakeBranchB', { type: 'AWS::Fake::Thing', properties: { @@ -150,8 +190,60 @@ export = { }); aspectBranch.node.apply(new Tag('aspects', 'rule')); root.node.prepareTree(); - test.deepEqual(aspectBranch.tags.renderTags(), [{key: 'aspects', value: 'rule'}]); + test.deepEqual(aspectBranch.testProperties().tags, [{key: 'aspects', value: 'rule'}, {key: 'cfn', value: 'is cool'}]); + test.deepEqual(asgResource.testProperties().tags, [ + {key: 'aspects', value: 'rule', propagateAtLaunch: true}, + {key: 'cfn', value: 'is cool', propagateAtLaunch: true} + ]); + test.deepEqual(mapTaggable.testProperties().tags, { + aspects: 'rule', + cfn: 'is cool', + }); test.deepEqual(cfnBranch.testProperties().tags, [{key: 'cfn', value: 'is cool'}]); test.done(); }, + 'when invalid tag properties are passed from L1s': { + 'map passed instead of array it raises'(test: Test) { + const root = new Stack(); + test.throws(() => { + new TaggableResource(root, 'FakeBranchA', { + type: 'AWS::Fake::Thing', + properties: { + tags: { + cfn: 'is cool', + aspects: 'overwrite', + }, + }, + }); + }); + test.throws(() => { + new AsgTaggableResource(root, 'FakeBranchA', { + type: 'AWS::Fake::Thing', + properties: { + tags: { + cfn: 'is cool', + aspects: 'overwrite', + propagateAtLaunch: true, + }, + }, + }); + }); + test.done(); + }, + 'if array is passed instead of map it raises'(test: Test) { + const root = new Stack(); + test.throws(() => { + new MapTaggableResource(root, 'FakeSam', { + type: 'AWS::Fake::Thing', + properties: { + tags: [ + {key: 'cfn', value: 'is cool', propagateAtLaunch: true}, + {key: 'aspects', value: 'overwrite'}, + ], + }, + }); + }); + test.done(); + }, + }, }; diff --git a/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts b/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts index 89ba6c7dac9b4..3916097ed6edd 100644 --- a/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts +++ b/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts @@ -12,7 +12,7 @@ export = { 'when a tag does not exist': { '#removeTag() does not throw an error'(test: Test) { const mgr = new TagManager(TagType.Standard, 'AWS::Resource::Type'); - test.doesNotThrow(() => (mgr.removeTag('dne'))); + test.doesNotThrow(() => (mgr.removeTag('dne', 0))); test.done(); }, '#setTag() creates the tag'(test: Test) { @@ -25,8 +25,8 @@ export = { 'when a tag does exist': { '#removeTag() deletes the tag'(test: Test) { const mgr = new TagManager(TagType.Standard, 'AWS::Resource::Type'); - mgr.setTag('dne', 'notanymore'); - mgr.removeTag('dne'); + mgr.setTag('dne', 'notanymore', 0); + mgr.removeTag('dne', 0); test.deepEqual(mgr.renderTags(), undefined); test.done(); }, @@ -55,7 +55,7 @@ export = { tagged.push(mapper); for (const res of tagged) { res.setTag('foo', 'bar'); - res.setTag('asg', 'only', {applyToLaunchedInstances: false}); + res.setTag('asg', 'only', 0, false); } test.deepEqual(standard.renderTags(), [ {key: 'foo', value: 'bar'}, @@ -73,32 +73,33 @@ export = { }, 'tags with higher or equal priority always take precedence'(test: Test) { const mgr = new TagManager(TagType.Standard, 'AWS::Resource::Type'); - mgr.setTag('key', 'myVal', { - priority: 2, - }); - mgr.setTag('key', 'newVal', { - priority: 1, - }); - mgr.removeTag('key', {priority: 1}); + mgr.setTag('key', 'myVal', 2); + mgr.setTag('key', 'newVal', 1); + test.deepEqual(mgr.renderTags(), [ + {key: 'key', value: 'myVal'}, + ]); + mgr.removeTag('key', 1); test.deepEqual(mgr.renderTags(), [ {key: 'key', value: 'myVal'}, ]); - mgr.removeTag('key', {priority: 2}); + mgr.removeTag('key', 2); test.deepEqual(mgr.renderTags(), undefined); test.done(); }, 'excludeResourceTypes only tags resources that do not match'(test: Test) { const mgr = new TagManager(TagType.Standard, 'AWS::Fake::Resource'); - mgr.setTag('key', 'value', {excludeResourceTypes: ['AWS::Fake::Resource']}); - mgr.setTag('sticky', 'value', {excludeResourceTypes: ['AWS::Wrong::Resource']}); - test.deepEqual(mgr.renderTags(), [{key: 'sticky', value: 'value'}]); + + test.equal(false, mgr.applyTagAspectHere([], ['AWS::Fake::Resource'])); + test.equal(true, mgr.applyTagAspectHere([], ['AWS::Wrong::Resource'])); + test.done(); }, 'includeResourceTypes only tags resources that match'(test: Test) { const mgr = new TagManager(TagType.Standard, 'AWS::Fake::Resource'); - mgr.setTag('key', 'value', {includeResourceTypes: ['AWS::Fake::Resource']}); - mgr.setTag('sticky', 'value', {includeResourceTypes: ['AWS::Wrong::Resource']}); - test.deepEqual(mgr.renderTags(), [{key: 'key', value: 'value'}]); + + test.equal(true, mgr.applyTagAspectHere(['AWS::Fake::Resource'], [])); + test.equal(false, mgr.applyTagAspectHere(['AWS::Wrong::Resource'], [])); + test.done(); } }; diff --git a/tools/cfn2ts/lib/codegen.ts b/tools/cfn2ts/lib/codegen.ts index e153f17b672a1..7d7c653bc508f 100644 --- a/tools/cfn2ts/lib/codegen.ts +++ b/tools/cfn2ts/lib/codegen.ts @@ -255,7 +255,7 @@ export default class CodeGenerator { this.code.line(' * used only the tags from the TagManager will be used. `Tag` (aspect)'); this.code.line(' * will use the manager.'); this.code.line(' */'); - this.code.line(`public readonly tags = new ${TAG_MANAGER}(${tagEnum}, ${resourceTypeName});`); + this.code.line(`public readonly tags: ${TAG_MANAGER};`); } // @@ -308,6 +308,10 @@ export default class CodeGenerator { if (deprecated) { this.code.line(`this.node.addWarning('DEPRECATION: ${deprecation}');`); } + if (tagEnum !== `${TAG_TYPE}.NotTaggable`) { + this.code.line('const tags = props === undefined ? undefined : props.tags;'); + this.code.line(`this.tags = new ${TAG_MANAGER}(${tagEnum}, ${resourceTypeName}, tags);`); + } this.code.closeBlock(); @@ -515,7 +519,7 @@ export default class CodeGenerator { const javascriptPropertyName = genspec.cloudFormationToScriptName(propName); this.docLink(spec.Documentation, additionalDocs); - this.code.line(`${javascriptPropertyName}${question}: ${this.findNativeType(context, spec)};`); + this.code.line(`${javascriptPropertyName}${question}: ${this.findNativeType(context, spec, propName)};`); return javascriptPropertyName; } @@ -568,14 +572,17 @@ export default class CodeGenerator { /** * Return the native type expression for the given propSpec */ - private findNativeType(resourceContext: genspec.CodeName, propSpec: schema.Property): string { + private findNativeType(resourceContext: genspec.CodeName, propSpec: schema.Property, propName?: string): string { const alternatives: string[] = []; if (schema.isCollectionProperty(propSpec)) { // render the union of all item types const itemTypes = genspec.specTypesToCodeTypes(resourceContext, itemTypeNames(propSpec)); - // Always accept a token in place of any list element - itemTypes.push(genspec.TOKEN_NAME); + + if (propName !== 'Tags') { + // Always accept a token in place of any list element + itemTypes.push(genspec.TOKEN_NAME); + } const union = this.renderTypeUnion(resourceContext, itemTypes); @@ -587,7 +594,7 @@ export default class CodeGenerator { if (union.indexOf('|') !== -1) { alternatives.push(`Array<${union}>`); } else { - alternatives.push(`(${union})[]`); + alternatives.push(`${union}[]`); } } } @@ -605,7 +612,7 @@ export default class CodeGenerator { // everything to be tokenizable because there are languages that do not // support union types (i.e. Java, .NET), so we lose type safety if we have // a union. - if (!tokenizableType(alternatives)) { + if (!tokenizableType(alternatives) && propName !== 'Tags') { alternatives.push(genspec.TOKEN_NAME.fqn); }