From b05e49263f5307e547baf384abde408da7d15ca8 Mon Sep 17 00:00:00 2001 From: Mike Cowgill Date: Thu, 18 Oct 2018 11:11:59 -0700 Subject: [PATCH] feat(cfn2ts) Add support for Aspects (#1007) * The first aspects are for tags * TagManager is now deprecated and aspects are preferred * Tags can propagate to a depth by number * Tags can be included or excluded based on Resource Type --- packages/@aws-cdk/assert/lib/expect.ts | 1 + .../aws-autoscaling/lib/auto-scaling-group.ts | 13 +- .../@aws-cdk/aws-ec2/lib/security-group.ts | 1 - packages/@aws-cdk/aws-ec2/lib/vpc.ts | 49 ++-- packages/@aws-cdk/aws-ec2/test/test.vpc.ts | 75 ++++--- packages/@aws-cdk/cdk/lib/app.ts | 5 +- .../@aws-cdk/cdk/lib/aspects/tag-aspect.ts | 149 +++++++++++++ .../cdk/lib/cloudformation/resource.ts | 58 ++++- packages/@aws-cdk/cdk/lib/core/construct.ts | 29 +++ packages/@aws-cdk/cdk/lib/core/tag-manager.ts | 123 +++++----- packages/@aws-cdk/cdk/lib/index.ts | 2 + .../cdk/test/aspects/test.tag-aspect.ts | 211 ++++++++++++++++++ .../cdk/test/cloudformation/test.resource.ts | 60 ++--- .../cdk/test/core/test.tag-manager.ts | 13 +- .../@aws-cdk/cfnspec/lib/schema/property.ts | 50 ++++- .../cfnspec/lib/schema/resource-type.ts | 25 ++- tools/cfn2ts/lib/codegen.ts | 35 ++- 17 files changed, 720 insertions(+), 179 deletions(-) create mode 100644 packages/@aws-cdk/cdk/lib/aspects/tag-aspect.ts create mode 100644 packages/@aws-cdk/cdk/test/aspects/test.tag-aspect.ts diff --git a/packages/@aws-cdk/assert/lib/expect.ts b/packages/@aws-cdk/assert/lib/expect.ts index 63108bd990b4d..92b4bdc3ffe87 100644 --- a/packages/@aws-cdk/assert/lib/expect.ts +++ b/packages/@aws-cdk/assert/lib/expect.ts @@ -15,6 +15,7 @@ export function expect(stack: api.SynthesizedStack | cdk.Stack, skipValidation = } } + stack.visit(); sstack = { name: 'test', template: stack.toCloudFormation(), diff --git a/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts b/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts index e1b926d6d240b..5ec0c780f16f9 100644 --- a/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts +++ b/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts @@ -209,7 +209,8 @@ export class AutoScalingGroup extends cdk.Construct implements IAutoScalingGroup }); this.connections = new ec2.Connections({ securityGroups: [this.securityGroup] }); this.securityGroups.push(this.securityGroup); - this.tags = new TagManager(this, {initialTags: props.tags}); + this.tags = new cdk.TagManager(this, {initialTags: props.tags, + tagFormatter: new cdk.AutoScalingGroupTagFormatter()}); this.tags.setTag(NAME_TAG, this.path, { overwrite: false }); this.role = new iam.Role(this, 'InstanceRole', { @@ -616,16 +617,6 @@ function renderRollingUpdateConfig(config: RollingUpdateConfiguration = {}): cdk }; } -class TagManager extends cdk.TagManager { - protected tagFormatResolve(tagGroups: cdk.TagGroups): any { - const tags = {...tagGroups.nonStickyTags, ...tagGroups.ancestorTags, ...tagGroups.stickyTags}; - return Object.keys(tags).map( (key) => { - const propagateAtLaunch = !!tagGroups.propagateTags[key] || !!tagGroups.ancestorTags[key]; - return {key, value: tags[key], propagateAtLaunch}; - }); - } -} - /** * Render a number of seconds to a PTnX string. */ diff --git a/packages/@aws-cdk/aws-ec2/lib/security-group.ts b/packages/@aws-cdk/aws-ec2/lib/security-group.ts index 001c3935b87f7..038a64749493a 100644 --- a/packages/@aws-cdk/aws-ec2/lib/security-group.ts +++ b/packages/@aws-cdk/aws-ec2/lib/security-group.ts @@ -178,7 +178,6 @@ export class SecurityGroup extends SecurityGroupBase implements ITaggable { securityGroupIngress: new Token(() => this.directIngressRules), securityGroupEgress: new Token(() => this.directEgressRules), vpcId: props.vpc.vpcId, - tags: this.tags, }); this.securityGroupId = this.securityGroup.securityGroupId; diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc.ts b/packages/@aws-cdk/aws-ec2/lib/vpc.ts index 080c0ae8bb922..e61cc077fd6a7 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc.ts @@ -184,7 +184,7 @@ export interface SubnetConfiguration { * * } */ -export class VpcNetwork extends VpcNetworkBase implements cdk.ITaggable { +export class VpcNetwork extends VpcNetworkBase { /** * @returns The IPv4 CidrBlock as returned by the VPC */ @@ -254,11 +254,6 @@ export class VpcNetwork extends VpcNetworkBase implements cdk.ITaggable { */ public readonly availabilityZones: string[]; - /** - * Manage tags for this construct and children - */ - public readonly tags: cdk.TagManager; - /** * The VPC resource */ @@ -293,8 +288,12 @@ export class VpcNetwork extends VpcNetworkBase implements cdk.ITaggable { throw new Error('To use DNS Hostnames, DNS Support must be enabled, however, it was explicitly disabled.'); } - this.tags = new cdk.TagManager(this, { initialTags: props.tags}); - this.tags.setTag(NAME_TAG, this.path, { overwrite: false }); + // set first and let initial tags overwrite + this.apply(new cdk.TagAspect(NAME_TAG, this.path)); + const tags = props.tags || {}; + for (const key of Object.keys(tags)) { + this.apply(new cdk.TagAspect(key, tags[key])); + } const cidrBlock = ifUndefined(props.cidr, VpcNetwork.DEFAULT_CIDR_RANGE); this.networkBuilder = new NetworkBuilder(cidrBlock); @@ -309,7 +308,6 @@ export class VpcNetwork extends VpcNetworkBase implements cdk.ITaggable { enableDnsHostnames, enableDnsSupport, instanceTenancy, - tags: this.tags, }); this.availabilityZones = new cdk.AvailabilityZoneProvider(this).availabilityZones; @@ -330,9 +328,7 @@ export class VpcNetwork extends VpcNetworkBase implements cdk.ITaggable { // Create an Internet Gateway and attach it if necessary if (allowOutbound) { - const igw = new CfnInternetGateway(this, 'IGW', { - tags: new cdk.TagManager(this), - }); + const igw = new CfnInternetGateway(this, 'IGW'); this.internetDependencies.push(igw); const att = new CfnVPCGatewayAttachment(this, 'VPCGW', { internetGatewayId: igw.ref, @@ -456,7 +452,6 @@ export class VpcNetwork extends VpcNetworkBase implements cdk.ITaggable { break; case SubnetType.Isolated: const isolatedSubnet = new VpcPrivateSubnet(this, name, subnetProps); - isolatedSubnet.tags.setTag(SUBNETTYPE_TAG, subnetTypeTagValue(subnetConfig.subnetType)); this.isolatedSubnets.push(isolatedSubnet); subnet = isolatedSubnet; break; @@ -465,8 +460,10 @@ export class VpcNetwork extends VpcNetworkBase implements cdk.ITaggable { } // These values will be used to recover the config upon provider import - subnet.tags.setTag(SUBNETNAME_TAG, subnetConfig.name, { propagate: false }); - subnet.tags.setTag(SUBNETTYPE_TAG, subnetTypeTagValue(subnetConfig.subnetType), { propagate: false }); + const include = [CfnSubnet.resourceTypeName]; + subnet.apply(new cdk.TagAspect(SUBNETNAME_TAG, subnetConfig.name, { include })); + subnet.apply(new cdk.TagAspect( + SUBNETTYPE_TAG, subnetTypeTagValue(subnetConfig.subnetType), { include })); }); } } @@ -518,7 +515,7 @@ export interface VpcSubnetProps { /** * Represents a new VPC subnet resource */ -export class VpcSubnet extends cdk.Construct implements IVpcSubnet, cdk.ITaggable, cdk.IDependable { +export class VpcSubnet extends cdk.Construct implements IVpcSubnet, cdk.IDependable { public static import(parent: cdk.Construct, name: string, props: VpcSubnetImportProps): IVpcSubnet { return new ImportedVpcSubnet(parent, name, props); } @@ -533,11 +530,6 @@ export class VpcSubnet extends cdk.Construct implements IVpcSubnet, cdk.ITaggabl */ public readonly subnetId: string; - /** - * Manage tags for Construct and propagate to children - */ - public readonly tags: cdk.TagManager; - /** * Parts of this VPC subnet */ @@ -550,8 +542,12 @@ export class VpcSubnet extends cdk.Construct implements IVpcSubnet, cdk.ITaggabl constructor(parent: cdk.Construct, name: string, props: VpcSubnetProps) { super(parent, name); - this.tags = new cdk.TagManager(this, {initialTags: props.tags}); - this.tags.setTag(NAME_TAG, this.path, {overwrite: false}); + // set first and let initial tags overwrite + this.apply(new cdk.TagAspect(NAME_TAG, this.path)); + const tags = props.tags || {}; + for (const key of Object.keys(tags)) { + this.apply(new cdk.TagAspect(key, tags[key])); + } this.availabilityZone = props.availabilityZone; const subnet = new CfnSubnet(this, 'Subnet', { @@ -559,12 +555,10 @@ export class VpcSubnet extends cdk.Construct implements IVpcSubnet, cdk.ITaggabl cidrBlock: props.cidrBlock, availabilityZone: props.availabilityZone, mapPublicIpOnLaunch: props.mapPublicIpOnLaunch, - tags: this.tags, }); this.subnetId = subnet.subnetId; const table = new CfnRouteTable(this, 'RouteTable', { vpcId: props.vpcId, - tags: new cdk.TagManager(this), }); this.routeTableId = table.ref; @@ -636,9 +630,8 @@ export class VpcPublicSubnet extends VpcSubnet { const ngw = new CfnNatGateway(this, `NATGateway`, { subnetId: this.subnetId, allocationId: new CfnEIP(this, `EIP`, { - domain: 'vpc' + domain: 'vpc', }).eipAllocationId, - tags: new cdk.TagManager(this), }); return ngw.natGatewayId; } @@ -708,4 +701,4 @@ export class ImportedVpcSubnet extends cdk.Construct implements IVpcSubnet { public export() { return this.props; } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts index f4cab6224ea79..b0c6b8f6a34b9 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts @@ -1,7 +1,7 @@ import { countResources, expect, haveResource, haveResourceLike, isSuperObject } from '@aws-cdk/assert'; -import { AvailabilityZoneProvider, Construct, resolve, Stack, Tags } from '@aws-cdk/cdk'; +import { AvailabilityZoneProvider, Construct, resolve, Stack, TagAspect, Tags } from '@aws-cdk/cdk'; import { Test } from 'nodeunit'; -import { DefaultInstanceTenancy, IVpcNetwork, SubnetType, VpcNetwork } from '../lib'; +import { CfnVPC, DefaultInstanceTenancy, IVpcNetwork, SubnetType, VpcNetwork } from '../lib'; export = { "When creating a VPC": { @@ -28,8 +28,10 @@ export = { 'the Name tag is defaulted to path'(test: Test) { const stack = getTestStack(); new VpcNetwork(stack, 'TheVPC'); - expect(stack).to(haveResource('AWS::EC2::VPC', - hasTags( [ {Key: 'Name', Value: 'TheVPC'} ]))); + expect(stack).to( + haveResource('AWS::EC2::VPC', + hasTags( [ {Key: 'Name', Value: 'TheVPC'} ])) + ); test.done(); }, @@ -124,25 +126,25 @@ export = { new VpcNetwork(stack, 'TheVPC', { cidr: '10.0.0.0/21', subnetConfiguration: [ - { - cidrMask: 24, - name: 'ingress', - subnetType: SubnetType.Public, - tags: { - type: 'Public', - init: 'No', + { + cidrMask: 24, + name: 'ingress', + subnetType: SubnetType.Public, + tags: { + type: 'Public', + init: 'No', + }, }, - }, - { - cidrMask: 24, - name: 'application', - subnetType: SubnetType.Private, - }, - { - cidrMask: 28, - name: 'rds', - subnetType: SubnetType.Isolated, - } + { + cidrMask: 24, + name: 'application', + subnetType: SubnetType.Private, + }, + { + cidrMask: 28, + name: 'rds', + subnetType: SubnetType.Isolated, + } ], maxAZs: 3 }); @@ -151,12 +153,12 @@ export = { expect(stack).to(countResources("AWS::EC2::Subnet", 9)); for (let i = 0; i < 6; i++) { expect(stack).to(haveResource("AWS::EC2::Subnet", { - CidrBlock: `10.0.${i}.0/24` + CidrBlock: `10.0.${i}.0/24` })); } for (let i = 0; i < 3; i++) { expect(stack).to(haveResource("AWS::EC2::Subnet", { - CidrBlock: `10.0.6.${i * 16}/28` + CidrBlock: `10.0.6.${i * 16}/28` })); } expect(stack).to(haveResource("AWS::EC2::Subnet", hasTags( @@ -289,14 +291,10 @@ export = { }); expect(stack).to(countResources("AWS::EC2::NatGateway", 3)); for (let i = 1; i < 4; i++) { - expect(stack).to(haveResource("AWS::EC2::NatGateway", { - Tags: [ - { - Key: 'Name', - Value: `VPC/egressSubnet${i}`, - } - ] - })); + expect(stack).to(haveResource('AWS::EC2::Subnet', hasTags([{ + Key: 'Name', + Value: `VPC/egressSubnet${i}`, + }]))); } test.done(); }, @@ -345,9 +343,9 @@ export = { }; const allTags: Tags = {...tags, ...noPropTags}; - const vpc = new VpcNetwork(stack, 'TheVPC', { tags: allTags }); + const vpc = new VpcNetwork(stack, 'TheVPC', { tags }); // overwrite to set propagate - vpc.tags.setTag('BusinessUnit', 'Marketing', {propagate: false}); + vpc.apply(new TagAspect('BusinessUnit', 'Marketing', {include: [CfnVPC.resourceTypeName]})); expect(stack).to(haveResource("AWS::EC2::VPC", hasTags(toCfnTags(allTags)))); const taggables = ['Subnet', 'InternetGateway', 'NatGateway', 'RouteTable']; const propTags = toCfnTags(tags); @@ -377,7 +375,7 @@ export = { const vpc = new VpcNetwork(stack, 'TheVPC'); const tag = {Key: 'Late', Value: 'Adder'}; expect(stack).notTo(haveResource('AWS::EC2::VPC', hasTags([tag]))); - vpc.tags.setTag(tag.Key, tag.Value); + vpc.apply(new TagAspect(tag.Key, tag.Value)); expect(stack).to(haveResource('AWS::EC2::VPC', hasTags([tag]))); test.done(); }, @@ -565,8 +563,11 @@ function hasTags(expectedTags: Array<{Key: string, Value: string}>): (props: any }); return actualTags.length === expectedTags.length; } catch (e) { - // tslint:disable-next-line:no-console - console.error('Invalid Tags array in ', props); + // tslint:disable:no-console + console.error('Tags are incorrect'); + console.error('found tags ', props.Tags); + console.error('expected tags ', expectedTags); + // tslint:enable:no-console throw e; } }; diff --git a/packages/@aws-cdk/cdk/lib/app.ts b/packages/@aws-cdk/cdk/lib/app.ts index bdf013f5091ac..16f1c2f7eca22 100644 --- a/packages/@aws-cdk/cdk/lib/app.ts +++ b/packages/@aws-cdk/cdk/lib/app.ts @@ -75,6 +75,9 @@ export class App extends Root { region }; + // visit all aspects + this.visit(); + const missing = Object.keys(stack.missingContext).length ? stack.missingContext : undefined; return { name: stack.id, @@ -226,4 +229,4 @@ function getJsiiAgentVersion() { } return jsiiAgent; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/cdk/lib/aspects/tag-aspect.ts b/packages/@aws-cdk/cdk/lib/aspects/tag-aspect.ts new file mode 100644 index 0000000000000..fb71210b4ee92 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/aspects/tag-aspect.ts @@ -0,0 +1,149 @@ +import { Resource, TagType } from '../cloudformation/resource'; +import { Construct, IAspect } from '../core/construct'; + +export interface TagAspectProps extends TagProperties { + // TODO docs + value?: string; +} + +export interface TagProperties { + // TODO docs + propagate?: number; + include?: string[]; + exclude?: string[]; +} + +export abstract class TagBaseAspect implements IAspect { + + public readonly type: string = 'taggable'; + + public readonly key: string; + public readonly value?: string; + + public propagate: number; + private readonly include: string[]; + private readonly exclude: string[]; + private readonly visitedBy: {[id: string]: boolean} = {}; + + private parent?: Construct; + + constructor(key: string, props: TagAspectProps = {}) { + this.key = key; + + this.propagate = props.propagate === undefined ? -1 : props.propagate; + this.value = props.value; + this.include = props.include || []; + this.exclude = props.exclude || []; + } + + public visit(node: Construct): void { + if (this.visitedBy[node.uniqueId] === true) { + return; + } + this.visitedBy[node.uniqueId] = true; + const propagate = this.propagate > 0 ? this.propagate - 1 : this.propagate; + this.parent = this.parent || node; + if (propagate !== 0) { + for (const child of node.children) { + this.propagate = propagate; + child.apply(this); + } + } + if (node instanceof Resource && (node as Resource).tagType !== TagType.NotTaggable) { + const resource = node as Resource; + if (this.exclude.length !== 0 && this.exclude.indexOf(resource.resourceType) !== -1) { + return; + } + if (this.include.length !== 0 && this.include.indexOf(resource.resourceType) === -1) { + return; + } + if (this.hasPrecedence(node.aspects)) { + this.applyTag(resource); + } + } + } + + protected abstract applyTag(resource: Resource): void; + + private hasPrecedence(aspects: IAspect[]): boolean { + const tagAspects = aspects.filter( a => a !== this && a.type === this.type); + const collisions = tagAspects.filter( a => (a as TagBaseAspect).key === this.key); + for (const collidingAspect of collisions) { + const collision = collidingAspect as TagBaseAspect; + // if the parent is not defined the apsect hasn't been visited and is + // local to the current node; thus the collision has precedence because it + // is later (e.g. last one in wins) in the array + if (collision.parent === undefined) { + return false; + } + // the aspect with the most ancestors is closest to this node in the tree + if (collision.parent.ancestors().length > this.parent!.ancestors().length) { + return false; + } + } + return true; + } +} + +export class TagAspect extends TagBaseAspect { + + constructor(key: string, value: string, props: TagProperties = {}) { + super(key, {value, ...props}); + if (this.value === undefined) { + throw new Error('Tag must have a value'); + } + } + + protected applyTag(resource: Resource) { + removeTag(this, resource); + setTag(this, resource); + } +} + +export class RemoveTag extends TagBaseAspect { + + constructor(key: string, props: TagProperties = {}) { + super(key, props); + } + + protected applyTag(resource: Resource) { + return removeTag(this, resource); + } +} + +function removeTag(tag: TagBaseAspect, resource: Resource): void { + const key = tag.key; + switch (resource.tagType) { + case TagType.Standard: + case TagType.AutoScalingGroup: { + if (resource.properties.tags === undefined) { break; } + resource.properties.tags = resource.properties.tags.filter( (t: any ) => t.key !== key); + break; + } + case TagType.Map: { + if (resource.properties.tags === undefined) { break; } + delete resource.properties.tags[key]; + break; + } + } +} + +function setTag(tag: TagBaseAspect, resource: Resource) { + switch (resource.tagType) { + case TagType.Standard: { + resource.properties.tags = resource.properties.tags || []; + resource.properties.tags.push({ key: tag.key, value: tag.value }); + break; + } + case TagType.AutoScalingGroup: { + resource.properties.tags = resource.properties.tags || []; + resource.properties.tags.push({ key: tag.key, value: tag.value, propagateAtLaunch: tag.propagate !== 0}); + break; + } + case TagType.Map: { + resource.properties.tags = resource.properties.tags || {}; + resource.properties.tags[tag.key] = tag.value; + break; + } + } +} diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts index 06895929f647f..9aca5929af556 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts @@ -1,5 +1,6 @@ import cxapi = require('@aws-cdk/cx-api'); import { Construct } from '../core/construct'; +import { ITagFormatter, TagGroups } from '../core/tag-manager'; import { capitalizePropertyNames, ignoreEmpty } from '../core/util'; import { CloudFormationToken } from './cloudformation-token'; import { Condition } from './condition'; @@ -48,6 +49,15 @@ export class Resource extends Referenceable { */ public readonly resourceType: string; + /** + * AWS resource properties. + * + * This object is rendered via a call to "renderProperties(this.properties)". + */ + public readonly properties: any; + + public readonly tagType: TagType = TagType.NotTaggable; + /** * AWS resource property overrides. * @@ -60,13 +70,6 @@ export class Resource extends Referenceable { */ protected readonly untypedPropertyOverrides: any = { }; - /** - * AWS resource properties. - * - * This object is rendered via a call to "renderProperties(this.properties)". - */ - protected readonly properties: any; - /** * An object to be merged on top of the entire resource definition. */ @@ -239,6 +242,47 @@ export class Resource extends Referenceable { } } +export enum TagType { + Standard = "StandardTag", + AutoScalingGroup = "AutoScalingGroupTag", + Map = "StringToStringMap", + NotTaggable = "NotTaggable", +} + +/** + * Handles converting TagManager tags to AutoScalingGroupTags + */ +export class AutoScalingGroupTagFormatter implements ITagFormatter { + public toCloudFormationTags(tagGroups: TagGroups): any { + const tags = {...tagGroups.nonStickyTags, ...tagGroups.ancestorTags, ...tagGroups.stickyTags}; + return Object.keys(tags).map( (key) => { + const propagateAtLaunch = !!tagGroups.propagateTags[key] || !!tagGroups.ancestorTags[key]; + return {key, value: tags[key], propagateAtLaunch}; + }); + } +} + +/** + * Handles converting TagManager tags to a map of string to string tags + */ +export class MapTagFormatter implements ITagFormatter { + public toCloudFormationTags(tagGroups: TagGroups): any { + const tags = {...tagGroups.nonStickyTags, ...tagGroups.ancestorTags, ...tagGroups.stickyTags}; + return Object.keys(tags).length === 0 ? undefined : tags; + } +} + +/** + * Handles converting TagManager tags to a standard array of key value pairs + */ +export class StandardTagFormatter implements ITagFormatter { + public toCloudFormationTags(tagGroups: TagGroups): any { + const tags = {...tagGroups.nonStickyTags, ...tagGroups.ancestorTags, ...tagGroups.stickyTags}; + const cfnTags = Object.keys(tags).map( key => ({key, value: tags[key]})); + return cfnTags.length === 0 ? undefined : cfnTags; + } +} + export interface ResourceOptions { /** * A condition to associate with this resource. This means that only if the condition evaluates to 'true' when the stack diff --git a/packages/@aws-cdk/cdk/lib/core/construct.ts b/packages/@aws-cdk/cdk/lib/core/construct.ts index 1fff75c333bbe..798f7838f9af8 100644 --- a/packages/@aws-cdk/cdk/lib/core/construct.ts +++ b/packages/@aws-cdk/cdk/lib/core/construct.ts @@ -31,6 +31,11 @@ export class Construct { */ public readonly uniqueId: string; + /** + * + */ + public readonly aspects: IAspect[] = []; + /** * List of children and their names */ @@ -299,6 +304,22 @@ export class Construct { return ret; } + /** + * TODO docs + */ + public apply(aspect: IAspect): void { + this.aspects.push(aspect); + } + + /** + * TODO docs + * can this be private? + */ + public visit(): void { + this.aspects.forEach( aspect => aspect.visit(this)); + this.children.forEach( child => child.visit()); + } + /** * Validate that the id of the construct legal. * Construct IDs can be any characters besides the path separator. @@ -417,6 +438,14 @@ export class Root extends Construct { } } +/** + * Aspects that can be applied to Constructs. + */ +export interface IAspect { + readonly type: string; + visit(node: Construct): void; +} + /** * An metadata entry in the construct. */ diff --git a/packages/@aws-cdk/cdk/lib/core/tag-manager.ts b/packages/@aws-cdk/cdk/lib/core/tag-manager.ts index 3b5ebf053320d..089d35ab8c434 100644 --- a/packages/@aws-cdk/cdk/lib/core/tag-manager.ts +++ b/packages/@aws-cdk/cdk/lib/core/tag-manager.ts @@ -1,3 +1,4 @@ +import { StandardTagFormatter } from '../cloudformation/resource'; import { Construct } from './construct'; import { Token } from './tokens'; @@ -85,6 +86,18 @@ export interface RemoveProps { blockPropagate?: boolean; } +/** + * This interface controls the final type of the tags + * + * This method is invoked inside `toCloudFormation` and developers should + * implement a class for this interface to extend formatting of tags for + * specialized CloudFormation Resources (e.g. AutoScalingGroups, Serverless and + * DAX) + */ +export interface ITagFormatter { + toCloudFormationTags(tagGroups: TagGroups): any; +} + /** * Properties for Tag Manager */ @@ -93,6 +106,11 @@ export interface TagManagerProps { * Initial tags to set on the tag manager using TAG_DEFAULTS */ initialTags?: Tags; + + /** + * Controls the toCloudFormation tag format + */ + tagFormatter?: ITagFormatter; } /** @@ -145,6 +163,13 @@ export class TagManager extends Token { overwrite: true }; + /** + * The format to resolve tags with for CloudFormation output + * + * @default `StandardTagFormatter` + */ + public tagFormatter: ITagFormatter; + /* * Internally tags will have properties set */ @@ -157,8 +182,8 @@ export class TagManager extends Token { constructor(private readonly parent: Construct, props: TagManagerProps = {}) { super(); - const initialTags = props.initialTags || {}; + this.tagFormatter = props.tagFormatter || new StandardTagFormatter(); for (const key of Object.keys(initialTags)) { const tag = { value: initialTags[key], @@ -169,9 +194,50 @@ export class TagManager extends Token { } /** - * Converts the `tags` to a Token for use in lazy evaluation + * Creates the CloudFormation representation of the tags + * + * This invokes the `tagFormatter.toCloudFormationTags` function */ public resolve(): any { + return this.tagFormatter.toCloudFormationTags(this.toTagGroups()); + } + /** + * 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` object for the tag @default `TagManager.DEFAULT_TAG_PROPS` + */ + public setTag(key: string, value: string, tagProps: TagProps = {}): void { + const props = {...TagManager.DEFAULT_TAG_PROPS, ...tagProps}; + if (!props.overwrite) { + this._tags[key] = this._tags[key] || {value, props}; + } else { + this._tags[key] = {value, props}; + } + const index = this.blockedTags.indexOf(key); + if (index > -1) { + this.blockedTags.splice(index, 1); + } + } + + /** + * Removes the specified tag from the array if it exists + * + * @param key The key of the tag to remove + * @param props The `RemoveProps` for the tag + */ + public removeTag(key: string, props: RemoveProps = {blockPropagate: true}): void { + if (props.blockPropagate) { + this.blockedTags.push(key); + } + delete this._tags[key]; + } + + /** + * Converts the `tags` to a `TagGroups` object + */ + private toTagGroups(): TagGroups { // need this for scoping const blockedTags = this.blockedTags; function filterTags(_tags: FullTags, filter: TagProps = {}): Tags { @@ -214,60 +280,11 @@ export class TagManager extends Token { const ancestors = this.parent.ancestors(); const ancestorTags = propagatedTags(ancestors); const propagateTags = filterTags(this._tags, {propagate: true}); - return this.tagFormatResolve( { + return { ancestorTags, nonStickyTags, stickyTags, propagateTags, - }); - } - - /** - * 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` object for the tag @default `TagManager.DEFAULT_TAG_PROPS` - */ - public setTag(key: string, value: string, tagProps: TagProps = {}): void { - const props = {...TagManager.DEFAULT_TAG_PROPS, ...tagProps}; - if (!props.overwrite) { - this._tags[key] = this._tags[key] || {value, props}; - } else { - this._tags[key] = {value, props}; - } - const index = this.blockedTags.indexOf(key); - if (index > -1) { - this.blockedTags.splice(index, 1); - } - } - - /** - * Removes the specified tag from the array if it exists - * - * @param key The key of the tag to remove - * @param props The `RemoveProps` for the tag - */ - public removeTag(key: string, props: RemoveProps = {blockPropagate: true}): void { - if (props.blockPropagate) { - this.blockedTags.push(key); - } - delete this._tags[key]; - } - - /** - * Handles returning the tags in the desired format - * - * This function can be overridden to support another tag format. This was - * specifically designed to enable AutoScalingGroup Tags that have an - * additional CloudFormation key for `PropagateAtLaunch` - */ - protected tagFormatResolve(tagGroups: TagGroups): any { - const tags = {...tagGroups.nonStickyTags, ...tagGroups.ancestorTags, ...tagGroups.stickyTags}; - for (const key of this.blockedTags) { delete tags[key]; } - if (Object.keys(tags).length === 0) { - return undefined; - } - return Object.keys(tags).map( key => ({key, value: tags[key]})); + }; } } diff --git a/packages/@aws-cdk/cdk/lib/index.ts b/packages/@aws-cdk/cdk/lib/index.ts index e04b06a8812d8..fe48caacb3e40 100644 --- a/packages/@aws-cdk/cdk/lib/index.ts +++ b/packages/@aws-cdk/cdk/lib/index.ts @@ -1,3 +1,5 @@ +export * from './aspects/tag-aspect'; + export * from './core/construct'; export * from './core/tokens'; export * from './core/tag-manager'; diff --git a/packages/@aws-cdk/cdk/test/aspects/test.tag-aspect.ts b/packages/@aws-cdk/cdk/test/aspects/test.tag-aspect.ts new file mode 100644 index 0000000000000..38281d33c1ce1 --- /dev/null +++ b/packages/@aws-cdk/cdk/test/aspects/test.tag-aspect.ts @@ -0,0 +1,211 @@ +import { Test } from 'nodeunit'; +import { RemoveTag, Resource, Stack, TagAspect, TagType } from '../../lib'; + +class TaggableResource extends Resource { + public readonly tagType = TagType.Standard; +} + +class AsgTaggableResource extends Resource { + public readonly tagType = TagType.AutoScalingGroup; +} + +class MapTaggableResource extends Resource { + public readonly tagType = TagType.Map; +} + +export = { + 'A resource can accept a Tag'(test: Test) { + const root = getTestStack(); + const res = new Resource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + + res.apply(new TagAspect('foo', 'bar')); + test.deepEqual(res.aspects.length, 1); + test.done(); + }, + 'A resource can propagate Tag'(test: Test) { + const root = getTestStack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + const res2 = new TaggableResource(res, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + const asg = new AsgTaggableResource(res, 'AsgFakeResource', { + type: 'AWS::Fake::Thing', + }); + + const map = new MapTaggableResource(res, 'MapFakeResource', { + type: 'AWS::Fake::Thing', + }); + res.apply(new TagAspect('foo', 'bar')); + root.visit(); + test.deepEqual(res.aspects.length, 1); + test.deepEqual(res2.aspects.length, 1); + test.deepEqual(res.properties.tags, [{key: 'foo', value: 'bar'}]); + test.deepEqual(res2.properties.tags, [{key: 'foo', value: 'bar'}]); + test.deepEqual(map.properties.tags, {foo: 'bar'}); + test.deepEqual(asg.properties.tags, [{key: 'foo', value: 'bar', propagateAtLaunch: true}]); + test.done(); + }, + 'The last aspect applied takes precedence'(test: Test) { + const root = getTestStack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + const res2 = new TaggableResource(res, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + res.apply(new TagAspect('foo', 'bar')); + res.apply(new TagAspect('foo', 'foobar')); + res.apply(new TagAspect('foo', 'baz')); + res2.apply(new TagAspect('foo', 'good')); + root.visit(); + test.deepEqual(res.properties.tags, [{key: 'foo', value: 'baz'}]); + test.deepEqual(res2.properties.tags, [{key: 'foo', value: 'good'}]); + test.done(); + }, + 'RemoveTag will remove a tag if it exists'(test: Test) { + const root = getTestStack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + properties: { + tags: [ + {key: 'foo', value: 'bar'}, + {key: 'baz', value: 'bar'}, + ] + }, + }); + const res2 = new TaggableResource(res, 'FakeResource', { + type: 'AWS::Fake::Thing', + properties: { + tags: [ + {key: 'foo', value: 'bar'}, + {key: 'baz', value: 'bar'}, + ] + }, + }); + const asg = new AsgTaggableResource(res, 'AsgFakeResource', { + type: 'AWS::Fake::Thing', + properties: { + tags: [ + {key: 'foo', value: 'bar', propagateAtLaunch: true}, + {key: 'baz', value: 'bar'}, + ] + }, + }); + + const map = new MapTaggableResource(res, 'MapFakeResource', { + type: 'AWS::Fake::Thing', + properties: { + tags: { + foo: 'bar', + baz: 'bar', + }, + }, + }); + res.apply(new RemoveTag('foo', {propagate: 0})); + res.apply(new RemoveTag('baz')); + res.apply(new RemoveTag('doesnotexist')); + root.visit(); + test.deepEqual(res.properties.tags, []); + test.deepEqual(map.properties.tags, {foo: 'bar'}); + test.deepEqual(asg.properties.tags, [{key: 'foo', value: 'bar', propagateAtLaunch: true}]); + test.deepEqual(res2.properties.tags, [{key: 'foo', value: 'bar'}]); + test.done(); + }, + 'the #visit function is idempotent'(test: Test) { + const root = getTestStack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + + res.apply(new TagAspect('foo', 'bar')); + root.visit(); + test.deepEqual(res.properties.tags, [{key: 'foo', value: 'bar'}]); + root.visit(); + test.deepEqual(res.properties.tags, [{key: 'foo', value: 'bar'}]); + res.visit(); + test.deepEqual(res.properties.tags, [{key: 'foo', value: 'bar'}]); + test.done(); + }, + 'include restricts tag application to resources types in the list'(test: Test) { + const root = getTestStack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + const res2 = new TaggableResource(res, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + const asg = new AsgTaggableResource(res, 'AsgFakeResource', { + type: 'AWS::Fake::Asg', + }); + + const map = new MapTaggableResource(res, 'MapFakeResource', { + type: 'AWS::Fake::Map', + }); + res.apply(new TagAspect('foo', 'bar', {include: ['AWS::Fake::Asg']})); + res.visit(); + test.deepEqual(res.properties.tags, undefined); + test.deepEqual(res2.properties.tags, undefined); + test.deepEqual(asg.properties.tags, [{key: 'foo', value: 'bar', propagateAtLaunch: true}]); + test.deepEqual(map.properties.tags, undefined); + test.done(); + }, + 'exclude prevents tag application to resource types in the list'(test: Test) { + const root = getTestStack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + const res2 = new TaggableResource(res, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + const asg = new AsgTaggableResource(res, 'AsgFakeResource', { + type: 'AWS::Fake::Asg', + }); + + const map = new MapTaggableResource(res, 'MapFakeResource', { + type: 'AWS::Fake::Map', + }); + res.apply(new TagAspect('foo', 'bar', {exclude: ['AWS::Fake::Asg']})); + res.visit(); + test.deepEqual(res.properties.tags, [{key: 'foo', value: 'bar'}]); + test.deepEqual(res2.properties.tags, [{key: 'foo', value: 'bar'}]); + test.deepEqual(asg.properties.tags, undefined); + test.deepEqual(map.properties.tags, {foo: 'bar'}); + test.done(); + }, + 'propagate number is the number of children the tag will traverse'(test: Test) { + const root = getTestStack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + let parent = res; + const depth = Array.from(Array(5).keys()); + const resources: TaggableResource[] = [parent]; + for (const propTo of depth) { + const child = new TaggableResource(parent, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + resources.push(child); + res.apply(new TagAspect(`${propTo + 1}`, 'value', { propagate: (propTo + 1) })); + parent = child; + } + res.visit(); + resources.forEach( (subject, index) => { + const tagCount = depth.length - index; + if (tagCount === 0) { + test.deepEqual(subject.properties.tags, undefined); + } else { + test.deepEqual(subject.properties.tags.length, depth.length - index); + } + }); + test.done(); + }, + +}; + +function getTestStack(): Stack { + return new Stack(undefined, 'TestStack', { env: { account: '123456789012', region: 'us-east-1' } }); +} diff --git a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts index c4feb2ee6d714..a6aa57f215e88 100644 --- a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts +++ b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts @@ -346,12 +346,12 @@ export = { MyC3C2R2F213BD26: { Type: 'T2' }, MyC3C2R38CE6F9F7: { Type: 'T3' }, MyResource: - { Type: 'R', - DependsOn: + { Type: 'R', + DependsOn: [ 'MyC1R1FB2A562F', - 'MyC1R2AE2B5066', - 'MyC2R3809EEAD6', - 'MyC3C2R38CE6F9F7' ] } } }); + 'MyC1R2AE2B5066', + 'MyC2R3809EEAD6', + 'MyC3C2R38CE6F9F7' ] } } }); test.done(); }, @@ -377,9 +377,9 @@ export = { // THEN test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'YouCanEvenOverrideTheType', - Use: { Dot: { Notation: 'To create subtrees' } }, - Metadata: { Key: 12 } } } }); + { Type: 'YouCanEvenOverrideTheType', + Use: { Dot: { Notation: 'To create subtrees' } }, + Metadata: { Key: 12 } } } }); test.done(); }, @@ -406,8 +406,8 @@ export = { // THEN test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'AWS::Resource::Type', - Properties: { Hello: { World: { Value1: 'Hello', Value2: null } } } } } }); + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello', Value2: null } } } } } }); test.done(); }, @@ -434,8 +434,8 @@ export = { // THEN test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'AWS::Resource::Type', - Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); test.done(); }, @@ -453,8 +453,8 @@ export = { // THEN test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'AWS::Resource::Type', - Properties: { Tree: { Exists: 42 } } } } }); + { Type: 'AWS::Resource::Type', + Properties: { Tree: { Exists: 42 } } } } }); test.done(); }, @@ -483,8 +483,8 @@ export = { // THEN test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'AWS::Resource::Type', - Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); test.done(); }, @@ -509,12 +509,12 @@ export = { // THEN test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'AWS::Resource::Type', - Properties: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Foo: { Bar: 42 } } }, - Override1: { - Override2: { Heyy: [ 1] } - } } } } }); + Override1: { + Override2: { Heyy: [ 1] } + } } } } }); test.done(); }, @@ -532,8 +532,8 @@ export = { // THEN test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'AWS::Resource::Type', - Properties: { Hello: { World: { Hey: 'Jude' } } } } } }); + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Hey: 'Jude' } } } } } }); test.done(); }, @@ -550,8 +550,8 @@ export = { test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'MyResourceType', - Properties: { PROP1: 'foo', PROP2: 'bar' } } } }); + { Type: 'MyResourceType', + Properties: { PROP1: 'foo', PROP2: 'bar' } } } }); test.done(); }, @@ -564,8 +564,8 @@ export = { test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'MyResourceType', - Properties: { PROP3: 'zoo' } } } }); + { Type: 'MyResourceType', + Properties: { PROP3: 'zoo' } } } }); test.done(); }, @@ -579,10 +579,10 @@ export = { test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'MyResourceType', - Properties: { PROP2: 'hey', PROP3: 'zoo' } } } }); + { Type: 'MyResourceType', + Properties: { PROP2: 'hey', PROP3: 'zoo' } } } }); 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 b34e57341772e..0199e130cb815 100644 --- a/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts +++ b/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts @@ -3,7 +3,7 @@ import { Construct, Root } from '../../lib/core/construct'; import { ITaggable, TagManager } from '../../lib/core/tag-manager'; class ChildTagger extends Construct implements ITaggable { - public readonly tags: TagManager; + public tags: TagManager; constructor(parent: Construct, name: string) { super(parent, name); this.tags = new TagManager(parent); @@ -53,8 +53,9 @@ export = { for (const construct of [ctagger1, ctagger2]) { test.deepEqual(construct.tags.resolve(), undefined); } - test.deepEqual(ctagger.tags.resolve()[0].key, 'Name'); - test.deepEqual(ctagger.tags.resolve()[0].value, 'TheCakeIsALie'); + const cfnTags = ctagger.tags.resolve() || []; + test.deepEqual(cfnTags[0].key, 'Name'); + test.deepEqual(cfnTags[0].value, 'TheCakeIsALie'); test.done(); }, 'setTag with overwrite false does not overwrite a tag'(test: Test) { @@ -145,7 +146,7 @@ export = { test.deepEqual(childTags, [tag2]); test.done(); }, - 'resolve() returns all tags'(test: Test) { + 'toCloudFormation() returns all tags'(test: Test) { const root = new Root(); const ctagger = new ChildTagger(root, 'one'); const ctagChild = new ChildTagger(ctagger, 'one'); @@ -167,11 +168,11 @@ export = { const cAll = ctagger.tags; const cProp = ctagChild.tags; - for (const tag of cAll.resolve()) { + for (const tag of cAll.resolve() || []) { const expectedTag = allTags.filter( (t) => (t.key === tag.key)); test.deepEqual(expectedTag[0].value, tag.value); } - for (const tag of cProp.resolve()) { + for (const tag of cProp.resolve() || []) { const expectedTag = tagsProp.filter( (t) => (t.key === tag.key)); test.deepEqual(expectedTag[0].value, tag.value); } diff --git a/packages/@aws-cdk/cfnspec/lib/schema/property.ts b/packages/@aws-cdk/cfnspec/lib/schema/property.ts index 16dbad3a016e5..bcb87287904db 100644 --- a/packages/@aws-cdk/cfnspec/lib/schema/property.ts +++ b/packages/@aws-cdk/cfnspec/lib/schema/property.ts @@ -5,6 +5,7 @@ export type ScalarProperty = PrimitiveProperty | ComplexProperty | UnionProperty export type CollectionProperty = ListProperty | MapProperty | UnionProperty; export type ListProperty = PrimitiveListProperty | ComplexListProperty; export type MapProperty = PrimitiveMapProperty | ComplexMapProperty; +export type TagProperty = TagPropertyStandard | TagPropertyAutoScalingGroup | TagPropertyJson | TagPropertyStringMap; export interface PropertyBase extends Documented { /** Indicates whether the property is required. */ @@ -79,6 +80,22 @@ export interface ComplexMapProperty extends MapPropertyBase { ItemType: string; } +export interface TagPropertyStandard extends PropertyBase { + ItemType: 'Tag'; +} + +export interface TagPropertyAutoScalingGroup extends PropertyBase { + ItemType: 'TagProperty'; +} + +export interface TagPropertyJson extends PropertyBase { + PrimitiveType: PrimitiveType.Json; +} + +export interface TagPropertyStringMap extends PropertyBase { + PrimitiveItemType: 'String'; +} + /** * A property type that can be one of several types. Currently used only in SAM. */ @@ -196,4 +213,35 @@ export enum PropertyScrutinyType { export function isPropertyScrutinyType(str: string): str is PropertyScrutinyType { return (PropertyScrutinyType as any)[str] !== undefined; -} \ No newline at end of file +} + +/** + * This function validates that the property **can** be a Tag Property + * + * The property is only a Tag if the name of this property is Tags, which is + * validated using `ResourceType.isTaggable(resource)`. + */ +export function isTagProperty(prop: Property): prop is TagProperty { + return ( + isTagPropertyStandard(prop) || + isTagPropertyAutoScalingGroup(prop) || + isTagPropertyJson(prop) || + isTagPropertyStringMap(prop) + ); +} + +export function isTagPropertyStandard(prop: Property): prop is TagPropertyStandard { + return (prop as TagPropertyStandard).ItemType === 'Tag'; +} + +export function isTagPropertyAutoScalingGroup(prop: Property): prop is TagPropertyAutoScalingGroup { + return (prop as TagPropertyAutoScalingGroup).ItemType === 'TagProperty'; +} + +export function isTagPropertyJson(prop: Property): prop is TagPropertyJson { + return (prop as TagPropertyJson).PrimitiveType === PrimitiveType.Json; +} + +export function isTagPropertyStringMap(prop: Property): prop is TagPropertyStringMap { + return (prop as TagPropertyStringMap).PrimitiveItemType === 'String'; +} diff --git a/packages/@aws-cdk/cfnspec/lib/schema/resource-type.ts b/packages/@aws-cdk/cfnspec/lib/schema/resource-type.ts index 1b00979d72cd6..7e561c4590f46 100644 --- a/packages/@aws-cdk/cfnspec/lib/schema/resource-type.ts +++ b/packages/@aws-cdk/cfnspec/lib/schema/resource-type.ts @@ -1,5 +1,5 @@ import { Documented, PrimitiveType } from './base-types'; -import { Property } from './property'; +import { isTagProperty, Property, TagProperty } from './property'; export interface ResourceType extends Documented { /** @@ -28,6 +28,13 @@ export interface ResourceType extends Documented { ScrutinyType?: ResourceScrutinyType; } +export interface TaggableResource extends ResourceType { + Properties: { + Tags: TagProperty; + [name: string]: Property; + } +} + export type Attribute = PrimitiveAttribute | ListAttribute; export interface PrimitiveAttribute { @@ -46,6 +53,20 @@ export interface ComplexListAttribute { ItemType: string; } +/** + * Determine if the resource supports tags + * + * This function combined with isTagProperty determines if the `cdk.TagManager` + * and `cdk.TaggableResource` can process these tags. If not, standard code + * generation of properties will be used. + */ +export function isTaggableResource(spec: ResourceType): spec is TaggableResource { + if (spec.Properties && spec.Properties.Tags) { + return isTagProperty(spec.Properties.Tags); + } + return false; +} + export function isPrimitiveAttribute(spec: Attribute): spec is PrimitiveAttribute { return !!(spec as PrimitiveAttribute).PrimitiveType; } @@ -117,4 +138,4 @@ export enum ResourceScrutinyType { export function isResourceScrutinyType(str: string): str is ResourceScrutinyType { return (ResourceScrutinyType as any)[str] !== undefined; -} \ No newline at end of file +} diff --git a/tools/cfn2ts/lib/codegen.ts b/tools/cfn2ts/lib/codegen.ts index 61e68d09d26a3..825f8c0caaa51 100644 --- a/tools/cfn2ts/lib/codegen.ts +++ b/tools/cfn2ts/lib/codegen.ts @@ -8,6 +8,7 @@ import { itemTypeNames, PropertyAttributeName, scalarTypeNames, SpecName } from const CORE = genspec.CORE_NAMESPACE; const RESOURCE_BASE_CLASS = `${CORE}.Resource`; // base class for all resources const CONSTRUCT_CLASS = `${CORE}.Construct`; +const TAG_TYPE = 'TagType'; interface Dictionary { [key: string]: T; } @@ -248,6 +249,10 @@ export default class CodeGenerator { } } + // set the TagType to help format tags later + const tagEnum = tagType(spec); + this.code.line(`public readonly tagType = ${tagEnum};`); + // // Constructor // @@ -263,7 +268,17 @@ export default class CodeGenerator { const optionalProps = spec.Properties && !Object.values(spec.Properties).some(p => p.Required); const propsArgument = propsType ? `, properties${optionalProps ? '?' : ''}: ${propsType.className}` : ''; this.code.openBlock(`constructor(parent: ${CONSTRUCT_CLASS}, name: string${propsArgument})`); - this.code.line(`super(parent, name, { type: ${resourceName.className}.resourceTypeName${propsType ? ', properties' : ''} });`); + + // + // Configure properties for super call + // + const superProps: string[] = []; + superProps.push(`type: ${resourceName.className}.resourceTypeName`); + if (propsType) { + superProps.push('properties'); + } + + this.code.line(`super(parent, name, { ${superProps.join(', ')} });`); // verify all required properties if (spec.Properties) { for (const propName of Object.keys(spec.Properties)) { @@ -583,7 +598,7 @@ export default class CodeGenerator { if (union.indexOf('|') !== -1) { alternatives.push(`Array<${union}>`); } else { - alternatives.push(`(${union})[]`); + alternatives.push(`${union}[]`); } } } @@ -649,3 +664,19 @@ function mapperNames(types: genspec.CodeName[]): string { function quoteCode(code: string): string { return '``' + code + '``'; } + +function tagType(resource: schema.ResourceType): string { + if (schema.isTaggableResource(resource)) { + const prop = resource.Properties.Tags; + if (schema.isTagPropertyStandard(prop)) { + return `${CORE}.${TAG_TYPE}.Standard`; + } + if (schema.isTagPropertyAutoScalingGroup(prop)) { + return `${CORE}.${TAG_TYPE}.AutoScalingGroup`; + } + if (schema.isTagPropertyJson(prop) || schema.isTagPropertyStringMap(prop)) { + return `${CORE}.${TAG_TYPE}.Map`; + } + } + return `${CORE}.${TAG_TYPE}.NotTaggable`; +}