diff --git a/examples/cdk-examples-typescript/hello-cdk-ecs-tags/cdk.json b/examples/cdk-examples-typescript/hello-cdk-ecs-tags/cdk.json new file mode 100644 index 0000000000000..2f0e44c6fd27b --- /dev/null +++ b/examples/cdk-examples-typescript/hello-cdk-ecs-tags/cdk.json @@ -0,0 +1,3 @@ +{ + "app": "node index" +} diff --git a/examples/cdk-examples-typescript/hello-cdk-ecs-tags/index.ts b/examples/cdk-examples-typescript/hello-cdk-ecs-tags/index.ts new file mode 100644 index 0000000000000..af9a3e0245707 --- /dev/null +++ b/examples/cdk-examples-typescript/hello-cdk-ecs-tags/index.ts @@ -0,0 +1,53 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import ecs = require('@aws-cdk/aws-ecs'); +import cdk = require('@aws-cdk/cdk'); + +const COST_CENTER_KEY = 'CostCenter'; + +class MarketingDepartmentStack extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // Create VPC and Fargate Cluster + // NOTE: Limit AZs to avoid reaching resource quotas + const vpc = new ec2.VpcNetwork(this, 'MyVpc', { maxAZs: 3 }); + + // override cost center to platform + vpc.apply(new cdk.Tag(COST_CENTER_KEY, 'Platform')); + + const cluster = new ecs.Cluster(this, 'Cluster', { vpc }); + + // Create a load-balanced Fargate service and make it public + const b2b = new ecs.LoadBalancedFargateService(this, 'B2BService', { + cluster, // Required + cpu: '512', // Default is 256 + desiredCount: 6, // Default is 1 + image: ecs.ContainerImage.fromDockerHub('amazon/amazon-ecs-sample'), // Required + memoryMiB: '2048', // Default is 512 + publicLoadBalancer: true // Default is false + }); + + // Create a load-balanced Fargate service and make it public + const b2c = new ecs.LoadBalancedFargateService(this, 'B2CService', { + cluster, // Required + cpu: '512', // Default is 256 + desiredCount: 6, // Default is 1 + image: ecs.ContainerImage.fromDockerHub('amazon/amazon-ecs-sample'), // Required + memoryMiB: '2048', // Default is 512 + publicLoadBalancer: true // Default is false + }); + + // Output the DNS where you can access your service + new cdk.Output(this, 'B2BLoadBalancerDNS', { value: b2b.loadBalancer.dnsName }); + new cdk.Output(this, 'B2CLoadBalancerDNS', { value: b2c.loadBalancer.dnsName }); + } +} + +const app = new cdk.App(); + +// by default bill everything to marketing overrides are in the stack +app.apply(new cdk.Tag(COST_CENTER_KEY, 'Marketing')); + +new MarketingDepartmentStack(app, 'Bonjour'); + +app.run(); 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 3ded196e45e66..ed9bd575c7144 100644 --- a/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts +++ b/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts @@ -137,11 +137,6 @@ export interface AutoScalingGroupProps { */ resourceSignalTimeoutSec?: number; - /** - * The AWS resource tags to associate with the ASG. - */ - tags?: cdk.Tags; - /** * Default scaling cooldown for this AutoScalingGroup * @@ -169,7 +164,7 @@ export interface AutoScalingGroupProps { * * The ASG spans all availability zones. */ -export class AutoScalingGroup extends cdk.Construct implements IAutoScalingGroup, cdk.ITaggable, elb.ILoadBalancerTarget, ec2.IConnectable, +export class AutoScalingGroup extends cdk.Construct implements IAutoScalingGroup, elb.ILoadBalancerTarget, ec2.IConnectable, elbv2.IApplicationLoadBalancerTarget, elbv2.INetworkLoadBalancerTarget { /** * The type of OS instances of this fleet are running. @@ -186,11 +181,6 @@ export class AutoScalingGroup extends cdk.Construct implements IAutoScalingGroup */ public readonly role: iam.Role; - /** - * Manage tags for this construct and children - */ - public readonly tags: cdk.TagManager; - /** * Name of the AutoScalingGroup */ @@ -217,8 +207,7 @@ 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.setTag(NAME_TAG, this.node.path, { overwrite: false }); + this.apply(new cdk.Tag(NAME_TAG, this.node.path)); this.role = new iam.Role(this, 'InstanceRole', { assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com') @@ -264,7 +253,6 @@ export class AutoScalingGroup extends cdk.Construct implements IAutoScalingGroup launchConfigurationName: launchConfig.ref, loadBalancerNames: new cdk.Token(() => this.loadBalancerNames.length > 0 ? this.loadBalancerNames : undefined), targetGroupArns: new cdk.Token(() => this.targetGroupArns.length > 0 ? this.targetGroupArns : undefined), - tags: this.tags, }; if (props.notificationsTopic) { @@ -623,16 +611,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-autoscaling/test/test.auto-scaling-group.ts b/packages/@aws-cdk/aws-autoscaling/test/test.auto-scaling-group.ts index ce067b7b52fd4..78de5fbe217a2 100644 --- a/packages/@aws-cdk/aws-autoscaling/test/test.auto-scaling-group.ts +++ b/packages/@aws-cdk/aws-autoscaling/test/test.auto-scaling-group.ts @@ -9,7 +9,7 @@ import autoscaling = require('../lib'); export = { 'default fleet'(test: Test) { - const stack = new cdk.Stack(undefined, 'MyStack', { env: { region: 'us-east-1', account: '1234' }}); + const stack = getTestStack(); const vpc = mockVpc(stack); new autoscaling.AutoScalingGroup(stack, 'MyFleet', { @@ -365,7 +365,8 @@ export = { }, 'can set tags'(test: Test) { // GIVEN - const stack = new cdk.Stack(undefined, 'MyStack', { env: { region: 'us-east-1', account: '1234' }}); + const stack = getTestStack(); + // new cdk.Stack(undefined, 'MyStack', { env: { region: 'us-east-1', account: '1234' }}); const vpc = mockVpc(stack); // WHEN @@ -378,27 +379,27 @@ export = { minSuccessfulInstancesPercent: 50, pauseTimeSec: 345 }, - tags: {superfood: 'acai'}, }); - asg.tags.setTag('notsuper', 'caramel', {propagate: false}); + asg.apply( new cdk.Tag('superfood', 'acai')); + asg.apply( new cdk.Tag('notsuper', 'caramel', { applyToLaunchedInstances: false })); // THEN expect(stack).to(haveResource("AWS::AutoScaling::AutoScalingGroup", { Tags: [ { - Key: 'superfood', - Value: 'acai', + Key: 'Name', PropagateAtLaunch: true, + Value: 'MyFleet', }, { - Key: 'Name', - Value: 'MyFleet', + Key: 'superfood', PropagateAtLaunch: true, + Value: 'acai', }, { Key: 'notsuper', - Value: 'caramel', PropagateAtLaunch: false, + Value: 'caramel', }, ] })); @@ -494,3 +495,7 @@ function mockSecurityGroup(stack: cdk.Stack) { securityGroupId: 'most-secure', }); } + +function getTestStack(): cdk.Stack { + return new cdk.Stack(undefined, 'TestStack', { env: { account: '1234', region: 'us-east-1' } }); +} diff --git a/packages/@aws-cdk/aws-autoscaling/test/test.scheduled-action.ts b/packages/@aws-cdk/aws-autoscaling/test/test.scheduled-action.ts index 8d7433e33a866..48615e9e84068 100644 --- a/packages/@aws-cdk/aws-autoscaling/test/test.scheduled-action.ts +++ b/packages/@aws-cdk/aws-autoscaling/test/test.scheduled-action.ts @@ -1,4 +1,4 @@ -import { expect, haveResource, MatchStyle } from '@aws-cdk/assert'; +import { expect, haveResource, MatchStyle, } from '@aws-cdk/assert'; import ec2 = require('@aws-cdk/aws-ec2'); import cdk = require('@aws-cdk/cdk'); import { Test } from 'nodeunit'; @@ -111,4 +111,4 @@ function makeAutoScalingGroup(scope: cdk.Construct) { machineImage: new ec2.AmazonLinuxImage(), updateType: autoscaling.UpdateType.RollingUpdate, }); -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-dynamodb/lib/table.ts b/packages/@aws-cdk/aws-dynamodb/lib/table.ts index 9fb9d7f887c5b..f267b38a7508b 100644 --- a/packages/@aws-cdk/aws-dynamodb/lib/table.ts +++ b/packages/@aws-cdk/aws-dynamodb/lib/table.ts @@ -1,7 +1,7 @@ import appscaling = require('@aws-cdk/aws-applicationautoscaling'); import iam = require('@aws-cdk/aws-iam'); import cdk = require('@aws-cdk/cdk'); -import { Construct, TagManager, Tags, Token } from '@aws-cdk/cdk'; +import { Construct, Token } from '@aws-cdk/cdk'; import { CfnTable } from './dynamodb.generated'; import { EnableScalingProps, IScalableTableAttribute } from './scalable-attribute-api'; import { ScalableTableAttribute } from './scalable-table-attribute'; @@ -94,12 +94,6 @@ export interface TableProps { */ streamSpecification?: StreamViewType; - /** - * The AWS resource tags to associate with the table. - * @default undefined - */ - tags?: Tags; - /** * The name of TTL attribute. * @default undefined, TTL is disabled @@ -234,7 +228,6 @@ export class Table extends Construct { }, sseSpecification: props.sseEnabled ? { sseEnabled: props.sseEnabled } : undefined, streamSpecification: props.streamSpecification ? { streamViewType: props.streamSpecification } : undefined, - tags: new TagManager(this, { initialTags: props.tags }), timeToLiveSpecification: props.ttlAttributeName ? { attributeName: props.ttlAttributeName, enabled: true } : undefined }); diff --git a/packages/@aws-cdk/aws-dynamodb/test/integ.dynamodb.ondemand.ts b/packages/@aws-cdk/aws-dynamodb/test/integ.dynamodb.ondemand.ts index e805abc7dd19e..f1c04e8ccdb52 100644 --- a/packages/@aws-cdk/aws-dynamodb/test/integ.dynamodb.ondemand.ts +++ b/packages/@aws-cdk/aws-dynamodb/test/integ.dynamodb.ondemand.ts @@ -1,4 +1,4 @@ -import { App, Stack } from '@aws-cdk/cdk'; +import { App, Stack, Tag } from '@aws-cdk/cdk'; import { Attribute, AttributeType, BillingMode, ProjectionType, StreamViewType, Table } from '../lib'; // CDK parameters @@ -48,11 +48,12 @@ const tableWithGlobalAndLocalSecondaryIndex = new Table(stack, TABLE_WITH_GLOBAL pitrEnabled: true, sseEnabled: true, streamSpecification: StreamViewType.KeysOnly, - tags: { Environment: 'Production' }, billingMode: BillingMode.PayPerRequest, ttlAttributeName: 'timeToLive' }); +tableWithGlobalAndLocalSecondaryIndex.apply(new Tag('Environment', 'Production')); + tableWithGlobalAndLocalSecondaryIndex.addPartitionKey(TABLE_PARTITION_KEY); tableWithGlobalAndLocalSecondaryIndex.addSortKey(TABLE_SORT_KEY); tableWithGlobalAndLocalSecondaryIndex.addGlobalSecondaryIndex({ diff --git a/packages/@aws-cdk/aws-dynamodb/test/integ.dynamodb.ts b/packages/@aws-cdk/aws-dynamodb/test/integ.dynamodb.ts index 9be63b69ddbc0..594bcd37c9045 100644 --- a/packages/@aws-cdk/aws-dynamodb/test/integ.dynamodb.ts +++ b/packages/@aws-cdk/aws-dynamodb/test/integ.dynamodb.ts @@ -1,5 +1,5 @@ import iam = require('@aws-cdk/aws-iam'); -import { App, Stack } from '@aws-cdk/cdk'; +import { App, Stack, Tag } from '@aws-cdk/cdk'; import { Attribute, AttributeType, ProjectionType, StreamViewType, Table } from '../lib'; // CDK parameters @@ -48,10 +48,10 @@ const tableWithGlobalAndLocalSecondaryIndex = new Table(stack, TABLE_WITH_GLOBAL pitrEnabled: true, sseEnabled: true, streamSpecification: StreamViewType.KeysOnly, - tags: { Environment: 'Production' }, ttlAttributeName: 'timeToLive' }); +tableWithGlobalAndLocalSecondaryIndex.apply(new Tag('Environment', 'Production')); tableWithGlobalAndLocalSecondaryIndex.addPartitionKey(TABLE_PARTITION_KEY); tableWithGlobalAndLocalSecondaryIndex.addSortKey(TABLE_SORT_KEY); tableWithGlobalAndLocalSecondaryIndex.addGlobalSecondaryIndex({ diff --git a/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts b/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts index c487df3d2ca0e..a1179d368fb26 100644 --- a/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts +++ b/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts @@ -1,6 +1,6 @@ -import { expect, haveResource } from '@aws-cdk/assert'; +import { countResources, expect, haveResource } from '@aws-cdk/assert'; import iam = require('@aws-cdk/aws-iam'); -import { App, Stack } from '@aws-cdk/cdk'; +import { Stack, Tag } from '@aws-cdk/cdk'; import { Test } from 'nodeunit'; import { Attribute, @@ -16,7 +16,6 @@ import { // tslint:disable:object-literal-key-quotes // CDK parameters -const STACK_NAME = 'MyStack'; const CONSTRUCT_NAME = 'MyTable'; // DynamoDB table parameters @@ -67,323 +66,246 @@ function* LSI_GENERATOR() { export = { 'default properties': { 'fails without a hash key'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME); - test.throws(() => app.synthesizeTemplate(), /partition key/); - + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME); + test.throws(() => expect(stack).to(countResources('AWS::DynamoDB::Table')), /partition key/); test.done(); }, 'hash key only'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME).addPartitionKey(TABLE_PARTITION_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [{ AttributeName: 'hashKey', AttributeType: 'S' }], - KeySchema: [{ AttributeName: 'hashKey', KeyType: 'HASH' }], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - } - } - } - }); - - test.done(); - }, - - 'hash + range key'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) - .addPartitionKey(TABLE_PARTITION_KEY) - .addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } - ], - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - } - } - } - }); + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME).addPartitionKey(TABLE_PARTITION_KEY); + expect(stack).to(haveResource('AWS::DynamoDB::Table', { + AttributeDefinitions: [{ AttributeName: 'hashKey', AttributeType: 'S' }], + KeySchema: [{ AttributeName: 'hashKey', KeyType: 'HASH' }], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + })); test.done(); }, - 'hash + range key can also be specified in props'(test: Test) { - const app = new TestApp(); + 'hash + range key'(test: Test) { + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) + .addPartitionKey(TABLE_PARTITION_KEY) + .addSortKey(TABLE_SORT_KEY); + expect(stack).to(haveResource('AWS::DynamoDB::Table', { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + })); + test.done(); + }, - new Table(app.stack, CONSTRUCT_NAME, { - partitionKey: TABLE_PARTITION_KEY, - sortKey: TABLE_SORT_KEY - }); + 'hash + range key can also be specified in props'(test: Test) { + const stack = new Stack(); - const template = app.synthesizeTemplate(); + new Table(stack, CONSTRUCT_NAME, { + partitionKey: TABLE_PARTITION_KEY, + sortKey: TABLE_SORT_KEY + }); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } - ], - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - } - } - } - }); + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + })); - test.done(); - }, + test.done(); + }, - 'point-in-time recovery is not enabled'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) - .addPartitionKey(TABLE_PARTITION_KEY) - .addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } - ], - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - } - } - } - }); + 'point-in-time recovery is not enabled'(test: Test) { + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) + .addPartitionKey(TABLE_PARTITION_KEY) + .addSortKey(TABLE_SORT_KEY); - test.done(); - }, + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + } + )); + test.done(); + }, - 'server-side encryption is not enabled'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) - .addPartitionKey(TABLE_PARTITION_KEY) - .addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } - ], - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - } - } - } - }); + 'server-side encryption is not enabled'(test: Test) { + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) + .addPartitionKey(TABLE_PARTITION_KEY) + .addSortKey(TABLE_SORT_KEY); - test.done(); - }, + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + } + )); + test.done(); + }, - 'stream is not enabled'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) - .addPartitionKey(TABLE_PARTITION_KEY) - .addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } - ], - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - } - } - } - }); + 'stream is not enabled'(test: Test) { + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) + .addPartitionKey(TABLE_PARTITION_KEY) + .addSortKey(TABLE_SORT_KEY); - test.done(); - }, + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + } + )); + test.done(); + }, - 'ttl is not enabled'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) - .addPartitionKey(TABLE_PARTITION_KEY) - .addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } - ], - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - } - } - } - }); + 'ttl is not enabled'(test: Test) { + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) + .addPartitionKey(TABLE_PARTITION_KEY) + .addSortKey(TABLE_SORT_KEY); - test.done(); - }, + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + } + )); + test.done(); + }, - 'can specify new and old images'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337, - streamSpecification: StreamViewType.NewAndOldImages - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } - ], - StreamSpecification: { StreamViewType: 'NEW_AND_OLD_IMAGES' }, - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - TableName: 'MyTable' - } - } - } - }); + 'can specify new and old images'(test: Test) { + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { + tableName: TABLE_NAME, + readCapacity: 42, + writeCapacity: 1337, + streamSpecification: StreamViewType.NewAndOldImages + }); + table.addPartitionKey(TABLE_PARTITION_KEY); + table.addSortKey(TABLE_SORT_KEY); - test.done(); - }, + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' } + ], + StreamSpecification: { StreamViewType: 'NEW_AND_OLD_IMAGES' }, + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, + TableName: 'MyTable' + } + )); + test.done(); + }, - 'can specify new images only'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337, - streamSpecification: StreamViewType.NewImage - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } - ], - StreamSpecification: { StreamViewType: 'NEW_IMAGE' }, - TableName: 'MyTable', - } - } - } - }); + 'can specify new images only'(test: Test) { + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { + tableName: TABLE_NAME, + readCapacity: 42, + writeCapacity: 1337, + streamSpecification: StreamViewType.NewImage + }); + table.addPartitionKey(TABLE_PARTITION_KEY); + table.addSortKey(TABLE_SORT_KEY); - test.done(); - }, + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' } + ], + StreamSpecification: { StreamViewType: 'NEW_IMAGE' }, + TableName: 'MyTable', + } + )); + test.done(); + }, - 'can specify old images only'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337, - streamSpecification: StreamViewType.OldImage - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } - ], - StreamSpecification: { StreamViewType: 'OLD_IMAGE' }, - TableName: 'MyTable', - } - } - } - }); + 'can specify old images only'(test: Test) { + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { + tableName: TABLE_NAME, + readCapacity: 42, + writeCapacity: 1337, + streamSpecification: StreamViewType.OldImage + }); + table.addPartitionKey(TABLE_PARTITION_KEY); + table.addSortKey(TABLE_SORT_KEY); - test.done(); - } - }, + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' } + ], + StreamSpecification: { StreamViewType: 'OLD_IMAGE' }, + TableName: 'MyTable', + } + )); + test.done(); + } +}, 'when specifying every property'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { tableName: TABLE_NAME, readCapacity: 42, writeCapacity: 1337, @@ -391,89 +313,75 @@ export = { sseEnabled: true, billingMode: BillingMode.Provisioned, streamSpecification: StreamViewType.KeysOnly, - tags: { Environment: 'Production' }, ttlAttributeName: 'timeToLive' }); table.addPartitionKey(TABLE_PARTITION_KEY); table.addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } - ], - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { - ReadCapacityUnits: 42, - WriteCapacityUnits: 1337 - }, - PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true }, - SSESpecification: { SSEEnabled: true }, - StreamSpecification: { StreamViewType: 'KEYS_ONLY' }, - TableName: 'MyTable', - Tags: [ { Key: 'Environment', Value: 'Production' } ], - TimeToLiveSpecification: { AttributeName: 'timeToLive', Enabled: true } - } - } + table.apply(new Tag('Environment', 'Production')); + + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { + ReadCapacityUnits: 42, + WriteCapacityUnits: 1337 + }, + PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true }, + SSESpecification: { SSEEnabled: true }, + StreamSpecification: { StreamViewType: 'KEYS_ONLY' }, + TableName: 'MyTable', + Tags: [ { Key: 'Environment', Value: 'Production' } ], + TimeToLiveSpecification: { AttributeName: 'timeToLive', Enabled: true } } - }); - + )); test.done(); }, 'when specifying PAY_PER_REQUEST billing mode'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME, { + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME, { tableName: TABLE_NAME, billingMode: BillingMode.PayPerRequest, partitionKey: TABLE_PARTITION_KEY }); - const template = app.synthesizeTemplate(); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - ], - BillingMode: 'PAY_PER_REQUEST', - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - ], - TableName: 'MyTable', - } - } + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + ], + BillingMode: 'PAY_PER_REQUEST', + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + ], + TableName: 'MyTable', } - }); - + )); test.done(); }, 'error when specifying read or write capacity with a PAY_PER_REQUEST billing mode'(test: Test) { - const app = new TestApp(); - test.throws(() => new Table(app.stack, CONSTRUCT_NAME, { + const stack = new Stack(); + test.throws(() => new Table(stack, CONSTRUCT_NAME, { tableName: TABLE_NAME, billingMode: BillingMode.PayPerRequest, partitionKey: TABLE_PARTITION_KEY, readCapacity: 1 })); - test.throws(() => new Table(app.stack, CONSTRUCT_NAME, { + test.throws(() => new Table(stack, CONSTRUCT_NAME, { tableName: TABLE_NAME, billingMode: BillingMode.PayPerRequest, partitionKey: TABLE_PARTITION_KEY, writeCapacity: 1 })); - test.throws(() => new Table(app.stack, CONSTRUCT_NAME, { + test.throws(() => new Table(stack, CONSTRUCT_NAME, { tableName: TABLE_NAME, billingMode: BillingMode.PayPerRequest, partitionKey: TABLE_PARTITION_KEY, @@ -484,8 +392,8 @@ export = { }, 'when adding a global secondary index with hash key only'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY) .addGlobalSecondaryIndex({ @@ -494,44 +402,37 @@ export = { readCapacity: 42, writeCapacity: 1337 }); - const template = app.synthesizeTemplate(); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' }, - { AttributeName: 'gsiHashKey', AttributeType: 'S' }, - ], + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' }, + { AttributeName: 'gsiHashKey', AttributeType: 'S' }, + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + GlobalSecondaryIndexes: [ + { + IndexName: 'MyGSI', KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } + { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - GlobalSecondaryIndexes: [ - { - IndexName: 'MyGSI', - KeySchema: [ - { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, - ], - Projection: { ProjectionType: 'ALL' }, - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 } - } - ] + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 } } - } + ] } - }); - + )); test.done(); }, 'when adding a global secondary index with hash + range key'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY) .addGlobalSecondaryIndex({ @@ -542,46 +443,39 @@ export = { readCapacity: 42, writeCapacity: 1337 }); - const template = app.synthesizeTemplate(); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' }, - { AttributeName: 'gsiHashKey', AttributeType: 'S' }, - { AttributeName: 'gsiSortKey', AttributeType: 'B' } - ], + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' }, + { AttributeName: 'gsiHashKey', AttributeType: 'S' }, + { AttributeName: 'gsiSortKey', AttributeType: 'B' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + GlobalSecondaryIndexes: [ + { + IndexName: 'MyGSI', KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } + { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, + { AttributeName: 'gsiSortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - GlobalSecondaryIndexes: [ - { - IndexName: 'MyGSI', - KeySchema: [ - { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, - { AttributeName: 'gsiSortKey', KeyType: 'RANGE' } - ], - Projection: { ProjectionType: 'ALL' }, - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 } - } - ] + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 } } - } + ] } - }); - + )); test.done(); }, 'when adding a global secondary index with projection type KEYS_ONLY'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY) .addGlobalSecondaryIndex({ @@ -590,46 +484,39 @@ export = { sortKey: GSI_SORT_KEY, projectionType: ProjectionType.KeysOnly, }); - const template = app.synthesizeTemplate(); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' }, - { AttributeName: 'gsiHashKey', AttributeType: 'S' }, - { AttributeName: 'gsiSortKey', AttributeType: 'B' } - ], + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' }, + { AttributeName: 'gsiHashKey', AttributeType: 'S' }, + { AttributeName: 'gsiSortKey', AttributeType: 'B' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + GlobalSecondaryIndexes: [ + { + IndexName: 'MyGSI', KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } + { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, + { AttributeName: 'gsiSortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - GlobalSecondaryIndexes: [ - { - IndexName: 'MyGSI', - KeySchema: [ - { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, - { AttributeName: 'gsiSortKey', KeyType: 'RANGE' } - ], - Projection: { ProjectionType: 'KEYS_ONLY' }, - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - } - ] + Projection: { ProjectionType: 'KEYS_ONLY' }, + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } } - } + ] } - }); - + )); test.done(); }, 'when adding a global secondary index with projection type INCLUDE'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); const gsiNonKeyAttributeGenerator = NON_KEY_ATTRIBUTE_GENERATOR(GSI_NON_KEY); @@ -642,46 +529,39 @@ export = { readCapacity: 42, writeCapacity: 1337 }); - const template = app.synthesizeTemplate(); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' }, - { AttributeName: 'gsiHashKey', AttributeType: 'S' }, - { AttributeName: 'gsiSortKey', AttributeType: 'B' } - ], + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' }, + { AttributeName: 'gsiHashKey', AttributeType: 'S' }, + { AttributeName: 'gsiSortKey', AttributeType: 'B' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + GlobalSecondaryIndexes: [ + { + IndexName: 'MyGSI', KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } + { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, + { AttributeName: 'gsiSortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - GlobalSecondaryIndexes: [ - { - IndexName: 'MyGSI', - KeySchema: [ - { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, - { AttributeName: 'gsiSortKey', KeyType: 'RANGE' } - ], - Projection: { NonKeyAttributes: ['gsiNonKey0', 'gsiNonKey1'], ProjectionType: 'INCLUDE' }, - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 } - } - ] + Projection: { NonKeyAttributes: ['gsiNonKey0', 'gsiNonKey1'], ProjectionType: 'INCLUDE' }, + ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 } } - } + ] } - }); - + )); test.done(); }, 'when adding a global secondary index on a table with PAY_PER_REQUEST billing mode'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME, { + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME, { billingMode: BillingMode.PayPerRequest, partitionKey: TABLE_PARTITION_KEY, sortKey: TABLE_SORT_KEY @@ -689,43 +569,36 @@ export = { indexName: GSI_NAME, partitionKey: GSI_PARTITION_KEY, }); - const template = app.synthesizeTemplate(); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' }, - { AttributeName: 'gsiHashKey', AttributeType: 'S' }, - ], - BillingMode: 'PAY_PER_REQUEST', + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' }, + { AttributeName: 'gsiHashKey', AttributeType: 'S' }, + ], + BillingMode: 'PAY_PER_REQUEST', + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + GlobalSecondaryIndexes: [ + { + IndexName: 'MyGSI', KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } + { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, ], - GlobalSecondaryIndexes: [ - { - IndexName: 'MyGSI', - KeySchema: [ - { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, - ], - Projection: { ProjectionType: 'ALL' } - } - ] + Projection: { ProjectionType: 'ALL' } } - } + ] } - }); - + )); test.done(); }, 'error when adding a global secondary index with projection type INCLUDE, but without specifying non-key attributes'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); @@ -740,8 +613,8 @@ export = { }, 'error when adding a global secondary index with projection type ALL, but with non-key attributes'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); const gsiNonKeyAttributeGenerator = NON_KEY_ATTRIBUTE_GENERATOR(GSI_NON_KEY); @@ -756,8 +629,8 @@ export = { }, 'error when adding a global secondary index with projection type KEYS_ONLY, but with non-key attributes'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); const gsiNonKeyAttributeGenerator = NON_KEY_ATTRIBUTE_GENERATOR(GSI_NON_KEY); @@ -773,8 +646,8 @@ export = { }, 'error when adding a global secondary index with projection type INCLUDE, but with more than 20 non-key attributes'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); const gsiNonKeyAttributeGenerator = NON_KEY_ATTRIBUTE_GENERATOR(GSI_NON_KEY); @@ -795,8 +668,8 @@ export = { }, 'error when adding a global secondary index with projection type INCLUDE, but with key attributes'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); @@ -813,8 +686,8 @@ export = { }, 'error when adding a global secondary index with read or write capacity on a PAY_PER_REQUEST table'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { partitionKey: TABLE_PARTITION_KEY, billingMode: BillingMode.PayPerRequest }); @@ -843,88 +716,81 @@ export = { }, 'when adding multiple global secondary indexes'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); const gsiGenerator = GSI_GENERATOR(); for (let i = 0; i < 5; i++) { table.addGlobalSecondaryIndex(gsiGenerator.next().value); } - const template = app.synthesizeTemplate(); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' }, - { AttributeName: 'gsiHashKey0', AttributeType: 'S' }, - { AttributeName: 'gsiHashKey1', AttributeType: 'S' }, - { AttributeName: 'gsiHashKey2', AttributeType: 'S' }, - { AttributeName: 'gsiHashKey3', AttributeType: 'S' }, - { AttributeName: 'gsiHashKey4', AttributeType: 'S' } + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' }, + { AttributeName: 'gsiHashKey0', AttributeType: 'S' }, + { AttributeName: 'gsiHashKey1', AttributeType: 'S' }, + { AttributeName: 'gsiHashKey2', AttributeType: 'S' }, + { AttributeName: 'gsiHashKey3', AttributeType: 'S' }, + { AttributeName: 'gsiHashKey4', AttributeType: 'S' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + GlobalSecondaryIndexes: [ + { + IndexName: 'MyGSI0', + KeySchema: [ + { AttributeName: 'gsiHashKey0', KeyType: 'HASH' }, ], + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + }, + { + IndexName: 'MyGSI1', KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } + { AttributeName: 'gsiHashKey1', KeyType: 'HASH' }, ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - GlobalSecondaryIndexes: [ - { - IndexName: 'MyGSI0', - KeySchema: [ - { AttributeName: 'gsiHashKey0', KeyType: 'HASH' }, - ], - Projection: { ProjectionType: 'ALL' }, - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - }, - { - IndexName: 'MyGSI1', - KeySchema: [ - { AttributeName: 'gsiHashKey1', KeyType: 'HASH' }, - ], - Projection: { ProjectionType: 'ALL' }, - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - }, - { - IndexName: 'MyGSI2', - KeySchema: [ - { AttributeName: 'gsiHashKey2', KeyType: 'HASH' }, - ], - Projection: { ProjectionType: 'ALL' }, - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - }, - { - IndexName: 'MyGSI3', - KeySchema: [ - { AttributeName: 'gsiHashKey3', KeyType: 'HASH' }, - ], - Projection: { ProjectionType: 'ALL' }, - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - }, - { - IndexName: 'MyGSI4', - KeySchema: [ - { AttributeName: 'gsiHashKey4', KeyType: 'HASH' }, - ], - Projection: { ProjectionType: 'ALL' }, - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - }, - ] - } - } + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + }, + { + IndexName: 'MyGSI2', + KeySchema: [ + { AttributeName: 'gsiHashKey2', KeyType: 'HASH' }, + ], + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + }, + { + IndexName: 'MyGSI3', + KeySchema: [ + { AttributeName: 'gsiHashKey3', KeyType: 'HASH' }, + ], + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + }, + { + IndexName: 'MyGSI4', + KeySchema: [ + { AttributeName: 'gsiHashKey4', KeyType: 'HASH' }, + ], + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + }, + ] } - }); - + )); test.done(); }, 'error when adding more than 5 global secondary indexes'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); const gsiGenerator = GSI_GENERATOR(); @@ -939,96 +805,82 @@ export = { }, 'when adding a global secondary index without specifying read and write capacity'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY) .addGlobalSecondaryIndex({ indexName: GSI_NAME, partitionKey: GSI_PARTITION_KEY, }); - const template = app.synthesizeTemplate(); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' }, - { AttributeName: 'gsiHashKey', AttributeType: 'S' } - ], + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' }, + { AttributeName: 'gsiHashKey', AttributeType: 'S' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + GlobalSecondaryIndexes: [ + { + IndexName: 'MyGSI', KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } + { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - GlobalSecondaryIndexes: [ - { - IndexName: 'MyGSI', - KeySchema: [ - { AttributeName: 'gsiHashKey', KeyType: 'HASH' }, - ], - Projection: { ProjectionType: 'ALL' }, - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } - } - ] + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } } - } + ] } - }); - + )); test.done(); }, 'when adding a local secondary index with hash + range key'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY) .addLocalSecondaryIndex({ indexName: LSI_NAME, sortKey: LSI_SORT_KEY, }); - const template = app.synthesizeTemplate(); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' }, - { AttributeName: 'lsiSortKey', AttributeType: 'N' } - ], + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' }, + { AttributeName: 'lsiSortKey', AttributeType: 'N' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + LocalSecondaryIndexes: [ + { + IndexName: 'MyLSI', KeySchema: [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - LocalSecondaryIndexes: [ - { - IndexName: 'MyLSI', - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'lsiSortKey', KeyType: 'RANGE' } - ], - Projection: { ProjectionType: 'ALL' }, - } + { AttributeName: 'lsiSortKey', KeyType: 'RANGE' } ], + Projection: { ProjectionType: 'ALL' }, } - } + ], } - }); - + )); test.done(); }, 'when adding a local secondary index with projection type KEYS_ONLY'(test: Test) { - const app = new TestApp(); - new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY) .addLocalSecondaryIndex({ @@ -1036,44 +888,37 @@ export = { sortKey: LSI_SORT_KEY, projectionType: ProjectionType.KeysOnly }); - const template = app.synthesizeTemplate(); - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' }, - { AttributeName: 'lsiSortKey', AttributeType: 'N' } - ], + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' }, + { AttributeName: 'lsiSortKey', AttributeType: 'N' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + LocalSecondaryIndexes: [ + { + IndexName: 'MyLSI', KeySchema: [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - LocalSecondaryIndexes: [ - { - IndexName: 'MyLSI', - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'lsiSortKey', KeyType: 'RANGE' } - ], - Projection: { ProjectionType: 'KEYS_ONLY' }, - } + { AttributeName: 'lsiSortKey', KeyType: 'RANGE' } ], + Projection: { ProjectionType: 'KEYS_ONLY' }, } - } + ], } - }); - + )); test.done(); }, 'when adding a local secondary index with projection type INCLUDE'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); const lsiNonKeyAttributeGenerator = NON_KEY_ATTRIBUTE_GENERATOR(LSI_NON_KEY); @@ -1084,44 +929,36 @@ export = { nonKeyAttributes: [ lsiNonKeyAttributeGenerator.next().value, lsiNonKeyAttributeGenerator.next().value ] }); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { - Resources: { - MyTable794EDED1: { - Type: 'AWS::DynamoDB::Table', - Properties: { - AttributeDefinitions: [ - { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' }, - { AttributeName: 'lsiSortKey', AttributeType: 'N' } - ], + expect(stack).to(haveResource('AWS::DynamoDB::Table', + { + AttributeDefinitions: [ + { AttributeName: 'hashKey', AttributeType: 'S' }, + { AttributeName: 'sortKey', AttributeType: 'N' }, + { AttributeName: 'lsiSortKey', AttributeType: 'N' } + ], + KeySchema: [ + { AttributeName: 'hashKey', KeyType: 'HASH' }, + { AttributeName: 'sortKey', KeyType: 'RANGE' } + ], + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + LocalSecondaryIndexes: [ + { + IndexName: 'MyLSI', KeySchema: [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } - ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, - LocalSecondaryIndexes: [ - { - IndexName: 'MyLSI', - KeySchema: [ - { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'lsiSortKey', KeyType: 'RANGE' } - ], - Projection: { NonKeyAttributes: ['lsiNonKey0', 'lsiNonKey1'], ProjectionType: 'INCLUDE' }, - } + { AttributeName: 'lsiSortKey', KeyType: 'RANGE' } ], + Projection: { NonKeyAttributes: ['lsiNonKey0', 'lsiNonKey1'], ProjectionType: 'INCLUDE' }, } - } + ], } - }); - + )); test.done(); }, 'error when adding more than 5 local secondary indexes'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); const lsiGenerator = LSI_GENERATOR(); @@ -1136,8 +973,8 @@ export = { }, 'error when adding a local secondary index before specifying a partition key of the table'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addSortKey(TABLE_SORT_KEY); test.throws(() => table.addLocalSecondaryIndex({ @@ -1149,8 +986,8 @@ export = { }, 'error when adding a local secondary index with the name of a global secondary index'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY) .addSortKey(TABLE_SORT_KEY); table.addGlobalSecondaryIndex({ @@ -1167,8 +1004,8 @@ export = { }, 'error when validating construct if a local secondary index exists without a sort key of the table'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME) + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME) .addPartitionKey(TABLE_PARTITION_KEY); table.addLocalSecondaryIndex({ indexName: LSI_NAME, @@ -1185,21 +1022,21 @@ export = { 'can enable Read AutoScaling'(test: Test) { // GIVEN - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); table.addPartitionKey(TABLE_PARTITION_KEY); // WHEN table.autoScaleReadCapacity({ minCapacity: 50, maxCapacity: 500 }).scaleOnUtilization({ targetUtilizationPercent: 75 }); // THEN - expect(app.stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { MaxCapacity: 500, MinCapacity: 50, ScalableDimension: 'dynamodb:table:ReadCapacityUnits', ServiceNamespace: 'dynamodb' })); - expect(app.stack).to(haveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { PolicyType: 'TargetTrackingScaling', TargetTrackingScalingPolicyConfiguration: { PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBReadCapacityUtilization' }, @@ -1212,21 +1049,21 @@ export = { 'can enable Write AutoScaling'(test: Test) { // GIVEN - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); table.addPartitionKey(TABLE_PARTITION_KEY); // WHEN table.autoScaleWriteCapacity({ minCapacity: 50, maxCapacity: 500 }).scaleOnUtilization({ targetUtilizationPercent: 75 }); // THEN - expect(app.stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { MaxCapacity: 500, MinCapacity: 50, ScalableDimension: 'dynamodb:table:WriteCapacityUnits', ServiceNamespace: 'dynamodb' })); - expect(app.stack).to(haveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { PolicyType: 'TargetTrackingScaling', TargetTrackingScalingPolicyConfiguration: { PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBWriteCapacityUtilization' }, @@ -1239,8 +1076,8 @@ export = { 'cannot enable AutoScaling twice on the same property'(test: Test) { // GIVEN - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); table.addPartitionKey(TABLE_PARTITION_KEY); table.autoScaleReadCapacity({ minCapacity: 50, maxCapacity: 500 }).scaleOnUtilization({ targetUtilizationPercent: 75 }); @@ -1254,8 +1091,8 @@ export = { 'error when enabling AutoScaling on the PAY_PER_REQUEST table'(test: Test) { // GIVEN - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { billingMode: BillingMode.PayPerRequest }); + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { billingMode: BillingMode.PayPerRequest }); table.addPartitionKey(TABLE_PARTITION_KEY); table.addGlobalSecondaryIndex({ indexName: GSI_NAME, @@ -1279,8 +1116,8 @@ export = { 'error when specifying Read Auto Scaling with invalid scalingTargetValue < 10'(test: Test) { // GIVEN - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); // THEN test.throws(() => { @@ -1292,8 +1129,8 @@ export = { 'error when specifying Read Auto Scaling with invalid minimumCapacity'(test: Test) { // GIVEN - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); // THEN test.throws(() => table.autoScaleReadCapacity({ minCapacity: 10, maxCapacity: 5 })); @@ -1303,8 +1140,8 @@ export = { 'can autoscale on a schedule'(test: Test) { // GIVEN - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); + const stack = new Stack(); + const table = new Table(stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); table.addPartitionKey({ name: 'Hash', type: AttributeType.String }); // WHEN @@ -1315,7 +1152,7 @@ export = { }); // THEN - expect(app.stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { ScheduledActions: [ { ScalableTargetAction: { "MaxCapacity": 10 }, @@ -1461,16 +1298,6 @@ export = { }, }; -class TestApp { - private readonly app = new App(); - // tslint:disable-next-line:member-ordering - public readonly stack: Stack = new Stack(this.app, STACK_NAME); - - public synthesizeTemplate() { - return this.app.synthesizeStack(this.stack.name).template; - } -} - function testGrant(test: Test, expectedActions: string[], invocation: (user: iam.IPrincipal, table: Table) => void) { // GIVEN const stack = new Stack(); diff --git a/packages/@aws-cdk/aws-ec2/lib/security-group.ts b/packages/@aws-cdk/aws-ec2/lib/security-group.ts index 187c9d77570c9..15fe5acd73fbf 100644 --- a/packages/@aws-cdk/aws-ec2/lib/security-group.ts +++ b/packages/@aws-cdk/aws-ec2/lib/security-group.ts @@ -1,4 +1,4 @@ -import { Construct, IConstruct, ITaggable, Output, Stack, TagManager, Tags, Token } from '@aws-cdk/cdk'; +import { Construct, IConstruct, Output, Stack, Token } from '@aws-cdk/cdk'; import { Connections, IConnectable } from './connections'; import { CfnSecurityGroup, CfnSecurityGroupEgress, CfnSecurityGroupIngress } from './ec2.generated'; import { IPortRange, ISecurityGroupRule } from './security-group-rule'; @@ -216,11 +216,6 @@ export interface SecurityGroupProps { */ description?: string; - /** - * The AWS resource tags to associate with the security group. - */ - tags?: Tags; - /** * The VPC in which to create the security group. */ @@ -245,7 +240,7 @@ export interface SecurityGroupProps { * inline ingress and egress rule (which saves on the total number of resources inside * the template). */ -export class SecurityGroup extends SecurityGroupBase implements ITaggable { +export class SecurityGroup extends SecurityGroupBase { /** * Import an existing SecurityGroup */ @@ -268,11 +263,6 @@ export class SecurityGroup extends SecurityGroupBase implements ITaggable { */ public readonly securityGroupId: string; - /** - * Manage tags for this construct and children - */ - public readonly tags: TagManager; - private readonly securityGroup: CfnSecurityGroup; private readonly directIngressRules: CfnSecurityGroup.IngressProperty[] = []; private readonly directEgressRules: CfnSecurityGroup.EgressProperty[] = []; @@ -282,7 +272,6 @@ export class SecurityGroup extends SecurityGroupBase implements ITaggable { constructor(scope: Construct, id: string, props: SecurityGroupProps) { super(scope, id); - this.tags = new TagManager(this, { initialTags: props.tags}); const groupDescription = props.description || this.node.path; this.allowAllOutbound = props.allowAllOutbound !== false; @@ -293,7 +282,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 e36dc13283984..389a2c492d446 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc.ts @@ -48,11 +48,6 @@ export interface VpcNetworkProps { */ defaultInstanceTenancy?: DefaultInstanceTenancy; - /** - * The AWS resource tags to associate with the VPC. - */ - tags?: cdk.Tags; - /** * Define the maximum number of AZs to use in this region * @@ -163,11 +158,6 @@ export interface SubnetConfiguration { * availability zone. */ name: string; - - /** - * The AWS resource tags to associate with the resource. - */ - tags?: cdk.Tags; } /** @@ -190,7 +180,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 */ @@ -260,11 +250,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 */ @@ -299,9 +284,6 @@ 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.node.path, { overwrite: false }); - const cidrBlock = ifUndefined(props.cidr, VpcNetwork.DEFAULT_CIDR_RANGE); this.networkBuilder = new NetworkBuilder(cidrBlock); @@ -315,9 +297,10 @@ export class VpcNetwork extends VpcNetworkBase implements cdk.ITaggable { enableDnsHostnames, enableDnsSupport, instanceTenancy, - tags: this.tags, }); + this.apply(new cdk.Tag(NAME_TAG, this.node.path)); + this.availabilityZones = new cdk.AvailabilityZoneProvider(this).availabilityZones; this.availabilityZones.sort(); @@ -336,7 +319,6 @@ 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), }); this.internetDependencies.push(igw); const att = new CfnVPCGatewayAttachment(this, 'VPCGW', { @@ -445,7 +427,6 @@ export class VpcNetwork extends VpcNetworkBase implements cdk.ITaggable { vpcId: this.vpcId, cidrBlock: this.networkBuilder.addSubnet(cidrMask), mapPublicIpOnLaunch: (subnetConfig.subnetType === SubnetType.Public), - tags: subnetConfig.tags, }; let subnet: VpcSubnet; @@ -462,7 +443,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; @@ -471,8 +451,9 @@ 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 includeResourceTypes = [CfnSubnet.resourceTypeName]; + subnet.apply(new cdk.Tag(SUBNETNAME_TAG, subnetConfig.name, {includeResourceTypes})); + subnet.apply(new cdk.Tag(SUBNETTYPE_TAG, subnetTypeTagValue(subnetConfig.subnetType), {includeResourceTypes})); }); } } @@ -514,17 +495,12 @@ export interface VpcSubnetProps { * Defaults to true in Subnet.Public, false in Subnet.Private or Subnet.Isolated. */ mapPublicIpOnLaunch?: boolean; - - /** - * The AWS resource tags to associate with the Subnet - */ - tags?: cdk.Tags; } /** * Represents a new VPC subnet resource */ -export class VpcSubnet extends cdk.Construct implements IVpcSubnet, cdk.ITaggable { +export class VpcSubnet extends cdk.Construct implements IVpcSubnet { public static import(scope: cdk.Construct, id: string, props: VpcSubnetImportProps): IVpcSubnet { return new ImportedVpcSubnet(scope, id, props); } @@ -540,9 +516,9 @@ export class VpcSubnet extends cdk.Construct implements IVpcSubnet, cdk.ITaggabl public readonly subnetId: string; /** - * Manage tags for Construct and propagate to children + * Parts of this VPC subnet */ - public readonly tags: cdk.TagManager; + public readonly dependencyElements: cdk.IDependable[] = []; /** * The routeTableId attached to this subnet. @@ -553,8 +529,7 @@ export class VpcSubnet extends cdk.Construct implements IVpcSubnet, cdk.ITaggabl constructor(scope: cdk.Construct, id: string, props: VpcSubnetProps) { super(scope, id); - this.tags = new cdk.TagManager(this, {initialTags: props.tags}); - this.tags.setTag(NAME_TAG, this.node.path, {overwrite: false}); + this.apply(new cdk.Tag(NAME_TAG, this.node.path)); this.availabilityZone = props.availabilityZone; const subnet = new CfnSubnet(this, 'Subnet', { @@ -562,12 +537,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; @@ -649,7 +622,6 @@ export class VpcPublicSubnet extends VpcSubnet { allocationId: new CfnEIP(this, `EIP`, { domain: 'vpc' }).eipAllocationId, - tags: new cdk.TagManager(this), }); return ngw; } diff --git a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts index ec8aa87d1403e..694eefe0a0573 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, Stack, Tags } from '@aws-cdk/cdk'; +import { AvailabilityZoneProvider, Construct, Stack, Tag } 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,14 @@ 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'} ])) + ); + expect(stack).to( + haveResource('AWS::EC2::InternetGateway', + hasTags( [ {Key: 'Name', Value: 'TheVPC'} ])) + ); test.done(); }, @@ -37,28 +43,19 @@ export = { "with all of the properties set, it successfully sets the correct VPC properties"(test: Test) { const stack = getTestStack(); - const tags = { - first: 'foo', - second: 'bar', - third: 'barz', - - }; new VpcNetwork(stack, 'TheVPC', { cidr: "192.168.0.0/16", enableDnsHostnames: false, enableDnsSupport: false, defaultInstanceTenancy: DefaultInstanceTenancy.Dedicated, - tags, }); - const cfnTags = toCfnTags(tags); expect(stack).to(haveResource('AWS::EC2::VPC', { CidrBlock: '192.168.0.0/16', EnableDnsHostnames: false, EnableDnsSupport: false, InstanceTenancy: DefaultInstanceTenancy.Dedicated, })); - expect(stack).to(haveResource('AWS::EC2::VPC', hasTags(cfnTags))); test.done(); }, @@ -124,25 +121,21 @@ 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, }, - }, - { - 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,20 +144,14 @@ 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( - [ - { Key: 'type', Value: 'Public'}, - { Key: 'init', Value: 'No'}, - ], - ))); test.done(); }, "with custom subents and natGateways = 2 there should be only two NATGW"(test: Test) { @@ -308,14 +295,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(); }, @@ -362,11 +345,12 @@ export = { const noPropTags = { BusinessUnit: 'Marketing', }; - const allTags: Tags = {...tags, ...noPropTags}; + const allTags = {...tags, ...noPropTags}; - const vpc = new VpcNetwork(stack, 'TheVPC', { tags: allTags }); + const vpc = new VpcNetwork(stack, 'TheVPC'); // overwrite to set propagate - vpc.tags.setTag('BusinessUnit', 'Marketing', {propagate: false}); + vpc.apply(new Tag('BusinessUnit', 'Marketing', {includeResourceTypes: [CfnVPC.resourceTypeName]})); + vpc.apply(new Tag('VpcType', 'Good')); expect(stack).to(haveResource("AWS::EC2::VPC", hasTags(toCfnTags(allTags)))); const taggables = ['Subnet', 'InternetGateway', 'NatGateway', 'RouteTable']; const propTags = toCfnTags(tags); @@ -393,10 +377,11 @@ export = { }, 'Tags can be added after the Vpc is created with `vpc.tags.setTag(...)`'(test: Test) { const stack = getTestStack(); + 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 Tag(tag.Key, tag.Value)); expect(stack).to(haveResource('AWS::EC2::VPC', hasTags([tag]))); test.done(); }, @@ -563,7 +548,7 @@ function doImportExportTest(constructFn: (scope: Construct) => VpcNetwork): IVpc return VpcNetwork.import(stack2, 'VPC2', vpc1.export()); } -function toCfnTags(tags: Tags): Array<{Key: string, Value: string}> { +function toCfnTags(tags: any): Array<{Key: string, Value: string}> { return Object.keys(tags).map( key => { return {Key: key, Value: tags[key]}; }); @@ -586,8 +571,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/aws-ecs/test/ec2/integ.event-task.lit.expected.json b/packages/@aws-cdk/aws-ecs/test/ec2/integ.event-task.lit.expected.json index c3b9d654c394a..364dfeb0633f4 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/integ.event-task.lit.expected.json +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.event-task.lit.expected.json @@ -452,6 +452,12 @@ } } }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/EcsCluster/DefaultAutoScalingGroup" + } + ], "Timeout": 310 }, "DependsOn": [ diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.expected.json b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.expected.json index 15f70f9eedcb4..e566dfadd397c 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.expected.json +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.expected.json @@ -608,6 +608,12 @@ } } }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/EcsCluster/DefaultAutoScalingGroup" + } + ], "Timeout": 310 }, "DependsOn": [ diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.expected.json b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.expected.json index 4149a3087889b..802bbbfff8f04 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.expected.json +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.expected.json @@ -629,6 +629,12 @@ } } }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/EcsCluster/DefaultAutoScalingGroup" + } + ], "Timeout": 310 }, "DependsOn": [ diff --git a/packages/@aws-cdk/aws-kms/lib/key.ts b/packages/@aws-cdk/aws-kms/lib/key.ts index 7b259bfb8fc25..20c8f4506a77b 100644 --- a/packages/@aws-cdk/aws-kms/lib/key.ts +++ b/packages/@aws-cdk/aws-kms/lib/key.ts @@ -1,5 +1,5 @@ import { PolicyDocument, PolicyStatement } from '@aws-cdk/aws-iam'; -import { Construct, DeletionPolicy, IConstruct, Output, TagManager, Tags } from '@aws-cdk/cdk'; +import { Construct, DeletionPolicy, IConstruct, Output } from '@aws-cdk/cdk'; import { EncryptionKeyAlias } from './alias'; import { CfnKey } from './kms.generated'; @@ -106,11 +106,6 @@ export interface EncryptionKeyProps { * administer the key will be created. */ policy?: PolicyDocument; - - /** - * The AWS resource tags to associate with the KMS key. - */ - tags?: Tags; } /** @@ -139,11 +134,6 @@ export class EncryptionKey extends EncryptionKeyBase { return new ImportedEncryptionKey(scope, id, props); } - /** - * Manage tags for this construct and children - */ - public readonly tags: TagManager; - public readonly keyArn: string; protected readonly policy?: PolicyDocument; @@ -157,14 +147,11 @@ export class EncryptionKey extends EncryptionKeyBase { this.allowAccountToAdmin(); } - this.tags = new TagManager(this, { initialTags: props.tags }); - const resource = new CfnKey(this, 'Resource', { description: props.description, enableKeyRotation: props.enableKeyRotation, enabled: props.enabled, keyPolicy: this.policy, - tags: this.tags }); this.keyArn = resource.keyArn; diff --git a/packages/@aws-cdk/aws-kms/test/test.key.ts b/packages/@aws-cdk/aws-kms/test/test.key.ts index 155347aa7a225..dd51d1d7bffe7 100644 --- a/packages/@aws-cdk/aws-kms/test/test.key.ts +++ b/packages/@aws-cdk/aws-kms/test/test.key.ts @@ -1,17 +1,16 @@ import { exactlyMatchTemplate, expect } from '@aws-cdk/assert'; import { PolicyDocument, PolicyStatement } from '@aws-cdk/aws-iam'; -import { App, Stack } from '@aws-cdk/cdk'; +import { App, Stack, Tag } from '@aws-cdk/cdk'; import { Test } from 'nodeunit'; import { EncryptionKey } from '../lib'; export = { 'default key'(test: Test) { - const app = new App(); - const stack = new Stack(app, 'TestStack'); + const stack = new Stack(); new EncryptionKey(stack, 'MyKey'); - expect(app.synthesizeStack(stack.name)).to(exactlyMatchTemplate({ + expect(stack).to(exactlyMatchTemplate({ Resources: { MyKey6AB29FA6: { Type: "AWS::KMS::Key", @@ -138,97 +137,95 @@ export = { }, 'key with some options'(test: Test) { - const app = new App(); - const stack = new Stack(app, 'Test'); + const stack = new Stack(); const key = new EncryptionKey(stack, 'MyKey', { enableKeyRotation: true, enabled: false, - tags: { - tag1: 'value1', - tag2: 'value2', - tag3: '' - } }); const p = new PolicyStatement().addAllResources().addAction('kms:encrypt'); p.addAwsPrincipal('arn'); key.addToResourcePolicy(p); - expect(app.synthesizeStack(stack.name)).to(exactlyMatchTemplate({ + key.apply(new Tag('tag1', 'value1')); + key.apply(new Tag('tag2', 'value2')); + key.apply(new Tag('tag3', '')); + + expect(stack).to(exactlyMatchTemplate({ Resources: { MyKey6AB29FA6: { - Type: "AWS::KMS::Key", - Properties: { - Enabled: false, - EnableKeyRotation: true, - KeyPolicy: { - Statement: [ - { - Action: [ - "kms:Create*", - "kms:Describe*", - "kms:Enable*", - "kms:List*", - "kms:Put*", - "kms:Update*", - "kms:Revoke*", - "kms:Disable*", - "kms:Get*", - "kms:Delete*", - "kms:ScheduleKeyDeletion", - "kms:CancelKeyDeletion" - ], - Effect: "Allow", - Principal: { - AWS: { - "Fn::Join": [ - "", - [ - "arn:", + Type: "AWS::KMS::Key", + Properties: { + KeyPolicy: { + Statement: [ { - Ref: "AWS::Partition" + Action: [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ], + Effect: "Allow", + Principal: { + AWS: { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition" + }, + ":iam::", + { + Ref: "AWS::AccountId" + }, + ":root" + ] + ] + } + }, + Resource: '*' }, - ":iam::", { - Ref: "AWS::AccountId" - }, - ":root" - ] - ] - } - }, - Resource: '*' - }, - { - Action: "kms:encrypt", - Effect: "Allow", - Principal: { - AWS: "arn" + Action: "kms:encrypt", + Effect: "Allow", + Principal: { + AWS: "arn" + }, + Resource: "*" + } + ], + Version: "2012-10-17" }, - Resource: "*" - } - ], - Version: "2012-10-17" + Enabled: false, + EnableKeyRotation: true, + Tags: [ + { + Key: "tag1", + Value: "value1" + }, + { + Key: "tag2", + Value: "value2" + }, + { + Key: "tag3", + Value: "" + } + ] }, - Tags: [ - { - Key: "tag1", - Value: "value1" - }, - { - Key: "tag2", - Value: "value2" - }, - { - Key: "tag3", - Value: "" - } - ] - }, - DeletionPolicy: "Retain" + DeletionPolicy: "Retain" } } - })); + })); test.done(); }, @@ -247,67 +244,67 @@ export = { test.deepEqual(app.synthesizeStack(stack.name).template, { Resources: { - MyKey6AB29FA6: { - Type: "AWS::KMS::Key", - Properties: { - EnableKeyRotation: true, - Enabled: false, - KeyPolicy: { - Statement: [ - { - Action: [ - "kms:Create*", - "kms:Describe*", - "kms:Enable*", - "kms:List*", - "kms:Put*", - "kms:Update*", - "kms:Revoke*", - "kms:Disable*", - "kms:Get*", - "kms:Delete*", - "kms:ScheduleKeyDeletion", - "kms:CancelKeyDeletion" - ], - Effect: "Allow", - Principal: { - AWS: { - "Fn::Join": [ - "", - [ - "arn:", - { - Ref: "AWS::Partition" - }, - ":iam::", + MyKey6AB29FA6: { + Type: "AWS::KMS::Key", + Properties: { + EnableKeyRotation: true, + Enabled: false, + KeyPolicy: { + Statement: [ { - Ref: "AWS::AccountId" - }, - ":root" - ] + Action: [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ], + Effect: "Allow", + Principal: { + AWS: { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition" + }, + ":iam::", + { + Ref: "AWS::AccountId" + }, + ":root" + ] + ] + } + }, + Resource: "*" + } + ], + Version: "2012-10-17" + } + }, + DeletionPolicy: "Retain" + }, + MyKeyAlias1B45D9DA: { + Type: "AWS::KMS::Alias", + Properties: { + AliasName: "alias/xoo", + TargetKeyId: { + "Fn::GetAtt": [ + "MyKey6AB29FA6", + "Arn" ] } - }, - Resource: "*" } - ], - Version: "2012-10-17" - } - }, - DeletionPolicy: "Retain" - }, - MyKeyAlias1B45D9DA: { - Type: "AWS::KMS::Alias", - Properties: { - AliasName: "alias/xoo", - TargetKeyId: { - "Fn::GetAtt": [ - "MyKey6AB29FA6", - "Arn" - ] } - } - } } }); @@ -362,16 +359,16 @@ export = { expect(stack2).toMatch({ Resources: { - MyKeyImportedAliasB1C5269F: { - Type: "AWS::KMS::Alias", - Properties: { - AliasName: "alias/hello", - TargetKeyId: { - "Fn::ImportValue": "MyKeyKeyArn317F1332" - } + MyKeyImportedAliasB1C5269F: { + Type: "AWS::KMS::Alias", + Properties: { + AliasName: "alias/hello", + TargetKeyId: { + "Fn::ImportValue": "MyKeyKeyArn317F1332" + } + } } } - } }); test.done(); diff --git a/packages/@aws-cdk/cdk/README.md b/packages/@aws-cdk/cdk/README.md index f1dcb5bfd938c..79400c9645d52 100644 --- a/packages/@aws-cdk/cdk/README.md +++ b/packages/@aws-cdk/cdk/README.md @@ -3,3 +3,177 @@ This library includes the basic building blocks of the [AWS Cloud Development Kit](https://github.com/awslabs/aws-cdk) (AWS CDK). +## Aspects + +Aspects are a mechanism to extend the CDK without having to directly impact the +class hierarchy. We have implemented aspects using the [Visitor +Pattern](https://en.wikipedia.org/wiki/Visitor_pattern). + +An aspect in the CDK is defined by this [interface](lib/aspects/aspect.ts) + +Aspects can be applied to any construct. During the tree +"prepare" phase the aspect will visit each construct in the tree once. +Aspects are invoked in the order they were added to the construct. They +traverse the construct tree in a breadth first order starting at the `App` +ending at the leaf nodes (most commonly the CloudFormation Resource). Aspect +authors implement the `visit(IConstruct)` function and can inspect the +`Construct` for specific characteristics. Such as, is this construct a +CloudFormation Resource? + +## Tagging + +Tags are implemented using aspects. + +Tags can be applied to any construct. Tags are inherited, based on the scope. If +you tag construct A, and A contains construct B, construct B inherits the tag. +The Tag API supports: + + * `Tag` add (apply) a tag, either to specific resources or all but specific resources + * `RemoveTag` remove a tag, again either from specific resources or all but specific resources + +A simple example, if you create a stack and want anything in the stack to receive a +tag: + +```ts +import cdk = require('@aws-cdk/cdk'); + +const app = new cdk.App(); +const theBestStack = new cdk.Stack(app, 'MarketingSystem'); +theBestStack.apply(new cdk.Tag('StackType', 'TheBest')); + +// any resources added that support tags will get them +``` + +> The goal was to enable the ability to define tags in one place and have them +> applied consistently for all resources that support tagging. In addition +> the developer should not have to know if the resource supports tags. The +> developer defines the tagging intents for all resources within a path. +> If the resources support tags they are added, else no action is taken. + +### Tag Example with ECS + +We are going to use the [ECS example](https://awslabs.github.io/aws-cdk/ecs_example.html) as starting point. + +For the purposes of example, this ECS cluster is for the Marketing Department. +Marketing has two core groups Business to Business (B2B) and Business to Consumer +(B2C). However, the Marketing team relies on the Platform team to help build the +common components across businesses and separates costs to match. The goal here +is tag the Platform team resources, the Marketing Department and then Marketing +groups to enable proper cost allocations. + +We have modified the example and the code is located: +examples/cdk-examples-typescript/hello-cdk-ecs-tags + +When the example is run the following tags are created: + +> We are omitting the default tags for VPC components. + +| Construct Path | Tag Key | Tag Value | +| ----------|:---------|:-----| +|MarketingSystem/MarketingVpc|CostCenter|Platform| +|MarketingSystem/MarketingVpc/PublicSubnet1| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PublicSubnet1/RouteTable| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PublicSubnet1/NATGateway| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PublicSubnet2| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PublicSubnet2/RouteTable| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PublicSubnet2/NATGateway| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PublicSubnet3| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PublicSubnet3/RouteTable| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PublicSubnet3/NATGateway| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PrivateSubnet1| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PrivateSubnet1/RouteTable| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PrivateSubnet2| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PrivateSubnet2/RouteTable| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PrivateSubnet3| CostCenter | Platform| +|MarketingSystem/MarketingVpc/PrivateSubnet3/RouteTable| CostCenter | Platform| +|MarketingSystem/MarketingVpc/IGW|CostCenter|Platform| +|MarketingSystem/B2BService/Service/SecurityGroup/Resource|CostCenter|Marketing| +|MarketingSystem/B2BService/LB/Resource|CostCenter|Marketing| +|MarketingSystem/B2BService/LB/SecurityGroup/Resource|CostCenter|Marketing| +|MarketingSystem/B2BService/LB/PublicListener/ECSGroup/Resource|CostCenter|Marketing| +|MarketingSystem/B2CService/Service/SecurityGroup/Resource|CostCenter|Marketing| +|MarketingSystem/B2CService/LB/Resource|CostCenter|Marketing| +|MarketingSystem/B2CService/LB/SecurityGroup/Resource|CostCenter|Marketing| +|MarketingSystem/B2CService/LB/PublicListener/ECSGroup/Resource|CostCenter|Marketing| + +As you can see many tags are generated with only a few intent based directives. The CDK does default some additional tags for suggested `Name` keys. If you want to remove those tags you can do so by using the `RemoveTag` aspect, see below: + +```ts +// snip // +const vpc = new ec2.VpcNetwork(marketingStack, 'MarketingVpc', { + maxAZs: 3 // Default is all AZs in region + }); +// override the VPC tags with Platform +// this will tag the VPC, Subnets, Route Tables, IGW, and NatGWs +vpc.apply(new cdk.Tag(COST_CENTER_KEY, 'Platform')); +vpc.apply(new cdk.RemoveTag('Name')); +// snip // +``` + +This will remove the name tags from the VPC, subnets, route tables and NAT +gateways. If you've been following closely, this may lead you to ask how does +remove work when the tag is actually applied closer to the resource? The Tag API +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 +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. + +#### applyToLaunchedInstances + +This property is a boolean that defaults to `true`. When `true` and the aspect +visits an AutoScalingGroup resource the `PropagateAtLaunch` property is set to +true. If false the property is set accordingly. + +```ts +// ... snip +const vpc = new ec2.VpcNetwork(this, 'MyVpc', { ... }); +vpc.apply(new cdk.Tag('MyKey', 'MyValue', { applyToLaunchedInstances: false })); +// ... snip +``` + +#### includeResourceTypes + +Include is an array property that contains strings of CloudFormation Resource +Types. As the aspect visits nodes it only takes action if node is one of the +resource types in the array. By default the array is empty and an empty array is +interpreted as apply to any resource type. + +```ts +// ... snip +const vpc = new ec2.VpcNetwork(this, 'MyVpc', { ... }); +vpc.apply(new cdk.Tag('MyKey', 'MyValue', { includeResourceTypes: ['AWS::EC2::Subnet']})); +// ... snip +``` + +#### excludeResourceTypes + +Exclude is the inverse of include. Exclude is also an array of CloudFormation +Resource Types. As the aspect visit nodes it will not take action if the node is +one of the resource types in the array. By default the array is empty and an +empty array is interpreted to match no resource type. Exclude takes precedence +over include in the event of a collision. + +```ts +// ... snip +const vpc = new ec2.VpcNetwork(this, 'MyVpc', { ... }); +vpc.apply(new cdk.Tag('MyKey', 'MyValue', { exludeResourceTypes: ['AWS::EC2::Subnet']})); +// ... snip +``` + +#### priority + +Priority is used to control precedence when the default pattern does not work. +In general users should try to avoid using priority, but in some situations it +is required. In the example above, this is how `RemoveTag` works. The default +setting for removing tags uses a higher priority than the standard tag. + +```ts +// ... snip +const vpc = new ec2.VpcNetwork(this, 'MyVpc', { ... }); +vpc.apply(new cdk.Tag('MyKey', 'MyValue', { priority: 2 })); +// ... snip +``` diff --git a/packages/@aws-cdk/cdk/lib/app.ts b/packages/@aws-cdk/cdk/lib/app.ts index dc38817aafba8..1d51d6f4ab033 100644 --- a/packages/@aws-cdk/cdk/lib/app.ts +++ b/packages/@aws-cdk/cdk/lib/app.ts @@ -232,4 +232,4 @@ function getJsiiAgentVersion() { function noEmptyArray(xs: T[]): T[] | undefined { return xs.length > 0 ? xs : undefined; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/cdk/lib/aspects/aspect.ts b/packages/@aws-cdk/cdk/lib/aspects/aspect.ts new file mode 100644 index 0000000000000..e8148bba35c58 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/aspects/aspect.ts @@ -0,0 +1,11 @@ +import { IConstruct } from '../core/construct'; + +/** + * Represents an Aspect + */ +export interface IAspect { + /** + * All aspects can visit an IConstruct + */ + visit(node: IConstruct): void; +} 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..45abee70023ec --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/aspects/tag-aspect.ts @@ -0,0 +1,75 @@ +import { ITaggable, Resource } from '../cloudformation/resource'; +import { IConstruct } from '../core/construct'; +import { TagProps } from '../core/tag-manager'; +import { IAspect } from './aspect'; + +/** + * The common functionality for Tag and Remove Tag Aspects + */ +export abstract class TagBase implements IAspect { + + /** + * The string key for the tag + */ + public readonly key: string; + + protected readonly props: TagProps; + + constructor(key: string, props: TagProps = {}) { + this.key = key; + this.props = props; + } + + public visit(construct: IConstruct): void { + if (!Resource.isResource(construct)) { + return; + } + const resource = construct as Resource; + if (Resource.isTaggable(resource)) { + this.applyTag(resource); + } + } + + protected abstract applyTag(resource: ITaggable): void; +} + +/** + * The Tag Aspect will handle adding a tag to this node and cascading tags to children + */ +export class Tag extends TagBase { + + /** + * The string value of the tag + */ + public readonly value: string; + + 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'); + } + this.value = value; + } + + protected applyTag(resource: ITaggable) { + resource.tags.setTag(this.key, this.value!, this.props); + } +} + +/** + * The RemoveTag Aspect will handle removing tags from this node and children + */ +export class RemoveTag extends TagBase { + + 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; + } +} diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts index f53cbc63bea02..79ef4aec3bd97 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, IConstruct } from '../core/construct'; +import { TagManager } from '../core/tag-manager'; import { capitalizePropertyNames, ignoreEmpty } from '../core/util'; import { CfnReference } from './cfn-tokens'; import { Condition } from './condition'; @@ -18,6 +19,12 @@ export interface ResourceProps { properties?: any; } +export interface ITaggable { + /** + * TagManager to set, remove and format tags + */ + readonly tags: TagManager; +} /** * Represents a CloudFormation resource. */ @@ -45,6 +52,13 @@ export class Resource extends Referenceable { 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. */ @@ -55,6 +69,13 @@ export class Resource extends Referenceable { */ public readonly resourceType: string; + /** + * AWS resource properties. + * + * This object is rendered via a call to "renderProperties(this.properties)". + */ + protected readonly properties: any; + /** * AWS resource property overrides. * @@ -67,13 +88,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. */ @@ -188,6 +202,10 @@ 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)); @@ -225,6 +243,13 @@ export class Resource extends Referenceable { } +export enum TagType { + Standard = 'StandardTag', + AutoScalingGroup = 'AutoScalingGroupTag', + Map = 'StringToStringMap', + NotTaggable = 'NotTaggable', +} + export interface ResourceOptions { /** * A condition to associate with this resource. This means that only if the condition evaluates to 'true' when the stack @@ -309,4 +334,4 @@ function sortedSet(xs: Set): T[] { const ret = Array.from(xs); ret.sort(); return ret; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts b/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts index d0f85247e1e65..f8ed92b837c7d 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts @@ -239,7 +239,6 @@ export class Stack extends Construct { * Rename a generated logical identities */ public renameLogical(oldId: string, newId: string) { - // tslint:disable-next-line:no-console if (this.node.children.length > 0) { throw new Error("All renames must be set up before adding elements to the stack"); } @@ -549,4 +548,4 @@ function findResources(roots: Iterable): Resource[] { ret.push(...root.node.findAll().filter(Resource.isResource)); } return ret; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/tag.ts b/packages/@aws-cdk/cdk/lib/cloudformation/tag.ts index 26b13b0b41d28..a05aa282335b9 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation/tag.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation/tag.ts @@ -1,7 +1,7 @@ /** * @link http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-resource-tags.html */ -export interface Tag { +export interface CfnTag { /** * @link http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-resource-tags.html#cfn-resource-tags-key */ diff --git a/packages/@aws-cdk/cdk/lib/core/construct.ts b/packages/@aws-cdk/cdk/lib/core/construct.ts index aa2d36ad451c8..d6ca456427802 100644 --- a/packages/@aws-cdk/cdk/lib/core/construct.ts +++ b/packages/@aws-cdk/cdk/lib/core/construct.ts @@ -1,4 +1,5 @@ import cxapi = require('@aws-cdk/cx-api'); +import { IAspect } from '../aspects/aspect'; import { CloudFormationJSON } from '../cloudformation/cloudformation-json'; import { makeUniqueId } from '../util/uniqueid'; import { IDependable } from './dependency'; @@ -32,6 +33,11 @@ export class ConstructNode { */ public readonly id: string; + /** + * An array of aspects applied to this node + */ + public readonly aspects: IAspect[] = []; + /** * List of children and their names */ @@ -47,6 +53,8 @@ export class ConstructNode { */ private _locked = false; + private invokedAspects: IAspect[] = []; + constructor(private readonly host: Construct, scope: IConstruct, id: string) { id = id || ''; // if undefined, convert to empty string @@ -119,7 +127,6 @@ export class ConstructNode { * @returns a child by path or undefined if not found. */ public tryFindChild(path: string): IConstruct | undefined { - // tslint:disable-next-line:no-console if (path.startsWith(PATH_SEP)) { throw new Error('Path must be relative'); } @@ -293,6 +300,10 @@ export class ConstructNode { */ public prepareTree() { const constructs = this.host.node.findAll(ConstructOrder.BreadthFirst); + // Aspects are applied root to leaf + for (const construct of constructs) { + construct.node.invokeAspects(); + } // Use .reverse() to achieve post-order traversal for (const construct of constructs.reverse()) { if (Construct.isConstruct(construct)) { @@ -485,6 +496,20 @@ export class ConstructNode { return ret; } + /** + * Triggers each aspect to invoke visit + */ + private invokeAspects(): void { + const descendants = this.findAll(); + for (const aspect of this.aspects) { + if (this.invokedAspects.includes(aspect)) { + continue; + } + descendants.forEach( member => aspect.visit(member)); + this.invokedAspects.push(aspect); + } + } + /** * Return the path of components up to but excluding the root */ @@ -550,6 +575,14 @@ export class Construct implements IConstruct { return this.node.typename + (path.length > 0 ? ` [${path}]` : ''); } + /** + * Applies the aspect to this Constructs node + */ + public apply(aspect: IAspect): void { + this.node.aspects.push(aspect); + return; + } + /** * Validate the current construct. * @@ -575,6 +608,7 @@ export class Construct implements IConstruct { protected prepare(): void { // Intentionally left blank } + } /** @@ -658,4 +692,19 @@ export interface Dependency { * Target of the dependency */ target: IConstruct; -} \ No newline at end of file +} + +/** + * A single dependency + */ +export interface Dependency { + /** + * Source the dependency + */ + source: IConstruct; + + /** + * Target of the dependency + */ + target: IConstruct; +} diff --git a/packages/@aws-cdk/cdk/lib/core/tag-manager.ts b/packages/@aws-cdk/cdk/lib/core/tag-manager.ts index 7f5f6c57d3814..55bcdeb74717e 100644 --- a/packages/@aws-cdk/cdk/lib/core/tag-manager.ts +++ b/packages/@aws-cdk/cdk/lib/core/tag-manager.ts @@ -1,273 +1,153 @@ -import { Construct, IConstruct } from './construct'; -import { ResolveContext, Token } from './tokens'; - -/** - * ITaggable indicates a entity manages tags via the `tags` property - */ -export interface ITaggable { - readonly tags: TagManager, -} +import { TagType } from '../cloudformation/resource'; /** * Properties Tags is a dictionary of tags as strings */ -export type Tags = { [key: string]: string }; - -/** - * An object of tags with value and properties - * - * This is used internally but not exported - */ -interface FullTags { - [key: string]: {value: string, props?: TagProps}; -} +type Tags = { [key: string]: {value: string, props: TagProps }}; /** * Properties for a tag */ export interface TagProps { /** - * If true all child taggable `Constructs` will receive this tag - * - * @default true + * Handles AutoScalingGroup PropagateAtLaunch property */ - propagate?: boolean; + applyToLaunchedInstances?: boolean; /** - * If set propagated tags from parents will not overwrite the tag + * An array of Resource Types that will not receive this tag * - * @default true + * 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 [] */ - sticky?: boolean; + excludeResourceTypes?: string[]; /** - * If set this tag will overwrite existing tags + * An array of Resource Types that will receive this tag * - * @default true - */ - overwrite?: boolean; -} - -/** - * This is the interface for arguments to `tagFormatResolve` to enable extensions - */ -export interface TagGroups { - /** - * Tags that overwrite ancestor tags - */ - stickyTags: Tags; - - /** - * Tags that are overwritten by ancestor tags - */ - nonStickyTags: Tags; - - /** - * Tags with propagate true not from an ancestor - */ - propagateTags: Tags; - - /** - * Tags that are propagated from ancestors + * 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 [] */ - ancestorTags: Tags; -} + includeResourceTypes?: string[]; -/** - * Properties for removing tags - */ -export interface RemoveProps { /** - * If true prevent this tag form being set via propagation + * Higher or equal priority tags will take precedence * - * @default true + * 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 */ - blockPropagate?: boolean; -} - -/** - * Properties for Tag Manager - */ -export interface TagManagerProps { - /** - * Initial tags to set on the tag manager using TAG_DEFAULTS - */ - initialTags?: Tags; + priority?: number; } /** * TagManager facilitates a common implementation of tagging for Constructs. - * - * Each construct that wants to support tags should implement the `ITaggable` - * interface and properly pass tags to the `Resources` (Cloudformation) elements - * the `Construct` creates. The `TagManager` extends `Token` the object can be - * passed directly to `Resources` that support tag properties. - * - * There are a few standard use cases the `TagManager` supports for managing - * tags across the resources in your stack. - * - * Propagation: If you tag a resource and it has children, by default those tags - * will be propagated to the children. This is controlled by - * `TagProps.propagate`. - * - * Default a tag unless an ancestor has a value: There are situations where a - * construct author might want to set a tag value, but choose to take a parents - * value. For example, you might default `{Key: "Compliance", Value: "None"}`, - * but if a parent has `{Key: "Compliance", Value: "PCI"}` allow that parent to - * override your tag. This is can be done by setting `TagProps.sticky` to false. - * The default behavior is that child tags have precedence and `TagProps.sticky` - * defaults to true to reflect this. - * - * Overwrite: Construct authors have the need to set a tag, but only if one was - * not provided by the consumer. The most common example is the `Name` tag. - * Overwrite is for this purpose and is controlled by `TagProps.overwrite`. The - * default is `true`. - * - * Removing Tags: Tags can be removed from the local manager via `removeTag`. If - * a parent also has a tag with the same name then it can be propagated to the - * child (after removal). The user can control this `RemoveProps.blockPropagate`. By default - * this is `true` and prevents a parent tag from propagating to the child after - * the `removeTag` is invoked. However, if user wants the parent tag to - * propagate, if it is provided by a parent this can be set to `false`. */ -export class TagManager extends Token { +export class TagManager { - /** - * Checks if the object implements the `ITaggable` interface - */ - public static isTaggable(taggable: ITaggable | any): taggable is ITaggable { - return ((taggable as ITaggable).tags !== undefined); - } + private readonly tags: Tags = {}; - private static readonly DEFAULT_TAG_PROPS: TagProps = { - propagate: true, - sticky: true, - overwrite: true - }; + private readonly removedTags: {[key: string]: number} = {}; - /* - * Internally tags will have properties set - */ - private readonly _tags: FullTags = {}; - - /* - * Tags that will be removed during `tags` method - */ - private readonly blockedTags: string[] = []; - - constructor(private readonly scope: Construct, props: TagManagerProps = {}) { - super(); - - const initialTags = props.initialTags || {}; - for (const key of Object.keys(initialTags)) { - const tag = { - value: initialTags[key], - props: TagManager.DEFAULT_TAG_PROPS, - }; - this._tags[key] = tag; - } - } - - /** - * Converts the `tags` to a Token for use in lazy evaluation - */ - public resolve(_context: ResolveContext): any { - // need this for scoping - const blockedTags = this.blockedTags; - function filterTags(_tags: FullTags, filter: TagProps = {}): Tags { - const filteredTags: Tags = {}; - Object.keys(_tags).map( key => { - let filterResult = true; - const props: TagProps = _tags[key].props || {}; - if (filter.propagate !== undefined) { - filterResult = filterResult && (filter.propagate === props.propagate); - } - if (filter.sticky !== undefined) { - filterResult = filterResult && - (filter.sticky === props.sticky); - } - if (filter.overwrite !== undefined) { - filterResult = filterResult && (filter.overwrite === props.overwrite); - } - if (filterResult) { - filteredTags[key] = _tags[key].value; - } - }); - for (const key of blockedTags) { delete filteredTags[key]; } - return filteredTags; - } - - function propagatedTags(tagProviders: IConstruct[]): Tags { - const parentTags: Tags = {}; - for (const ancestor of tagProviders) { - if (TagManager.isTaggable(ancestor)) { - const tagsFrom = filterTags(ancestor.tags._tags, {propagate: true}); - Object.assign(parentTags, tagsFrom); - } - } - for (const key of blockedTags) { delete parentTags[key]; } - return parentTags; - } - - const nonStickyTags = filterTags(this._tags, {sticky: false}); - const stickyTags = filterTags(this._tags, {sticky: true}); - const ancestors = this.scope.node.ancestors(); - const ancestorTags = propagatedTags(ancestors); - const propagateTags = filterTags(this._tags, {propagate: true}); - return this.tagFormatResolve( { - ancestorTags, - nonStickyTags, - stickyTags, - propagateTags, - }); - } + constructor(private readonly tagType: TagType, private readonly resourceTypeName: string) { } /** * 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` + * @param props A `TagProps` defaulted to applyToLaunchInstances true */ - 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); + 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]; } /** * 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); + 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; } - delete this._tags[key]; + delete this.tags[key]; + this.removedTags[key] = priority; } /** - * 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; + * 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; + } + } + } + + 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) { + return false; + } + if (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!; + } + } + if (this.removedTags[key]) { + return priority >= this.removedTags[key]; } - return Object.keys(tags).map( key => ({key, value: tags[key]})); + return true; } } diff --git a/packages/@aws-cdk/cdk/lib/index.ts b/packages/@aws-cdk/cdk/lib/index.ts index 1764a441a92b6..49ffef7cdb7bf 100644 --- a/packages/@aws-cdk/cdk/lib/index.ts +++ b/packages/@aws-cdk/cdk/lib/index.ts @@ -1,3 +1,6 @@ +export * from './aspects/aspect'; +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/lib/runtime.ts b/packages/@aws-cdk/cdk/lib/runtime.ts index e8fb8ba893064..00d55a2cdcfbe 100644 --- a/packages/@aws-cdk/cdk/lib/runtime.ts +++ b/packages/@aws-cdk/cdk/lib/runtime.ts @@ -48,7 +48,7 @@ function pad(x: number) { /** * Turn a tag object into the proper CloudFormation representation */ -export function tagToCloudFormation(x: any): any { +export function cfnTagToCloudFormation(x: any): any { return { Key: x.key, Value: x.value @@ -249,7 +249,7 @@ export function validateObject(x: any): ValidationResult { return VALIDATION_SUCCESS; } -export function validateTag(x: any): ValidationResult { +export function validateCfnTag(x: any): ValidationResult { if (!canInspect(x)) { return VALIDATION_SUCCESS; } if (x.key == null || x.value == null) { 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..542692c92666d --- /dev/null +++ b/packages/@aws-cdk/cdk/test/aspects/test.tag-aspect.ts @@ -0,0 +1,157 @@ +import { Test } from 'nodeunit'; +import { RemoveTag, Resource, Stack, Tag, TagManager, TagType } from '../../lib'; + +class TaggableResource extends Resource { + public readonly tags = new TagManager(TagType.Standard, 'AWS::Fake::Resource'); + public testProperties() { + return this.properties; + } +} + +class AsgTaggableResource extends TaggableResource { + public readonly tags = new TagManager(TagType.AutoScalingGroup, 'AWS::Fake::Resource'); +} + +class MapTaggableResource extends TaggableResource { + public readonly tags = new TagManager(TagType.Map, 'AWS::Fake::Resource'); +} + +export = { + 'Tag visit all children of the applied node'(test: Test) { + const root = new Stack(); + 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 Tag('foo', 'bar')); + test.deepEqual(res.node.aspects.length, 1); + root.node.prepareTree(); + test.deepEqual(res.tags.renderTags(), [{key: 'foo', value: 'bar'}]); + test.deepEqual(res2.tags.renderTags(), [{key: 'foo', value: 'bar'}]); + test.deepEqual(map.tags.renderTags(), {foo: 'bar'}); + test.deepEqual(asg.tags.renderTags(), [{key: 'foo', value: 'bar', propagateAtLaunch: true}]); + test.done(); + }, + 'The last aspect applied takes precedence'(test: Test) { + const root = new Stack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + const res2 = new TaggableResource(res, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + res.apply(new Tag('foo', 'bar')); + res.apply(new Tag('foo', 'foobar')); + res.apply(new Tag('foo', 'baz')); + res2.apply(new Tag('foo', 'good')); + root.node.prepareTree(); + test.deepEqual(res.tags.renderTags(), [{key: 'foo', value: 'baz'}]); + test.deepEqual(res2.tags.renderTags(), [{key: 'foo', value: 'good'}]); + test.done(); + }, + 'RemoveTag will remove a tag if it exists'(test: Test) { + const root = new Stack(); + 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', + }); + root.apply(new Tag('root', 'was here')); + res.apply(new Tag('first', 'there is only 1')); + res.apply(new RemoveTag('root')); + res.apply(new RemoveTag('doesnotexist')); + root.node.prepareTree(); + + test.deepEqual(res.tags.renderTags(), [{key: 'first', value: 'there is only 1'}]); + test.deepEqual(map.tags.renderTags(), {first: 'there is only 1'}); + test.deepEqual(asg.tags.renderTags(), [{key: 'first', value: 'there is only 1', propagateAtLaunch: true}]); + test.deepEqual(res2.tags.renderTags(), [{key: 'first', value: 'there is only 1'}]); + test.done(); + }, + 'the #visit function is idempotent'(test: Test) { + const root = new Stack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + + res.apply(new Tag('foo', 'bar')); + root.node.prepareTree(); + test.deepEqual(res.tags.renderTags(), [{key: 'foo', value: 'bar'}]); + root.node.prepareTree(); + test.deepEqual(res.tags.renderTags(), [{key: 'foo', value: 'bar'}]); + root.node.prepareTree(); + test.deepEqual(res.tags.renderTags(), [{key: 'foo', value: 'bar'}]); + test.done(); + }, + 'removeTag Aspects by default will override child Tag Aspects'(test: Test) { + const root = new Stack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + const res2 = new TaggableResource(res, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + res.apply(new RemoveTag('key')); + res2.apply(new Tag('key', 'value')); + root.node.prepareTree(); + test.deepEqual(res.tags.renderTags(), undefined); + test.deepEqual(res2.tags.renderTags(), undefined); + test.done(); + }, + 'removeTag Aspects with priority 0 will not override child Tag Aspects'(test: Test) { + const root = new Stack(); + const res = new TaggableResource(root, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + const res2 = new TaggableResource(res, 'FakeResource', { + type: 'AWS::Fake::Thing', + }); + res.apply(new RemoveTag('key', {priority: 0})); + res2.apply(new Tag('key', 'value')); + root.node.prepareTree(); + test.deepEqual(res.tags.renderTags(), undefined); + test.deepEqual(res2.tags.renderTags(), [{key: 'key', value: 'value'}]); + test.done(); + }, + 'Aspects are mutually exclusive 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: 'cfn', value: 'is cool'}, + ], + }, + }); + const cfnBranch = new TaggableResource(root, 'FakeBranchB', { + type: 'AWS::Fake::Thing', + properties: { + tags: [ + {key: 'cfn', value: 'is cool'}, + ], + }, + }); + aspectBranch.apply(new Tag('aspects', 'rule')); + root.node.prepareTree(); + test.deepEqual(aspectBranch.tags.renderTags(), [{key: 'aspects', value: 'rule'}]); + test.deepEqual(cfnBranch.testProperties().tags, [{key: 'cfn', value: 'is cool'}]); + test.done(); + }, +}; diff --git a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts index 90e3cf0c5d9a9..27b08e598343e 100644 --- a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts +++ b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts @@ -325,12 +325,12 @@ export = { MyC2R3809EEAD6: { Type: 'T3' }, MyC3C2R38CE6F9F7: { Type: 'T3' }, MyResource: - { Type: 'R', - DependsOn: + { Type: 'R', + DependsOn: [ 'MyC1R1FB2A562F', - 'MyC1R2AE2B5066', - 'MyC2R3809EEAD6', - 'MyC3C2R38CE6F9F7' ] } } }); + 'MyC1R2AE2B5066', + 'MyC2R3809EEAD6', + 'MyC3C2R38CE6F9F7' ] } } }); test.done(); }, @@ -356,9 +356,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(); }, @@ -385,8 +385,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(); }, @@ -413,8 +413,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(); }, @@ -432,8 +432,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(); }, @@ -462,8 +462,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(); }, @@ -488,12 +488,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(); }, @@ -511,8 +511,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(); }, @@ -529,8 +529,8 @@ export = { test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'MyResourceType', - Properties: { PROP1: 'foo', PROP2: 'bar' } } } }); + { Type: 'MyResourceType', + Properties: { PROP1: 'foo', PROP2: 'bar' } } } }); test.done(); }, @@ -543,8 +543,8 @@ export = { test.deepEqual(stack.toCloudFormation(), { Resources: { MyResource: - { Type: 'MyResourceType', - Properties: { PROP3: 'zoo' } } } }); + { Type: 'MyResourceType', + Properties: { PROP3: 'zoo' } } } }); test.done(); }, @@ -558,10 +558,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 71af1326f6b7b..89ba6c7dac9b4 100644 --- a/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts +++ b/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts @@ -1,180 +1,104 @@ import { Test } from 'nodeunit'; -import { Construct, ITaggable, Root, TagManager } from '../../lib'; - -class ChildTagger extends Construct implements ITaggable { - public readonly tags: TagManager; - constructor(scope: Construct, id: string) { - super(scope, id); - this.tags = new TagManager(scope); - } -} - -class Child extends Construct { - constructor(scope: Construct, id: string) { - super(scope, id); - } -} +import { TagType } from '../../lib/cloudformation/resource'; +import { TagManager } from '../../lib/core/tag-manager'; export = { - 'TagManger handles tags for a Contruct Tree': { - 'setTag by default propagates to children'(test: Test) { - const root = new Root(); - const ctagger = new ChildTagger(root, 'one'); - const ctagger1 = new ChildTagger(ctagger, 'two'); - const ctagger2 = new ChildTagger(root, 'three'); - - // not taggable at all - new Child(ctagger, 'notag'); - - const tag = {key: 'Name', value: 'TheCakeIsALie'}; - ctagger.tags.setTag(tag.key, tag.value); - - const tagArray = [tag]; - for (const construct of [ctagger, ctagger1]) { - test.deepEqual(root.node.resolve(construct.tags), tagArray); - } - - test.deepEqual(root.node.resolve(ctagger2.tags), undefined); - test.done(); - }, - 'setTag with propagate false tags do not propagate'(test: Test) { - const root = new Root(); - const ctagger = new ChildTagger(root, 'one'); - const ctagger1 = new ChildTagger(ctagger, 'two'); - const ctagger2 = new ChildTagger(root, 'three'); - - // not taggable at all - new Child(ctagger, 'notag'); - - const tag = {key: 'Name', value: 'TheCakeIsALie'}; - ctagger.tags.setTag(tag.key, tag.value, {propagate: false}); - - for (const construct of [ctagger1, ctagger2]) { - test.deepEqual(root.node.resolve(construct.tags), undefined); - } - test.deepEqual(root.node.resolve(ctagger.tags)[0].key, 'Name'); - test.deepEqual(root.node.resolve(ctagger.tags)[0].value, 'TheCakeIsALie'); - test.done(); - }, - 'setTag with overwrite false does not overwrite a tag'(test: Test) { - const root = new Root(); - const ctagger = new ChildTagger(root, 'one'); - ctagger.tags.setTag('Env', 'Dev'); - ctagger.tags.setTag('Env', 'Prod', {overwrite: false}); - const result = root.node.resolve(ctagger.tags); - test.deepEqual(result, [{key: 'Env', value: 'Dev'}]); - test.done(); - }, - 'setTag with sticky false enables propagations to overwrite child tags'(test: Test) { - const root = new Root(); - const ctagger = new ChildTagger(root, 'one'); - const ctagger1 = new ChildTagger(ctagger, 'two'); - ctagger.tags.setTag('Parent', 'Is always right'); - ctagger1.tags.setTag('Parent', 'Is wrong', {sticky: false}); - const parent = root.node.resolve(ctagger.tags); - const child = root.node.resolve(ctagger1.tags); - test.deepEqual(parent, child); - test.done(); - - }, - 'tags propagate from all parents'(test: Test) { - const root = new Root(); - const ctagger = new ChildTagger(root, 'one'); - new ChildTagger(ctagger, 'two'); - const cNoTag = new Child(ctagger, 'three'); - const ctagger2 = new ChildTagger(cNoTag, 'four'); - const tag = {key: 'Name', value: 'TheCakeIsALie'}; - ctagger.tags.setTag(tag.key, tag.value, {propagate: true}); - test.deepEqual(root.node.resolve(ctagger2.tags), [tag]); + '#setTag() supports setting a tag regardless of Type'(test: Test) { + const notTaggable = new TagManager(TagType.NotTaggable, 'AWS::Resource::Type'); + notTaggable.setTag('key', 'value'); + test.deepEqual(notTaggable.renderTags(), undefined); + test.done(); + }, + '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.done(); }, - 'a tag can be removed and added back'(test: Test) { - const root = new Root(); - const ctagger = new ChildTagger(root, 'one'); - const tag = {key: 'Name', value: 'TheCakeIsALie'}; - ctagger.tags.setTag(tag.key, tag.value, {propagate: true}); - test.deepEqual(root.node.resolve(ctagger.tags), [tag]); - ctagger.tags.removeTag(tag.key); - test.deepEqual(root.node.resolve(ctagger.tags), undefined); - ctagger.tags.setTag(tag.key, tag.value, {propagate: true}); - test.deepEqual(root.node.resolve(ctagger.tags), [tag]); + '#setTag() creates the tag'(test: Test) { + const mgr = new TagManager(TagType.Standard, 'AWS::Resource::Type'); + mgr.setTag('dne', 'notanymore'); + test.deepEqual(mgr.renderTags(), [{key: 'dne', value: 'notanymore'}]); test.done(); - }, - 'removeTag removes a tag by key'(test: Test) { - const root = new Root(); - const ctagger = new ChildTagger(root, 'one'); - const ctagger1 = new ChildTagger(ctagger, 'two'); - const ctagger2 = new ChildTagger(root, 'three'); - - // not taggable at all - new Child(ctagger, 'notag'); - - const tag = {key: 'Name', value: 'TheCakeIsALie'}; - ctagger.tags.setTag(tag.key, tag.value); - ctagger.tags.removeTag('Name'); - - for (const construct of [ctagger, ctagger1, ctagger2]) { - test.deepEqual(root.node.resolve(construct.tags), undefined); - } - test.done(); - }, - 'removeTag with blockPropagate removes any propagated tags'(test: Test) { - const root = new Root(); - const ctagger = new ChildTagger(root, 'one'); - const ctagger1 = new ChildTagger(ctagger, 'two'); - ctagger.tags.setTag('Env', 'Dev'); - ctagger1.tags.removeTag('Env', {blockPropagate: true}); - const result = root.node.resolve(ctagger.tags); - test.deepEqual(result, [{key: 'Env', value: 'Dev'}]); - test.deepEqual(root.node.resolve(ctagger1.tags), undefined); + } + }, + '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'); + test.deepEqual(mgr.renderTags(), undefined); test.done(); }, - 'children can override parent propagated tags'(test: Test) { - const root = new Root(); - const ctagger = new ChildTagger(root, 'one'); - const ctagChild = new ChildTagger(ctagger, 'one'); - const tag = {key: 'BestBeach', value: 'StoneSteps'}; - const tag2 = {key: 'BestBeach', value: 'k-38'}; - ctagger.tags.setTag(tag2.key, tag2.value); - ctagger.tags.setTag(tag.key, tag.value); - ctagChild.tags.setTag(tag2.key, tag2.value); - const parentTags = root.node.resolve(ctagger.tags); - const childTags = root.node.resolve(ctagChild.tags); - test.deepEqual(parentTags, [tag]); - test.deepEqual(childTags, [tag2]); + '#setTag() overwrites the tag'(test: Test) { + const mgr = new TagManager(TagType.Standard, 'AWS::Resource::Type'); + mgr.setTag('dne', 'notanymore'); + mgr.setTag('dne', 'iwin'); + test.deepEqual(mgr.renderTags(), [{key: 'dne', value: 'iwin'}]); test.done(); - }, - 'resolve() returns all tags'(test: Test) { - const root = new Root(); - const ctagger = new ChildTagger(root, 'one'); - const ctagChild = new ChildTagger(ctagger, 'one'); - const tagsNoProp = [ - {key: 'NorthCountySpot', value: 'Tabletops'}, - {key: 'Crowded', value: 'Trestles'}, - ]; - const tagsProp = [ - {key: 'BestBeach', value: 'StoneSteps'}, - {key: 'BestWaves', value: 'Blacks'}, - ]; - for (const tag of tagsNoProp) { - ctagger.tags.setTag(tag.key, tag.value, {propagate: false}); - } - for (const tag of tagsProp) { - ctagger.tags.setTag(tag.key, tag.value); - } - const allTags = tagsNoProp.concat(tagsProp); - const cAll = ctagger.tags; - const cProp = ctagChild.tags; - - for (const tag of root.node.resolve(cAll)) { - const expectedTag = allTags.filter( (t) => (t.key === tag.key)); - test.deepEqual(expectedTag[0].value, tag.value); - } - for (const tag of root.node.resolve(cProp)) { - const expectedTag = tagsProp.filter( (t) => (t.key === tag.key)); - test.deepEqual(expectedTag[0].value, tag.value); - } + } + }, + '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.done(); }, }, + '#renderTags() handles standard, map, 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 mapper = new TagManager(TagType.Map, 'AWS::Resource::Type'); + tagged.push(standard); + tagged.push(asg); + tagged.push(mapper); + for (const res of tagged) { + res.setTag('foo', 'bar'); + res.setTag('asg', 'only', {applyToLaunchedInstances: false}); + } + test.deepEqual(standard.renderTags(), [ + {key: 'foo', value: 'bar'}, + {key: 'asg', value: 'only'}, + ]); + test.deepEqual(asg.renderTags(), [ + {key: 'foo', value: 'bar', propagateAtLaunch: true}, + {key: 'asg', value: 'only', propagateAtLaunch: false}, + ]); + test.deepEqual(mapper.renderTags(), { + foo: 'bar', + asg: 'only', + }); + 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', { + priority: 2, + }); + mgr.setTag('key', 'newVal', { + priority: 1, + }); + mgr.removeTag('key', {priority: 1}); + test.deepEqual(mgr.renderTags(), [ + {key: 'key', value: 'myVal'}, + ]); + mgr.removeTag('key', {priority: 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.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.done(); + } }; diff --git a/packages/@aws-cdk/cdk/test/test.aspect.ts b/packages/@aws-cdk/cdk/test/test.aspect.ts new file mode 100644 index 0000000000000..7681b4ccfbac4 --- /dev/null +++ b/packages/@aws-cdk/cdk/test/test.aspect.ts @@ -0,0 +1,29 @@ +import { Test } from 'nodeunit'; +import { IAspect } from '../lib/aspects/aspect'; +import { IConstruct, Root } from '../lib/core/construct'; + +class MyConstruct extends Root { + public static IsMyConstruct(x: any): x is MyConstruct { + return x.visitCounter !== undefined; + } + public visitCounter: number = 0; +} + +class VisitOnce implements IAspect { + public visit(node: IConstruct): void { + if (MyConstruct.IsMyConstruct(node)) { + node.visitCounter += 1; + } + } +} +export = { + 'Aspects are invoked only once'(test: Test) { + const root = new MyConstruct(); + root.apply(new VisitOnce()); + root.node.prepareTree(); + test.deepEqual(root.visitCounter, 1); + root.node.prepareTree(); + test.deepEqual(root.visitCounter, 1); + test.done(); + }, +}; 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 72c2878a75840..045b2e635ce5f 100644 --- a/tools/cfn2ts/lib/codegen.ts +++ b/tools/cfn2ts/lib/codegen.ts @@ -8,6 +8,8 @@ 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 = `${CORE}.TagType`; +const TAG_MANAGER = `${CORE}.TagManager`; interface Dictionary { [key: string]: T; } @@ -186,10 +188,11 @@ export default class CodeGenerator { // Static inspectors. // + const resourceTypeName = `${JSON.stringify(resourceName.specName!.fqn)}`; this.code.line('/**'); this.code.line(` * The CloudFormation resource type name for this resource class.`); this.code.line(' */'); - this.code.line(`public static readonly resourceTypeName = ${JSON.stringify(resourceName.specName!.fqn)};`); + this.code.line(`public static readonly resourceTypeName = ${resourceTypeName};`); if (spec.RequiredTransform) { this.code.line('/**'); @@ -231,6 +234,20 @@ export default class CodeGenerator { attributes.push(refAttribute); } } + // set the TagType to help format tags later + const tagEnum = tagType(spec); + if (tagEnum !== `${TAG_TYPE}.NotTaggable`) { + this.code.line(); + this.code.line('/**'); + this.code.line(' * The ``TagManager`` handles setting, removing and formatting tags'); + this.code.line(' *'); + this.code.line(' * Tags should be managed either passing them as properties during'); + this.code.line(' * initiation or by calling methods on this object. If both techniques are'); + 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});`); + } // // Constructor @@ -633,4 +650,20 @@ function tokenizableType(alternatives: string[]) { // TODO: number return false; -} \ No newline at end of file +} + +function tagType(resource: schema.ResourceType): string { + if (schema.isTaggableResource(resource)) { + const prop = resource.Properties.Tags; + if (schema.isTagPropertyStandard(prop)) { + return `${TAG_TYPE}.Standard`; + } + if (schema.isTagPropertyAutoScalingGroup(prop)) { + return `${TAG_TYPE}.AutoScalingGroup`; + } + if (schema.isTagPropertyJson(prop) || schema.isTagPropertyStringMap(prop)) { + return `${TAG_TYPE}.Map`; + } + } + return `${TAG_TYPE}.NotTaggable`; +} diff --git a/tools/cfn2ts/lib/genspec.ts b/tools/cfn2ts/lib/genspec.ts index 1d65543141817..add31b6df554d 100644 --- a/tools/cfn2ts/lib/genspec.ts +++ b/tools/cfn2ts/lib/genspec.ts @@ -87,7 +87,7 @@ export class CodeName { } } -export const TAG_NAME = new CodeName('', CORE_NAMESPACE, 'Tag'); +export const TAG_NAME = new CodeName('', CORE_NAMESPACE, 'CfnTag'); export const TOKEN_NAME = new CodeName('', CORE_NAMESPACE, 'Token'); /**