From aae14b7fd281cd209b2a0dfd39ea46f0b1295767 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Wed, 19 Jun 2019 14:42:14 +0300 Subject: [PATCH] feat(core): environment-agnostic cloud assemblies Formalize the simple use case for synthesizing cloudformation templates that are not pre-associated with a specific AWS account/region. When a CDK stack is defined without an explicit `env` configuration, or if `env.account` and/or `env.region` are set to `Aws.accountId`/`Aws.region`, the stack is said to be "environment-agnostic". This means that when a template is synthesized, we will use the CloudFormation intrinsics `AWS::AccountId` and `AWS::Region` instead of concrete account/region. The cloud assembly manifest for such stacks will indicate `aws://unknown-account/unknown-region` to represent that this stack is environment-agnostic, and tooling should rely on external configuration to determine the deployment environment. Environment-agnostic stacks have limitations. For example, their resources cannot be referenced across accounts or regions, and context providers such as SSM, AZs, VPC and Route53 lookup cannot be used since they won't know which environment to query. To faciliate the env-agnostic use case at the AWS Construct Library level, this change removes any dependency on concrete environment specification. Namely: - The AZ provider, which is now accessible through `stack.availabilityZones` will fall back to use `[ Fn::GetAZs[0], Fn::GetAZs[1] ]` in case the stack is env-agnostic. This is a safe fallback since all AWS regions have at least two AZs. - The use of the SSM context provider by the EC2 and ECS libraries to retrieve AMIs was replaced by deploy-time resolution of SSM parameters, so no fallback is required. See list of breaking API changes below. Added a few static methods to `ssm.StringParameter` to make it easier to reference values directly: * `valueFromLookup` will read a value during synthesis using the SSM context provider. * `valueForStringParameter` will return a deploy-time resolved value. * `valueForSecureStringParameter` will return a deploy-time resolved secure string value. Fixes #2866 BREAKING CHANGE: `ContextProvider` is no longer designed to be extended. Use `ContextProvider.getValue` and `ContextProvider.getKey` as utilities. * **core:** `Context.getSsmParameter` has been removed. Use `ssm.StringParameter.valueFromLookup` * **core:** `Context.getAvailabilityZones` has been removed. Use `stack.availabilityZones` * **core:** `Context.getDefaultAccount` and `getDefaultRegion` have been removed an no longer available. * **route52:** `HostedZoneProvider` has been removed. Use `HostedZone.fromLookup`. * **ec2:** `VpcNetworkProvider` has been removed. Use `Vpc.fromLookup`. * **ec2:** `ec2.MachineImage` will now resolve AMIs from SSM during deployment. * **ecs:** `ecs.EcsOptimizedAmi` will now resolve AMis from SSM during deployment. --- .../aws-apigateway/test/integ.restapi.ts | 1 + packages/@aws-cdk/aws-ec2/lib/index.ts | 2 +- .../@aws-cdk/aws-ec2/lib/machine-image.ts | 12 +- packages/@aws-cdk/aws-ec2/lib/vpc-lookup.ts | 41 +++ .../aws-ec2/lib/vpc-network-provider.ts | 85 ------ packages/@aws-cdk/aws-ec2/lib/vpc.ts | 88 ++++-- packages/@aws-cdk/aws-ec2/package.json | 2 + packages/@aws-cdk/aws-ec2/test/test.vpc.ts | 8 +- packages/@aws-cdk/aws-ecs/lib/cluster.ts | 8 +- packages/@aws-cdk/aws-ecs/package.json | 2 + .../aws-route53/lib/hosted-zone-provider.ts | 50 ---- .../@aws-cdk/aws-route53/lib/hosted-zone.ts | 35 ++- .../test/test.hosted-zone-provider.ts | 15 +- packages/@aws-cdk/aws-ssm/lib/parameter.ts | 56 +++- packages/@aws-cdk/aws-ssm/package.json | 2 + .../@aws-cdk/aws-ssm/test/test.parameter.ts | 76 ++++- packages/@aws-cdk/aws-ssm/test/test.ssm.ts | 8 - packages/@aws-cdk/cdk/lib/construct.ts | 8 + packages/@aws-cdk/cdk/lib/context-provider.ts | 124 ++++++++ packages/@aws-cdk/cdk/lib/context.ts | 282 ------------------ packages/@aws-cdk/cdk/lib/environment.ts | 20 +- packages/@aws-cdk/cdk/lib/index.ts | 2 +- packages/@aws-cdk/cdk/lib/stack.ts | 115 +++++-- packages/@aws-cdk/cdk/test/test.construct.ts | 9 +- packages/@aws-cdk/cdk/test/test.context.ts | 132 ++++---- .../@aws-cdk/cdk/test/test.environment.ts | 78 ++--- packages/@aws-cdk/cdk/test/test.stack.ts | 30 +- packages/@aws-cdk/cx-api/lib/environment.ts | 3 + 28 files changed, 645 insertions(+), 649 deletions(-) create mode 100644 packages/@aws-cdk/aws-ec2/lib/vpc-lookup.ts delete mode 100644 packages/@aws-cdk/aws-ec2/lib/vpc-network-provider.ts delete mode 100644 packages/@aws-cdk/aws-ssm/test/test.ssm.ts create mode 100644 packages/@aws-cdk/cdk/lib/context-provider.ts delete mode 100644 packages/@aws-cdk/cdk/lib/context.ts diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.restapi.ts b/packages/@aws-cdk/aws-apigateway/test/integ.restapi.ts index 689374700d7fd..91815bffd75d5 100644 --- a/packages/@aws-cdk/aws-apigateway/test/integ.restapi.ts +++ b/packages/@aws-cdk/aws-apigateway/test/integ.restapi.ts @@ -58,6 +58,7 @@ class Test extends cdk.Stack { name: 'Basic', apiKey: key, description: 'Free tier monthly usage plan', + throttle: { rateLimit: 5 }, quota: { limit: 10000, period: apigateway.Period.Month diff --git a/packages/@aws-cdk/aws-ec2/lib/index.ts b/packages/@aws-cdk/aws-ec2/lib/index.ts index d086304362bec..4bbfedd98c42e 100644 --- a/packages/@aws-cdk/aws-ec2/lib/index.ts +++ b/packages/@aws-cdk/aws-ec2/lib/index.ts @@ -4,7 +4,7 @@ export * from './machine-image'; export * from './security-group'; export * from './security-group-rule'; export * from './vpc'; -export * from './vpc-network-provider'; +export * from './vpc-lookup'; export * from './vpn'; export * from './vpc-endpoint'; diff --git a/packages/@aws-cdk/aws-ec2/lib/machine-image.ts b/packages/@aws-cdk/aws-ec2/lib/machine-image.ts index 3fe42fbd6abfb..de885d2197e7a 100644 --- a/packages/@aws-cdk/aws-ec2/lib/machine-image.ts +++ b/packages/@aws-cdk/aws-ec2/lib/machine-image.ts @@ -1,4 +1,5 @@ -import { Construct, Context, Stack, Token } from '@aws-cdk/cdk'; +import ssm = require('@aws-cdk/aws-ssm'); +import { Construct, Stack, Token } from '@aws-cdk/cdk'; /** * Interface for classes that can select an appropriate machine image to use @@ -25,7 +26,8 @@ export class WindowsImage implements IMachineImageSource { * Return the image to use in the given context */ public getImage(scope: Construct): MachineImage { - const ami = Context.getSsmParameter(scope, this.imageParameterName(this.version)); + const parameterName = this.imageParameterName(this.version); + const ami = ssm.StringParameter.valueForStringParameter(scope, parameterName); return new MachineImage(ami, new WindowsOS()); } @@ -102,7 +104,7 @@ export class AmazonLinuxImage implements IMachineImageSource { ].filter(x => x !== undefined); // Get rid of undefineds const parameterName = '/aws/service/ami-amazon-linux-latest/' + parts.join('-'); - const ami = Context.getSsmParameter(scope, parameterName); + const ami = ssm.StringParameter.valueForStringParameter(scope, parameterName); return new MachineImage(ami, new LinuxOS()); } } @@ -180,9 +182,9 @@ export class GenericLinuxImage implements IMachineImageSource { } public getImage(scope: Construct): MachineImage { - let region = Stack.of(scope).region; + const region = Stack.of(scope).region; if (Token.isUnresolved(region)) { - region = Context.getDefaultRegion(scope); + throw new Error(`Unable to determine AMI from AMI map since stack is region-agnostic`); } const ami = region !== 'test-region' ? this.amiMap[region] : 'ami-12345'; diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc-lookup.ts b/packages/@aws-cdk/aws-ec2/lib/vpc-lookup.ts new file mode 100644 index 0000000000000..573ff75538e2e --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/vpc-lookup.ts @@ -0,0 +1,41 @@ +/** + * Properties for looking up an existing VPC. + * + * The combination of properties must specify filter down to exactly one + * non-default VPC, otherwise an error is raised. + */ +export interface VpcLookupOptions { + /** + * The ID of the VPC + * + * If given, will import exactly this VPC. + * + * @default Don't filter on vpcId + */ + readonly vpcId?: string; + + /** + * The name of the VPC + * + * If given, will import the VPC with this name. + * + * @default Don't filter on vpcName + */ + readonly vpcName?: string; + + /** + * Tags on the VPC + * + * The VPC must have all of these tags + * + * @default Don't filter on tags + */ + readonly tags?: {[key: string]: string}; + + /** + * Whether to match the default VPC + * + * @default Don't care whether we return the default VPC + */ + readonly isDefault?: boolean; +} diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc-network-provider.ts b/packages/@aws-cdk/aws-ec2/lib/vpc-network-provider.ts deleted file mode 100644 index 574059f78887f..0000000000000 --- a/packages/@aws-cdk/aws-ec2/lib/vpc-network-provider.ts +++ /dev/null @@ -1,85 +0,0 @@ -import cdk = require('@aws-cdk/cdk'); -import cxapi = require('@aws-cdk/cx-api'); -import { VpcAttributes } from './vpc'; - -/** - * Properties for looking up an existing VPC. - * - * The combination of properties must specify filter down to exactly one - * non-default VPC, otherwise an error is raised. - */ -export interface VpcLookupOptions { - /** - * The ID of the VPC - * - * If given, will import exactly this VPC. - * - * @default Don't filter on vpcId - */ - readonly vpcId?: string; - - /** - * The name of the VPC - * - * If given, will import the VPC with this name. - * - * @default Don't filter on vpcName - */ - readonly vpcName?: string; - - /** - * Tags on the VPC - * - * The VPC must have all of these tags - * - * @default Don't filter on tags - */ - readonly tags?: {[key: string]: string}; - - /** - * Whether to match the default VPC - * - * @default Don't care whether we return the default VPC - */ - readonly isDefault?: boolean; -} - -/** - * Context provider to discover and import existing VPCs - */ -export class VpcNetworkProvider { - private provider: cdk.ContextProvider; - - constructor(context: cdk.Construct, options: VpcLookupOptions) { - const filter: {[key: string]: string} = options.tags || {}; - - // We give special treatment to some tags - if (options.vpcId) { filter['vpc-id'] = options.vpcId; } - if (options.vpcName) { filter['tag:Name'] = options.vpcName; } - if (options.isDefault !== undefined) { - filter.isDefault = options.isDefault ? 'true' : 'false'; - } - - this.provider = new cdk.ContextProvider(context, cxapi.VPC_PROVIDER, { filter } as cxapi.VpcContextQuery); - } - - /** - * Return the VPC import props matching the filter - */ - public get vpcProps(): VpcAttributes { - const ret: cxapi.VpcContextResponse = this.provider.getValue(DUMMY_VPC_PROPS); - return ret; - } -} - -/** - * There are returned when the provider has not supplied props yet - * - * It's only used for testing and on the first run-through. - */ -const DUMMY_VPC_PROPS: cxapi.VpcContextResponse = { - availabilityZones: ['dummy-1a', 'dummy-1b'], - vpcId: 'vpc-12345', - publicSubnetIds: ['s-12345', 's-67890'], - privateSubnetIds: ['p-12345', 'p-67890'], -}; diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc.ts b/packages/@aws-cdk/aws-ec2/lib/vpc.ts index b8bf5f146ce68..1903d194c892a 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc.ts @@ -1,12 +1,22 @@ -import cdk = require('@aws-cdk/cdk'); -import { ConcreteDependable, Construct, IConstruct, IDependable, IResource, Resource, Stack } from '@aws-cdk/cdk'; +import { + ConcreteDependable, + Construct, + ContextProvider, + DependableTrait, + IConstruct, + IDependable, + IResource, + Resource, + Stack, + Tag } from '@aws-cdk/cdk'; +import cxapi = require('@aws-cdk/cx-api'); import { CfnEIP, CfnInternetGateway, CfnNatGateway, CfnRoute, CfnVPNGateway, CfnVPNGatewayRoutePropagation } from './ec2.generated'; import { CfnRouteTable, CfnSubnet, CfnSubnetRouteTableAssociation, CfnVPC, CfnVPCGatewayAttachment } from './ec2.generated'; import { NetworkBuilder } from './network-util'; import { defaultSubnetName, ImportSubnetGroup, subnetId, subnetName } from './util'; import { GatewayVpcEndpoint, GatewayVpcEndpointAwsService, GatewayVpcEndpointOptions } from './vpc-endpoint'; import { InterfaceVpcEndpoint, InterfaceVpcEndpointOptions } from './vpc-endpoint'; -import { VpcLookupOptions, VpcNetworkProvider } from './vpc-network-provider'; +import { VpcLookupOptions } from './vpc-network-provider'; import { VpnConnection, VpnConnectionOptions, VpnConnectionType } from './vpn'; const VPC_SUBNET_SYMBOL = Symbol.for('@aws-cdk/aws-ec2.VpcSubnet'); @@ -666,15 +676,30 @@ export class Vpc extends VpcBase { /** * Import an exported VPC */ - public static fromVpcAttributes(scope: cdk.Construct, id: string, attrs: VpcAttributes): IVpc { + public static fromVpcAttributes(scope: Construct, id: string, attrs: VpcAttributes): IVpc { return new ImportedVpc(scope, id, attrs); } /** * Import an existing VPC from by querying the AWS environment this stack is deployed to. */ - public static fromLookup(scope: cdk.Construct, id: string, options: VpcLookupOptions): IVpc { - return Vpc.fromVpcAttributes(scope, id, new VpcNetworkProvider(scope, options).vpcProps); + public static fromLookup(scope: Construct, id: string, options: VpcLookupOptions): IVpc { + const filter: {[key: string]: string} = options.tags || {}; + + // We give special treatment to some tags + if (options.vpcId) { filter['vpc-id'] = options.vpcId; } + if (options.vpcName) { filter['tag:Name'] = options.vpcName; } + if (options.isDefault !== undefined) { + filter.isDefault = options.isDefault ? 'true' : 'false'; + } + + const attributes = ContextProvider.getValue(scope, { + provider: cxapi.VPC_PROVIDER, + props: { filter } as cxapi.VpcContextQuery, + dummyValue: DUMMY_VPC_PROPS + }); + + return this.fromVpcAttributes(scope, id, attributes); } /** @@ -758,9 +783,11 @@ export class Vpc extends VpcBase { * Network routing for the public subnets will be configured to allow outbound access directly via an Internet Gateway. * Network routing for the private subnets will be configured to allow outbound access via a set of resilient NAT Gateways (one per AZ). */ - constructor(scope: cdk.Construct, id: string, props: VpcProps = {}) { + constructor(scope: Construct, id: string, props: VpcProps = {}) { super(scope, id); + const stack = Stack.of(this); + // Can't have enabledDnsHostnames without enableDnsSupport if (props.enableDnsHostnames && !props.enableDnsSupport) { throw new Error('To use DNS Hostnames, DNS Support must be enabled, however, it was explicitly disabled.'); @@ -787,9 +814,9 @@ export class Vpc extends VpcBase { this.vpcDefaultSecurityGroup = this.resource.attrDefaultSecurityGroup; this.vpcIpv6CidrBlocks = this.resource.attrIpv6CidrBlocks; - this.node.applyAspect(new cdk.Tag(NAME_TAG, this.node.path)); + this.node.applyAspect(new Tag(NAME_TAG, this.node.path)); - this.availabilityZones = cdk.Context.getAvailabilityZones(this); + this.availabilityZones = stack.availabilityZones; const maxAZs = props.maxAZs !== undefined ? props.maxAZs : 3; this.availabilityZones = this.availabilityZones.slice(0, maxAZs); @@ -877,7 +904,6 @@ export class Vpc extends VpcBase { } } } - /** * Adds a new gateway endpoint to this VPC */ @@ -999,8 +1025,8 @@ export class Vpc extends VpcBase { // These values will be used to recover the config upon provider import const includeResourceTypes = [CfnSubnet.cfnResourceTypeName]; - subnet.node.applyAspect(new cdk.Tag(SUBNETNAME_TAG, subnetConfig.name, {includeResourceTypes})); - subnet.node.applyAspect(new cdk.Tag(SUBNETTYPE_TAG, subnetTypeTagValue(subnetConfig.subnetType), {includeResourceTypes})); + subnet.node.applyAspect(new Tag(SUBNETNAME_TAG, subnetConfig.name, {includeResourceTypes})); + subnet.node.applyAspect(new Tag(SUBNETTYPE_TAG, subnetTypeTagValue(subnetConfig.subnetType), {includeResourceTypes})); }); } } @@ -1049,13 +1075,13 @@ export interface SubnetProps { * * @resource AWS::EC2::Subnet */ -export class Subnet extends cdk.Resource implements ISubnet { +export class Subnet extends Resource implements ISubnet { public static isVpcSubnet(x: any): x is Subnet { return VPC_SUBNET_SYMBOL in x; } - public static fromSubnetAttributes(scope: cdk.Construct, id: string, attrs: SubnetAttributes): ISubnet { + public static fromSubnetAttributes(scope: Construct, id: string, attrs: SubnetAttributes): ISubnet { return new ImportedSubnet(scope, id, attrs); } @@ -1092,7 +1118,7 @@ export class Subnet extends cdk.Resource implements ISubnet { /** * Parts of this VPC subnet */ - public readonly dependencyElements: cdk.IDependable[] = []; + public readonly dependencyElements: IDependable[] = []; /** * The routeTableId attached to this subnet. @@ -1101,12 +1127,12 @@ export class Subnet extends cdk.Resource implements ISubnet { private readonly internetDependencies = new ConcreteDependable(); - constructor(scope: cdk.Construct, id: string, props: SubnetProps) { + constructor(scope: Construct, id: string, props: SubnetProps) { super(scope, id); Object.defineProperty(this, VPC_SUBNET_SYMBOL, { value: true }); - this.node.applyAspect(new cdk.Tag(NAME_TAG, this.node.path)); + this.node.applyAspect(new Tag(NAME_TAG, this.node.path)); this.availabilityZone = props.availabilityZone; const subnet = new CfnSubnet(this, 'Subnet', { @@ -1144,7 +1170,7 @@ export class Subnet extends cdk.Resource implements ISubnet { * @param gatewayId the logical ID (ref) of the gateway attached to your VPC * @param gatewayAttachment the gateway attachment construct to be added as a dependency */ - public addDefaultInternetRoute(gatewayId: string, gatewayAttachment: cdk.IDependable) { + public addDefaultInternetRoute(gatewayId: string, gatewayAttachment: IDependable) { const route = new CfnRoute(this, `DefaultRoute`, { routeTableId: this.routeTableId!, destinationCidrBlock: '0.0.0.0/0', @@ -1188,7 +1214,7 @@ export class PublicSubnet extends Subnet implements IPublicSubnet { return new ImportedSubnet(scope, id, attrs); } - constructor(scope: cdk.Construct, id: string, props: PublicSubnetProps) { + constructor(scope: Construct, id: string, props: PublicSubnetProps) { super(scope, id, props); } @@ -1227,7 +1253,7 @@ export class PrivateSubnet extends Subnet implements IPrivateSubnet { return new ImportedSubnet(scope, id, attrs); } - constructor(scope: cdk.Construct, id: string, props: PrivateSubnetProps) { + constructor(scope: Construct, id: string, props: PrivateSubnetProps) { super(scope, id, props); } } @@ -1244,7 +1270,7 @@ class ImportedVpc extends VpcBase { public readonly availabilityZones: string[]; public readonly vpnGatewayId?: string; - constructor(scope: cdk.Construct, id: string, props: VpcAttributes) { + constructor(scope: Construct, id: string, props: VpcAttributes) { super(scope, id); this.vpcId = props.vpcId; @@ -1299,11 +1325,11 @@ class CompositeDependable implements IDependable { constructor() { const self = this; - cdk.DependableTrait.implement(this, { + DependableTrait.implement(this, { get dependencyRoots() { const ret = []; for (const dep of self.dependables) { - ret.push(...cdk.DependableTrait.get(dep).dependencyRoots); + ret.push(...DependableTrait.get(dep).dependencyRoots); } return ret; } @@ -1330,8 +1356,8 @@ function notUndefined(x: T | undefined): x is T { return x !== undefined; } -class ImportedSubnet extends cdk.Resource implements ISubnet, IPublicSubnet, IPrivateSubnet { - public readonly internetConnectivityEstablished: cdk.IDependable = new cdk.ConcreteDependable(); +class ImportedSubnet extends Resource implements ISubnet, IPublicSubnet, IPrivateSubnet { + public readonly internetConnectivityEstablished: IDependable = new ConcreteDependable(); public readonly availabilityZone: string; public readonly subnetId: string; public readonly routeTableId?: string = undefined; @@ -1343,3 +1369,15 @@ class ImportedSubnet extends cdk.Resource implements ISubnet, IPublicSubnet, IPr this.subnetId = attrs.subnetId; } } + +/** + * There are returned when the provider has not supplied props yet + * + * It's only used for testing and on the first run-through. + */ +const DUMMY_VPC_PROPS: cxapi.VpcContextResponse = { + availabilityZones: ['dummy-1a', 'dummy-1b'], + vpcId: 'vpc-12345', + publicSubnetIds: ['s-12345', 's-67890'], + privateSubnetIds: ['p-12345', 'p-67890'], +}; diff --git a/packages/@aws-cdk/aws-ec2/package.json b/packages/@aws-cdk/aws-ec2/package.json index 23d6beb190553..87ac225bb4d57 100644 --- a/packages/@aws-cdk/aws-ec2/package.json +++ b/packages/@aws-cdk/aws-ec2/package.json @@ -71,6 +71,7 @@ }, "dependencies": { "@aws-cdk/aws-cloudwatch": "^0.34.0", + "@aws-cdk/aws-ssm": "^0.34.0", "@aws-cdk/aws-iam": "^0.34.0", "@aws-cdk/cdk": "^0.34.0", "@aws-cdk/cx-api": "^0.34.0" @@ -78,6 +79,7 @@ "homepage": "https://github.com/awslabs/aws-cdk", "peerDependencies": { "@aws-cdk/aws-cloudwatch": "^0.34.0", + "@aws-cdk/aws-ssm": "^0.34.0", "@aws-cdk/aws-iam": "^0.34.0", "@aws-cdk/cdk": "^0.34.0", "@aws-cdk/cx-api": "^0.34.0" diff --git a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts index 4600d5721f0d3..e0e19f4246443 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts @@ -1,5 +1,5 @@ import { countResources, expect, haveResource, haveResourceLike, isSuperObject } from '@aws-cdk/assert'; -import { Construct, Context, Stack, Tag } from '@aws-cdk/cdk'; +import { Construct, Stack, Tag } from '@aws-cdk/cdk'; import { Test } from 'nodeunit'; import { CfnVPC, DefaultInstanceTenancy, IVpc, SubnetType, Vpc } from '../lib'; import { exportVpc } from './export-helper'; @@ -63,7 +63,7 @@ export = { "contains the correct number of subnets"(test: Test) { const stack = getTestStack(); const vpc = new Vpc(stack, 'TheVPC'); - const zones = Context.getAvailabilityZones(stack).length; + const zones = stack.availabilityZones.length; test.equal(vpc.publicSubnets.length, zones); test.equal(vpc.privateSubnets.length, zones); test.deepEqual(stack.resolve(vpc.vpcId), { Ref: 'TheVPC92636AB0' }); @@ -109,7 +109,7 @@ export = { "with no subnets defined, the VPC should have an IGW, and a NAT Gateway per AZ"(test: Test) { const stack = getTestStack(); - const zones = Context.getAvailabilityZones(stack).length; + const zones = stack.availabilityZones.length; new Vpc(stack, 'TheVPC', { }); expect(stack).to(countResources("AWS::EC2::InternetGateway", 1)); expect(stack).to(countResources("AWS::EC2::NatGateway", zones)); @@ -186,7 +186,7 @@ export = { }, "with custom subnets, the VPC should have the right number of subnets, an IGW, and a NAT Gateway per AZ"(test: Test) { const stack = getTestStack(); - const zones = Context.getAvailabilityZones(stack).length; + const zones = stack.availabilityZones.length; new Vpc(stack, 'TheVPC', { cidr: '10.0.0.0/21', subnetConfiguration: [ diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index 9d65e43a5d059..d3981b4bc9314 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -3,7 +3,8 @@ import cloudwatch = require ('@aws-cdk/aws-cloudwatch'); import ec2 = require('@aws-cdk/aws-ec2'); import iam = require('@aws-cdk/aws-iam'); import cloudmap = require('@aws-cdk/aws-servicediscovery'); -import { Construct, Context, IResource, Resource, Stack } from '@aws-cdk/cdk'; +import ssm = require('@aws-cdk/aws-ssm'); +import { Construct, IResource, Resource, Stack } from '@aws-cdk/cdk'; import { InstanceDrainHook } from './drain-hook/instance-drain-hook'; import { CfnCluster } from './ecs.generated'; @@ -264,15 +265,14 @@ export class EcsOptimizedAmi implements ec2.IMachineImageSource { + ( this.generation === ec2.AmazonLinuxGeneration.AmazonLinux2 ? "amazon-linux-2/" : "" ) + ( this.hwType === AmiHardwareType.Gpu ? "gpu/" : "" ) + ( this.hwType === AmiHardwareType.Arm ? "arm64/" : "" ) - + "recommended"; + + "recommended/image_id"; } /** * Return the correct image */ public getImage(scope: Construct): ec2.MachineImage { - const json = Context.getSsmParameter(scope, this.amiParameterName, { defaultValue: "{\"image_id\": \"\"}" }); - const ami = JSON.parse(json).image_id; + const ami = ssm.StringParameter.valueForStringParameter(scope, this.amiParameterName); return new ec2.MachineImage(ami, new ec2.LinuxOS()); } } diff --git a/packages/@aws-cdk/aws-ecs/package.json b/packages/@aws-cdk/aws-ecs/package.json index 7daeea38840e0..1217c55f02c5f 100644 --- a/packages/@aws-cdk/aws-ecs/package.json +++ b/packages/@aws-cdk/aws-ecs/package.json @@ -81,6 +81,7 @@ "@aws-cdk/aws-cloudwatch": "^0.34.0", "@aws-cdk/aws-ec2": "^0.34.0", "@aws-cdk/aws-ecr": "^0.34.0", + "@aws-cdk/aws-ssm": "^0.34.0", "@aws-cdk/aws-elasticloadbalancing": "^0.34.0", "@aws-cdk/aws-elasticloadbalancingv2": "^0.34.0", "@aws-cdk/aws-iam": "^0.34.0", @@ -107,6 +108,7 @@ "@aws-cdk/aws-ec2": "^0.34.0", "@aws-cdk/aws-ecr": "^0.34.0", "@aws-cdk/aws-elasticloadbalancing": "^0.34.0", + "@aws-cdk/aws-ssm": "^0.34.0", "@aws-cdk/aws-elasticloadbalancingv2": "^0.34.0", "@aws-cdk/aws-iam": "^0.34.0", "@aws-cdk/aws-lambda": "^0.34.0", diff --git a/packages/@aws-cdk/aws-route53/lib/hosted-zone-provider.ts b/packages/@aws-cdk/aws-route53/lib/hosted-zone-provider.ts index 0bf6f903d8523..0106187ec492c 100644 --- a/packages/@aws-cdk/aws-route53/lib/hosted-zone-provider.ts +++ b/packages/@aws-cdk/aws-route53/lib/hosted-zone-provider.ts @@ -1,8 +1,3 @@ -import cdk = require('@aws-cdk/cdk'); -import cxapi = require('@aws-cdk/cx-api'); -import { HostedZone } from './hosted-zone'; -import { HostedZoneAttributes, IHostedZone } from './hosted-zone-ref'; - /** * Zone properties for looking up the Hosted Zone */ @@ -22,48 +17,3 @@ export interface HostedZoneProviderProps { */ readonly vpcId?: string; } - -const DEFAULT_HOSTED_ZONE: HostedZoneContextResponse = { - Id: '/hostedzone/DUMMY', - Name: 'example.com', -}; - -/** - * Context provider that will lookup the Hosted Zone ID for the given arguments - */ -export class HostedZoneProvider { - private provider: cdk.ContextProvider; - constructor(context: cdk.Construct, props: HostedZoneProviderProps) { - this.provider = new cdk.ContextProvider(context, cxapi.HOSTED_ZONE_PROVIDER, props); - } - - /** - * This method calls `findHostedZone` and returns the imported hosted zone - */ - public findAndImport(scope: cdk.Construct, id: string): IHostedZone { - return HostedZone.fromHostedZoneAttributes(scope, id, this.findHostedZone()); - } - /** - * Return the hosted zone meeting the filter - */ - public findHostedZone(): HostedZoneAttributes { - const zone = this.provider.getValue(DEFAULT_HOSTED_ZONE) as HostedZoneContextResponse; - // CDK handles the '.' at the end, so remove it here - if (zone.Name.endsWith('.')) { - zone.Name = zone.Name.substring(0, zone.Name.length - 1); - } - return { - hostedZoneId: zone.Id, - zoneName: zone.Name, - }; - } -} - -/** - * A mirror of the definition in cxapi, but can use the capital letters - * since it doesn't need to be published via JSII. - */ -interface HostedZoneContextResponse { - Id: string; - Name: string; -} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts b/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts index 1f86cfcff00fa..87d2f346b103e 100644 --- a/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts +++ b/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts @@ -1,5 +1,7 @@ import ec2 = require('@aws-cdk/aws-ec2'); -import { Construct, Lazy, Resource } from '@aws-cdk/cdk'; +import { Construct, ContextProvider, Lazy, Resource } from '@aws-cdk/cdk'; +import cxapi = require('@aws-cdk/cx-api'); +import { HostedZoneProviderProps } from './hosted-zone-provider'; import { HostedZoneAttributes, IHostedZone } from './hosted-zone-ref'; import { CaaAmazonRecord, ZoneDelegationRecord } from './record-set'; import { CfnHostedZone } from './route53.generated'; @@ -67,6 +69,37 @@ export class HostedZone extends Resource implements IHostedZone { return new Import(scope, id); } + /** + * Lookup a hosted zone in the current account/region based on query parameters. + */ + public static fromLookup(scope: Construct, id: string, query: HostedZoneProviderProps): IHostedZone { + const DEFAULT_HOSTED_ZONE: HostedZoneContextResponse = { + Id: '/hostedzone/DUMMY', + Name: 'example.com', + }; + + interface HostedZoneContextResponse { + Id: string; + Name: string; + } + + const response: HostedZoneContextResponse = ContextProvider.getValue(scope, { + provider: cxapi.HOSTED_ZONE_PROVIDER, + dummyValue: DEFAULT_HOSTED_ZONE, + props: query + }); + + // CDK handles the '.' at the end, so remove it here + if (response.Name.endsWith('.')) { + response.Name = response.Name.substring(0, response.Name.length - 1); + } + + return this.fromHostedZoneAttributes(scope, id, { + hostedZoneId: response.Id, + zoneName: response.Name, + }); + } + public readonly hostedZoneId: string; public readonly zoneName: string; public readonly hostedZoneNameServers?: string[]; diff --git a/packages/@aws-cdk/aws-route53/test/test.hosted-zone-provider.ts b/packages/@aws-cdk/aws-route53/test/test.hosted-zone-provider.ts index c9cb57a4e4aee..c83b163fc01a8 100644 --- a/packages/@aws-cdk/aws-route53/test/test.hosted-zone-provider.ts +++ b/packages/@aws-cdk/aws-route53/test/test.hosted-zone-provider.ts @@ -1,7 +1,7 @@ import { SynthUtils } from '@aws-cdk/assert'; import cdk = require('@aws-cdk/cdk'); import { Test } from 'nodeunit'; -import { HostedZone, HostedZoneAttributes, HostedZoneProvider } from '../lib'; +import { HostedZone, HostedZoneAttributes } from '../lib'; export = { 'Hosted Zone Provider': { @@ -9,7 +9,8 @@ export = { // GIVEN const stack = new cdk.Stack(undefined, 'TestStack', { env: { account: '12345', region: 'us-east-1' } }); const filter = {domainName: 'test.com'}; - new HostedZoneProvider(stack, filter).findHostedZone(); + + HostedZone.fromLookup(stack, 'Ref', filter); const missing = SynthUtils.synthesize(stack).assembly.manifest.missing!; test.ok(missing && missing.length === 1); @@ -25,22 +26,20 @@ export = { ResourceRecordSetCount: 3 }; - stack.node.setContext(missing[0].key, fakeZone); + const stack2 = new cdk.Stack(undefined, 'TestStack', { env: { account: '12345', region: 'us-east-1' } }); + stack2.node.setContext(missing[0].key, fakeZone); const cdkZoneProps: HostedZoneAttributes = { hostedZoneId: fakeZone.Id, zoneName: 'example.com', }; - const cdkZone = HostedZone.fromHostedZoneAttributes(stack, 'MyZone', cdkZoneProps); + const cdkZone = HostedZone.fromHostedZoneAttributes(stack2, 'MyZone', cdkZoneProps); // WHEN - const provider = new HostedZoneProvider(stack, filter); - const zoneProps = stack.resolve(provider.findHostedZone()); - const zoneRef = provider.findAndImport(stack, 'MyZoneProvider'); + const zoneRef = HostedZone.fromLookup(stack2, 'MyZoneProvider', filter); // THEN - test.deepEqual(zoneProps, cdkZoneProps); test.deepEqual(zoneRef.hostedZoneId, cdkZone.hostedZoneId); test.done(); }, diff --git a/packages/@aws-cdk/aws-ssm/lib/parameter.ts b/packages/@aws-cdk/aws-ssm/lib/parameter.ts index e79729cf26120..ccd405abef105 100644 --- a/packages/@aws-cdk/aws-ssm/lib/parameter.ts +++ b/packages/@aws-cdk/aws-ssm/lib/parameter.ts @@ -1,5 +1,8 @@ import iam = require('@aws-cdk/aws-iam'); -import { CfnDynamicReference, CfnDynamicReferenceService, CfnParameter, Construct, Fn, IResource, Resource, Stack, Token } from '@aws-cdk/cdk'; +import { + CfnDynamicReference, CfnDynamicReferenceService, CfnParameter, + Construct, ContextProvider, Fn, IResource, Resource, Stack, Token } from '@aws-cdk/cdk'; +import cxapi = require('@aws-cdk/cx-api'); import ssm = require('./ssm.generated'); /** @@ -223,6 +226,53 @@ export class StringParameter extends ParameterBase implements IStringParameter { return new Import(scope, id); } + /** + * Reads the value of an SSM parameter during synthesis through an + * environmental context provider. + * + * Requires that the stack this scope is defined in will have explicit + * account/region information. Otherwise, it will fail during synthesis. + */ + public static valueFromLookup(scope: Construct, parameterName: string): string { + const value = ContextProvider.getValue(scope, { + provider: cxapi.SSM_PARAMETER_PROVIDER, + props: { parameterName }, + dummyValue: `dummy-value-for-${parameterName}` + }); + + return value; + } + + /** + * Returns a token that will resolve (during deployment) to the string value of an SSM string parameter. + * @param scope Some scope within a stack + * @param parameterName The name of the SSM parameter. + * @param version The parameter version (recommended in order to ensure that the value won't change during deployment) + */ + public static valueForStringParameter(scope: Construct, parameterName: string, version?: number): string { + const stack = Stack.of(scope); + const id = makeIdentityForImportedValue(parameterName); + const exists = stack.node.tryFindChild(id) as IStringParameter; + if (exists) { return exists.stringValue; } + + return this.fromStringParameterAttributes(stack, id, { parameterName, version }).stringValue; + } + + /** + * Returns a token that will resolve (during deployment) + * @param scope Some scope within a stack + * @param parameterName The name of the SSM parameter + * @param version The parameter version (required for secure strings) + */ + public static valueForSecureStringParameter(scope: Construct, parameterName: string, version: number): string { + const stack = Stack.of(scope); + const id = makeIdentityForImportedValue(parameterName); + const exists = stack.node.tryFindChild(id) as IStringParameter; + if (exists) { return exists.stringValue; } + + return this.fromSecureStringParameterAttributes(stack, id, { parameterName, version }).stringValue; + } + public readonly parameterName: string; public readonly parameterType: string; public readonly stringValue: string; @@ -314,3 +364,7 @@ function _assertValidValue(value: string, allowedPattern: string): void { throw new Error(`The supplied value (${value}) does not match the specified allowedPattern (${allowedPattern})`); } } + +function makeIdentityForImportedValue(parameterName: string) { + return `SsmParameterValue:${parameterName}:C96584B6-F00A-464E-AD19-53AFF4B05118`; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ssm/package.json b/packages/@aws-cdk/aws-ssm/package.json index 20a8597a837f7..f094722a0d450 100644 --- a/packages/@aws-cdk/aws-ssm/package.json +++ b/packages/@aws-cdk/aws-ssm/package.json @@ -70,11 +70,13 @@ "pkglint": "^0.34.0" }, "dependencies": { + "@aws-cdk/cx-api": "^0.34.0", "@aws-cdk/aws-iam": "^0.34.0", "@aws-cdk/cdk": "^0.34.0" }, "homepage": "https://github.com/awslabs/aws-cdk", "peerDependencies": { + "@aws-cdk/cx-api": "^0.34.0", "@aws-cdk/aws-iam": "^0.34.0", "@aws-cdk/cdk": "^0.34.0" }, diff --git a/packages/@aws-cdk/aws-ssm/test/test.parameter.ts b/packages/@aws-cdk/aws-ssm/test/test.parameter.ts index 5fcaebf1b679f..95fcfa71fa285 100644 --- a/packages/@aws-cdk/aws-ssm/test/test.parameter.ts +++ b/packages/@aws-cdk/aws-ssm/test/test.parameter.ts @@ -1,6 +1,6 @@ import { expect, haveResource } from '@aws-cdk/assert'; import cdk = require('@aws-cdk/cdk'); -import { Stack } from '@aws-cdk/cdk'; +import { App, Stack } from '@aws-cdk/cdk'; import { Test } from 'nodeunit'; import ssm = require('../lib'); @@ -209,5 +209,79 @@ export = { test.deepEqual(stack.resolve(param.parameterType), 'StringList'); test.deepEqual(stack.resolve(param.stringListValue), { 'Fn::Split': [ ',', '{{resolve:ssm:MyParamName}}' ] }); test.done(); + }, + + 'fromLookup will use the SSM context provider to read value during synthesis'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'my-staq', { env: { region: 'us-east-1', account: '12344' }}); + + // WHEN + const value = ssm.StringParameter.valueFromLookup(stack, 'my-param-name'); + + // THEN + test.deepEqual(value, 'dummy-value-for-my-param-name'); + test.deepEqual(app.synth().manifest.missing, [ + { + key: 'ssm:account=12344:parameterName=my-param-name:region=us-east-1', + props: { + account: '12344', + region: 'us-east-1', + parameterName: 'my-param-name' + }, + provider: 'ssm' + } + ]); + test.done(); + }, + + 'valueForStringParameter': { + + 'returns a token that represents the SSM parameter value'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const value = ssm.StringParameter.valueForStringParameter(stack, 'my-param-name'); + + // THEN + expect(stack).toMatch({ + Parameters: { + SsmParameterValuemyparamnameC96584B6F00A464EAD1953AFF4B05118Parameter: { + Type: "AWS::SSM::Parameter::Value", + Default: "my-param-name" + } + } + }); + test.deepEqual(stack.resolve(value), { Ref: 'SsmParameterValuemyparamnameC96584B6F00A464EAD1953AFF4B05118Parameter' }); + test.done(); + }, + + 'de-dup based on parameter name'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + ssm.StringParameter.valueForStringParameter(stack, 'my-param-name'); + ssm.StringParameter.valueForStringParameter(stack, 'my-param-name'); + ssm.StringParameter.valueForStringParameter(stack, 'my-param-name-2'); + ssm.StringParameter.valueForStringParameter(stack, 'my-param-name'); + + // THEN + expect(stack).toMatch({ + Parameters: { + SsmParameterValuemyparamnameC96584B6F00A464EAD1953AFF4B05118Parameter: { + Type: "AWS::SSM::Parameter::Value", + Default: "my-param-name" + }, + SsmParameterValuemyparamname2C96584B6F00A464EAD1953AFF4B05118Parameter: { + Type: "AWS::SSM::Parameter::Value", + Default: "my-param-name-2" + } + } + }); + test.done(); + } + } }; diff --git a/packages/@aws-cdk/aws-ssm/test/test.ssm.ts b/packages/@aws-cdk/aws-ssm/test/test.ssm.ts deleted file mode 100644 index 820f6b467f38f..0000000000000 --- a/packages/@aws-cdk/aws-ssm/test/test.ssm.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Test, testCase } from 'nodeunit'; - -export = testCase({ - notTested(test: Test) { - test.ok(true, 'No tests are specified for this package.'); - test.done(); - } -}); diff --git a/packages/@aws-cdk/cdk/lib/construct.ts b/packages/@aws-cdk/cdk/lib/construct.ts index a0696ec0241c8..3a94d1eabe3d3 100644 --- a/packages/@aws-cdk/cdk/lib/construct.ts +++ b/packages/@aws-cdk/cdk/lib/construct.ts @@ -267,6 +267,10 @@ export class ConstructNode { * @param value The context value */ public setContext(key: string, value: any) { + if (Token.isUnresolved(key)) { + throw new Error(`Invalid context key "${key}". It contains unresolved tokens`); + } + if (this.children.length > 0) { const names = this.children.map(c => c.node.id); throw new Error('Cannot set context after children have been added: ' + names.join(',')); @@ -283,6 +287,10 @@ export class ConstructNode { * @returns The context value or `undefined` if there is no context value for thie key. */ public tryGetContext(key: string): any { + if (Token.isUnresolved(key)) { + throw new Error(`Invalid context key "${key}". It contains unresolved tokens`); + } + const value = this._context[key]; if (value !== undefined) { return value; } diff --git a/packages/@aws-cdk/cdk/lib/context-provider.ts b/packages/@aws-cdk/cdk/lib/context-provider.ts new file mode 100644 index 0000000000000..4353218580111 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/context-provider.ts @@ -0,0 +1,124 @@ +import { Construct } from './construct'; +import { Stack } from './stack'; +import { Token } from './token'; + +export interface GetContextKeyOptions { + /** + * The context provider to query. + */ + readonly provider: string; + + /** + * Provider-specific properties. + */ + readonly props?: { [key: string]: any }; +} + +export interface GetContextValueOptions extends GetContextKeyOptions { + /** + * The value to return if the context value was not found and a missing + * context is reported. This should be a dummy value that should preferably + * fail during deployment since it represents an invalid state. + */ + readonly dummyValue: any; +} + +export interface GetContextKeyResult { + readonly key: string; + readonly props: { [key: string]: any }; +} + +export interface GetContextValueResult { + readonly value?: any; +} + +/** + * Base class for the model side of context providers + * + * Instances of this class communicate with context provider plugins in the 'cdk + * toolkit' via context variables (input), outputting specialized queries for + * more context variables (output). + * + * ContextProvider needs access to a Construct to hook into the context mechanism. + */ +export class ContextProvider { + /** + * @returns the context key or undefined if a key cannot be rendered (due to tokens used in any of the props) + */ + public static getKey(scope: Construct, options: GetContextKeyOptions): GetContextKeyResult { + const stack = Stack.of(scope); + + const props = { + account: stack.account, + region: stack.region, + ...options.props || {}, + }; + + if (Object.values(props).find(x => Token.isUnresolved(x))) { + throw new Error( + `Cannot determine scope for context provider ${options.provider}.\n` + + `This usually happens when one or more of the provider props have unresolved tokens`); + } + + const propStrings = propsToArray(props); + return { + key: `${options.provider}:${propStrings.join(':')}`, + props + }; + } + + public static getValue(scope: Construct, options: GetContextValueOptions): any { + const stack = Stack.of(scope); + + if (Token.isUnresolved(stack.account) || Token.isUnresolved(stack.region)) { + throw new Error(`Cannot retrieve value from context provider ${options.provider} since account/region are not specified at the stack level`); + } + + const { key, props } = this.getKey(scope, options); + const value = scope.node.tryGetContext(key); + + // if context is missing, report and return a dummy value + if (value === undefined) { + stack.reportMissingContext({ key, props, provider: options.provider, }); + return options.dummyValue; + } + + return value; + } + + private constructor() { } +} + +/** + * Quote colons in all strings so that we can undo the quoting at a later point + * + * We'll use $ as a quoting character, for no particularly good reason other + * than that \ is going to lead to quoting hell when the keys are stored in JSON. + */ +function colonQuote(xs: string): string { + return xs.replace('$', '$$').replace(':', '$:'); +} + +function propsToArray(props: {[key: string]: any}, keyPrefix = ''): string[] { + const ret: string[] = []; + + for (const key of Object.keys(props)) { + switch (typeof props[key]) { + case 'object': { + ret.push(...propsToArray(props[key], `${keyPrefix}${key}.`)); + break; + } + case 'string': { + ret.push(`${keyPrefix}${key}=${colonQuote(props[key])}`); + break; + } + default: { + ret.push(`${keyPrefix}${key}=${JSON.stringify(props[key])}`); + break; + } + } + } + + ret.sort(); + return ret; +} diff --git a/packages/@aws-cdk/cdk/lib/context.ts b/packages/@aws-cdk/cdk/lib/context.ts deleted file mode 100644 index 7e85c422eb381..0000000000000 --- a/packages/@aws-cdk/cdk/lib/context.ts +++ /dev/null @@ -1,282 +0,0 @@ -import cxapi = require('@aws-cdk/cx-api'); -import { Construct } from './construct'; -import { Stack } from './stack'; -import { Token } from './token'; - -type ContextProviderProps = {[key: string]: any}; - -/** - * Methods for CDK-related context information. - */ -export class Context { - /** - * Returns the default region as passed in through the CDK CLI. - * - * @returns The default region as specified in context or `undefined` if the region is not specified. - */ - public static getDefaultRegion(scope: Construct) { return scope.node.tryGetContext(cxapi.DEFAULT_REGION_CONTEXT_KEY); } - - /** - * Returns the default account ID as passed in through the CDK CLI. - * - * @returns The default account ID as specified in context or `undefined` if the account ID is not specified. - */ - public static getDefaultAccount(scope: Construct) { return scope.node.tryGetContext(cxapi.DEFAULT_ACCOUNT_CONTEXT_KEY); } - - /** - * Returnst the list of AZs in the scope's environment (account/region). - * - * If they are not available in the context, returns a set of dummy values and - * reports them as missing, and let the CLI resolve them by calling EC2 - * `DescribeAvailabilityZones` on the target environment. - */ - public static getAvailabilityZones(scope: Construct) { - return new AvailabilityZoneProvider(scope).availabilityZones; - } - - /** - * Retrieves the value of an SSM parameter. - * @param scope Some construct scope. - * @param parameterName The name of the parameter - * @param options Options - */ - public static getSsmParameter(scope: Construct, parameterName: string, options: SsmParameterOptions = { }) { - return new SsmParameterProvider(scope, parameterName).parameterValue(options.defaultValue); - } - - private constructor() { } -} - -export interface SsmParameterOptions { - /** - * The default/dummy value to return if the SSM parameter is not available in the context. - */ - readonly defaultValue?: string; -} - -/** - * Base class for the model side of context providers - * - * Instances of this class communicate with context provider plugins in the 'cdk - * toolkit' via context variables (input), outputting specialized queries for - * more context variables (output). - * - * ContextProvider needs access to a Construct to hook into the context mechanism. - */ -export class ContextProvider { - private readonly props: ContextProviderProps; - - constructor(private readonly context: Construct, - private readonly provider: string, - props: ContextProviderProps = {}) { - - const stack = Stack.of(context); - - let account: undefined | string = stack.account; - let region: undefined | string = stack.region; - - // stack.account and stack.region will defer to deploy-time resolution - // (AWS::Region, AWS::AccountId) if user did not explicitly specify them - // when they defined the stack, but this is not good enough for - // environmental context because we need concrete values during synthesis. - if (!account || Token.isUnresolved(account)) { - account = Context.getDefaultAccount(this.context); - } - - if (!region || Token.isUnresolved(region)) { - region = Context.getDefaultRegion(this.context); - } - - // this is probably an issue. we can't have only account but no region specified - if (account && !region) { - throw new Error(`A region must be specified in order to obtain environmental context: ${provider}`); - } - - this.props = { - account, - region, - ...props, - }; - } - - public get key(): string { - const propStrings: string[] = propsToArray(this.props); - return `${this.provider}:${propStrings.join(':')}`; - } - - /** - * Read a provider value and verify it is not `null` - */ - public getValue(defaultValue: any): any { - const value = this.context.node.tryGetContext(this.key); - if (value != null) { - return value; - } - - // if account or region is not defined this is probably a test mode, so we just - // return the default value - if (!this.props.account || !this.props.region) { - this.context.node.addError(formatMissingScopeError(this.provider, this.props)); - return defaultValue; - } - - this.reportMissingContext({ - key: this.key, - provider: this.provider, - props: this.props, - }); - - return defaultValue; - } - /** - * Read a provider value, verifying it's a string - * @param defaultValue The value to return if there is no value defined for this context key - */ - public getStringValue( defaultValue: string): string { - const value = this.context.node.tryGetContext(this.key); - - if (value != null) { - if (typeof value !== 'string') { - throw new TypeError(`Expected context parameter '${this.key}' to be a string, but got '${JSON.stringify(value)}'`); - } - return value; - } - - // if scope is undefined, this is probably a test mode, so we just - // return the default value - if (!this.props.account || !this.props.region) { - this.context.node.addError(formatMissingScopeError(this.provider, this.props)); - return defaultValue; - } - - this.reportMissingContext({ - key: this.key, - provider: this.provider, - props: this.props, - }); - - return defaultValue; - } - - /** - * Read a provider value, verifying it's a list - * @param defaultValue The value to return if there is no value defined for this context key - */ - public getStringListValue(defaultValue: string[]): string[] { - const value = this.context.node.tryGetContext(this.key); - - if (value != null) { - if (!value.map) { - throw new Error(`Context value '${this.key}' is supposed to be a list, got '${JSON.stringify(value)}'`); - } - return value; - } - - // if scope is undefined, this is probably a test mode, so we just - // return the default value and report an error so this in not accidentally used - // in the toolkit - if (!this.props.account || !this.props.region) { - this.context.node.addError(formatMissingScopeError(this.provider, this.props)); - return defaultValue; - } - - this.reportMissingContext({ - key: this.key, - provider: this.provider, - props: this.props, - }); - - return defaultValue; - } - - protected reportMissingContext(report: cxapi.MissingContext) { - Stack.of(this.context).reportMissingContext(report); - } -} - -/** - * Quote colons in all strings so that we can undo the quoting at a later point - * - * We'll use $ as a quoting character, for no particularly good reason other - * than that \ is going to lead to quoting hell when the keys are stored in JSON. - */ -function colonQuote(xs: string): string { - return xs.replace('$', '$$').replace(':', '$:'); -} - -/** - * Context provider that will return the availability zones for the current account and region - */ -class AvailabilityZoneProvider { - private provider: ContextProvider; - - constructor(context: Construct) { - this.provider = new ContextProvider(context, cxapi.AVAILABILITY_ZONE_PROVIDER); - } - - /** - * Returns the context key the AZ provider looks up in the context to obtain - * the list of AZs in the current environment. - */ - public get key() { - return this.provider.key; - } - - /** - * Return the list of AZs for the current account and region - */ - public get availabilityZones(): string[] { - return this.provider.getStringListValue(['dummy1a', 'dummy1b', 'dummy1c']); - } -} - -/** - * Context provider that will read values from the SSM parameter store in the indicated account and region - */ -class SsmParameterProvider { - private provider: ContextProvider; - - constructor(context: Construct, parameterName: string) { - this.provider = new ContextProvider(context, cxapi.SSM_PARAMETER_PROVIDER, { parameterName }); - } - - /** - * Return the SSM parameter string with the indicated key - */ - public parameterValue(defaultValue = 'dummy'): any { - return this.provider.getStringValue(defaultValue); - } -} - -function formatMissingScopeError(provider: string, props: {[key: string]: string}) { - let s = `Cannot determine scope for context provider ${provider}`; - const propsString = Object.keys(props).map( key => (`${key}=${props[key]}`)); - s += ` with props: ${propsString}.`; - s += '\n'; - s += 'This usually happens when AWS credentials are not available and the default account/region cannot be determined.'; - return s; -} - -function propsToArray(props: {[key: string]: any}, keyPrefix = ''): string[] { - const ret: string[] = []; - - for (const key of Object.keys(props)) { - switch (typeof props[key]) { - case 'object': { - ret.push(...propsToArray(props[key], `${keyPrefix}${key}.`)); - break; - } - case 'string': { - ret.push(`${keyPrefix}${key}=${colonQuote(props[key])}`); - break; - } - default: { - ret.push(`${keyPrefix}${key}=${JSON.stringify(props[key])}`); - break; - } - } - } - - ret.sort(); - return ret; -} diff --git a/packages/@aws-cdk/cdk/lib/environment.ts b/packages/@aws-cdk/cdk/lib/environment.ts index 6df08fb48fac5..a4982f1703ac2 100644 --- a/packages/@aws-cdk/cdk/lib/environment.ts +++ b/packages/@aws-cdk/cdk/lib/environment.ts @@ -4,13 +4,29 @@ export interface Environment { /** * The AWS account ID for this environment. - * If not specified, the context parameter `default-account` is used. + * + * This can be either a concrete value such as `` or `Aws.accountId` which + * indicates that account ID will only be determined during deployment (it + * will resolve to the CloudFormation intrinsic `{"Ref":"AWS::AccountId"}`). + * Note that certain features, such as cross-stack references and + * environmental context providers require concerete region information and + * will cause this stack to emit synthesis errors. + * + * @default Aws.accountId which means that the stack will be account-agnostic. */ readonly account?: string; /** * The AWS region for this environment. - * If not specified, the context parameter `default-region` is used. + * + * This can be either a concrete value such as `eu-west-2` or `Aws.region` + * which indicates that account ID will only be determined during deployment + * (it will resolve to the CloudFormation intrinsic `{"Ref":"AWS::Region"}`). + * Note that certain features, such as cross-stack references and + * environmental context providers require concerete region information and + * will cause this stack to emit synthesis errors. + * + * @default Aws.region which means that the stack will be region-agnostic. */ readonly region?: string; } diff --git a/packages/@aws-cdk/cdk/lib/index.ts b/packages/@aws-cdk/cdk/lib/index.ts index ad156c94b63d4..a624d4cdf9892 100644 --- a/packages/@aws-cdk/cdk/lib/index.ts +++ b/packages/@aws-cdk/cdk/lib/index.ts @@ -29,7 +29,7 @@ export * from './arn'; export * from './stack-trace'; export * from './app'; -export * from './context'; +export * from './context-provider'; export * from './environment'; export * from './runtime'; diff --git a/packages/@aws-cdk/cdk/lib/stack.ts b/packages/@aws-cdk/cdk/lib/stack.ts index bde986884950e..04e2d40222fef 100644 --- a/packages/@aws-cdk/cdk/lib/stack.ts +++ b/packages/@aws-cdk/cdk/lib/stack.ts @@ -4,6 +4,7 @@ import fs = require('fs'); import path = require('path'); import { CLOUDFORMATION_TOKEN_RESOLVER, CloudFormationLang } from './cloudformation-lang'; import { Construct, ConstructNode, IConstruct, ISynthesisSession } from './construct'; +import { ContextProvider } from './context-provider'; import { Environment } from './environment'; import { LogicalIDs } from './logical-id'; import { resolve } from './private/resolve'; @@ -94,20 +95,46 @@ export class Stack extends Construct implements ITaggable { public readonly stackName: string; /** - * The region into which this stack will be deployed. + * The AWS region into which this stack will be deployed (e.g. `us-west-2`). * - * This will be a concrete value only if an account was specified in `env` - * when the stack was defined. Otherwise, it will be a string that resolves to - * `{ "Ref": "AWS::Region" }` + * This value is resolved according to the following rules: + * + * 1. The value provided to `env.region` when the stack is defined. This can + * either be a concerete region (e.g. `us-west-2`) or the `Aws.region` + * token. + * 3. `Aws.region`, which is represents the CloudFormation intrinsic reference + * `{ "Ref": "AWS::Region" }` encoded as a string token. + * + * Preferably, you should use the return value as an opaque string and not + * attempt to parse it to implement your logic. If you do, you must first + * check that it is a concerete value an not an unresolved token. If this + * value is an unresolved token (`Token.isUnresolved(stack.region)` returns + * `true`), this implies that the user wishes that this stack will synthesize + * into a **region-agnostic template**. In this case, your code should either + * fail (throw an error, emit a synth error using `node.addError`) or + * implement some other region-agnostic behavior. */ public readonly region: string; /** - * The account into which this stack will be deployed. + * The AWS account into which this stack will be deployed. + * + * This value is resolved according to the following rules: + * + * 1. The value provided to `env.account` when the stack is defined. This can + * either be a concerete region (e.g. `1772638347`) or the `Aws.accountId` + * token. + * 3. `Aws.accountId`, which represents the CloudFormation intrinsic reference + * `{ "Ref": "AWS::AccountId" }` encoded as a string token. * - * This will be a concrete value only if an account was specified in `env` - * when the stack was defined. Otherwise, it will be a string that resolves to - * `{ "Ref": "AWS::AccountId" }` + * Preferably, you should use the return value as an opaque string and not + * attempt to parse it to implement your logic. If you do, you must first + * check that it is a concerete value an not an unresolved token. If this + * value is an unresolved token (`Token.isUnresolved(stack.account)` returns + * `true`), this implies that the user wishes that this stack will synthesize + * into a **account-agnostic template**. In this case, your code should either + * fail (throw an error, emit a synth error using `node.addError`) or + * implement some other region-agnostic behavior. */ public readonly account: string; @@ -116,8 +143,13 @@ export class Stack extends Construct implements ITaggable { * `aws://account/region`. Use `stack.account` and `stack.region` to obtain * the specific values, no need to parse. * - * If either account or region are undefined, `unknown-account` or - * `unknown-region` will be used respectively. + * You can use this value to determine if two stacks are targeting the same + * environment. + * + * If either `stack.account` or `stack.region` are not concrete values (e.g. + * `Aws.account` or `Aws.region`) the special strings `unknown-account` and/or + * `unknown-region` will be used respectively to indicate this stack is + * region/account-agnostic. */ public readonly environment: string; @@ -303,6 +335,43 @@ export class Stack extends Construct implements ITaggable { return Arn.format(components, this); } + /** + * Returnst the list of AZs that are availability in the AWS environment + * (account/region) associated with this stack. + * + * If the stack is environment-agnostic (either account and/or region are + * tokens), this property will return an array with 2 tokens that will resolve + * at deploy-time to the first two availability zones returned from CloudFormation's + * `Fn::GetAZs` intrinsic function. + * + * If they are not available in the context, returns a set of dummy values and + * reports them as missing, and let the CLI resolve them by calling EC2 + * `DescribeAvailabilityZones` on the target environment. + */ + public get availabilityZones() { + // if account/region are tokens, we can't obtain AZs through the context + // provider, so we fallback to use Fn::GetAZs. the current lowest common + // denominator is 2 AZs across all AWS regions. + const agnostic = Token.isUnresolved(this.account) || Token.isUnresolved(this.region); + if (agnostic) { + return [ + Fn.select(0, Fn.getAZs()), + Fn.select(1, Fn.getAZs()) + ]; + } + + const value = ContextProvider.getValue(this, { + provider: cxapi.AVAILABILITY_ZONE_PROVIDER, + dummyValue: ['dummy1a', 'dummy1b', 'dummy1c'], + }); + + if (!Array.isArray(value)) { + throw new Error(`Provider ${cxapi.AVAILABILITY_ZONE_PROVIDER} expects a list`); + } + + return value; + } + /** * Given an ARN, parses it and returns components. * @@ -505,23 +574,21 @@ export class Stack extends Construct implements ITaggable { */ private parseEnvironment(env: Environment = {}) { // if an environment property is explicitly specified when the stack is - // created, it will be used as concrete values for all intents. if not, use - // tokens for account and region but they do not need to be scoped, the only - // situation in which export/fn::importvalue would work if { Ref: - // "AWS::AccountId" } is the same for provider and consumer anyway. - const region = env.region || Aws.region; + // created, it will be used. if not, use tokens for account and region but + // they do not need to be scoped, the only situation in which + // export/fn::importvalue would work if { Ref: "AWS::AccountId" } is the + // same for provider and consumer anyway. const account = env.account || Aws.accountId; + const region = env.region || Aws.region; - // temporary fix for #2853, eventually behavior will be based on #2866. - // set the cloud assembly manifest environment spec of this stack to use the - // default account/region from the toolkit in case account/region are undefined or - // unresolved (i.e. tokens). - const envAccount = !Token.isUnresolved(account) ? account : Context.getDefaultAccount(this) || 'unknown-account'; - const envRegion = !Token.isUnresolved(region) ? region : Context.getDefaultRegion(this) || 'unknown-region'; + // this is the "aws://" env specification that will be written to the cloud assembly + // manifest. it will use "unknown-account" and "unknown-region" to indicate + // environment-agnosticness. + const envAccount = !Token.isUnresolved(account) ? account : cxapi.UNKNOWN_ACCOUNT; + const envRegion = !Token.isUnresolved(region) ? region : cxapi.UNKNOWN_REGION; return { - account: account || Aws.accountId, - region: region || Aws.region, + account, region, environment: EnvironmentUtils.format(envAccount, envRegion) }; } @@ -658,7 +725,7 @@ function cfnElements(node: IConstruct, into: CfnElement[] = []): CfnElement[] { import { Arn, ArnComponents } from './arn'; import { CfnElement } from './cfn-element'; import { CfnResource, TagType } from './cfn-resource'; -import { Context } from './context'; +import { Fn } from './fn'; import { CfnReference } from './private/cfn-reference'; import { Aws, ScopedAws } from './pseudo'; import { ITaggable, TagManager } from './tag-manager'; diff --git a/packages/@aws-cdk/cdk/test/test.construct.ts b/packages/@aws-cdk/cdk/test/test.construct.ts index bbe740acdc508..31c3a246852ea 100644 --- a/packages/@aws-cdk/cdk/test/test.construct.ts +++ b/packages/@aws-cdk/cdk/test/test.construct.ts @@ -1,6 +1,6 @@ import cxapi = require('@aws-cdk/cx-api'); import { Test } from 'nodeunit'; -import { App as Root, Construct, ConstructNode, ConstructOrder, IConstruct, Lazy, ValidationError } from '../lib'; +import { App as Root, Aws, Construct, ConstructNode, ConstructOrder, IConstruct, Lazy, ValidationError } from '../lib'; // tslint:disable:variable-name // tslint:disable:max-line-length @@ -185,6 +185,13 @@ export = { test.done(); }, + 'fails if context key contains unresolved tokens'(test: Test) { + const root = new Root(); + test.throws(() => root.node.setContext(`my-${Aws.region}`, 'foo'), /Invalid context key/); + test.throws(() => root.node.tryGetContext(Aws.region), /Invalid context key/); + test.done(); + }, + 'construct.pathParts returns an array of strings of all names from root to node'(test: Test) { const tree = createTree(); test.deepEqual(tree.root.node.path, ''); diff --git a/packages/@aws-cdk/cdk/test/test.context.ts b/packages/@aws-cdk/cdk/test/test.context.ts index 25df0b72b6b2f..7746f85529771 100644 --- a/packages/@aws-cdk/cdk/test/test.context.ts +++ b/packages/@aws-cdk/cdk/test/test.context.ts @@ -1,11 +1,11 @@ -import cxapi = require('@aws-cdk/cx-api'); import { Test } from 'nodeunit'; -import { App, Construct, ConstructNode, Context, ContextProvider, Stack } from '../lib'; +import { ConstructNode, Stack } from '../lib'; +import { ContextProvider } from '../lib/context-provider'; export = { 'AvailabilityZoneProvider returns a list with dummy values if the context is not available'(test: Test) { const stack = new Stack(undefined, 'TestStack', { env: { account: '12345', region: 'us-east-1' } }); - const azs = Context.getAvailabilityZones(stack); + const azs = stack.availabilityZones; test.deepEqual(azs, ['dummy1a', 'dummy1b', 'dummy1c']); test.done(); @@ -13,13 +13,13 @@ export = { 'AvailabilityZoneProvider will return context list if available'(test: Test) { const stack = new Stack(undefined, 'TestStack', { env: { account: '12345', region: 'us-east-1' } }); - const before = Context.getAvailabilityZones(stack); + const before = stack.availabilityZones; test.deepEqual(before, [ 'dummy1a', 'dummy1b', 'dummy1c' ]); const key = expectedContextKey(stack); stack.node.setContext(key, ['us-east-1a', 'us-east-1b']); - const azs = Context.getAvailabilityZones(stack); + const azs = stack.availabilityZones; test.deepEqual(azs, ['us-east-1a', 'us-east-1b']); test.done(); @@ -27,14 +27,14 @@ export = { 'AvailabilityZoneProvider will complain if not given a list'(test: Test) { const stack = new Stack(undefined, 'TestStack', { env: { account: '12345', region: 'us-east-1' } }); - const before = Context.getAvailabilityZones(stack); + const before = stack.availabilityZones; test.deepEqual(before, [ 'dummy1a', 'dummy1b', 'dummy1c' ]); const key = expectedContextKey(stack); stack.node.setContext(key, 'not-a-list'); test.throws( - () => Context.getAvailabilityZones(stack) + () => stack.availabilityZones ); test.done(); @@ -42,20 +42,42 @@ export = { 'ContextProvider consistently generates a key'(test: Test) { const stack = new Stack(undefined, 'TestStack', { env: { account: '12345', region: 'us-east-1' } }); - const provider = new ContextProvider(stack, 'ssm', { - parameterName: 'foo', - anyStringParam: 'bar', + const key = ContextProvider.getKey(stack, { + provider: 'ssm', + props: { + parameterName: 'foo', + anyStringParam: 'bar' + }, }); - const key = provider.key; - test.deepEqual(key, 'ssm:account=12345:anyStringParam=bar:parameterName=foo:region=us-east-1'); - const complex = new ContextProvider(stack, 'vpc', { - cidrBlock: '192.168.0.16', - tags: { Name: 'MyVPC', Env: 'Preprod' }, - igw: false, + + test.deepEqual(key, { + key: 'ssm:account=12345:anyStringParam=bar:parameterName=foo:region=us-east-1', + props: { + account: '12345', + region: 'us-east-1', + parameterName: 'foo', + anyStringParam: 'bar' + } + }); + + const complexKey = ContextProvider.getKey(stack, { + provider: 'vpc', + props: { + cidrBlock: '192.168.0.16', + tags: { Name: 'MyVPC', Env: 'Preprod' }, + igw: false, + } + }); + test.deepEqual(complexKey, { + key: 'vpc:account=12345:cidrBlock=192.168.0.16:igw=false:region=us-east-1:tags.Env=Preprod:tags.Name=MyVPC', + props: { + account: '12345', + region: 'us-east-1', + cidrBlock: '192.168.0.16', + tags: { Name: 'MyVPC', Env: 'Preprod' }, + igw: false, + } }); - const complexKey = complex.key; - test.deepEqual(complexKey, - 'vpc:account=12345:cidrBlock=192.168.0.16:igw=false:region=us-east-1:tags.Env=Preprod:tags.Name=MyVPC'); test.done(); }, @@ -64,65 +86,31 @@ export = { const stack = new Stack(undefined, 'TestStack', { env: { account: '12345', region: 'us-east-1' } }); // WHEN - const provider = new ContextProvider(stack, 'provider', { - list: [ - { key: 'key1', value: 'value1' }, - { key: 'key2', value: 'value2' }, - ], + const key = ContextProvider.getKey(stack, { + provider: 'provider', + props: { + list: [ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2' }, + ], + } }); // THEN - test.equals(provider.key, 'provider:account=12345:list.0.key=key1:list.0.value=value1:list.1.key=key2:list.1.value=value2:region=us-east-1'); - - test.done(); - }, - - 'SSM parameter provider will return context values if available'(test: Test) { - const stack = new Stack(undefined, 'TestStack', { env: { account: '12345', region: 'us-east-1' } }); - Context.getSsmParameter(stack, 'test'); - const key = expectedContextKey(stack); - - stack.node.setContext(key, 'abc'); - - const ssmp = Context.getSsmParameter(stack, 'test'); - const azs = stack.resolve(ssmp); - test.deepEqual(azs, 'abc'); - - test.done(); - }, - - 'Return default values if "env" is undefined to facilitate unit tests, but also expect metadata to include "error" messages'(test: Test) { - const app = new App(); - const stack = new Stack(app, 'test-stack'); - - const child = new Construct(stack, 'ChildConstruct'); - - test.deepEqual(Context.getAvailabilityZones(stack), [ 'dummy1a', 'dummy1b', 'dummy1c' ]); - test.deepEqual(Context.getSsmParameter(child, 'foo'), 'dummy'); - - const assembly = app.synth(); - const output = assembly.getStack('test-stack'); - const metadata = output.manifest.metadata || {}; - const azError: cxapi.MetadataEntry | undefined = metadata['/test-stack'].find(x => x.type === cxapi.ERROR_METADATA_KEY); - const ssmError: cxapi.MetadataEntry | undefined = metadata['/test-stack/ChildConstruct'].find(x => x.type === cxapi.ERROR_METADATA_KEY); - - test.ok(azError && (azError.data as string).includes('Cannot determine scope for context provider availability-zones')); - test.ok(ssmError && (ssmError.data as string).includes('Cannot determine scope for context provider ssm')); + test.deepEqual(key, { + key: 'provider:account=12345:list.0.key=key1:list.0.value=value1:list.1.key=key2:list.1.value=value2:region=us-east-1', + props: { + account: '12345', + region: 'us-east-1', + list: [ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2' }, + ], + } + }); test.done(); }, - - 'fails if region is not specified in CLI context'(test: Test) { - // GIVEN - const stack = new Stack(); - - // WHEN - stack.node.setContext(cxapi.DEFAULT_ACCOUNT_CONTEXT_KEY, '1111111111'); - - // THEN - test.throws(() => Context.getAvailabilityZones(stack), /A region must be specified in order to obtain environmental context: availability-zones/); - test.done(); - } }; /** diff --git a/packages/@aws-cdk/cdk/test/test.environment.ts b/packages/@aws-cdk/cdk/test/test.environment.ts index 47fac41072d0f..a7b65fe7b555a 100644 --- a/packages/@aws-cdk/cdk/test/test.environment.ts +++ b/packages/@aws-cdk/cdk/test/test.environment.ts @@ -1,5 +1,3 @@ -import { DEFAULT_ACCOUNT_CONTEXT_KEY, DEFAULT_REGION_CONTEXT_KEY } from '@aws-cdk/cx-api'; -import cxapi = require('@aws-cdk/cx-api'); import { Test } from 'nodeunit'; import { App, Aws, Stack, Token } from '../lib'; @@ -13,20 +11,6 @@ export = { test.done(); }, - 'Even if account and region are set in context, stack.account and region returns Refs)'(test: Test) { - const app = new App(); - - app.node.setContext(DEFAULT_ACCOUNT_CONTEXT_KEY, 'my-default-account'); - app.node.setContext(DEFAULT_REGION_CONTEXT_KEY, 'my-default-region'); - - const stack = new Stack(app, 'my-stack'); - - test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); - test.deepEqual(stack.resolve(stack.region), { Ref: 'AWS::Region' }); - - test.done(); - }, - 'If only `env.region` or `env.account` are specified, Refs will be used for the other'(test: Test) { const app = new App(); @@ -43,52 +27,49 @@ export = { }, 'environment defaults': { - 'default-account-unknown-region'(test: Test) { + 'if "env" is not specified, it implies account/region agnostic'(test: Test) { // GIVEN const app = new App(); // WHEN - app.node.setContext(cxapi.DEFAULT_ACCOUNT_CONTEXT_KEY, 'my-default-account'); const stack = new Stack(app, 'stack'); // THEN - test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); // TODO: after we implement #2866 this should be 'my-default-account' + test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); test.deepEqual(stack.resolve(stack.region), { Ref: 'AWS::Region' }); test.deepEqual(app.synth().getStack(stack.stackName).environment, { - account: 'my-default-account', + account: 'unknown-account', region: 'unknown-region', - name: 'aws://my-default-account/unknown-region' + name: 'aws://unknown-account/unknown-region' }); test.done(); }, - 'default-account-explicit-region'(test: Test) { + 'only region is set'(test: Test) { // GIVEN const app = new App(); // WHEN - app.node.setContext(cxapi.DEFAULT_ACCOUNT_CONTEXT_KEY, 'my-default-account'); const stack = new Stack(app, 'stack', { env: { region: 'explicit-region' }}); // THEN - test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); // TODO: after we implement #2866 this should be 'my-default-account' + test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); test.deepEqual(stack.resolve(stack.region), 'explicit-region'); test.deepEqual(app.synth().getStack(stack.stackName).environment, { - account: 'my-default-account', + account: 'unknown-account', region: 'explicit-region', - name: 'aws://my-default-account/explicit-region' + name: 'aws://unknown-account/explicit-region' }); test.done(); }, - 'explicit-account-explicit-region'(test: Test) { + 'both "region" and "account" are set'(test: Test) { // GIVEN const app = new App(); // WHEN - app.node.setContext(cxapi.DEFAULT_ACCOUNT_CONTEXT_KEY, 'my-default-account'); const stack = new Stack(app, 'stack', { env: { account: 'explicit-account', region: 'explicit-region' @@ -106,34 +87,11 @@ export = { test.done(); }, - 'default-account-default-region'(test: Test) { + 'token-account and token-region'(test: Test) { // GIVEN const app = new App(); // WHEN - app.node.setContext(cxapi.DEFAULT_ACCOUNT_CONTEXT_KEY, 'my-default-account'); - app.node.setContext(cxapi.DEFAULT_REGION_CONTEXT_KEY, 'my-default-region'); - const stack = new Stack(app, 'stack'); - - // THEN - test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); // TODO: after we implement #2866 this should be 'my-default-account' - test.deepEqual(stack.resolve(stack.region), { Ref: 'AWS::Region' }); // TODO: after we implement #2866 this should be 'my-default-region' - test.deepEqual(app.synth().getStack(stack.stackName).environment, { - account: 'my-default-account', - region: 'my-default-region', - name: 'aws://my-default-account/my-default-region' - }); - - test.done(); - }, - - 'token-account-token-region-no-defaults'(test: Test) { - // GIVEN - const app = new App(); - - // WHEN - app.node.setContext(cxapi.DEFAULT_ACCOUNT_CONTEXT_KEY, 'my-default-account'); - app.node.setContext(cxapi.DEFAULT_REGION_CONTEXT_KEY, 'my-default-region'); const stack = new Stack(app, 'stack', { env: { account: Aws.accountId, @@ -145,15 +103,15 @@ export = { test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); test.deepEqual(stack.resolve(stack.region), { Ref: 'AWS::Region' }); test.deepEqual(app.synth().getStack(stack.stackName).environment, { - account: 'my-default-account', - region: 'my-default-region', - name: 'aws://my-default-account/my-default-region' + account: 'unknown-account', + region: 'unknown-region', + name: 'aws://unknown-account/unknown-region' }); test.done(); }, - 'token-account-token-region-with-defaults'(test: Test) { + 'token-account explicit region'(test: Test) { // GIVEN const app = new App(); @@ -161,17 +119,17 @@ export = { const stack = new Stack(app, 'stack', { env: { account: Aws.accountId, - region: Aws.region + region: 'us-east-2' } }); // THEN test.deepEqual(stack.resolve(stack.account), { Ref: 'AWS::AccountId' }); - test.deepEqual(stack.resolve(stack.region), { Ref: 'AWS::Region' }); + test.deepEqual(stack.resolve(stack.region), 'us-east-2'); test.deepEqual(app.synth().getStack(stack.stackName).environment, { account: 'unknown-account', - region: 'unknown-region', - name: 'aws://unknown-account/unknown-region' + region: 'us-east-2', + name: 'aws://unknown-account/us-east-2' }); test.done(); diff --git a/packages/@aws-cdk/cdk/test/test.stack.ts b/packages/@aws-cdk/cdk/test/test.stack.ts index 73e55363b5c98..7ea682d5d71b7 100644 --- a/packages/@aws-cdk/cdk/test/test.stack.ts +++ b/packages/@aws-cdk/cdk/test/test.stack.ts @@ -1,4 +1,3 @@ -import cxapi = require('@aws-cdk/cx-api'); import { Test } from 'nodeunit'; import { App, CfnCondition, CfnOutput, CfnParameter, CfnResource, Construct, ConstructNode, Include, Lazy, ScopedAws, Stack } from '../lib'; import { Intrinsic } from '../lib/private/intrinsic'; @@ -350,19 +349,6 @@ export = { test.done(); }, - 'stack with region supplied via context returns symbolic value'(test: Test) { - // GIVEN - const app = new App(); - - app.node.setContext(cxapi.DEFAULT_REGION_CONTEXT_KEY, 'es-norst-1'); - const stack = new Stack(app, 'Stack1'); - - // THEN - test.deepEqual(stack.resolve(stack.region), { Ref: 'AWS::Region' }); - - test.done(); - }, - 'overrideLogicalId(id) can be used to override the logical ID of a resource'(test: Test) { // GIVEN const stack = new Stack(); @@ -451,6 +437,22 @@ export = { test.throws(() => Stack.of(construct), /No stack could be identified for the construct at path/); test.done(); }, + + 'stack.availabilityZones falls back to Fn::GetAZ[0],[2] if region is not specified'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'MyStack'); + + // WHEN + const azs = stack.availabilityZones; + + // THEN + test.deepEqual(stack.resolve(azs), [ + { "Fn::Select": [ 0, { "Fn::GetAZs": "" } ] }, + { "Fn::Select": [ 1, { "Fn::GetAZs": "" } ] } + ]); + test.done(); + } }; class StackWithPostProcessor extends Stack { diff --git a/packages/@aws-cdk/cx-api/lib/environment.ts b/packages/@aws-cdk/cx-api/lib/environment.ts index cb6845f058af9..42eb7697b8662 100644 --- a/packages/@aws-cdk/cx-api/lib/environment.ts +++ b/packages/@aws-cdk/cx-api/lib/environment.ts @@ -19,6 +19,9 @@ export interface Environment { readonly region: string; } +export const UNKNOWN_ACCOUNT = 'unknown-account'; +export const UNKNOWN_REGION = 'unknown-region'; + export class EnvironmentUtils { public static parse(environment: string): Environment { const env = AWS_ENV_REGEX.exec(environment);