From fdfe8751209f274286eb2526c351103186def41a Mon Sep 17 00:00:00 2001 From: Mike Cowgill Date: Fri, 3 Aug 2018 23:39:22 -0700 Subject: [PATCH] feat(cdk): Tagging support via TagManager (#538) Fixes #91, Closes #458 (as obsolete) The TagManager is a class Construct authors can use to implement tagging consistently. The manager provides the ability to propagate tags from parents to child, override parent tags in the child, set default tags on the child that can be overwritten by parents, and block tags from parents. Adding tagging support for the Vpc and Subnet constructs. --- .../integ.asg-w-loadbalancer.expected.json | 137 +++++++++-- packages/@aws-cdk/aws-ec2/lib/vpc.ts | 54 ++++- .../aws-ec2/test/integ.vpc.expected.json | 137 +++++++++-- packages/@aws-cdk/aws-ec2/test/test.vpc.ts | 110 ++++++++- .../aws-rds/test/integ.cluster.expected.json | 97 +++++++- .../test/integ.route53.expected.json | 137 +++++++++-- packages/@aws-cdk/cdk/lib/core/construct.ts | 2 +- packages/@aws-cdk/cdk/lib/core/tag-manager.ts | 224 ++++++++++++++++++ packages/@aws-cdk/cdk/lib/index.ts | 1 + .../cdk/test/core/test.tag-manager.ts | 191 +++++++++++++++ 10 files changed, 1006 insertions(+), 84 deletions(-) create mode 100644 packages/@aws-cdk/cdk/lib/core/tag-manager.ts create mode 100644 packages/@aws-cdk/cdk/test/core/test.tag-manager.ts diff --git a/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-loadbalancer.expected.json b/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-loadbalancer.expected.json index 65b6fa4d5ddb6..848931edf69db 100644 --- a/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-loadbalancer.expected.json +++ b/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-loadbalancer.expected.json @@ -7,7 +7,12 @@ "EnableDnsHostnames": true, "EnableDnsSupport": true, "InstanceTenancy": "default", - "Tags": [] + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC" + } + ] } }, "VPCPublicSubnet1SubnetB4246D30": { @@ -18,7 +23,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1a", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PublicSubnet1" + } + ] } }, "VPCPublicSubnet1RouteTableFEE4B781": { @@ -26,7 +37,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PublicSubnet1" + } + ] } }, "VPCPublicSubnet1RouteTableAssociatioin249B4093": { @@ -57,7 +74,13 @@ }, "SubnetId": { "Ref": "VPCPublicSubnet1SubnetB4246D30" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PublicSubnet1" + } + ] } }, "VPCPublicSubnet1DefaultRoute91CEF279": { @@ -80,7 +103,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1b", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PublicSubnet2" + } + ] } }, "VPCPublicSubnet2RouteTable6F1A15F1": { @@ -88,7 +117,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PublicSubnet2" + } + ] } }, "VPCPublicSubnet2RouteTableAssociatioin766225D7": { @@ -119,7 +154,13 @@ }, "SubnetId": { "Ref": "VPCPublicSubnet2Subnet74179F39" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PublicSubnet2" + } + ] } }, "VPCPublicSubnet2DefaultRouteB7481BBA": { @@ -142,7 +183,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1c", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PublicSubnet3" + } + ] } }, "VPCPublicSubnet3RouteTable98AE0E14": { @@ -150,7 +197,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PublicSubnet3" + } + ] } }, "VPCPublicSubnet3RouteTableAssociatioinF4E24B3B": { @@ -181,7 +234,13 @@ }, "SubnetId": { "Ref": "VPCPublicSubnet3Subnet631C5E25" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PublicSubnet3" + } + ] } }, "VPCPublicSubnet3DefaultRouteA0D29D46": { @@ -204,7 +263,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1a", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PrivateSubnet1" + } + ] } }, "VPCPrivateSubnet1RouteTableBE8A6027": { @@ -212,7 +277,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PrivateSubnet1" + } + ] } }, "VPCPrivateSubnet1RouteTableAssociatioin77F7CA18": { @@ -246,7 +317,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1b", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PrivateSubnet2" + } + ] } }, "VPCPrivateSubnet2RouteTable0A19E10E": { @@ -254,7 +331,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PrivateSubnet2" + } + ] } }, "VPCPrivateSubnet2RouteTableAssociatioinC31995B4": { @@ -288,7 +371,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1c", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PrivateSubnet3" + } + ] } }, "VPCPrivateSubnet3RouteTable192186F8": { @@ -296,7 +385,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC/PrivateSubnet3" + } + ] } }, "VPCPrivateSubnet3RouteTableAssociatioin3B0B6B38": { @@ -323,7 +418,15 @@ } }, "VPCIGWB7E252D3": { - "Type": "AWS::EC2::InternetGateway" + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-integ/VPC" + } + ] + } }, "VPCVPCGW99B986DC": { "Type": "AWS::EC2::VPCGatewayAttachment", diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc.ts b/packages/@aws-cdk/aws-ec2/lib/vpc.ts index 17c2c33a368a1..fc8be40f7fe9b 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc.ts @@ -3,6 +3,12 @@ import { Obj } from '@aws-cdk/util'; import { cloudformation } from './ec2.generated'; import { NetworkBuilder } from './network-util'; import { VpcNetworkId, VpcNetworkRef, VpcSubnetId, VpcSubnetRef } from './vpc-ref'; + +/** + * Name tag constant + */ +const NAME_TAG: string = 'Name'; + /** * VpcNetworkProps allows you to specify configuration options for a VPC */ @@ -42,7 +48,7 @@ export interface VpcNetworkProps { /** * The AWS resource tags to associate with the VPC. */ - tags?: cdk.Tag[]; + tags?: cdk.Tags; /** * Define the maximum number of AZs to use in this region @@ -181,6 +187,11 @@ export interface SubnetConfiguration { * availability zone. */ name: string; + + /** + * The AWS resource tags to associate with the resource. + */ + tags?: cdk.Tags; } /** @@ -203,7 +214,7 @@ export interface SubnetConfiguration { * * } */ -export class VpcNetwork extends VpcNetworkRef { +export class VpcNetwork extends VpcNetworkRef implements cdk.ITaggable { /** * The default CIDR range used when creating VPCs. @@ -248,6 +259,11 @@ export class VpcNetwork extends VpcNetworkRef { */ public readonly isolatedSubnets: VpcSubnetRef[] = []; + /** + * Manage tags for this construct and children + */ + public readonly tags: cdk.TagManager; + /** * Maximum Number of NAT Gateways used to control cost * @@ -296,13 +312,15 @@ export class VpcNetwork extends VpcNetworkRef { throw new Error('To use DNS Hostnames, DNS Support must be enabled, however, it was explicitly disabled.'); } + this.tags = new cdk.TagManager(this, props.tags); + this.tags.setTag(NAME_TAG, this.path, { overwrite: false }); + const cidrBlock = ifUndefined(props.cidr, VpcNetwork.DEFAULT_CIDR_RANGE); this.networkBuilder = new NetworkBuilder(cidrBlock); const enableDnsHostnames = props.enableDnsHostnames == null ? true : props.enableDnsHostnames; const enableDnsSupport = props.enableDnsSupport == null ? true : props.enableDnsSupport; const instanceTenancy = props.defaultInstanceTenancy || 'default'; - const tags = props.tags || []; // Define a VPC using the provided CIDR range this.resource = new cloudformation.VPCResource(this, 'Resource', { @@ -310,7 +328,7 @@ export class VpcNetwork extends VpcNetworkRef { enableDnsHostnames, enableDnsSupport, instanceTenancy, - tags + tags: this.tags, }); this.availabilityZones = new cdk.AvailabilityZoneProvider(this).availabilityZones; @@ -336,7 +354,9 @@ export class VpcNetwork extends VpcNetworkRef { // Create an Internet Gateway and attach it if necessary if (allowOutbound) { - const igw = new cloudformation.InternetGatewayResource(this, 'IGW'); + const igw = new cloudformation.InternetGatewayResource(this, 'IGW', { + tags: new cdk.TagManager(this), + }); const att = new cloudformation.VPCGatewayAttachmentResource(this, 'VPCGW', { internetGatewayId: igw.ref, vpcId: this.resource.ref @@ -395,11 +415,12 @@ export class VpcNetwork extends VpcNetworkRef { private createSubnetResources(subnetConfig: SubnetConfiguration, cidrMask: number) { this.availabilityZones.forEach((zone, index) => { const name: string = `${subnetConfig.name}Subnet${index + 1}`; - const subnetProps = { + const subnetProps: VpcSubnetProps = { availabilityZone: zone, vpcId: this.vpcId, cidrBlock: this.networkBuilder.addSubnet(cidrMask), mapPublicIpOnLaunch: (subnetConfig.subnetType === SubnetType.Public), + tags: subnetConfig.tags, }; switch (subnetConfig.subnetType) { @@ -452,12 +473,18 @@ 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 VpcSubnetRef { +export class VpcSubnet extends VpcSubnetRef implements cdk.ITaggable { + /** * The Availability Zone the subnet is located in */ @@ -468,6 +495,11 @@ export class VpcSubnet extends VpcSubnetRef { */ public readonly subnetId: VpcSubnetId; + /** + * Manage tags for Construct and propagate to children + */ + public readonly tags: cdk.TagManager; + /** * The routeTableId attached to this subnet. */ @@ -475,16 +507,21 @@ export class VpcSubnet extends VpcSubnetRef { constructor(parent: cdk.Construct, name: string, props: VpcSubnetProps) { super(parent, name); + this.tags = new cdk.TagManager(this, props.tags); + this.tags.setTag(NAME_TAG, this.path, {overwrite: false}); + this.availabilityZone = props.availabilityZone; const subnet = new cloudformation.SubnetResource(this, 'Subnet', { vpcId: props.vpcId, cidrBlock: props.cidrBlock, availabilityZone: props.availabilityZone, mapPublicIpOnLaunch: props.mapPublicIpOnLaunch, + tags: this.tags, }); this.subnetId = subnet.ref; const table = new cloudformation.RouteTableResource(this, 'RouteTable', { vpcId: props.vpcId, + tags: new cdk.TagManager(this), }); this.routeTableId = table.ref; @@ -540,7 +577,8 @@ export class VpcPublicSubnet extends VpcSubnet { subnetId: this.subnetId, allocationId: new cloudformation.EIPResource(this, `EIP`, { domain: 'vpc' - }).eipAllocationId + }).eipAllocationId, + tags: new cdk.TagManager(this), }); return ngw.ref; } diff --git a/packages/@aws-cdk/aws-ec2/test/integ.vpc.expected.json b/packages/@aws-cdk/aws-ec2/test/integ.vpc.expected.json index 4bc476853450f..511cafc47ace0 100644 --- a/packages/@aws-cdk/aws-ec2/test/integ.vpc.expected.json +++ b/packages/@aws-cdk/aws-ec2/test/integ.vpc.expected.json @@ -7,7 +7,12 @@ "EnableDnsHostnames": true, "EnableDnsSupport": true, "InstanceTenancy": "default", - "Tags": [] + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc" + } + ] } }, "MyVpcPublicSubnet1SubnetF6608456": { @@ -18,7 +23,13 @@ "Ref": "MyVpcF9F0CA6F" }, "AvailabilityZone": "test-region-1a", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PublicSubnet1" + } + ] } }, "MyVpcPublicSubnet1RouteTableC46AB2F4": { @@ -26,7 +37,13 @@ "Properties": { "VpcId": { "Ref": "MyVpcF9F0CA6F" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PublicSubnet1" + } + ] } }, "MyVpcPublicSubnet1RouteTableAssociatioin3562612E": { @@ -57,7 +74,13 @@ }, "SubnetId": { "Ref": "MyVpcPublicSubnet1SubnetF6608456" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PublicSubnet1" + } + ] } }, "MyVpcPublicSubnet1DefaultRoute95FDF9EB": { @@ -80,7 +103,13 @@ "Ref": "MyVpcF9F0CA6F" }, "AvailabilityZone": "test-region-1b", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PublicSubnet2" + } + ] } }, "MyVpcPublicSubnet2RouteTable1DF17386": { @@ -88,7 +117,13 @@ "Properties": { "VpcId": { "Ref": "MyVpcF9F0CA6F" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PublicSubnet2" + } + ] } }, "MyVpcPublicSubnet2RouteTableAssociatioin8E74FB35": { @@ -119,7 +154,13 @@ }, "SubnetId": { "Ref": "MyVpcPublicSubnet2Subnet492B6BFB" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PublicSubnet2" + } + ] } }, "MyVpcPublicSubnet2DefaultRoute052936F6": { @@ -142,7 +183,13 @@ "Ref": "MyVpcF9F0CA6F" }, "AvailabilityZone": "test-region-1c", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PublicSubnet3" + } + ] } }, "MyVpcPublicSubnet3RouteTable15028F08": { @@ -150,7 +197,13 @@ "Properties": { "VpcId": { "Ref": "MyVpcF9F0CA6F" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PublicSubnet3" + } + ] } }, "MyVpcPublicSubnet3RouteTableAssociatioinA3FD1B71": { @@ -181,7 +234,13 @@ }, "SubnetId": { "Ref": "MyVpcPublicSubnet3Subnet57EEE236" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PublicSubnet3" + } + ] } }, "MyVpcPublicSubnet3DefaultRoute3A83AB36": { @@ -204,7 +263,13 @@ "Ref": "MyVpcF9F0CA6F" }, "AvailabilityZone": "test-region-1a", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PrivateSubnet1" + } + ] } }, "MyVpcPrivateSubnet1RouteTable8819E6E2": { @@ -212,7 +277,13 @@ "Properties": { "VpcId": { "Ref": "MyVpcF9F0CA6F" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PrivateSubnet1" + } + ] } }, "MyVpcPrivateSubnet1RouteTableAssociatioin90CF6BAB": { @@ -246,7 +317,13 @@ "Ref": "MyVpcF9F0CA6F" }, "AvailabilityZone": "test-region-1b", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PrivateSubnet2" + } + ] } }, "MyVpcPrivateSubnet2RouteTableCEDCEECE": { @@ -254,7 +331,13 @@ "Properties": { "VpcId": { "Ref": "MyVpcF9F0CA6F" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PrivateSubnet2" + } + ] } }, "MyVpcPrivateSubnet2RouteTableAssociatioin803693C0": { @@ -288,7 +371,13 @@ "Ref": "MyVpcF9F0CA6F" }, "AvailabilityZone": "test-region-1c", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PrivateSubnet3" + } + ] } }, "MyVpcPrivateSubnet3RouteTableB790927C": { @@ -296,7 +385,13 @@ "Properties": { "VpcId": { "Ref": "MyVpcF9F0CA6F" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc/PrivateSubnet3" + } + ] } }, "MyVpcPrivateSubnet3RouteTableAssociatioinFB4A6FE6": { @@ -323,7 +418,15 @@ } }, "MyVpcIGW5C4A4F63": { - "Type": "AWS::EC2::InternetGateway" + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-ec2-vpc/MyVpc" + } + ] + } }, "MyVpcVPCGW488ACE0D": { "Type": "AWS::EC2::VPCGatewayAttachment", diff --git a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts index 64204e1873016..14604eb238e7d 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts @@ -1,10 +1,9 @@ -import { countResources, expect, haveResource } from '@aws-cdk/assert'; -import { AvailabilityZoneProvider, resolve, Stack } from '@aws-cdk/cdk'; +import { countResources, expect, haveResource, isSuperObject } from '@aws-cdk/assert'; +import { AvailabilityZoneProvider, resolve, Stack, Tags } from '@aws-cdk/cdk'; import { Test } from 'nodeunit'; import { DefaultInstanceTenancy, SubnetType, VpcNetwork } from '../lib'; export = { - "When creating a VPC": { "with the default CIDR range": { @@ -16,40 +15,50 @@ export = { }, "it uses the correct network range"(test: Test) { - const stack = getTestStack(); + const stack = getTestStack(); new VpcNetwork(stack, 'TheVPC'); expect(stack).to(haveResource('AWS::EC2::VPC', { CidrBlock: VpcNetwork.DEFAULT_CIDR_RANGE, EnableDnsHostnames: true, EnableDnsSupport: true, InstanceTenancy: DefaultInstanceTenancy.Default, - Tags: [] })); test.done(); - } + }, + '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'} ]))); + test.done(); + }, + }, "with all of the properties set, it successfully sets the correct VPC properties"(test: Test) { const stack = getTestStack(); - const tag = { - key: 'testKey', - value: 'testValue' + 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: [tag] + 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, - Tags: [{ Key: tag.key, Value: tag.value }] })); + expect(stack).to(haveResource('AWS::EC2::VPC', hasTags(cfnTags))); test.done(); }, @@ -148,7 +157,7 @@ export = { } test.done(); }, - "with custom subents and natGateways = 2 there should be only to NATGW"(test: Test) { + "with custom subents and natGateways = 2 there should be only two NATGW"(test: Test) { const stack = getTestStack(); new VpcNetwork(stack, 'TheVPC', { cidr: '10.0.0.0/21', @@ -257,7 +266,54 @@ export = { } }, + 'When tagging': { + 'VPC propagated tags will be on subnet, IGW, routetables, NATGW'(test: Test) { + const stack = getTestStack(); + const tags = { + VpcType: 'Good', + }; + const noPropTags = { + BusinessUnit: 'Marketing', + }; + const allTags: Tags = {...tags, ...noPropTags}; + const vpc = new VpcNetwork(stack, 'TheVPC', { tags: allTags }); + // overwrite to set propagate + vpc.tags.setTag('BusinessUnit', 'Marketing', {propagate: false}); + expect(stack).to(haveResource("AWS::EC2::VPC", hasTags(toCfnTags(allTags)))); + const taggables = ['Subnet', 'InternetGateway', 'NatGateway', 'RouteTable']; + const propTags = toCfnTags(tags); + const noProp = toCfnTags(noPropTags); + for (const resource of taggables) { + expect(stack).to(haveResource(`AWS::EC2::${resource}`, hasTags(propTags))); + expect(stack).notTo(haveResource(`AWS::EC2::${resource}`, hasTags(noProp))); + } + test.done(); + }, + 'Subnet Name will propagate to route tables and NATGW'(test: Test) { + const stack = getTestStack(); + const vpc = new VpcNetwork(stack, 'TheVPC'); + for (const subnet of vpc.publicSubnets) { + const tag = {Key: 'Name', Value: subnet.path}; + expect(stack).to(haveResource('AWS::EC2::NatGateway', hasTags([tag]))); + expect(stack).to(haveResource('AWS::EC2::RouteTable', hasTags([tag]))); + } + for (const subnet of vpc.privateSubnets) { + const tag = {Key: 'Name', Value: subnet.path}; + expect(stack).to(haveResource('AWS::EC2::RouteTable', hasTags([tag]))); + } + test.done(); + }, + '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); + expect(stack).to(haveResource('AWS::EC2::VPC', hasTags([tag]))); + test.done(); + }, + }, 'export/import'(test: Test) { // GIVEN const stack1 = getTestStack(); @@ -280,3 +336,33 @@ export = { function getTestStack(): Stack { return new Stack(undefined, 'TestStack', { env: { account: '123456789012', region: 'us-east-1' } }); } + +function toCfnTags(tags: Tags): Array<{Key: string, Value: string}> { + return Object.keys(tags).map( key => { + return {Key: key, Value: tags[key]}; + }); +} + +function hasTags(expectedTags: Array<{Key: string, Value: string}>): (props: any) => boolean { + return (props: any) => { + try { + const tags = props.Tags; + const actualTags = tags.filter( (tag: {Key: string, Value: string}) => { + for (const expectedTag of expectedTags) { + if (isSuperObject(expectedTag, tag)) { + return true; + } else { + continue; + } + } + // no values in array so expecting empty + return false; + }); + return actualTags.length === expectedTags.length; + } catch (e) { + // tslint:disable-next-line:no-console + console.error('Invalid Tags array in ', props); + throw e; + } + }; +} diff --git a/packages/@aws-cdk/aws-rds/test/integ.cluster.expected.json b/packages/@aws-cdk/aws-rds/test/integ.cluster.expected.json index 4bd5e110dfb43..d7c0ecd373561 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.cluster.expected.json +++ b/packages/@aws-cdk/aws-rds/test/integ.cluster.expected.json @@ -7,7 +7,12 @@ "EnableDnsHostnames": true, "EnableDnsSupport": true, "InstanceTenancy": "default", - "Tags": [] + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC" + } + ] } }, "VPCPublicSubnet1SubnetB4246D30": { @@ -18,7 +23,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1a", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC/PublicSubnet1" + } + ] } }, "VPCPublicSubnet1RouteTableFEE4B781": { @@ -26,7 +37,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC/PublicSubnet1" + } + ] } }, "VPCPublicSubnet1RouteTableAssociatioin249B4093": { @@ -57,7 +74,13 @@ }, "SubnetId": { "Ref": "VPCPublicSubnet1SubnetB4246D30" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC/PublicSubnet1" + } + ] } }, "VPCPublicSubnet1DefaultRoute91CEF279": { @@ -80,7 +103,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1b", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC/PublicSubnet2" + } + ] } }, "VPCPublicSubnet2RouteTable6F1A15F1": { @@ -88,7 +117,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC/PublicSubnet2" + } + ] } }, "VPCPublicSubnet2RouteTableAssociatioin766225D7": { @@ -119,7 +154,13 @@ }, "SubnetId": { "Ref": "VPCPublicSubnet2Subnet74179F39" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC/PublicSubnet2" + } + ] } }, "VPCPublicSubnet2DefaultRouteB7481BBA": { @@ -142,7 +183,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1a", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC/PrivateSubnet1" + } + ] } }, "VPCPrivateSubnet1RouteTableBE8A6027": { @@ -150,7 +197,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC/PrivateSubnet1" + } + ] } }, "VPCPrivateSubnet1RouteTableAssociatioin77F7CA18": { @@ -184,7 +237,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1b", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC/PrivateSubnet2" + } + ] } }, "VPCPrivateSubnet2RouteTable0A19E10E": { @@ -192,7 +251,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC/PrivateSubnet2" + } + ] } }, "VPCPrivateSubnet2RouteTableAssociatioinC31995B4": { @@ -219,7 +284,15 @@ } }, "VPCIGWB7E252D3": { - "Type": "AWS::EC2::InternetGateway" + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-integ/VPC" + } + ] + } }, "VPCVPCGW99B986DC": { "Type": "AWS::EC2::VPCGatewayAttachment", diff --git a/packages/@aws-cdk/aws-route53/test/integ.route53.expected.json b/packages/@aws-cdk/aws-route53/test/integ.route53.expected.json index 6383b4c17ebf5..4da2fb5686c5f 100644 --- a/packages/@aws-cdk/aws-route53/test/integ.route53.expected.json +++ b/packages/@aws-cdk/aws-route53/test/integ.route53.expected.json @@ -7,7 +7,12 @@ "EnableDnsHostnames": true, "EnableDnsSupport": true, "InstanceTenancy": "default", - "Tags": [] + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC" + } + ] } }, "VPCPublicSubnet1SubnetB4246D30": { @@ -18,7 +23,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1a", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PublicSubnet1" + } + ] } }, "VPCPublicSubnet1RouteTableFEE4B781": { @@ -26,7 +37,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PublicSubnet1" + } + ] } }, "VPCPublicSubnet1RouteTableAssociatioin249B4093": { @@ -57,7 +74,13 @@ }, "SubnetId": { "Ref": "VPCPublicSubnet1SubnetB4246D30" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PublicSubnet1" + } + ] } }, "VPCPublicSubnet1DefaultRoute91CEF279": { @@ -80,7 +103,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1b", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PublicSubnet2" + } + ] } }, "VPCPublicSubnet2RouteTable6F1A15F1": { @@ -88,7 +117,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PublicSubnet2" + } + ] } }, "VPCPublicSubnet2RouteTableAssociatioin766225D7": { @@ -119,7 +154,13 @@ }, "SubnetId": { "Ref": "VPCPublicSubnet2Subnet74179F39" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PublicSubnet2" + } + ] } }, "VPCPublicSubnet2DefaultRouteB7481BBA": { @@ -142,7 +183,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1c", - "MapPublicIpOnLaunch": true + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PublicSubnet3" + } + ] } }, "VPCPublicSubnet3RouteTable98AE0E14": { @@ -150,7 +197,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PublicSubnet3" + } + ] } }, "VPCPublicSubnet3RouteTableAssociatioinF4E24B3B": { @@ -181,7 +234,13 @@ }, "SubnetId": { "Ref": "VPCPublicSubnet3Subnet631C5E25" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PublicSubnet3" + } + ] } }, "VPCPublicSubnet3DefaultRouteA0D29D46": { @@ -204,7 +263,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1a", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PrivateSubnet1" + } + ] } }, "VPCPrivateSubnet1RouteTableBE8A6027": { @@ -212,7 +277,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PrivateSubnet1" + } + ] } }, "VPCPrivateSubnet1RouteTableAssociatioin77F7CA18": { @@ -246,7 +317,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1b", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PrivateSubnet2" + } + ] } }, "VPCPrivateSubnet2RouteTable0A19E10E": { @@ -254,7 +331,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PrivateSubnet2" + } + ] } }, "VPCPrivateSubnet2RouteTableAssociatioinC31995B4": { @@ -288,7 +371,13 @@ "Ref": "VPCB9E5F0B4" }, "AvailabilityZone": "test-region-1c", - "MapPublicIpOnLaunch": false + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PrivateSubnet3" + } + ] } }, "VPCPrivateSubnet3RouteTable192186F8": { @@ -296,7 +385,13 @@ "Properties": { "VpcId": { "Ref": "VPCB9E5F0B4" - } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC/PrivateSubnet3" + } + ] } }, "VPCPrivateSubnet3RouteTableAssociatioin3B0B6B38": { @@ -323,7 +418,15 @@ } }, "VPCIGWB7E252D3": { - "Type": "AWS::EC2::InternetGateway" + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-route53-integ/VPC" + } + ] + } }, "VPCVPCGW99B986DC": { "Type": "AWS::EC2::VPCGatewayAttachment", diff --git a/packages/@aws-cdk/cdk/lib/core/construct.ts b/packages/@aws-cdk/cdk/lib/core/construct.ts index 16fd813a89130..97e08df2cc486 100644 --- a/packages/@aws-cdk/cdk/lib/core/construct.ts +++ b/packages/@aws-cdk/cdk/lib/core/construct.ts @@ -276,7 +276,7 @@ export class Construct { * @param to The construct to return the path components relative to, or * the entire list of ancestors (including root) if omitted. */ - protected ancestors(upTo?: Construct): Construct[] { + public ancestors(upTo?: Construct): Construct[] { const ret = new Array(); let curr: Construct | undefined = this; diff --git a/packages/@aws-cdk/cdk/lib/core/tag-manager.ts b/packages/@aws-cdk/cdk/lib/core/tag-manager.ts new file mode 100644 index 0000000000000..7d44587d90c57 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/core/tag-manager.ts @@ -0,0 +1,224 @@ +import { Tag } from '../cloudformation/tag'; +import { Construct } from './construct'; +import { Token } from './tokens'; + +/** + * ITaggable indicates a entity manages tags via the `tags` property + */ +export interface ITaggable { + tags: TagManager, +} + +/** + * Properties Tags is a dictionary of tags as strings + */ +export type Tags = { [key: string]: string }; + +/** + * A an object of tags with value and properties + * + * This is used internally but not exported + */ +interface FullTags { + [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 + */ + propagate?: boolean; + + /** + * If set propagated tags from parents will not overwrite the tag + * + * @default true + */ + sticky?: boolean; + + /** + * If set this tag will overwrite existing tags + * + * @default true + */ + overwrite?: boolean; +} + +/** + * Properties for removing tags + */ +export interface RemoveProps { + /** + * If true prevent this tag form being set via propagation + * + * @default true + */ + blockPropagate?: boolean; +} + +/** + * 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 using `toCloudformation()` for lazy evaluations + */ +export class TagManager extends Token { + + public static readonly DEFAULT_TAG_PROPS: TagProps = { + propagate: true, + sticky: true, + overwrite: true + }; + + /** + * Checks if the object implements the `ITaggable` interface + */ + public static isTaggable(taggable: ITaggable | any): taggable is ITaggable { + return ((taggable as ITaggable).tags !== undefined); + } + + /* + * Internally tags will have properties set + */ + private readonly _tags: FullTags = {}; + + /* + * Tags that will be reomved during `tags` method + */ + private readonly blockedTags: string[] = []; + + constructor(private readonly parent: Construct, initialTags: Tags = {}) { + super(); + for (const key of Object.keys(initialTags)) { + const tag = {value: initialTags[key], + props: {propagate: true, sticky: true}}; + this._tags[key] = tag; + } + } + + /** + * Converts the `tags` to a Token for use in lazy evaluation + */ + public resolve(): any { + return this.toArray(); + } + + /** + * Adds the specified tag to the array of tags + * + * @param key The key value of the tag + * @param value The value value of the tag + * @param props A `TagProps` object for the tag @default `TagManager.DEFAULT_TAG_PROPS` + */ + public setTag(key: string, value: string, tagProps: TagProps = {} ): void { + const props = {...TagManager.DEFAULT_TAG_PROPS, ...tagProps}; + if (props.overwrite === false) { + this._tags[key] = this._tags[key] || {value, props}; + } else { + this._tags[key] = {value, props}; + } + const index = this.blockedTags.indexOf(key); + if (index > -1) { + this.blockedTags.splice(index, 1); + } + } + + /** + * Removes the specified tag from the array if it exists + * + * @param key The key of the tag to remove + */ + public removeTag(key: string, props: RemoveProps = {blockPropagate: true}): void { + if (props.blockPropagate) { + this.blockedTags.push(key); + } + delete this._tags[key]; + } + + /** + * Check that a key exists including known propagated tags + * + * This checks the tags known at the time method of invocation. If tags are + * added or removed after this method is invoked the result can change. + * + * @param key The key of the tag to check existence + */ + public hasTag(key: string): boolean { + return this.tags[key] !== undefined; + } + + /** + * Returns tags that meet the criteria of the filter + * + * The function operates only tags local to this contsturct. The function + * provides the ability to filter tags baseed on the `TagProps`. + * + * @param filter The tag props to filter tags + */ + private filterTags(filter: TagProps = {}): Tags { + const tags: Tags = {}; + Object.keys(this._tags).map( key => { + let filterResult = true; + const props: TagProps = this._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) { + tags[key] = this._tags[key].value; + } + }); + return tags; + } + + /** + * Returns a deep copy of the tags object and current propagated tags + * + * This takes into account blockedTags and propagationOverride tags. + */ + private get tags(): Tags { + const propOverwrite = this.filterTags({sticky: false}); + const nonOverwrite = this.filterTags({sticky: true}); + const tags = {...propOverwrite, ...this.propagatedTags(), ...nonOverwrite}; + for (const key of this.blockedTags) { delete tags[key]; } + return tags; + } + + /** + * Returns a deep copy of the tags as `Array<{key: string, value: any}>` + */ + private toArray(): Tag[] { + const tags = this.tags; + return Object.keys(tags).map( key => ({key, value: tags[key]})); + } + + /** + * Retrieve all propagated tags from all ancestors + * + * This retrieves tags from parents but not local tags + */ + private propagatedTags(): Tags { + const tags: Tags = {}; + const ancestors: Construct[] = this.parent.ancestors(); + ancestors.push(this.parent); + for (const ancestor of ancestors) { + if (TagManager.isTaggable(ancestor)) { + const tagsFrom = ancestor.tags.filterTags({propagate: true}); + Object.assign(tags, tagsFrom); + } + } + return tags; + } +} diff --git a/packages/@aws-cdk/cdk/lib/index.ts b/packages/@aws-cdk/cdk/lib/index.ts index 4a1167345295f..0f78d369d02d9 100644 --- a/packages/@aws-cdk/cdk/lib/index.ts +++ b/packages/@aws-cdk/cdk/lib/index.ts @@ -1,5 +1,6 @@ export * from './core/construct'; export * from './core/tokens'; +export * from './core/tag-manager'; export * from './core/jsx'; export * from './cloudformation/cloudformation-json'; diff --git a/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts b/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts new file mode 100644 index 0000000000000..f0a422c4123d4 --- /dev/null +++ b/packages/@aws-cdk/cdk/test/core/test.tag-manager.ts @@ -0,0 +1,191 @@ +import { Test } from 'nodeunit'; +import { Construct, Root } from '../../lib/core/construct'; +import { ITaggable, TagManager } from '../../lib/core/tag-manager'; + +class ChildTagger extends Construct implements ITaggable { + public readonly tags: TagManager; + constructor(parent: Construct, name: string) { + super(parent, name); + this.tags = new TagManager(parent); + } +} +class Child extends Construct { + constructor(parent: Construct, name: string) { + super(parent, name); + } +} + +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(construct.tags.resolve(), tagArray); + } + + test.deepEqual(ctagger2.tags.resolve().length, 0); + 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(construct.tags.resolve().length, 0); + } + test.deepEqual(ctagger.tags.resolve()[0].key, 'Name'); + test.deepEqual(ctagger.tags.resolve()[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 = ctagger.tags.resolve(); + 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 = ctagger.tags.resolve(); + const child = ctagger1.tags.resolve(); + 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(ctagger2.tags.resolve(), [tag]); + 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}); + ctagger.tags.removeTag(tag.key); + test.deepEqual(ctagger.tags.hasTag('Name'), false); + ctagger.tags.setTag(tag.key, tag.value, {propagate: true}); + test.deepEqual(ctagger.tags.hasTag('Name'), true); + 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(construct.tags.resolve().length, 0); + } + 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 = ctagger.tags.resolve(); + test.ok(ctagger.tags.hasTag('Env')); + test.deepEqual(ctagger1.tags.hasTag('Env'), false); + test.deepEqual(result, [{key: 'Env', value: 'Dev'}]); + test.deepEqual(ctagger1.tags.resolve(), []); + 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 = ctagger.tags.resolve(); + const childTags = ctagChild.tags.resolve(); + test.deepEqual(parentTags, [tag]); + test.deepEqual(childTags, [tag2]); + test.done(); + }, + 'hasTag returns whether the tag exists'(test: Test) { + const root = new Root(); + const ctagger = new ChildTagger(root, 'one'); + const tag = {key: 'BestBeach', value: 'StoneSteps'}; + ctagger.tags.setTag(tag.key, tag.value); + test.ok(ctagger.tags.hasTag('BestBeach')); + test.ok(!ctagger.tags.hasTag('PollutedBeach')); + 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 cAll.resolve()) { + const expectedTag = allTags.filter( (t) => (t.key === tag.key)); + test.deepEqual(expectedTag[0].value, tag.value); + } + for (const tag of cProp.resolve()) { + const expectedTag = tagsProp.filter( (t) => (t.key === tag.key)); + test.deepEqual(expectedTag[0].value, tag.value); + } + test.done(); + }, + }, +};