diff --git a/packages/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.ts b/packages/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.ts index 6b36c01db3f8a..d8222699ecd8b 100644 --- a/packages/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.ts +++ b/packages/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.ts @@ -105,7 +105,7 @@ export class PipelineDeployStackAction extends cdk.Construct { constructor(scope: cdk.Construct, id: string, props: PipelineDeployStackActionProps) { super(scope, id); - if (!cdk.environmentEquals(props.stack.env, cdk.Stack.of(this).env)) { + if (props.stack.environment !== cdk.Stack.of(this).environment) { // FIXME: Add the necessary to extend to stacks in a different account throw new Error(`Cross-environment deployment is not supported`); } @@ -125,8 +125,8 @@ export class PipelineDeployStackAction extends cdk.Construct { actionName: 'ChangeSet', changeSetName, runOrder: createChangeSetRunOrder, - stackName: props.stack.name, - templatePath: props.input.atPath(`${props.stack.name}.template.yaml`), + stackName: props.stack.stackName, + templatePath: props.input.atPath(`${props.stack.stackName}.template.yaml`), adminPermissions: props.adminPermissions, deploymentRole: props.role, capabilities, @@ -136,7 +136,7 @@ export class PipelineDeployStackAction extends cdk.Construct { actionName: 'Execute', changeSetName, runOrder: executeChangeSetRunOrder, - stackName: props.stack.name, + stackName: props.stack.stackName, })); this.deploymentRole = changeSetAction.deploymentRole; @@ -160,7 +160,7 @@ export class PipelineDeployStackAction extends cdk.Construct { const assets = this.stack.node.metadata.filter(md => md.type === cxapi.ASSET_METADATA); if (assets.length > 0) { // FIXME: Implement the necessary actions to publish assets - result.push(`Cannot deploy the stack ${this.stack.name} because it references ${assets.length} asset(s)`); + result.push(`Cannot deploy the stack ${this.stack.stackName} because it references ${assets.length} asset(s)`); } return result; } diff --git a/packages/@aws-cdk/assert/lib/synth-utils.ts b/packages/@aws-cdk/assert/lib/synth-utils.ts index 37a7a2818e841..edbdb155a70dc 100644 --- a/packages/@aws-cdk/assert/lib/synth-utils.ts +++ b/packages/@aws-cdk/assert/lib/synth-utils.ts @@ -9,7 +9,7 @@ export class SynthUtils { // always synthesize against the root (be it an App or whatever) so all artifacts will be included const root = stack.node.root; const assembly = ConstructNode.synth(root.node, options); - return assembly.getStack(stack.name); + return assembly.getStack(stack.stackName); } /** diff --git a/packages/@aws-cdk/assert/test/test.assertions.ts b/packages/@aws-cdk/assert/test/test.assertions.ts index 168dd6c6a56f6..0f2ce92cfda5c 100644 --- a/packages/@aws-cdk/assert/test/test.assertions.ts +++ b/packages/@aws-cdk/assert/test/test.assertions.ts @@ -210,7 +210,7 @@ function synthesizedStack(fn: (stack: cdk.Stack) => void): cx.CloudFormationStac fn(stack); const assembly = app.synth(); - return assembly.getStack(stack.name); + return assembly.getStack(stack.stackName); } interface TestResourceProps extends cdk.CfnResourceProps { diff --git a/packages/@aws-cdk/assets/test/test.asset.ts b/packages/@aws-cdk/assets/test/test.asset.ts index 5814620eb7c30..5d70326fc14d9 100644 --- a/packages/@aws-cdk/assets/test/test.asset.ts +++ b/packages/@aws-cdk/assets/test/test.asset.ts @@ -57,7 +57,7 @@ export = { path: dirPath }); - const synth = app.synth().getStack(stack.name); + const synth = app.synth().getStack(stack.stackName); const meta = synth.manifest.metadata || {}; test.ok(meta['/my-stack/MyAsset']); test.ok(meta['/my-stack/MyAsset'][0]); @@ -344,7 +344,7 @@ export = { // WHEN const session = app.synth(); - const artifact = session.getStack(stack.name); + const artifact = session.getStack(stack.stackName); const metadata = artifact.manifest.metadata || {}; const md = Object.values(metadata)[0]![0]!.data; test.deepEqual(md.path, 'asset.6b84b87243a4a01c592d78e1fd3855c4bfef39328cd0a450cc97e81717fea2a2'); diff --git a/packages/@aws-cdk/aws-apigateway/lib/deployment.ts b/packages/@aws-cdk/aws-apigateway/lib/deployment.ts index 1102b6e4bb774..13245884277d0 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/deployment.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/deployment.ts @@ -99,7 +99,7 @@ class LatestDeploymentResource extends CfnDeployment { constructor(scope: Construct, id: string, props: CfnDeploymentProps) { super(scope, id, props); - this.originalLogicalId = Stack.of(this).logicalIds.getLogicalId(this); + this.originalLogicalId = Stack.of(this).getLogicalId(this); } /** diff --git a/packages/@aws-cdk/aws-cloudtrail/lib/index.ts b/packages/@aws-cdk/aws-cloudtrail/lib/index.ts index 61349309500e0..bb69c1d10b65b 100644 --- a/packages/@aws-cdk/aws-cloudtrail/lib/index.ts +++ b/packages/@aws-cdk/aws-cloudtrail/lib/index.ts @@ -136,7 +136,7 @@ export class Trail extends Resource { .addServicePrincipal(cloudTrailPrincipal)); s3bucket.addToResourcePolicy(new iam.PolicyStatement() - .addResource(s3bucket.arnForObjects(`AWSLogs/${Stack.of(this).accountId}/*`)) + .addResource(s3bucket.arnForObjects(`AWSLogs/${Stack.of(this).account}/*`)) .addActions("s3:PutObject") .addServicePrincipal(cloudTrailPrincipal) .setCondition("StringEquals", {'s3:x-amz-acl': "bucket-owner-full-control"})); diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/test.pipeline.ts b/packages/@aws-cdk/aws-codepipeline-actions/test/test.pipeline.ts index e75cb83e80a50..4fef28801a7c8 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/test.pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/test.pipeline.ts @@ -7,7 +7,7 @@ import iam = require('@aws-cdk/aws-iam'); import lambda = require('@aws-cdk/aws-lambda'); import s3 = require('@aws-cdk/aws-s3'); import sns = require('@aws-cdk/aws-sns'); -import { App, CfnParameter, ConstructNode, PhysicalName, SecretValue, Stack } from '@aws-cdk/cdk'; +import { App, Aws, CfnParameter, ConstructNode, PhysicalName, SecretValue, Stack } from '@aws-cdk/cdk'; import { Test } from 'nodeunit'; import cpactions = require('../lib'); @@ -56,7 +56,7 @@ export = { const stack = new Stack(undefined, 'StackName'); new codepipeline.Pipeline(stack, 'Pipeline', { - pipelineName: stack.stackName, + pipelineName: Aws.stackName, }); expect(stack, true).to(haveResourceLike('AWS::CodePipeline::Pipeline', { @@ -695,8 +695,8 @@ export = { const usEast1ScaffoldStack = pipeline.crossRegionScaffolding['us-east-1']; test.notEqual(usEast1ScaffoldStack, undefined); - test.equal(usEast1ScaffoldStack.env.region, 'us-east-1'); - test.equal(usEast1ScaffoldStack.env.account, pipelineAccount); + test.equal(usEast1ScaffoldStack.region, 'us-east-1'); + test.equal(usEast1ScaffoldStack.account, pipelineAccount); test.ok(usEast1ScaffoldStack.node.id.indexOf('us-east-1') !== -1, `expected '${usEast1ScaffoldStack.node.id}' to contain 'us-east-1'`); diff --git a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts index 966d70cae181f..da6d042233552 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts @@ -2,7 +2,7 @@ import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import s3 = require('@aws-cdk/aws-s3'); -import { Construct, Lazy, PhysicalName, RemovalPolicy, Resource, Stack } from '@aws-cdk/cdk'; +import { App, Construct, Lazy, PhysicalName, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/cdk'; import { Action, IPipeline, IStage } from "./action"; import { CfnPipeline } from './codepipeline.generated'; import { Stage } from './stage'; @@ -364,14 +364,21 @@ export class Pipeline extends PipelineBase { }); } + private requireRegion() { + const region = Stack.of(this).region; + if (Token.isUnresolved(region)) { + throw new Error(`You need to specify an explicit region when using CodePipeline's cross-region support`); + } + return region; + } + private ensureReplicationBucketExistsFor(region?: string) { if (!region) { return; } // get the region the Pipeline itself is in - const pipelineRegion = Stack.of(this).requireRegion( - "You need to specify an explicit region when using CodePipeline's cross-region support"); + const pipelineRegion = this.requireRegion(); // if we already have an ArtifactStore generated for this region, or it's the Pipeline's region, nothing to do if (this.artifactStores[region] || region === pipelineRegion) { @@ -380,11 +387,14 @@ export class Pipeline extends PipelineBase { let replicationBucketName = this.crossRegionReplicationBuckets[region]; if (!replicationBucketName) { - const pipelineAccount = Stack.of(this).requireAccountId( - "You need to specify an explicit account when using CodePipeline's cross-region support"); - const app = Stack.of(this).parentApp(); - if (!app) { - throw new Error(`Pipeline stack which uses cross region actions must be part of an application`); + const pipelineAccount = Stack.of(this).account; + if (Token.isUnresolved(pipelineAccount)) { + throw new Error("You need to specify an explicit account when using CodePipeline's cross-region support"); + } + + const app = this.node.root; + if (!app || !App.isApp(app)) { + throw new Error(`Pipeline stack which uses cross region actions must be part of a CDK app`); } const crossRegionScaffoldStack = new CrossRegionScaffoldStack(this, `cross-region-stack-${pipelineAccount}:${region}`, { region, @@ -421,8 +431,7 @@ export class Pipeline extends PipelineBase { const pipelineStack = Stack.of(this); const resourceStack = Stack.of(action.resource); // check if resource is from a different account - if (pipelineStack.env.account && resourceStack.env.account && - pipelineStack.env.account !== resourceStack.env.account) { + if (pipelineStack.environment !== resourceStack.environment) { // if it is, the pipeline's bucket must have a KMS key if (!this.artifactBucket.encryptionKey) { throw new Error('The Pipeline is being used in a cross-account manner, ' + @@ -434,7 +443,7 @@ export class Pipeline extends PipelineBase { // generate a role in the other stack, that the Pipeline will assume for executing this action actionRole = new iam.Role(resourceStack, `${this.node.uniqueId}-${stage.stageName}-${action.actionName}-ActionRole`, { - assumedBy: new iam.AccountPrincipal(pipelineStack.env.account), + assumedBy: new iam.AccountPrincipal(pipelineStack.account), roleName: PhysicalName.auto({ crossEnvironment: true }), }); @@ -555,7 +564,8 @@ export class Pipeline extends PipelineBase { // add the Pipeline's artifact store const primaryStore = this.renderPrimaryArtifactStore(); - this.artifactStores[Stack.of(this).requireRegion()] = { + const primaryRegion = this.requireRegion(); + this.artifactStores[primaryRegion] = { location: primaryStore.location, type: primaryStore.type, encryptionKey: primaryStore.encryptionKey, diff --git a/packages/@aws-cdk/aws-ec2/lib/machine-image.ts b/packages/@aws-cdk/aws-ec2/lib/machine-image.ts index e975fcb941245..3fe42fbd6abfb 100644 --- a/packages/@aws-cdk/aws-ec2/lib/machine-image.ts +++ b/packages/@aws-cdk/aws-ec2/lib/machine-image.ts @@ -1,4 +1,4 @@ -import { Construct, SSMParameterProvider, Stack } from '@aws-cdk/cdk'; +import { Construct, Context, Stack, Token } from '@aws-cdk/cdk'; /** * Interface for classes that can select an appropriate machine image to use @@ -25,11 +25,7 @@ export class WindowsImage implements IMachineImageSource { * Return the image to use in the given context */ public getImage(scope: Construct): MachineImage { - const ssmProvider = new SSMParameterProvider(scope, { - parameterName: this.imageParameterName(this.version), - }); - - const ami = ssmProvider.parameterValue(); + const ami = Context.getSsmParameter(scope, this.imageParameterName(this.version)); return new MachineImage(ami, new WindowsOS()); } @@ -106,11 +102,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 ssmProvider = new SSMParameterProvider(scope, { - parameterName, - }); - const ami = ssmProvider.parameterValue(); + const ami = Context.getSsmParameter(scope, parameterName); return new MachineImage(ami, new LinuxOS()); } } @@ -188,7 +180,11 @@ export class GenericLinuxImage implements IMachineImageSource { } public getImage(scope: Construct): MachineImage { - const region = Stack.of(scope).requireRegion('AMI cannot be determined'); + let region = Stack.of(scope).region; + if (Token.isUnresolved(region)) { + region = Context.getDefaultRegion(scope); + } + const ami = region !== 'test-region' ? this.amiMap[region] : 'ami-12345'; if (!ami) { throw new Error(`Unable to find AMI in AMI map: no AMI specified for region '${region}'`); diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc.ts b/packages/@aws-cdk/aws-ec2/lib/vpc.ts index a4a25c41713cd..ff468d5408679 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc.ts @@ -789,8 +789,7 @@ export class Vpc extends VpcBase { this.node.applyAspect(new cdk.Tag(NAME_TAG, this.node.path)); - this.availabilityZones = new cdk.AvailabilityZoneProvider(this).availabilityZones; - this.availabilityZones.sort(); + this.availabilityZones = cdk.Context.getAvailabilityZones(this); const maxAZs = props.maxAZs !== undefined ? props.maxAZs : 3; this.availabilityZones = this.availabilityZones.slice(0, maxAZs); @@ -946,10 +945,6 @@ export class Vpc extends VpcBase { private createSubnets() { const remainingSpaceSubnets: SubnetConfiguration[] = []; - // Calculate number of public/private subnets based on number of AZs - const zones = new cdk.AvailabilityZoneProvider(this).availabilityZones; - zones.sort(); - for (const subnet of this.subnetConfiguration) { if (subnet.cidrMask === undefined) { remainingSpaceSubnets.push(subnet); diff --git a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts index f42e2d3bfb82b..7d4f011a25686 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 { AvailabilityZoneProvider, Construct, Stack, Tag } from '@aws-cdk/cdk'; +import { Construct, Context, 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 = new AvailabilityZoneProvider(stack).availabilityZones.length; + const zones = Context.getAvailabilityZones(stack).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 = new AvailabilityZoneProvider(stack).availabilityZones.length; + const zones = Context.getAvailabilityZones(stack).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 = new AvailabilityZoneProvider(stack).availabilityZones.length; + const zones = Context.getAvailabilityZones(stack).length; new Vpc(stack, 'TheVPC', { cidr: '10.0.0.0/21', subnetConfiguration: [ diff --git a/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts index 33f30feebec03..ff2c41da4eaa8 100644 --- a/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts @@ -227,11 +227,29 @@ export abstract class BaseService extends Resource return new cloudwatch.Metric({ namespace: 'AWS/ECS', metricName, - dimensions: { ServiceName: this.serviceName }, + dimensions: { ClusterName: this.clusterName, ServiceName: this.serviceName }, ...props }); } + /** + * Metric for cluster Memory utilization + * + * @default average over 5 minutes + */ + public metricMemoryUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('MemoryUtilization', props); + } + + /** + * Metric for cluster CPU utilization + * + * @default average over 5 minutes + */ + public metricCpuUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('CPUUtilization', props); + } + /** * Set up AWSVPC networking for this construct */ diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index cf9812bbe887a..37ed20215b144 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -3,7 +3,7 @@ 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, IResource, Resource, SSMParameterProvider, Stack } from '@aws-cdk/cdk'; +import { Construct, Context, IResource, Resource, Stack } from '@aws-cdk/cdk'; import { InstanceDrainHook } from './drain-hook/instance-drain-hook'; import { CfnCluster } from './ecs.generated'; @@ -268,13 +268,8 @@ export class EcsOptimizedAmi implements ec2.IMachineImageSource { * Return the correct image */ public getImage(scope: Construct): ec2.MachineImage { - const ssmProvider = new SSMParameterProvider(scope, { - parameterName: this.amiParameterName - }); - - const json = ssmProvider.parameterValue("{\"image_id\": \"\"}"); + const json = Context.getSsmParameter(scope, this.amiParameterName, { defaultValue: "{\"image_id\": \"\"}" }); const ami = JSON.parse(json).image_id; - return new ec2.MachineImage(ami, new ec2.LinuxOS()); } } diff --git a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts index f9ae74eb5f4e8..dccf0cd573e4a 100644 --- a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts @@ -1,4 +1,3 @@ -import cloudwatch = require ('@aws-cdk/aws-cloudwatch'); import ec2 = require('@aws-cdk/aws-ec2'); import elb = require('@aws-cdk/aws-elasticloadbalancing'); import { Construct, Lazy, Resource } from '@aws-cdk/cdk'; @@ -242,36 +241,6 @@ export class Ec2Service extends BaseService implements IEc2Service, elb.ILoadBal }); } - /** - * Return the given named metric for this Service - */ - public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return new cloudwatch.Metric({ - namespace: 'AWS/ECS', - metricName, - dimensions: { ClusterName: this.clusterName, ServiceName: this.serviceName }, - ...props - }); - } - - /** - * Metric for cluster Memory utilization - * - * @default average over 5 minutes - */ - public metricMemoryUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('MemoryUtilization', props ); - } - - /** - * Metric for cluster CPU utilization - * - * @default average over 5 minutes - */ - public metricCpuUtilization(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('CPUUtilization', props); - } - /** * Validate this Ec2Service */ diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts index 57566c5cc95c1..891d3592325bc 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts @@ -1007,5 +1007,37 @@ export = { test.done(); }, + }, + + 'Metric'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'FargateTaskDef'); + taskDefinition.addContainer('Container', { + image: ecs.ContainerImage.fromRegistry('hello') + }); + + // WHEN + const service = new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + }); + + // THEN + test.deepEqual(stack.resolve(service.metricMemoryUtilization()), { + dimensions: { + ClusterName: { Ref: 'EcsCluster97242B84' }, + ServiceName: { 'Fn::GetAtt': ['ServiceD69D759B', 'Name'] } + }, + namespace: 'AWS/ECS', + metricName: 'MemoryUtilization', + periodSec: 300, + statistic: 'Average' + }); + + test.done(); } }; diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts b/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts index 0345d199ab196..16950b85d80ef 100644 --- a/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts @@ -467,5 +467,36 @@ export = { test.done(); }, + }, + + 'Metric'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + taskDefinition.addContainer('Container', { + image: ecs.ContainerImage.fromRegistry('hello') + }); + + // WHEN + const service = new ecs.FargateService(stack, 'Service', { + cluster, + taskDefinition, + }); + + // THEN + test.deepEqual(stack.resolve(service.metricCpuUtilization()), { + dimensions: { + ClusterName: { Ref: 'EcsCluster97242B84' }, + ServiceName: { 'Fn::GetAtt': ['ServiceD69D759B', 'Name'] } + }, + namespace: 'AWS/ECS', + metricName: 'CPUUtilization', + periodSec: 300, + statistic: 'Average' + }); + + test.done(); } }; diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts index 023dd5686c155..0d2d5e4a323f2 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts @@ -2,7 +2,7 @@ import cloudwatch = require('@aws-cdk/aws-cloudwatch'); import ec2 = require('@aws-cdk/aws-ec2'); import iam = require('@aws-cdk/aws-iam'); import s3 = require('@aws-cdk/aws-s3'); -import { Construct, Lazy, Resource, Stack } from '@aws-cdk/cdk'; +import { Construct, Lazy, Resource, Stack, Token } from '@aws-cdk/cdk'; import { BaseLoadBalancer, BaseLoadBalancerProps, ILoadBalancerV2 } from '../shared/base-load-balancer'; import { IpAddressType } from '../shared/enums'; import { ApplicationListener, BaseApplicationListenerProps } from './application-listener'; @@ -86,7 +86,11 @@ export class ApplicationLoadBalancer extends BaseLoadBalancer implements IApplic this.setAttribute('access_logs.s3.bucket', bucket.bucketName.toString()); this.setAttribute('access_logs.s3.prefix', prefix); - const region = Stack.of(this).requireRegion('Enable ELBv2 access logging'); + const region = Stack.of(this).region; + if (Token.isUnresolved(region)) { + throw new Error(`Region is required to enable ELBv2 access logging`); + } + const account = ELBV2_ACCOUNTS[region]; if (!account) { throw new Error(`Cannot enable access logging; don't know ELBv2 account for region ${region}`); diff --git a/packages/@aws-cdk/aws-glue/lib/database.ts b/packages/@aws-cdk/aws-glue/lib/database.ts index 07f38aa422cbc..5bc505944f584 100644 --- a/packages/@aws-cdk/aws-glue/lib/database.ts +++ b/packages/@aws-cdk/aws-glue/lib/database.ts @@ -54,7 +54,7 @@ export class Database extends Resource implements IDatabase { public databaseArn = databaseArn; public databaseName = stack.parseArn(databaseArn).resourceName!; public catalogArn = stack.formatArn({ service: 'glue', resource: 'catalog' }); - public catalogId = stack.accountId; + public catalogId = stack.account; } return new Import(scope, id); @@ -95,7 +95,7 @@ export class Database extends Resource implements IDatabase { this.locationUri = `s3://${bucket.bucketName}/${props.databaseName}`; } - this.catalogId = Stack.of(this).accountId; + this.catalogId = Stack.of(this).account; const resource = new CfnDatabase(this, 'Resource', { catalogId: this.catalogId, databaseInput: { diff --git a/packages/@aws-cdk/aws-iam/README.md b/packages/@aws-cdk/aws-iam/README.md index d03c162d306ed..373242060c871 100644 --- a/packages/@aws-cdk/aws-iam/README.md +++ b/packages/@aws-cdk/aws-iam/README.md @@ -26,6 +26,30 @@ Managed policies can be attached using `xxx.attachManagedPolicy(arn)`: [attaching managed policies](test/example.managedpolicy.lit.ts) +### Granting permissions to resources + +Many of the AWS CDK resources have `grant*` methods that allow you to grant other resources access to that resource. As an example, the following code gives a Lambda function write permissions (Put, Update, Delete) to a DynamoDB table. + +```typescript +const fn = new lambda.Function(...); +const table = new dynamodb.Table(...); + +table.grantWriteData(fn); +``` + +The more generic `grant` method allows you to give specific permissions to a resource: + +```typescript +const fn = new lambda.Function(...); +const table = new dynamodb.Table(...); + +table.grant(fn, 'dynamodb:PutItem'); +``` + +The `grant*` methods accept an `IGrantable` object. This interface is implemented by IAM principles resources (groups, users and roles) and resources that assume a role such as a Lambda function, EC2 instance or a Codebuild project. + +You can find which `grant*` methods exist for a resource in the [AWS CDK API Reference](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-construct-library.html). + ### Configuring an ExternalId If you need to create roles that will be assumed by 3rd parties, it is generally a good idea to [require an `ExternalId` diff --git a/packages/@aws-cdk/aws-iam/lib/policy-statement.ts b/packages/@aws-cdk/aws-iam/lib/policy-statement.ts index ec5bb844a80c8..c2c93d6eeac3a 100644 --- a/packages/@aws-cdk/aws-iam/lib/policy-statement.ts +++ b/packages/@aws-cdk/aws-iam/lib/policy-statement.ts @@ -288,26 +288,26 @@ export interface PolicyStatementAttributes { * * @default - no actions */ - actions?: string[]; + readonly actions?: string[]; /** * List of principals to add to the statement * * @default - no principals */ - principals?: IPrincipal[]; + readonly principals?: IPrincipal[]; /** * Resource ARNs to add to the statement * * @default - no principals */ - resourceArns?: string[]; + readonly resourceArns?: string[]; /** * Conditions to add to the statement * * @default - no condition */ - conditions?: {[key: string]: any}; + readonly conditions?: {[key: string]: any}; } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/lib/principals.ts b/packages/@aws-cdk/aws-iam/lib/principals.ts index 458f62f86a68d..87008fd260eae 100644 --- a/packages/@aws-cdk/aws-iam/lib/principals.ts +++ b/packages/@aws-cdk/aws-iam/lib/principals.ts @@ -232,7 +232,7 @@ export class FederatedPrincipal extends PrincipalBase { export class AccountRootPrincipal extends AccountPrincipal { constructor() { - super(new StackDependentToken(stack => stack.accountId).toString()); + super(new StackDependentToken(stack => stack.account).toString()); } public toString() { diff --git a/packages/@aws-cdk/aws-kms/test/integ.key.ts b/packages/@aws-cdk/aws-kms/test/integ.key.ts index c212903dfefa1..c655b68e55beb 100644 --- a/packages/@aws-cdk/aws-kms/test/integ.key.ts +++ b/packages/@aws-cdk/aws-kms/test/integ.key.ts @@ -11,7 +11,7 @@ const key = new Key(stack, 'MyKey', { retain: false }); key.addToResourcePolicy(new PolicyStatement() .addAllResources() .addAction('kms:encrypt') - .addArnPrincipal(stack.accountId)); + .addArnPrincipal(stack.account)); key.addAlias('alias/bar'); diff --git a/packages/@aws-cdk/aws-lambda/lib/function.ts b/packages/@aws-cdk/aws-lambda/lib/function.ts index 5329ad923ba0d..9b9e4b4ceb0bb 100644 --- a/packages/@aws-cdk/aws-lambda/lib/function.ts +++ b/packages/@aws-cdk/aws-lambda/lib/function.ts @@ -3,7 +3,7 @@ import ec2 = require('@aws-cdk/aws-ec2'); import iam = require('@aws-cdk/aws-iam'); import logs = require('@aws-cdk/aws-logs'); import sqs = require('@aws-cdk/aws-sqs'); -import { Construct, Fn, Lazy, Stack } from '@aws-cdk/cdk'; +import { Construct, Fn, Lazy, Stack, Token } from '@aws-cdk/cdk'; import { Code } from './code'; import { IEventSource } from './event-source'; import { FunctionAttributes, FunctionBase, IFunction } from './function-base'; @@ -414,8 +414,8 @@ export class Function extends FunctionBase { this.role.addToPolicy(statement); } - const region = Stack.of(this).env.region; - const isChina = region && region.startsWith('cn-'); + const region = Stack.of(this).region; + const isChina = !Token.isUnresolved(region) && region.startsWith('cn-'); if (isChina && props.environment && Object.keys(props.environment).length > 0) { // tslint:disable-next-line:max-line-length throw new Error(`Environment variables are not supported in this region (${region}); consider using tags or SSM parameters instead`); diff --git a/packages/@aws-cdk/aws-lambda/test/integ.layer-version.lit.ts b/packages/@aws-cdk/aws-lambda/test/integ.layer-version.lit.ts index b79c4957eafd9..a98f6af2254d9 100644 --- a/packages/@aws-cdk/aws-lambda/test/integ.layer-version.lit.ts +++ b/packages/@aws-cdk/aws-lambda/test/integ.layer-version.lit.ts @@ -7,7 +7,7 @@ const app = new cdk.App(); const stack = new cdk.Stack(app, 'aws-cdk-layer-version-1'); // Just for the example - granting to the current account is not necessary. -const awsAccountId = stack.accountId; +const awsAccountId = stack.account; /// !show const layer = new lambda.LayerVersion(stack, 'MyLayer', { diff --git a/packages/@aws-cdk/aws-lambda/test/test.lambda.ts b/packages/@aws-cdk/aws-lambda/test/test.lambda.ts index f491ff4eb101c..9b8399ebca119 100644 --- a/packages/@aws-cdk/aws-lambda/test/test.lambda.ts +++ b/packages/@aws-cdk/aws-lambda/test/test.lambda.ts @@ -118,7 +118,7 @@ export = { fn.addPermission('S3Permission', { action: 'lambda:*', principal: new iam.ServicePrincipal('s3.amazonaws.com'), - sourceAccount: stack.accountId, + sourceAccount: stack.account, sourceArn: 'arn:aws:s3:::my_bucket' }); diff --git a/packages/@aws-cdk/aws-logs/lib/cross-account-destination.ts b/packages/@aws-cdk/aws-logs/lib/cross-account-destination.ts index 68eaa0d4ced04..7422a24eebd68 100644 --- a/packages/@aws-cdk/aws-logs/lib/cross-account-destination.ts +++ b/packages/@aws-cdk/aws-logs/lib/cross-account-destination.ts @@ -92,7 +92,7 @@ export class CrossAccountDestination extends cdk.Construct implements ILogSubscr */ private generateUniqueName(): string { // Combination of stack name and LogicalID, which are guaranteed to be unique. - return Stack.of(this).name + '-' + this.resource.logicalId; + return Stack.of(this).stackName + '-' + this.resource.logicalId; } /** diff --git a/packages/@aws-cdk/aws-s3-notifications/lib/lambda.ts b/packages/@aws-cdk/aws-s3-notifications/lib/lambda.ts index e3206b82b10dd..27ef63893f0fe 100644 --- a/packages/@aws-cdk/aws-s3-notifications/lib/lambda.ts +++ b/packages/@aws-cdk/aws-s3-notifications/lib/lambda.ts @@ -15,7 +15,7 @@ export class LambdaDestination implements s3.IBucketNotificationDestination { if (this.fn.node.tryFindChild(permissionId) === undefined) { this.fn.addPermission(permissionId, { - sourceAccount: Stack.of(bucket).accountId, + sourceAccount: Stack.of(bucket).account, principal: new iam.ServicePrincipal('s3.amazonaws.com'), sourceArn: bucket.bucketArn }); diff --git a/packages/@aws-cdk/aws-s3/lib/bucket.ts b/packages/@aws-cdk/aws-s3/lib/bucket.ts index 5aabf47ccbacf..5ca9a0dc32e86 100644 --- a/packages/@aws-cdk/aws-s3/lib/bucket.ts +++ b/packages/@aws-cdk/aws-s3/lib/bucket.ts @@ -548,13 +548,12 @@ abstract class BucketBase extends Resource implements IBucket { } private isGranteeFromAnotherAccount(grantee: iam.IGrantable): boolean { - if (!(grantee instanceof Construct)) { + if (!(Construct.isConstruct(grantee))) { return false; } - const c = grantee as Construct; const bucketStack = Stack.of(this); - const identityStack = Stack.of(c); - return bucketStack.env.account !== identityStack.env.account; + const identityStack = Stack.of(grantee); + return bucketStack.account !== identityStack.account; } } diff --git a/packages/@aws-cdk/aws-sqs/lib/queue.ts b/packages/@aws-cdk/aws-sqs/lib/queue.ts index ad1a9bec47a12..80b6ecba10be5 100644 --- a/packages/@aws-cdk/aws-sqs/lib/queue.ts +++ b/packages/@aws-cdk/aws-sqs/lib/queue.ts @@ -191,7 +191,7 @@ export class Queue extends QueueBase { public static fromQueueAttributes(scope: Construct, id: string, attrs: QueueAttributes): IQueue { const stack = Stack.of(scope); const queueName = attrs.queueName || stack.parseArn(attrs.queueArn).resource; - const queueUrl = attrs.queueUrl || `https://sqs.${stack.region}.${stack.urlSuffix}/${stack.accountId}/${queueName}`; + const queueUrl = attrs.queueUrl || `https://sqs.${stack.region}.${stack.urlSuffix}/${stack.account}/${queueName}`; class Import extends QueueBase { public readonly queueArn = attrs.queueArn; // arn:aws:sqs:us-east-1:123456789012:queue1 diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts b/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts index 8bf24d2ea79ed..2341cd8beb1a7 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts @@ -37,20 +37,39 @@ export interface StateMachineProps { } /** - * Define a StepFunctions State Machine + * A new or imported state machine. */ -export class StateMachine extends Resource implements IStateMachine { +abstract class StateMachineBase extends Resource implements IStateMachine { /** * Import a state machine */ public static fromStateMachineArn(scope: Construct, id: string, stateMachineArn: string): IStateMachine { - class Import extends Resource implements IStateMachine { + class Import extends StateMachineBase { public readonly stateMachineArn = stateMachineArn; } return new Import(scope, id); } + public abstract readonly stateMachineArn: string; + + /** + * Grant the given identity permissions to start an execution of this state + * machine. + */ + public grantStartExecution(identity: iam.IGrantable): iam.Grant { + return iam.Grant.addToPrincipal({ + grantee: identity, + actions: ['states:StartExecution'], + resourceArns: [this.stateMachineArn] + }); + } +} + +/** + * Define a StepFunctions State Machine + */ +export class StateMachine extends StateMachineBase { /** * Execution role of this state machine */ diff --git a/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts b/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts index 718937305faa8..1f508280d9d89 100644 --- a/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts +++ b/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts @@ -129,4 +129,47 @@ export = { test.done(); }, -}; \ No newline at end of file + 'Can grant start execution to a role'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const task = new stepfunctions.Task(stack, 'Task', { + task: { + bind: () => ({ resourceArn: 'resource' }) + } + }); + const stateMachine = new stepfunctions.StateMachine(stack, 'StateMachine', { + definition: task + }); + const role = new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com') + }); + + // WHEN + stateMachine.grantStartExecution(role); + + // THEN + expect(stack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'states:StartExecution', + Effect: 'Allow', + Resource: { + Ref: 'StateMachine2E01A3A5' + } + } + ], + Version: '2012-10-17', + }, + PolicyName: 'RoleDefaultPolicy5FFB7DAB', + Roles: [ + { + Ref: 'Role1ABCC5F0' + } + ] + })); + + test.done(); + } + +}; diff --git a/packages/@aws-cdk/cdk/lib/arn.ts b/packages/@aws-cdk/cdk/lib/arn.ts index 51761d8f007d3..2a74dbb0f89f9 100644 --- a/packages/@aws-cdk/cdk/lib/arn.ts +++ b/packages/@aws-cdk/cdk/lib/arn.ts @@ -3,144 +3,204 @@ import { Stack } from './stack'; import { Token } from './token'; import { filterUndefined } from './util'; -/** - * Creates an ARN from components. - * - * If `partition`, `region` or `account` are not specified, the stack's - * partition, region and account will be used. - * - * If any component is the empty string, an empty string will be inserted - * into the generated ARN at the location that component corresponds to. - * - * The ARN will be formatted as follows: - * - * arn:{partition}:{service}:{region}:{account}:{resource}{sep}{resource-name} - * - * The required ARN pieces that are omitted will be taken from the stack that - * the 'scope' is attached to. If all ARN pieces are supplied, the supplied scope - * can be 'undefined'. - */ -export function arnFromComponents(components: ArnComponents, stack: Stack): string { - const partition = components.partition !== undefined ? components.partition : stack.partition; - const region = components.region !== undefined ? components.region : stack.region; - const account = components.account !== undefined ? components.account : stack.accountId; - const sep = components.sep !== undefined ? components.sep : '/'; +export interface ArnComponents { + /** + * The partition that the resource is in. For standard AWS regions, the + * partition is aws. If you have resources in other partitions, the + * partition is aws-partitionname. For example, the partition for resources + * in the China (Beijing) region is aws-cn. + * + * @default The AWS partition the stack is deployed to. + */ + readonly partition?: string; - const values = [ 'arn', ':', partition, ':', components.service, ':', region, ':', account, ':', components.resource ]; + /** + * The service namespace that identifies the AWS product (for example, + * 's3', 'iam', 'codepipline'). + */ + readonly service: string; - if (sep !== '/' && sep !== ':' && sep !== '') { - throw new Error('resourcePathSep may only be ":", "/" or an empty string'); - } + /** + * The region the resource resides in. Note that the ARNs for some resources + * do not require a region, so this component might be omitted. + * + * @default The region the stack is deployed to. + */ + readonly region?: string; - if (components.resourceName != null) { - values.push(sep); - values.push(components.resourceName); - } + /** + * The ID of the AWS account that owns the resource, without the hyphens. + * For example, 123456789012. Note that the ARNs for some resources don't + * require an account number, so this component might be omitted. + * + * @default The account the stack is deployed to. + */ + readonly account?: string; - return values.join(''); + /** + * Resource type (e.g. "table", "autoScalingGroup", "certificate"). + * For some resource types, e.g. S3 buckets, this field defines the bucket name. + */ + readonly resource: string; + + /** + * Separator between resource type and the resource. + * + * Can be either '/', ':' or an empty string. Will only be used if resourceName is defined. + * @default '/' + */ + readonly sep?: string; + + /** + * Resource name or path within the resource (i.e. S3 bucket object key) or + * a wildcard such as ``"*"``. This is service-dependent. + */ + readonly resourceName?: string; } -/** - * Given an ARN, parses it and returns components. - * - * If the ARN is a concrete string, it will be parsed and validated. The - * separator (`sep`) will be set to '/' if the 6th component includes a '/', - * in which case, `resource` will be set to the value before the '/' and - * `resourceName` will be the rest. In case there is no '/', `resource` will - * be set to the 6th components and `resourceName` will be set to the rest - * of the string. - * - * If the ARN includes tokens (or is a token), the ARN cannot be validated, - * since we don't have the actual value yet at the time of this function - * call. You will have to know the separator and the type of ARN. The - * resulting `ArnComponents` object will contain tokens for the - * subexpressions of the ARN, not string literals. In this case this - * function cannot properly parse the complete final resourceName (path) out - * of ARNs that use '/' to both separate the 'resource' from the - * 'resourceName' AND to subdivide the resourceName further. For example, in - * S3 ARNs: - * - * arn:aws:s3:::my_corporate_bucket/path/to/exampleobject.png - * - * After parsing the resourceName will not contain - * 'path/to/exampleobject.png' but simply 'path'. This is a limitation - * because there is no slicing functionality in CloudFormation templates. - * - * @param sep The separator used to separate resource from resourceName - * @param hasName Whether there is a name component in the ARN at all. For - * example, SNS Topics ARNs have the 'resource' component contain the topic - * name, and no 'resourceName' component. - * - * @returns an ArnComponents object which allows access to the various - * components of the ARN. - * - * @returns an ArnComponents object which allows access to the various - * components of the ARN. - */ -export function parseArn(arn: string, sepIfToken: string = '/', hasName: boolean = true): ArnComponents { - if (Token.isUnresolved(arn)) { - return parseToken(arn, sepIfToken, hasName); - } +export class Arn { + /** + * Creates an ARN from components. + * + * If `partition`, `region` or `account` are not specified, the stack's + * partition, region and account will be used. + * + * If any component is the empty string, an empty string will be inserted + * into the generated ARN at the location that component corresponds to. + * + * The ARN will be formatted as follows: + * + * arn:{partition}:{service}:{region}:{account}:{resource}{sep}{resource-name} + * + * The required ARN pieces that are omitted will be taken from the stack that + * the 'scope' is attached to. If all ARN pieces are supplied, the supplied scope + * can be 'undefined'. + */ + public static format(components: ArnComponents, stack: Stack): string { + const partition = components.partition !== undefined ? components.partition : stack.partition; + const region = components.region !== undefined ? components.region : stack.region; + const account = components.account !== undefined ? components.account : stack.account; + const sep = components.sep !== undefined ? components.sep : '/'; - const components = arn.split(':') as Array; + const values = [ 'arn', ':', partition, ':', components.service, ':', region, ':', account, ':', components.resource ]; - if (components.length < 6) { - throw new Error('ARNs must have at least 6 components: ' + arn); - } + if (sep !== '/' && sep !== ':' && sep !== '') { + throw new Error('resourcePathSep may only be ":", "/" or an empty string'); + } - const [ arnPrefix, partition, service, region, account, sixth, ...rest ] = components; + if (components.resourceName != null) { + values.push(sep); + values.push(components.resourceName); + } - if (arnPrefix !== 'arn') { - throw new Error('ARNs must start with "arn:": ' + arn); + return values.join(''); } - if (!service) { - throw new Error('The `service` component (3rd component) is required: ' + arn); - } + /** + * Given an ARN, parses it and returns components. + * + * If the ARN is a concrete string, it will be parsed and validated. The + * separator (`sep`) will be set to '/' if the 6th component includes a '/', + * in which case, `resource` will be set to the value before the '/' and + * `resourceName` will be the rest. In case there is no '/', `resource` will + * be set to the 6th components and `resourceName` will be set to the rest + * of the string. + * + * If the ARN includes tokens (or is a token), the ARN cannot be validated, + * since we don't have the actual value yet at the time of this function + * call. You will have to know the separator and the type of ARN. The + * resulting `ArnComponents` object will contain tokens for the + * subexpressions of the ARN, not string literals. In this case this + * function cannot properly parse the complete final resourceName (path) out + * of ARNs that use '/' to both separate the 'resource' from the + * 'resourceName' AND to subdivide the resourceName further. For example, in + * S3 ARNs: + * + * arn:aws:s3:::my_corporate_bucket/path/to/exampleobject.png + * + * After parsing the resourceName will not contain + * 'path/to/exampleobject.png' but simply 'path'. This is a limitation + * because there is no slicing functionality in CloudFormation templates. + * + * @param arn The ARN to parse + * @param sepIfToken The separator used to separate resource from resourceName + * @param hasName Whether there is a name component in the ARN at all. For + * example, SNS Topics ARNs have the 'resource' component contain the topic + * name, and no 'resourceName' component. + * + * @returns an ArnComponents object which allows access to the various + * components of the ARN. + * + * @returns an ArnComponents object which allows access to the various + * components of the ARN. + */ + public static parse(arn: string, sepIfToken: string = '/', hasName: boolean = true): ArnComponents { + if (Token.isUnresolved(arn)) { + return parseToken(arn, sepIfToken, hasName); + } - if (!sixth) { - throw new Error('The `resource` component (6th component) is required: ' + arn); - } + const components = arn.split(':') as Array; - let resource: string; - let resourceName: string | undefined; - let sep: string | undefined; + if (components.length < 6) { + throw new Error('ARNs must have at least 6 components: ' + arn); + } - let sepIndex = sixth.indexOf('/'); - if (sepIndex !== -1) { - sep = '/'; - } else if (rest.length > 0) { - sep = ':'; - sepIndex = -1; - } + const [ arnPrefix, partition, service, region, account, sixth, ...rest ] = components; - if (sepIndex !== -1) { - resource = sixth.substr(0, sepIndex); - resourceName = sixth.substr(sepIndex + 1); - } else { - resource = sixth; - } + if (arnPrefix !== 'arn') { + throw new Error('ARNs must start with "arn:": ' + arn); + } + + if (!service) { + throw new Error('The `service` component (3rd component) is required: ' + arn); + } + + if (!sixth) { + throw new Error('The `resource` component (6th component) is required: ' + arn); + } - if (rest.length > 0) { - if (!resourceName) { - resourceName = ''; + let resource: string; + let resourceName: string | undefined; + let sep: string | undefined; + + let sepIndex = sixth.indexOf('/'); + if (sepIndex !== -1) { + sep = '/'; + } else if (rest.length > 0) { + sep = ':'; + sepIndex = -1; + } + + if (sepIndex !== -1) { + resource = sixth.substr(0, sepIndex); + resourceName = sixth.substr(sepIndex + 1); } else { - resourceName += ':'; + resource = sixth; + } + + if (rest.length > 0) { + if (!resourceName) { + resourceName = ''; + } else { + resourceName += ':'; + } + + resourceName += rest.join(':'); } - resourceName += rest.join(':'); + // "|| undefined" will cause empty strings to be treated as "undefined" + return filterUndefined({ + service: service || undefined, + resource: resource || undefined , + partition: partition || undefined, + region: region || undefined, + account: account || undefined, + resourceName, + sep + }); } - // "|| undefined" will cause empty strings to be treated as "undefined" - return filterUndefined({ - service: service || undefined, - resource: resource || undefined , - partition: partition || undefined, - region: region || undefined, - account: account || undefined, - resourceName, - sep - }); + private constructor() { } } /** @@ -202,58 +262,3 @@ function parseToken(arnToken: string, sep: string = '/', hasName: boolean = true return { partition, service, region, account, resource, resourceName, sep }; } } - -export interface ArnComponents { - /** - * The partition that the resource is in. For standard AWS regions, the - * partition is aws. If you have resources in other partitions, the - * partition is aws-partitionname. For example, the partition for resources - * in the China (Beijing) region is aws-cn. - * - * @default The AWS partition the stack is deployed to. - */ - readonly partition?: string; - - /** - * The service namespace that identifies the AWS product (for example, - * 's3', 'iam', 'codepipline'). - */ - readonly service: string; - - /** - * The region the resource resides in. Note that the ARNs for some resources - * do not require a region, so this component might be omitted. - * - * @default The region the stack is deployed to. - */ - readonly region?: string; - - /** - * The ID of the AWS account that owns the resource, without the hyphens. - * For example, 123456789012. Note that the ARNs for some resources don't - * require an account number, so this component might be omitted. - * - * @default The account the stack is deployed to. - */ - readonly account?: string; - - /** - * Resource type (e.g. "table", "autoScalingGroup", "certificate"). - * For some resource types, e.g. S3 buckets, this field defines the bucket name. - */ - readonly resource: string; - - /** - * Separator between resource type and the resource. - * - * Can be either '/', ':' or an empty string. Will only be used if resourceName is defined. - * @default '/' - */ - readonly sep?: string; - - /** - * Resource name or path within the resource (i.e. S3 bucket object key) or - * a wildcard such as ``"*"``. This is service-dependent. - */ - readonly resourceName?: string; -} diff --git a/packages/@aws-cdk/cdk/lib/cfn-element.ts b/packages/@aws-cdk/cdk/lib/cfn-element.ts index e94f92a78a00f..35590c6abcf40 100644 --- a/packages/@aws-cdk/cdk/lib/cfn-element.ts +++ b/packages/@aws-cdk/cdk/lib/cfn-element.ts @@ -38,7 +38,10 @@ export abstract class CfnElement extends Construct { */ public readonly stack: Stack; - private _logicalId: string; + /** + * An explicit logical ID provided by `overrideLogicalId`. + */ + private _logicalIdOverride?: string; /** * Creates an entity and binds it to a tree. @@ -53,8 +56,8 @@ export abstract class CfnElement extends Construct { Object.defineProperty(this, CFN_ELEMENT_SYMBOL, { value: true }); this.stack = Stack.of(this); - this._logicalId = this.stack.logicalIds.getLogicalId(this); - this.logicalId = Lazy.stringValue({ produce: () => this._logicalId }, { + + this.logicalId = Lazy.stringValue({ produce: () => this.synthesizeLogicalId() }, { displayHint: `${notTooLong(this.node.path)}.LogicalID` }); @@ -66,7 +69,7 @@ export abstract class CfnElement extends Construct { * @param newLogicalId The new logical ID to use for this stack element. */ public overrideLogicalId(newLogicalId: string) { - this._logicalId = newLogicalId; + this._logicalIdOverride = newLogicalId; } /** @@ -141,6 +144,19 @@ export abstract class CfnElement extends Construct { protected get ref(): IResolvable { return CfnReference.for(this, 'Ref'); } + + /** + * Called during synthesize to render the logical ID of this element. If + * `overrideLogicalId` was it will be used, otherwise, we will allocate the + * logical ID through the stack. + */ + private synthesizeLogicalId() { + if (this._logicalIdOverride) { + return this._logicalIdOverride; + } else { + return this.stack.getLogicalId(this); + } + } } /** diff --git a/packages/@aws-cdk/cdk/lib/cfn-output.ts b/packages/@aws-cdk/cdk/lib/cfn-output.ts index 8223bcb986ee7..e5f2a764d777d 100644 --- a/packages/@aws-cdk/cdk/lib/cfn-output.ts +++ b/packages/@aws-cdk/cdk/lib/cfn-output.ts @@ -157,7 +157,7 @@ export class CfnOutput extends CfnElement { */ private uniqueOutputName() { // prefix export name with stack name since exports are global within account + region. - const stackName = this.stack.name; + const stackName = this.stack.stackName; return (stackName ? stackName + ':' : '') + this.logicalId; } } diff --git a/packages/@aws-cdk/cdk/lib/construct.ts b/packages/@aws-cdk/cdk/lib/construct.ts index 65a1abfa2fc2d..b3c7ba7f6adb5 100644 --- a/packages/@aws-cdk/cdk/lib/construct.ts +++ b/packages/@aws-cdk/cdk/lib/construct.ts @@ -40,19 +40,26 @@ export class ConstructNode { // prepare this.prepare(root); - // validate - const validate = options.skipValidation === undefined ? true : !options.skipValidation; - if (validate) { - const errors = this.validate(root); - if (errors.length > 0) { - const errorList = errors.map(e => `[${e.source.node.path}] ${e.message}`).join('\n '); - throw new Error(`Validation failed with the following errors:\n ${errorList}`); + // do not allow adding children after this stage + root._lock(); + + try { + // validate + const validate = options.skipValidation === undefined ? true : !options.skipValidation; + if (validate) { + const errors = this.validate(root); + if (errors.length > 0) { + const errorList = errors.map(e => `[${e.source.node.path}] ${e.message}`).join('\n '); + throw new Error(`Validation failed with the following errors:\n ${errorList}`); + } } - } - // synthesize (leaves first) - for (const construct of root.findAll(ConstructOrder.PostOrder)) { - (construct as any).synthesize({ assembly: builder }); // "as any" is needed because we want to keep "synthesize" protected + // synthesize (leaves first) + for (const construct of root.findAll(ConstructOrder.PostOrder)) { + (construct as any).synthesize({ assembly: builder }); // "as any" is needed because we want to keep "synthesize" protected + } + } finally { + root._unlock(); } // write session manifest and lock store @@ -371,21 +378,6 @@ export class ConstructNode { return this.scopes[0]; } - /** - * Locks this construct from allowing more children to be added. After this - * call, no more children can be added to this construct or to any children. - */ - public lock() { - this._locked = true; - } - - /** - * Unlocks this costruct and allows mutations (adding children). - */ - public unlock() { - this._locked = false; - } - /** * Returns true if this construct or the scopes in which it is defined are * locked. @@ -470,6 +462,23 @@ export class ConstructNode { return ret; } + /** + * Locks this construct from allowing more children to be added. After this + * call, no more children can be added to this construct or to any children. + * @internal + */ + private _lock() { + this._locked = true; + } + + /** + * Unlocks this costruct and allows mutations (adding children). + * @internal + */ + private _unlock() { + this._locked = false; + } + /** * Adds a child construct to this node. * diff --git a/packages/@aws-cdk/cdk/lib/context.ts b/packages/@aws-cdk/cdk/lib/context.ts index 980bbfe43328c..7e85c422eb381 100644 --- a/packages/@aws-cdk/cdk/lib/context.ts +++ b/packages/@aws-cdk/cdk/lib/context.ts @@ -1,9 +1,59 @@ 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 * @@ -22,9 +72,29 @@ export class ContextProvider { 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: stack.env.account, - region: stack.env.region, + account, + region, ...props, }; } @@ -38,6 +108,11 @@ export class ContextProvider { * 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) { @@ -45,12 +120,6 @@ export class ContextProvider { return defaultValue; } - const value = this.context.node.tryGetContext(this.key); - - if (value != null) { - return value; - } - this.reportMissingContext({ key: this.key, provider: this.provider, @@ -64,13 +133,6 @@ export class ContextProvider { * @param defaultValue The value to return if there is no value defined for this context key */ public getStringValue( defaultValue: string): string { - // 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; - } - const value = this.context.node.tryGetContext(this.key); if (value != null) { @@ -80,6 +142,13 @@ export class ContextProvider { 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, @@ -94,14 +163,6 @@ export class ContextProvider { * @param defaultValue The value to return if there is no value defined for this context key */ public getStringListValue(defaultValue: string[]): string[] { - // 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; - } - const value = this.context.node.tryGetContext(this.key); if (value != null) { @@ -111,6 +172,14 @@ export class ContextProvider { 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, @@ -138,36 +207,37 @@ function colonQuote(xs: string): string { /** * Context provider that will return the availability zones for the current account and region */ -export class AvailabilityZoneProvider { +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']); } } -export interface SSMParameterProviderProps { - /** - * The name of the parameter to lookup - */ - readonly parameterName: string; -} /** * Context provider that will read values from the SSM parameter store in the indicated account and region */ -export class SSMParameterProvider { +class SsmParameterProvider { private provider: ContextProvider; - constructor(context: Construct, props: SSMParameterProviderProps) { - this.provider = new ContextProvider(context, cxapi.SSM_PARAMETER_PROVIDER, props); + constructor(context: Construct, parameterName: string) { + this.provider = new ContextProvider(context, cxapi.SSM_PARAMETER_PROVIDER, { parameterName }); } /** diff --git a/packages/@aws-cdk/cdk/lib/environment.ts b/packages/@aws-cdk/cdk/lib/environment.ts index 8ae44a4aebed2..6df08fb48fac5 100644 --- a/packages/@aws-cdk/cdk/lib/environment.ts +++ b/packages/@aws-cdk/cdk/lib/environment.ts @@ -14,13 +14,3 @@ export interface Environment { */ readonly region?: string; } - -/** - * Checks whether two environments are equal. - * @param left one of the environments to compare. - * @param right the other environment. - * @returns ``true`` if both environments are guaranteed to be in the same account and region. - */ -export function environmentEquals(left: Environment, right: Environment): boolean { - return left.account === right.account && left.region === right.region; -} diff --git a/packages/@aws-cdk/cdk/lib/index.ts b/packages/@aws-cdk/cdk/lib/index.ts index bc99eb685e1cf..6bc9427edd92e 100644 --- a/packages/@aws-cdk/cdk/lib/index.ts +++ b/packages/@aws-cdk/cdk/lib/index.ts @@ -13,7 +13,6 @@ export * from './reference'; export * from './cfn-condition'; export * from './fn'; export * from './include'; -export * from './logical-id'; export * from './cfn-mapping'; export * from './cfn-output'; export * from './cfn-parameter'; diff --git a/packages/@aws-cdk/cdk/lib/logical-id.ts b/packages/@aws-cdk/cdk/lib/logical-id.ts index 56d929ede614e..34044ad3db667 100644 --- a/packages/@aws-cdk/cdk/lib/logical-id.ts +++ b/packages/@aws-cdk/cdk/lib/logical-id.ts @@ -1,58 +1,3 @@ -import { CfnElement } from './cfn-element'; -import { makeUniqueId } from './uniqueid'; - -/** - * Interface for classes that implementation logical ID assignment strategies - */ -export interface IAddressingScheme { - /** - * Return the logical ID for the given list of Construct names on the path. - */ - allocateAddress(addressComponents: string[]): string; -} - -/** - * Renders a hashed ID for a resource. - * - * In order to make sure logical IDs are unique and stable, we hash the resource - * construct tree path (i.e. toplevel/secondlevel/.../myresource) and add it as - * a suffix to the path components joined without a separator (CloudFormation - * IDs only allow alphanumeric characters). - * - * The result will be: - * - * - * "human" "hash" - * - * If the "human" part of the ID exceeds 240 characters, we simply trim it so - * the total ID doesn't exceed CloudFormation's 255 character limit. - * - * We only take 8 characters from the md5 hash (0.000005 chance of collision). - * - * Special cases: - * - * - If the path only contains a single component (i.e. it's a top-level - * resource), we won't add the hash to it. The hash is not needed for - * disamiguation and also, it allows for a more straightforward migration an - * existing CloudFormation template to a CDK stack without logical ID changes - * (or renames). - * - For aesthetic reasons, if the last components of the path are the same - * (i.e. `L1/L2/Pipeline/Pipeline`), they will be de-duplicated to make the - * resulting human portion of the ID more pleasing: `L1L2Pipeline` - * instead of `L1L2PipelinePipeline` - * - If a component is named "Default" it will be omitted from the path. This - * allows refactoring higher level abstractions around constructs without affecting - * the IDs of already deployed resources. - * - If a component is named "Resource" it will be omitted from the user-visible - * path, but included in the hash. This reduces visual noise in the human readable - * part of the identifier. - */ -export class HashedAddressingScheme implements IAddressingScheme { - public allocateAddress(addressComponents: string[]): string { - return makeUniqueId(addressComponents); - } -} - /** * Class that keeps track of the logical IDs that are assigned to resources * @@ -74,13 +19,10 @@ export class LogicalIDs { */ private readonly reverse: {[id: string]: string} = {}; - constructor(private readonly namingScheme: IAddressingScheme) { - } - /** * Rename a logical ID from an old ID to a new ID */ - public renameLogical(oldId: string, newId: string) { + public addRename(oldId: string, newId: string) { if (oldId in this.renames) { throw new Error(`A rename has already been registered for '${oldId}'`); } @@ -88,16 +30,23 @@ export class LogicalIDs { } /** - * Return the logical ID for the given stack element + * Return the renamed version of an ID or the original ID. */ - public getLogicalId(cfnElement: CfnElement): string { - const scopes = cfnElement.node.scopes; - const stackIndex = scopes.indexOf(cfnElement.stack); - const path = scopes.slice(stackIndex + 1).map(x => x.node.id); - const generatedId = this.namingScheme.allocateAddress(path); - const finalId = this.applyRename(generatedId); - validateLogicalId(finalId); - return finalId; + public applyRename(oldId: string) { + let newId = oldId; + if (oldId in this.renames) { + newId = this.renames[oldId]; + } + + // If this newId has already been used, it must have been with the same oldId + if (newId in this.reverse && this.reverse[newId] !== oldId) { + // tslint:disable-next-line:max-line-length + throw new Error(`Two objects have been assigned the same Logical ID: '${this.reverse[newId]}' and '${oldId}' are now both named '${newId}'.`); + } + this.reverse[newId] = oldId; + + validateLogicalId(newId); + return newId; } /** @@ -118,25 +67,6 @@ export class LogicalIDs { throw new Error(`The following Logical IDs were attempted to be renamed, but not found: ${unusedRenames.join(', ')}`); } } - - /** - * Return the renamed version of an ID, if applicable - */ - private applyRename(oldId: string) { - let newId = oldId; - if (oldId in this.renames) { - newId = this.renames[oldId]; - } - - // If this newId has already been used, it must have been with the same oldId - if (newId in this.reverse && this.reverse[newId] !== oldId) { - // tslint:disable-next-line:max-line-length - throw new Error(`Two objects have been assigned the same Logical ID: '${this.reverse[newId]}' and '${oldId}' are now both named '${newId}'.`); - } - this.reverse[newId] = oldId; - - return newId; - } } const VALID_LOGICALID_REGEX = /^[A-Za-z][A-Za-z0-9]{1,254}$/; diff --git a/packages/@aws-cdk/cdk/lib/physical-name-generator.ts b/packages/@aws-cdk/cdk/lib/physical-name-generator.ts index dc0d3fdfffe49..c57e9abc6a8d4 100644 --- a/packages/@aws-cdk/cdk/lib/physical-name-generator.ts +++ b/packages/@aws-cdk/cdk/lib/physical-name-generator.ts @@ -1,12 +1,23 @@ import crypto = require('crypto'); import { IResource } from './resource'; import { Stack } from './stack'; +import { Token } from './token'; export function generatePhysicalName(resource: IResource): string { const stack = Stack.of(resource); - const stackPart = new PrefixNamePart(stack.name, 25); + const stackPart = new PrefixNamePart(stack.stackName, 25); const idPart = new SuffixNamePart(resource.node.uniqueId, 24); + let region: string | undefined = stack.region; + if (Token.isUnresolved(region)) { + region = undefined; + } + + let account: string | undefined = stack.account; + if (Token.isUnresolved(account)) { + account = undefined; + } + const parts = [stackPart, idPart] .map(part => part.generate()); @@ -14,8 +25,8 @@ export function generatePhysicalName(resource: IResource): string { const sha256 = crypto.createHash('sha256') .update(stackPart.bareStr) .update(idPart.bareStr) - .update(stack.env.region || '') - .update(stack.env.account || ''); + .update(region || '') + .update(account || ''); const hash = sha256.digest('hex').slice(0, hashLength); const ret = [...parts, hash].join(''); diff --git a/packages/@aws-cdk/cdk/lib/private/cfn-reference.ts b/packages/@aws-cdk/cdk/lib/private/cfn-reference.ts index 839f073b13774..538d8cf987e43 100644 --- a/packages/@aws-cdk/cdk/lib/private/cfn-reference.ts +++ b/packages/@aws-cdk/cdk/lib/private/cfn-reference.ts @@ -143,7 +143,7 @@ export class CfnReference extends Reference { private exportValue(tokenValue: Token, consumingStack: Stack): IResolvable { const producingStack = this.producingStack!; - if (producingStack.env.account !== consumingStack.env.account || producingStack.env.region !== consumingStack.env.region) { + if (producingStack.environment !== consumingStack.environment) { throw this.newError(`Can only reference cross stacks in the same region and account. ${this.humanReadableDesc}`); } diff --git a/packages/@aws-cdk/cdk/lib/private/cross-environment-token.ts b/packages/@aws-cdk/cdk/lib/private/cross-environment-token.ts index e9689f79b971d..90e27556e52d8 100644 --- a/packages/@aws-cdk/cdk/lib/private/cross-environment-token.ts +++ b/packages/@aws-cdk/cdk/lib/private/cross-environment-token.ts @@ -26,8 +26,7 @@ export abstract class CrossEnvironmentToken implements IResolvable { const consumingStack = Stack.of(context.scope); const owningStack = Stack.of(this.resource); - if (consumingStack.env.account !== owningStack.env.account || - consumingStack.env.region !== owningStack.env.region) { + if (consumingStack.environment !== owningStack.environment) { this.resource.physicalName._resolveCrossEnvironment(this.resource); return this.crossEnvironmentValue; } else { diff --git a/packages/@aws-cdk/cdk/lib/pseudo.ts b/packages/@aws-cdk/cdk/lib/pseudo.ts index f248ed8b5de14..0985404329453 100644 --- a/packages/@aws-cdk/cdk/lib/pseudo.ts +++ b/packages/@aws-cdk/cdk/lib/pseudo.ts @@ -19,42 +19,16 @@ const AWS_NOVALUE = 'AWS::NoValue'; * values can be obtained as properties from an scoped object. */ export class Aws { - private constructor() { - } - - public static get accountId(): string { - return pseudoString(AWS_ACCOUNTID); - } - - public static get urlSuffix(): string { - return pseudoString(AWS_URLSUFFIX); - } - - public static get notificationArns(): string[] { - return Token.asList({ Ref: AWS_NOTIFICATIONARNS }, { - displayHint: AWS_NOTIFICATIONARNS - }); - } - - public static get partition(): string { - return pseudoString(AWS_PARTITION); - } - - public static get region(): string { - return pseudoString(AWS_REGION); - } - - public static get stackId(): string { - return pseudoString(AWS_STACKID); - } - - public static get stackName(): string { - return pseudoString(AWS_STACKNAME); - } - - public static get noValue(): string { - return pseudoString(AWS_NOVALUE); - } + public static readonly accountId = pseudoString(AWS_ACCOUNTID); + public static readonly urlSuffix = pseudoString(AWS_URLSUFFIX); + public static readonly notificationArns = Token.asList({ Ref: AWS_NOTIFICATIONARNS }, { displayHint: AWS_NOTIFICATIONARNS }); + public static readonly partition = pseudoString(AWS_PARTITION); + public static readonly region = pseudoString(AWS_REGION); + public static readonly stackId = pseudoString(AWS_STACKID); + public static readonly stackName = pseudoString(AWS_STACKNAME); + public static readonly noValue = pseudoString(AWS_NOVALUE); + + private constructor() { } } /** diff --git a/packages/@aws-cdk/cdk/lib/stack.ts b/packages/@aws-cdk/cdk/lib/stack.ts index fcf3401b2ec4b..44f0aec2219c5 100644 --- a/packages/@aws-cdk/cdk/lib/stack.ts +++ b/packages/@aws-cdk/cdk/lib/stack.ts @@ -1,12 +1,11 @@ import cxapi = require('@aws-cdk/cx-api'); +import { EnvironmentUtils } from '@aws-cdk/cx-api'; import fs = require('fs'); import path = require('path'); -import { App } from './app'; -import { CfnParameter } from './cfn-parameter'; import { CLOUDFORMATION_TOKEN_RESOLVER, CloudFormationLang } from './cloudformation-lang'; import { Construct, ConstructNode, IConstruct, ISynthesisSession } from './construct'; import { Environment } from './environment'; -import { HashedAddressingScheme, IAddressingScheme, LogicalIDs } from './logical-id'; +import { LogicalIDs } from './logical-id'; import { resolve } from './private/resolve'; import { makeUniqueId } from './uniqueid'; @@ -29,13 +28,6 @@ export interface StackProps { */ readonly stackName?: string; - /** - * Strategy for logical ID generation - * - * @default - The HashedNamingScheme will be used. - */ - readonly namingScheme?: IAddressingScheme; - /** * Stack tags that will be applied to all the taggable resources and the stack itself. * @@ -54,7 +46,7 @@ export class Stack extends Construct implements ITaggable { * We do attribute detection since we can't reliably use 'instanceof'. */ public static isStack(x: any): x is Stack { - return STACK_SYMBOL in x; + return x !== null && typeof(x) === 'object' && STACK_SYMBOL in x; } /** @@ -83,51 +75,68 @@ export class Stack extends Construct implements ITaggable { public readonly tags: TagManager; /** - * The environment in which this stack is deployed. + * Options for CloudFormation template (like version, transform, description). */ - public readonly env: Environment; + public readonly templateOptions: ITemplateOptions = {}; /** - * Logical ID generation strategy + * The concrete CloudFormation physical stack name. + * + * This is either the name defined explicitly in the `stackName` prop or + * allocated based on the stack's location in the construct tree. Stacks that + * are directly defined under the app use their construct `id` as their stack + * name. Stacks that are defined deeper within the tree will use a hashed naming + * scheme based on the construct path to ensure uniqueness. + * + * If you wish to obtain the deploy-time AWS::StackName intrinsic, + * you can use `Aws.stackName` directly. */ - public readonly logicalIds: LogicalIDs; + public readonly stackName: string; /** - * Options for CloudFormation template (like version, transform, description). + * The region into which this stack will be deployed. + * + * 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" }` */ - public readonly templateOptions: ITemplateOptions = {}; + public readonly region: string; /** - * The CloudFormation stack name. + * The account into which this stack will be deployed. * - * This is the stack name either configuration via the `stackName` property - * or automatically derived from the construct path. + * 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" }` */ - public readonly name: string; + public readonly account: string; /** - * Other stacks this stack depends on + * The environment coordinates in which this stack is deployed. In the form + * `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. */ - private readonly stackDependencies = new Set(); + public readonly environment: string; /** - * Values set for parameters in cloud assembly. + * Logical ID generation strategy */ - private readonly parameterValues: { [logicalId: string]: string } = { }; + private readonly _logicalIds: LogicalIDs; /** - * Environment as configured via props - * - * (Both on Stack and inherited from App) + * Other stacks this stack depends on */ - private readonly configuredEnv: Environment; + private readonly _stackDependencies = new Set(); /** * Lists all missing contextual information. * This is returned when the stack is synthesized under the 'missing' attribute * and allows tooling to obtain the context and re-synthesize. */ - private readonly missingContext = new Array(); + private readonly _missingContext = new Array(); /** * Creates a new stack. @@ -142,42 +151,20 @@ export class Stack extends Construct implements ITaggable { Object.defineProperty(this, STACK_SYMBOL, { value: true }); - this.configuredEnv = props.env || {}; - this.env = this.parseEnvironment(props.env); + this._logicalIds = new LogicalIDs(); - this.logicalIds = new LogicalIDs(props.namingScheme ? props.namingScheme : new HashedAddressingScheme()); - this.name = props.stackName !== undefined ? props.stackName : this.calculateStackName(); - this.tags = new TagManager(TagType.KeyValue, 'aws:cdk:stack', props.tags); + const { account, region, environment } = this.parseEnvironment(props.env); - if (!VALID_STACK_NAME_REGEX.test(this.name)) { - throw new Error(`Stack name must match the regular expression: ${VALID_STACK_NAME_REGEX.toString()}, got '${name}'`); - } - } + this.account = account; + this.region = region; + this.environment = environment; - /** - * Returns the environment specification for this stack (aws://account/region). - */ - public get environment() { - const account = this.env.account || 'unknown-account'; - const region = this.env.region || 'unknown-region'; - return cxapi.EnvironmentUtils.format(account, region); - } - - /** - * Looks up a resource by path. - * - * @returns The Resource or undefined if not found - */ - public findResource(constructPath: string): CfnResource | undefined { - const r = this.node.findChild(constructPath); - if (!r) { return undefined; } + this.stackName = props.stackName !== undefined ? props.stackName : this.calculateStackName(); + this.tags = new TagManager(TagType.KeyValue, 'aws:cdk:stack', props.tags); - // found an element, check if it's a resource (duck-type) - if (!('resourceType' in r)) { - throw new Error(`Found a stack element for ${constructPath} but it is not a resource: ${r.toString()}`); + if (!VALID_STACK_NAME_REGEX.test(this.stackName)) { + throw new Error(`Stack name must match the regular expression: ${VALID_STACK_NAME_REGEX.toString()}, got '${name}'`); } - - return r as CfnResource; } /** @@ -198,42 +185,6 @@ export class Stack extends Construct implements ITaggable { return CloudFormationLang.toJSON(obj).toString(); } - /** - * @param why more information about why region is required. - * @returns The region in which this stack is deployed. Throws if region is not defined. - */ - public requireRegion(why?: string) { - if (!this.env.region) { - throw new Error(`${why ? why + '. ' : ''}Stack requires region information. It can be either supplied via the "env" property, ` + - `via the "${cxapi.DEFAULT_REGION_CONTEXT_KEY}" context parameters or using "aws configure"`); - } - - return this.env.region; - } - - /** - * Returns the AWS account ID of this Stack, - * or throws an exception if the account ID is not set in the environment. - * - * @param why more information about why is the account ID required - * @returns the AWS account ID of this Stack - */ - public requireAccountId(why?: string): string { - if (!this.env.account) { - throw new Error(`${why ? why + '. ' : ''}Stack requires account information. ` + - 'It can be supplied either via the "env" property when creating the Stack, or by using "aws configure"'); - } - - return this.env.account; - } - - public parentApp(): App | undefined { - const parent = this.node.scope; - return parent instanceof App - ? parent - : undefined; - } - /** * Indicate that a context key was expected * @@ -243,18 +194,37 @@ export class Stack extends Construct implements ITaggable { * @param report The set of parameters needed to obtain the context */ public reportMissingContext(report: cxapi.MissingContext) { - this.missingContext.push(report); + this._missingContext.push(report); } /** * Rename a generated logical identities + * + * To modify the naming scheme strategy, extend the `Stack` class and + * override the `createNamingScheme` method. */ - public renameLogical(oldId: string, newId: string) { - if (this.node.children.length > 0) { - throw new Error("All renames must be set up before adding elements to the stack"); - } + public renameLogicalId(oldId: string, newId: string) { + this._logicalIds.addRename(oldId, newId); + } - this.logicalIds.renameLogical(oldId, newId); + /** + * Allocates a stack-unique CloudFormation-compatible logical identity for a + * specific resource. + * + * This method is called when a `CfnElement` is created and used to render the + * initial logical identity of resources. Logical ID renames are applied at + * this stage. + * + * This method uses the protected method `allocateLogicalId` to render the + * logical ID for an element. To modify the naming scheme, extend the `Stack` + * class and override this method. + * + * @param element The CloudFormation element for which a logical identity is + * needed. + */ + public getLogicalId(element: CfnElement): string { + const logicalId = this.allocateLogicalId(element); + return this._logicalIds.applyRename(logicalId); } /** @@ -269,48 +239,14 @@ export class Stack extends Construct implements ITaggable { // tslint:disable-next-line:max-line-length throw new Error(`'${stack.node.path}' depends on '${this.node.path}' (${dep.join(', ')}). Adding this dependency (${reason}) would create a cyclic reference.`); } - this.stackDependencies.add({ stack, reason }); + this._stackDependencies.add({ stack, reason }); } /** * Return the stacks this stack depends on */ public get dependencies(): Stack[] { - return Array.from(this.stackDependencies.values()).map(d => d.stack); - } - - /** - * The account in which this stack is defined - * - * Either returns the literal account for this stack if it was specified - * literally upon Stack construction, or a symbolic value that will evaluate - * to the correct account at deployment time. - */ - public get accountId(): string { - if (this.configuredEnv.account) { - return this.configuredEnv.account; - } - // Does 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. - return Aws.accountId; - } - - /** - * The region in which this stack is defined - * - * Either returns the literal region for this stack if it was specified - * literally upon Stack construction, or a symbolic value that will evaluate - * to the correct region at deployment time. - */ - public get region(): string { - if (this.configuredEnv.region) { - return this.configuredEnv.region; - } - // Does 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. - return Aws.region; + return Array.from(this._stackDependencies.values()).map(d => d.stack); } /** @@ -339,15 +275,6 @@ export class Stack extends Construct implements ITaggable { return new ScopedAws(this).stackId; } - /** - * The name of the stack currently being deployed - * - * Only available at deployment time; this will always return an unresolved value. - */ - public get stackName(): string { - return new ScopedAws(this).stackName; - } - /** * Returns the list of notification Amazon Resource Names (ARNs) for the current stack. */ @@ -373,7 +300,7 @@ export class Stack extends Construct implements ITaggable { * can be 'undefined'. */ public formatArn(components: ArnComponents): string { - return arnFromComponents(components, this); + return Arn.format(components, this); } /** @@ -415,16 +342,54 @@ export class Stack extends Construct implements ITaggable { * components of the ARN. */ public parseArn(arn: string, sepIfToken: string = '/', hasName: boolean = true): ArnComponents { - return parseArn(arn, sepIfToken, hasName); + return Arn.parse(arn, sepIfToken, hasName); } /** - * Sets the value of a CloudFormation parameter. - * @param parameter The parameter to set the value for - * @param value The value, can use `${}` notation to reference other assembly block attributes. + * Returns the naming scheme used to allocate logical IDs. By default, uses + * the `HashedAddressingScheme` but this method can be overridden to customize + * this behavior. + * + * In order to make sure logical IDs are unique and stable, we hash the resource + * construct tree path (i.e. toplevel/secondlevel/.../myresource) and add it as + * a suffix to the path components joined without a separator (CloudFormation + * IDs only allow alphanumeric characters). + * + * The result will be: + * + * + * "human" "hash" + * + * If the "human" part of the ID exceeds 240 characters, we simply trim it so + * the total ID doesn't exceed CloudFormation's 255 character limit. + * + * We only take 8 characters from the md5 hash (0.000005 chance of collision). + * + * Special cases: + * + * - If the path only contains a single component (i.e. it's a top-level + * resource), we won't add the hash to it. The hash is not needed for + * disamiguation and also, it allows for a more straightforward migration an + * existing CloudFormation template to a CDK stack without logical ID changes + * (or renames). + * - For aesthetic reasons, if the last components of the path are the same + * (i.e. `L1/L2/Pipeline/Pipeline`), they will be de-duplicated to make the + * resulting human portion of the ID more pleasing: `L1L2Pipeline` + * instead of `L1L2PipelinePipeline` + * - If a component is named "Default" it will be omitted from the path. This + * allows refactoring higher level abstractions around constructs without affecting + * the IDs of already deployed resources. + * - If a component is named "Resource" it will be omitted from the user-visible + * path, but included in the hash. This reduces visual noise in the human readable + * part of the identifier. + * + * @param cfnElement The element for which the logical ID is allocated. */ - public setParameterValue(parameter: CfnParameter, value: string) { - this.parameterValues[parameter.logicalId] = value; + protected allocateLogicalId(cfnElement: CfnElement): string { + const scopes = cfnElement.node.scopes; + const stackIndex = scopes.indexOf(cfnElement.stack); + const pathComponents = scopes.slice(stackIndex + 1).map(x => x.node.id); + return makeUniqueId(pathComponents); } /** @@ -477,22 +442,21 @@ export class Stack extends Construct implements ITaggable { protected synthesize(session: ISynthesisSession): void { const builder = session.assembly; - const template = `${this.name}.template.json`; + const template = `${this.stackName}.template.json`; // write the CloudFormation template as a JSON file const outPath = path.join(builder.outdir, template); fs.writeFileSync(outPath, JSON.stringify(this._toCloudFormation(), undefined, 2)); - const deps = this.dependencies.map(s => s.name); + const deps = this.dependencies.map(s => s.stackName); const meta = this.collectMetadata(); const properties: cxapi.AwsCloudFormationStackProperties = { - templateFile: template, - parameters: Object.keys(this.parameterValues).length > 0 ? this.resolve(this.parameterValues) : undefined + templateFile: template }; // add an artifact that represents this stack - builder.addArtifact(this.name, { + builder.addArtifact(this.stackName, { type: cxapi.ArtifactType.AwsCloudFormationStack, environment: this.environment, properties, @@ -500,7 +464,7 @@ export class Stack extends Construct implements ITaggable { metadata: Object.keys(meta).length > 0 ? meta : undefined, }); - for (const ctx of this.missingContext) { + for (const ctx of this._missingContext) { builder.addMissing(ctx); } } @@ -512,44 +476,46 @@ export class Stack extends Construct implements ITaggable { * @internal */ protected _toCloudFormation() { - // before we begin synthesis, we shall lock this stack, so children cannot be added - this.node.lock(); - - try { - const template: any = { - Description: this.templateOptions.description, - Transform: this.templateOptions.transform, - AWSTemplateFormatVersion: this.templateOptions.templateFormatVersion, - Metadata: this.templateOptions.metadata - }; - - const elements = cfnElements(this); - const fragments = elements.map(e => this.resolve(e._toCloudFormation())); - - // merge in all CloudFormation fragments collected from the tree - for (const fragment of fragments) { - merge(template, fragment); - } - - // resolve all tokens and remove all empties - const ret = this.resolve(template) || {}; + const template: any = { + Description: this.templateOptions.description, + Transform: this.templateOptions.transform, + AWSTemplateFormatVersion: this.templateOptions.templateFormatVersion, + Metadata: this.templateOptions.metadata + }; - this.logicalIds.assertAllRenamesApplied(); + const elements = cfnElements(this); + const fragments = elements.map(e => this.resolve(e._toCloudFormation())); - return ret; - } finally { - // allow mutations after synthesis is finished. - this.node.unlock(); + // merge in all CloudFormation fragments collected from the tree + for (const fragment of fragments) { + merge(template, fragment); } + + // resolve all tokens and remove all empties + const ret = this.resolve(template) || {}; + + this._logicalIds.assertAllRenamesApplied(); + + return ret; } /** - * Applied defaults to environment attributes. + * Determine the various stack environment attributes. + * */ 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. + const region = env.region; + const account = env.account; + + // account and region 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. return { - account: env.account ? env.account : this.node.tryGetContext(cxapi.DEFAULT_ACCOUNT_CONTEXT_KEY), - region: env.region ? env.region : this.node.tryGetContext(cxapi.DEFAULT_REGION_CONTEXT_KEY) + account: account || Aws.accountId, + region: region || Aws.region, + environment: EnvironmentUtils.format(account || 'unknown-account', region || 'unknown-region') }; } @@ -561,7 +527,7 @@ export class Stack extends Construct implements ITaggable { */ private stackDependencyReasons(other: Stack): string[] | undefined { if (this === other) { return []; } - for (const dep of this.stackDependencies) { + for (const dep of this._stackDependencies) { const ret = dep.stack.stackDependencyReasons(other); if (ret !== undefined) { return [dep.reason].concat(ret); @@ -576,12 +542,6 @@ export class Stack extends Construct implements ITaggable { visit(this); - const app = this.parentApp(); - - if (app && app.node.metadata.length > 0) { - output[ConstructNode.PATH_SEP] = app.node.metadata; - } - return output; function visit(node: IConstruct) { @@ -688,7 +648,7 @@ function cfnElements(node: IConstruct, into: CfnElement[] = []): CfnElement[] { } // These imports have to be at the end to prevent circular imports -import { ArnComponents, arnFromComponents, parseArn } from './arn'; +import { Arn, ArnComponents } from './arn'; import { CfnElement } from './cfn-element'; import { CfnResource, TagType } from './cfn-resource'; import { CfnReference } from './private/cfn-reference'; diff --git a/packages/@aws-cdk/cdk/test/test.app.ts b/packages/@aws-cdk/cdk/test/test.app.ts index a4d3862350a1e..43ecd055a6104 100644 --- a/packages/@aws-cdk/cdk/test/test.app.ts +++ b/packages/@aws-cdk/cdk/test/test.app.ts @@ -67,7 +67,6 @@ export = { [{ type: 'aws:cdk:logicalId', data: 's1c2' }, { type: 'aws:cdk:warning', data: 'warning1' }, { type: 'aws:cdk:warning', data: 'warning2' }], - '/': [{ type: 'applevel', data: 123 }] }); const stack2 = response.stacks[1]; @@ -84,7 +83,6 @@ export = { [{ type: 'aws:cdk:logicalId', data: 's1c2r1D1791C01' }], '/stack2/s1c2/r2': [{ type: 'aws:cdk:logicalId', data: 's1c2r25F685FFF' }], - '/': [{ type: 'applevel', data: 123 }] }); test.done(); diff --git a/packages/@aws-cdk/cdk/test/test.construct.ts b/packages/@aws-cdk/cdk/test/test.construct.ts index a467bef17a774..bbe740acdc508 100644 --- a/packages/@aws-cdk/cdk/test/test.construct.ts +++ b/packages/@aws-cdk/cdk/test/test.construct.ts @@ -359,11 +359,11 @@ export = { class LockableConstruct extends Construct { public lockMe() { - this.node.lock(); + (this.node as any)._lock(); } public unlockMe() { - this.node.unlock(); + (this.node as any)._unlock(); } } diff --git a/packages/@aws-cdk/cdk/test/test.context.ts b/packages/@aws-cdk/cdk/test/test.context.ts index 5ffc6e43e704d..25df0b72b6b2f 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, AvailabilityZoneProvider, Construct, ConstructNode, ContextProvider, SSMParameterProvider, Stack } from '../lib'; +import { App, Construct, ConstructNode, Context, ContextProvider, Stack } from '../lib'; 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 = new AvailabilityZoneProvider(stack).availabilityZones; + const azs = Context.getAvailabilityZones(stack); 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 = new AvailabilityZoneProvider(stack).availabilityZones; + const before = Context.getAvailabilityZones(stack); test.deepEqual(before, [ 'dummy1a', 'dummy1b', 'dummy1c' ]); const key = expectedContextKey(stack); stack.node.setContext(key, ['us-east-1a', 'us-east-1b']); - const azs = new AvailabilityZoneProvider(stack).availabilityZones; + const azs = Context.getAvailabilityZones(stack); 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 = new AvailabilityZoneProvider(stack).availabilityZones; + const before = Context.getAvailabilityZones(stack); test.deepEqual(before, [ 'dummy1a', 'dummy1b', 'dummy1c' ]); const key = expectedContextKey(stack); stack.node.setContext(key, 'not-a-list'); test.throws( - () => new AvailabilityZoneProvider(stack).availabilityZones + () => Context.getAvailabilityZones(stack) ); test.done(); @@ -79,13 +79,13 @@ export = { 'SSM parameter provider will return context values if available'(test: Test) { const stack = new Stack(undefined, 'TestStack', { env: { account: '12345', region: 'us-east-1' } }); - new SSMParameterProvider(stack, {parameterName: 'test'}).parameterValue(); + Context.getSsmParameter(stack, 'test'); const key = expectedContextKey(stack); stack.node.setContext(key, 'abc'); - const ssmp = new SSMParameterProvider(stack, {parameterName: 'test'}); - const azs = stack.resolve(ssmp.parameterValue()); + const ssmp = Context.getSsmParameter(stack, 'test'); + const azs = stack.resolve(ssmp); test.deepEqual(azs, 'abc'); test.done(); @@ -97,8 +97,8 @@ export = { const child = new Construct(stack, 'ChildConstruct'); - test.deepEqual(new AvailabilityZoneProvider(stack).availabilityZones, [ 'dummy1a', 'dummy1b', 'dummy1c' ]); - test.deepEqual(new SSMParameterProvider(child, {parameterName: 'foo'}).parameterValue(), 'dummy'); + 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'); @@ -111,6 +111,18 @@ export = { 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 012c9f211994e..eaa0b8443b75f 100644 --- a/packages/@aws-cdk/cdk/test/test.environment.ts +++ b/packages/@aws-cdk/cdk/test/test.environment.ts @@ -1,17 +1,18 @@ import { DEFAULT_ACCOUNT_CONTEXT_KEY, DEFAULT_REGION_CONTEXT_KEY } from '@aws-cdk/cx-api'; import { Test } from 'nodeunit'; -import { App, Stack } from '../lib'; +import { App, Stack, Token } from '../lib'; export = { - 'By default, environment region and account are not defined'(test: Test) { + 'By default, environment region and account are not defined and resolve to intrinsics'(test: Test) { const stack = new Stack(); - test.ok(stack.env); - test.equal(stack.env.account, null); - test.equal(stack.env.region, null); + test.ok(Token.isUnresolved(stack.account)); + test.ok(Token.isUnresolved(stack.region)); + test.deepEqual(stack.resolve(stack.account), { Ref: "AWS::AccountId" }); + test.deepEqual(stack.resolve(stack.region), { Ref: "AWS::Region" }); test.done(); }, - 'Default account and region can be set in context (`aws:cdk:toolkit:default-account` and `aws:cdk:toolkit:default-region`)'(test: Test) { + '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'); @@ -19,27 +20,24 @@ export = { const stack = new Stack(app, 'my-stack'); - test.equal(stack.env.account, 'my-default-account'); - test.equal(stack.env.region, 'my-default-region'); + 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, defaults will be used for the other'(test: Test) { + 'If only `env.region` or `env.account` are specified, Refs will be used for the other'(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 stack1 = new Stack(app, 'S1', { env: { region: 'only-region' } }); const stack2 = new Stack(app, 'S2', { env: { account: 'only-account' } }); - test.equal(stack1.env.account, 'my-default-account'); - test.equal(stack1.env.region, 'only-region'); + test.deepEqual(stack1.resolve(stack1.account), { Ref: 'AWS::AccountId' }); + test.deepEqual(stack1.resolve(stack1.region), 'only-region'); - test.equal(stack2.env.account, 'only-account'); - test.equal(stack2.env.region, 'my-default-region'); + test.deepEqual(stack2.resolve(stack2.account), 'only-account'); + test.deepEqual(stack2.resolve(stack2.region), { Ref: 'AWS::Region' }); test.done(); }, -} as any; +}; diff --git a/packages/@aws-cdk/cdk/test/test.logical-id.ts b/packages/@aws-cdk/cdk/test/test.logical-id.ts index f8122064b238b..25a6219ee86a8 100644 --- a/packages/@aws-cdk/cdk/test/test.logical-id.ts +++ b/packages/@aws-cdk/cdk/test/test.logical-id.ts @@ -1,14 +1,14 @@ import { Test } from 'nodeunit'; -import { CfnResource, Construct, ConstructNode, HashedAddressingScheme, IAddressingScheme, Stack } from '../lib'; +import { CfnElement, CfnResource, Construct, Stack } from '../lib'; import { toCloudFormation } from './util'; /** * These tests are executed once (for specific ID schemes) */ -const uniqueTests = { +export = { 'if the naming scheme uniquifies with a hash we can have the same concatenated identifier'(test: Test) { // GIVEN - const stack = new Stack(undefined, 'TestStack', { namingScheme: new HashedAddressingScheme() }); + const stack = new Stack(undefined, 'TestStack'); const A = new Construct(stack, 'A'); new CfnResource(A, 'BC', { type: 'Resource' }); @@ -24,7 +24,7 @@ const uniqueTests = { 'special case: if the resource is top-level, a hash is not added'(test: Test) { // GIVEN - const stack = new Stack(undefined, 'TestStack', { namingScheme: new HashedAddressingScheme() }); + const stack = new Stack(undefined, 'TestStack'); // WHEN const r = new CfnResource(stack, 'MyAwesomeness', { type: 'Resource' }); @@ -38,11 +38,11 @@ const uniqueTests = { 'Logical IDs can be renamed at the stack level'(test: Test) { // GIVEN const stack = new Stack(); - stack.renameLogical('ParentThingResource75D1D9CB', 'Renamed'); // WHEN const parent = new Construct(stack, 'Parent'); new CfnResource(parent, 'ThingResource', { type: 'AWS::TAAS::Thing' }); + stack.renameLogicalId('ParentThingResource75D1D9CB', 'Renamed'); // THEN const template = toCloudFormation(stack); @@ -54,10 +54,10 @@ const uniqueTests = { 'Renames for objects that don\'t exist fail'(test: Test) { // GIVEN const stack = new Stack(); - stack.renameLogical('DOESNOTEXIST', 'Renamed'); + new Construct(stack, 'Parent'); // WHEN - new Construct(stack, 'Parent'); + stack.renameLogicalId('DOESNOTEXIST', 'Renamed'); // THEN test.throws(() => toCloudFormation(stack)); @@ -68,17 +68,15 @@ const uniqueTests = { 'ID Renames that collide with existing IDs should fail'(test: Test) { // GIVEN const stack = new Stack(); - stack.renameLogical('ParentThingResource1916E7808', 'ParentThingResource2F19948CB'); + stack.renameLogicalId('ParentThingResource1916E7808', 'ParentThingResource2F19948CB'); // WHEN const parent = new Construct(stack, 'Parent'); new CfnResource(parent, 'ThingResource1', { type: 'AWS::TAAS::Thing' }); + new CfnResource(parent, 'ThingResource2', { type: 'AWS::TAAS::Thing' }); // THEN - test.throws(() => { - new CfnResource(parent, 'ThingResource2', { type: 'AWS::TAAS::Thing' }); - }); - + test.throws(() => toCloudFormation(stack), /Two objects have been assigned the same Logical ID/); test.done(); }, @@ -134,9 +132,8 @@ const uniqueTests = { }, 'non-alphanumeric characters are removed from the human part of the logical ID'(test: Test) { - const scheme = new HashedAddressingScheme(); - const val1 = scheme.allocateAddress([ 'Foo-bar', 'B00m', 'Hello_World', '&&Horray Horray.' ]); - const val2 = scheme.allocateAddress([ 'Foobar', 'B00m', 'HelloWorld', 'HorrayHorray' ]); + const val1 = logicalForElementInPath([ 'Foo-bar', 'B00m', 'Hello_World', '&&Horray Horray.' ]); + const val2 = logicalForElementInPath([ 'Foobar', 'B00m', 'HelloWorld', 'HorrayHorray' ]); // same human part, different hash test.deepEqual(val1, 'FoobarB00mHelloWorldHorrayHorray640E99FB'); @@ -145,38 +142,28 @@ const uniqueTests = { }, 'non-alphanumeric characters are removed even if the ID has only one component'(test: Test) { - const scheme = new HashedAddressingScheme(); - const val1 = scheme.allocateAddress([ 'Foo-bar' ]); + const val1 = logicalForElementInPath([ 'Foo-bar' ]); // same human part, different hash test.deepEqual(val1, 'Foobar'); test.done(); - } -}; - -const schemes: {[name: string]: IAddressingScheme} = { - "hashing scheme": new HashedAddressingScheme(), -}; + }, -/** - * These tests are executed for all generators - */ -const allSchemesTests: {[name: string]: (scheme: IAddressingScheme, test: Test) => void } = { - 'empty identifiers are not allowed'(scheme: IAddressingScheme, test: Test) { + 'empty identifiers are not allowed'(test: Test) { // GIVEN - const stack = new Stack(undefined, 'TestStack', { namingScheme: scheme }); + const stack = new Stack(); // WHEN - test.throws(() => { - new CfnResource(stack, '.', { type: 'R' }); - }); + new CfnResource(stack, '.', { type: 'R' }); + + // THEN + test.throws(() => toCloudFormation(stack), /Logical ID must adhere to the regular expression/); test.done(); }, - 'too large identifiers are truncated yet still remain unique'(scheme: IAddressingScheme, test: Test) { + 'too large identifiers are truncated yet still remain unique'(test: Test) { // GIVEN - const stack = new Stack(undefined, 'TestStack', { namingScheme: scheme }); - + const stack = new Stack(); const A = new Construct(stack, generateString(100)); const B = new Construct(A, generateString(100)); @@ -196,10 +183,10 @@ const allSchemesTests: {[name: string]: (scheme: IAddressingScheme, test: Test) test.done(); }, - 'Refs and dependencies will correctly reflect renames done at the stack level'(scheme: IAddressingScheme, test: Test) { + 'Refs and dependencies will correctly reflect renames done at the stack level'(test: Test) { // GIVEN - const stack = new Stack(undefined, 'TestStack', { namingScheme: scheme }); - stack.renameLogical('OriginalName', 'NewName'); + const stack = new Stack(); + stack.renameLogicalId('OriginalName', 'NewName'); // WHEN const c1 = new CfnResource(stack, 'OriginalName', { type: 'R1' }); @@ -209,34 +196,50 @@ const allSchemesTests: {[name: string]: (scheme: IAddressingScheme, test: Test) c2.node.addDependency(c1); // THEN - ConstructNode.prepare(stack.node); test.deepEqual(toCloudFormation(stack), { Resources: { - NewName: { - Type: 'R1' }, + NewName: { Type: 'R1' }, Construct2: { Type: 'R2', - Properties: { - ReferenceToR1: { Ref: 'NewName' } }, - DependsOn: [ 'NewName' ] } } }); + Properties: { ReferenceToR1: { Ref: 'NewName' } }, + DependsOn: [ 'NewName' ] + } + } + }); test.done(); }, -}; -// Combine the one-off tests and generate tests for each scheme -const exp: any = uniqueTests; -Object.keys(schemes).forEach(schemeName => { - const scheme = schemes[schemeName]; - Object.keys(allSchemesTests).forEach(testName => { - const testFunction = allSchemesTests[testName]; - exp[`${schemeName}: ${testName}`] = (test: Test) => { - testFunction(scheme, test); - }; - }); -}); + 'customize logical id allocation behavior by overriding `Stack.allocateLogicalId`'(test: Test) { + class MyStack extends Stack { + protected allocateLogicalId(element: CfnElement): string { + if (element.node.id === 'A') { return 'LogicalIdOfA'; } + if (element.node.id === 'B') { return 'LogicalIdOfB'; } + throw new Error(`Invalid element ID`); + } + } -export = exp; + const stack = new MyStack(); + new CfnResource(stack, 'A', { type: 'Type::Of::A' }); + const group = new Construct(stack, 'Group'); + new CfnResource(group, 'B', { type: 'Type::Of::B' }); + + // renames can also be applied on custom logical IDs. + stack.renameLogicalId('LogicalIdOfB', 'BoomBoomB'); + + const c = new CfnResource(stack, 'B', { type: 'Type::Of::C' }); + c.overrideLogicalId('TheC'); + + test.deepEqual(toCloudFormation(stack), { + Resources: { + LogicalIdOfA: { Type: 'Type::Of::A' }, + BoomBoomB: { Type: 'Type::Of::B' }, + TheC: { Type: 'Type::Of::C' } + } + }); + test.done(); + } +}; function generateString(chars: number) { let s = ''; @@ -249,3 +252,13 @@ function generateString(chars: number) { return String.fromCharCode('a'.charCodeAt(0) + Math.floor(Math.random() * 26)); } } + +function logicalForElementInPath(constructPath: string[]): string { + const stack = new Stack(); + let scope: Construct = stack; + for (const component of constructPath) { + scope = new CfnResource(scope, component, { type: 'Foo' }); + } + + return stack.resolve((scope as CfnResource).logicalId); +} \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/test/test.resource.ts b/packages/@aws-cdk/cdk/test/test.resource.ts index d66c13f871c63..ce2bba32967c8 100644 --- a/packages/@aws-cdk/cdk/test/test.resource.ts +++ b/packages/@aws-cdk/cdk/test/test.resource.ts @@ -2,7 +2,7 @@ import cxapi = require('@aws-cdk/cx-api'); import { Test } from 'nodeunit'; import { App, App as Root, applyRemovalPolicy, CfnCondition, CfnResource, Construct, ConstructNode, DeletionPolicy, - Fn, HashedAddressingScheme, RemovalPolicy, Stack } from '../lib'; + Fn, RemovalPolicy, Stack } from '../lib'; import { toCloudFormation } from './util'; export = { @@ -38,7 +38,7 @@ export = { }, 'all entities have a logical ID calculated based on their full path in the tree'(test: Test) { - const stack = new Stack(undefined, 'TestStack', { namingScheme: new HashedAddressingScheme() }); + const stack = new Stack(undefined, 'TestStack'); const level1 = new Construct(stack, 'level1'); const level2 = new Construct(level1, 'level2'); const level3 = new Construct(level2, 'level3'); @@ -662,7 +662,7 @@ export = { // THEN const assembly = app.run(); - const templateB = assembly.getStack(stackB.name).template; + const templateB = assembly.getStack(stackB.stackName).template; test.deepEqual(templateB, { Resources: { diff --git a/packages/@aws-cdk/cdk/test/test.stack.ts b/packages/@aws-cdk/cdk/test/test.stack.ts index 28ed10d1f5eb0..73e55363b5c98 100644 --- a/packages/@aws-cdk/cdk/test/test.stack.ts +++ b/packages/@aws-cdk/cdk/test/test.stack.ts @@ -90,31 +90,6 @@ export = { test.done(); }, - 'Construct.findResource(logicalId) can be used to retrieve a resource by its path'(test: Test) { - const stack = new Stack(); - - test.ok(!stack.node.tryFindChild('foo'), 'empty stack'); - - const r1 = new CfnResource(stack, 'Hello', { type: 'MyResource' }); - test.equal(stack.findResource(r1.node.path), r1, 'look up top-level'); - - const child = new Construct(stack, 'Child'); - const r2 = new CfnResource(child, 'Hello', { type: 'MyResource' }); - - test.equal(stack.findResource(r2.node.path), r2, 'look up child'); - - test.done(); - }, - - 'Stack.findResource will fail if the element is not a resource'(test: Test) { - const stack = new Stack(); - - const p = new CfnParameter(stack, 'MyParam', { type: 'String' }); - - test.throws(() => stack.findResource(p.node.path)); - test.done(); - }, - 'Stack.getByPath can be used to find any CloudFormation element (Parameter, Output, etc)'(test: Test) { const stack = new Stack(); @@ -166,8 +141,8 @@ export = { // THEN const assembly = app.synth(); - const template1 = assembly.getStack(stack1.name).template; - const template2 = assembly.getStack(stack2.name).template; + const template1 = assembly.getStack(stack1.stackName).template; + const template2 = assembly.getStack(stack2.stackName).template; test.deepEqual(template1, { Outputs: { @@ -204,7 +179,7 @@ export = { // THEN const assembly = app.synth(); - const template2 = assembly.getStack(stack2.name).template; + const template2 = assembly.getStack(stack2.stackName).template; test.deepEqual(template2, { Resources: { @@ -230,8 +205,8 @@ export = { new CfnParameter(stack2, 'SomeParameter', { type: 'String', default: Lazy.stringValue({ produce: () => account1 }) }); const assembly = app.synth(); - const template1 = assembly.getStack(stack1.name).template; - const template2 = assembly.getStack(stack2.name).template; + const template1 = assembly.getStack(stack1.stackName).template; + const template2 = assembly.getStack(stack2.stackName).template; // THEN test.deepEqual(template1, { @@ -255,7 +230,7 @@ export = { test.done(); }, - 'Cross-stack use of Region returns nonscoped intrinsic'(test: Test) { + 'Cross-stack use of Region and account returns nonscoped intrinsic because the two stacks must be in the same region anyway'(test: Test) { // GIVEN const app = new App(); const stack1 = new Stack(app, 'Stack1'); @@ -263,15 +238,19 @@ export = { // WHEN - used in another stack new CfnOutput(stack2, 'DemOutput', { value: stack1.region }); + new CfnOutput(stack2, 'DemAccount', { value: stack1.account }); // THEN const assembly = app.synth(); - const template2 = assembly.getStack(stack2.name).template; + const template2 = assembly.getStack(stack2.stackName).template; test.deepEqual(template2, { Outputs: { DemOutput: { Value: { Ref: 'AWS::Region' }, + }, + DemAccount: { + Value: { Ref: 'AWS::AccountId' }, } } }); @@ -290,7 +269,7 @@ export = { new CfnParameter(stack2, 'SomeParameter', { type: 'String', default: `TheAccountIs${account1}` }); const assembly = app.synth(); - const template2 = assembly.getStack(stack2.name).template; + const template2 = assembly.getStack(stack2.stackName).template; // THEN test.deepEqual(template2, { @@ -413,7 +392,7 @@ export = { const stack = new Stack(undefined, 'Stack', { stackName: 'otherName' }); // THEN - test.deepEqual(stack.name, 'otherName'); + test.deepEqual(stack.stackName, 'otherName'); test.done(); }, @@ -425,7 +404,7 @@ export = { const stack = new Stack(app, 'Stack'); // THEN - test.deepEqual(stack.name, 'ProdStackD5279B22'); + test.deepEqual(stack.stackName, 'ProdStackD5279B22'); test.done(); }, @@ -441,7 +420,7 @@ export = { // THEN const session = app.synth(); - test.deepEqual(stack.name, 'valid-stack-name'); + test.deepEqual(stack.stackName, 'valid-stack-name'); test.ok(session.tryGetArtifact('valid-stack-name')); test.done(); }, diff --git a/packages/@aws-cdk/cdk/test/test.synthesis.ts b/packages/@aws-cdk/cdk/test/test.synthesis.ts index bd7aa617d7b4e..c7b2a506e8c7c 100644 --- a/packages/@aws-cdk/cdk/test/test.synthesis.ts +++ b/packages/@aws-cdk/cdk/test/test.synthesis.ts @@ -142,24 +142,6 @@ export = { test.deepEqual(stack.environment, { region: 'us-east-1', account: 'unknown-account', name: 'aws://unknown-account/us-east-1' }); test.done(); }, - - 'stack.setParameterValue can be used to assign parameters'(test: Test) { - // GIVEN - const app = createModernApp(); - const stack = new cdk.Stack(app, 'my-stack'); - const param = new cdk.CfnParameter(stack, 'MyParam', { type: 'string' }); - - // WHEN - stack.setParameterValue(param, 'Foo'); - - // THEN - const session = app.synth(); - const artifact = session.getStack('my-stack'); - test.deepEqual(artifact.parameters, { - MyParam: 'Foo' - }); - test.done(); - }, }; function list(outdir: string) { diff --git a/packages/@aws-cdk/cdk/test/util.ts b/packages/@aws-cdk/cdk/test/util.ts index de39d66917b16..e921f7b97d859 100644 --- a/packages/@aws-cdk/cdk/test/util.ts +++ b/packages/@aws-cdk/cdk/test/util.ts @@ -1,5 +1,5 @@ import { ConstructNode, Stack } from '../lib'; export function toCloudFormation(stack: Stack): any { - return ConstructNode.synth(stack.node, { skipValidation: true }).getStack(stack.name).template; + return ConstructNode.synth(stack.node, { skipValidation: true }).getStack(stack.stackName).template; } diff --git a/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts b/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts index 551d92cc7015d..1510c9df566b3 100644 --- a/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts @@ -31,9 +31,9 @@ export class CloudFormationStackArtifact extends CloudArtifact { public readonly parameters: { [id: string]: string }; /** - * The name of this stack. This is read/write and can be used to rename the stack. + * The name of this stack. */ - public name: string; + public readonly name: string; constructor(assembly: CloudAssembly, name: string, artifact: ArtifactManifest) { super(assembly, name, artifact); diff --git a/packages/@aws-cdk/cx-api/lib/environment.ts b/packages/@aws-cdk/cx-api/lib/environment.ts index 5a11eaa602653..cb6845f058af9 100644 --- a/packages/@aws-cdk/cx-api/lib/environment.ts +++ b/packages/@aws-cdk/cx-api/lib/environment.ts @@ -1,7 +1,9 @@ /** * Parser for the artifact environment field. + * + * Account validation is relaxed to allow account aliasing in the future. */ -const AWS_ENV_REGEX = /aws\:\/\/([0-9]+|unknown-account)\/([a-z\-0-9]+)/; +const AWS_ENV_REGEX = /aws\:\/\/([a-z0-9A-Z\-\@\.\_]+)\/([a-z\-0-9]+)/; /** * Models an AWS execution environment, for use within the CDK toolkit. @@ -10,7 +12,7 @@ export interface Environment { /** The arbitrary name of this environment (user-set, or at least user-meaningful) */ readonly name: string; - /** The 12-digit AWS account ID for the account this environment deploys into */ + /** The AWS account this environment deploys into */ readonly account: string; /** The AWS region name where this environment deploys into */ @@ -23,7 +25,7 @@ export class EnvironmentUtils { if (!env) { throw new Error( `Unable to parse environment specification "${environment}". ` + - `Expected format: aws://acount/region`); + `Expected format: aws://account/region`); } const [ , account, region ] = env; diff --git a/packages/@aws-cdk/cx-api/test/environment.test.ts b/packages/@aws-cdk/cx-api/test/environment.test.ts new file mode 100644 index 0000000000000..e5d067e1b0635 --- /dev/null +++ b/packages/@aws-cdk/cx-api/test/environment.test.ts @@ -0,0 +1,27 @@ +import { EnvironmentUtils } from '../lib'; + +test('format', () => { + expect(EnvironmentUtils.format('my-account', 'my-region')).toBe('aws://my-account/my-region'); +}); + +test('parse', () => { + expect(EnvironmentUtils.parse('aws://123456789/us-east-1')).toStrictEqual({ + name: 'aws://123456789/us-east-1', + account: '123456789', + region: 'us-east-1' + }); + + // parser is not super strict to allow users to do some magical things if they want + expect(EnvironmentUtils.parse('aws://boom@voom.com/ok-x-x-123')).toStrictEqual({ + name: 'aws://boom@voom.com/ok-x-x-123', + account: 'boom@voom.com', + region: 'ok-x-x-123' + }); +}); + +test('parse failures', () => { + expect(() => EnvironmentUtils.parse('boom')).toThrow('Unable to parse environment specification'); + expect(() => EnvironmentUtils.parse('boom://boom/boom')).toThrow('Unable to parse environment specification'); + expect(() => EnvironmentUtils.parse('boom://xx//xz/x/boom')).toThrow('Unable to parse environment specification'); + expect(() => EnvironmentUtils.parse('aws:://998988383/fu-x-x')).toThrow('Unable to parse environment specification'); +}); \ No newline at end of file diff --git a/packages/aws-cdk/.gitignore b/packages/aws-cdk/.gitignore index 00f5e67ea2b57..93f15b0ddeed7 100644 --- a/packages/aws-cdk/.gitignore +++ b/packages/aws-cdk/.gitignore @@ -7,7 +7,6 @@ dist # Generated by generate.sh build-info.json -.LAST_VERSION_CHECK .LAST_BUILD .nyc_output diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 8c5b259fdc23f..cd79e56322fba 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -14,7 +14,6 @@ import { RequireApproval } from '../lib/diff'; import { availableInitLanguages, cliInit, printAvailableTemplates } from '../lib/init'; import { data, debug, error, print, setVerbose, success } from '../lib/logging'; import { PluginHost } from '../lib/plugin'; -import { parseRenames } from '../lib/renames'; import { serializeStructure } from '../lib/serialize'; import { Configuration, Settings } from '../lib/settings'; import version = require('../lib/version'); @@ -31,7 +30,6 @@ async function parseCommandLineArguments() { .option('app', { type: 'string', alias: 'a', desc: 'REQUIRED: command-line for executing your app or a cloud assembly directory (e.g. "node bin/my-app.js")', requiresArg: true }) .option('context', { type: 'array', alias: 'c', desc: 'Add contextual string parameter (KEY=VALUE)', nargs: 1, requiresArg: true }) .option('plugin', { type: 'array', alias: 'p', desc: 'Name or path of a node package that extend the CDK features. Can be specified multiple times', nargs: 1 }) - .option('rename', { type: 'string', desc: 'Rename stack name if different from the one defined in the cloud executable ([ORIGINAL:]RENAMED)', requiresArg: true }) .option('trace', { type: 'boolean', desc: 'Print trace for stack warnings' }) .option('strict', { type: 'boolean', desc: 'Do not construct stacks with warnings' }) .option('ignore-errors', { type: 'boolean', default: false, desc: 'Ignores synthesis errors, which will likely produce an invalid output' }) @@ -112,7 +110,6 @@ async function initCommandLine() { configuration, aws, synthesizer: execProgram, - renames: parseRenames(argv.rename) }); /** Function to load plug-ins, using configurations additively. */ diff --git a/packages/aws-cdk/lib/api/cxapp/stacks.ts b/packages/aws-cdk/lib/api/cxapp/stacks.ts index ce870249115e1..e9438c5e2c359 100644 --- a/packages/aws-cdk/lib/api/cxapp/stacks.ts +++ b/packages/aws-cdk/lib/api/cxapp/stacks.ts @@ -4,7 +4,6 @@ import colors = require('colors/safe'); import minimatch = require('minimatch'); import contextproviders = require('../../context-providers'); import { debug, error, print, warning } from '../../logging'; -import { Renames } from '../../renames'; import { Configuration } from '../../settings'; import { SDK } from '../util/sdk'; @@ -45,11 +44,6 @@ export interface AppStacksProps { */ aws: SDK; - /** - * Renames to apply - */ - renames?: Renames; - /** * Callback invoked to synthesize the actual stacks */ @@ -69,11 +63,7 @@ export class AppStacks { */ public assembly?: cxapi.CloudAssembly; - private readonly renames: Renames; - - constructor(private readonly props: AppStacksProps) { - this.renames = props.renames || new Renames({}); - } + constructor(private readonly props: AppStacksProps) {} /** * List all stacks in the CX and return the selected ones @@ -92,7 +82,6 @@ export class AppStacks { if (selectors.length === 0) { // remove non-auto deployed Stacks debug('Stack name not specified, so defaulting to all available stacks: ' + listStackNames(stacks)); - this.applyRenames(stacks); return stacks; } @@ -132,7 +121,6 @@ export class AppStacks { // Only check selected stacks for errors this.processMessages(selectedList); - this.applyRenames(selectedList); return selectedList; } @@ -144,8 +132,6 @@ export class AppStacks { * topologically sorted order. If there are dependencies that are not in the * set, they will be ignored; it is the user's responsibility that the * non-selected stacks have already been deployed previously. - * - * Renames are *NOT* applied in list mode. */ public async listStacks(): Promise { const response = await this.synthesizeStacks(); @@ -158,7 +144,6 @@ export class AppStacks { public async synthesizeStack(stackName: string): Promise { const resp = await this.synthesizeStacks(); const stack = resp.getStack(stackName); - this.applyRenames([stack]); return stack; } @@ -277,13 +262,6 @@ export class AppStacks { logFn(` ${entry.trace.join('\n ')}`); } } - - private applyRenames(stacks: cxapi.CloudFormationStackArtifact[]) { - this.renames.validateSelectedStacks(stacks); - for (const stack of stacks) { - stack.name = this.renames.finalName(stack.name); - } - } } /** diff --git a/packages/aws-cdk/lib/renames.ts b/packages/aws-cdk/lib/renames.ts deleted file mode 100644 index ec422d4f56af5..0000000000000 --- a/packages/aws-cdk/lib/renames.ts +++ /dev/null @@ -1,83 +0,0 @@ -import cxapi = require('@aws-cdk/cx-api'); -import util = require('./util'); - -export type RenameTable = {[key: string]: string}; - -/** - * A class used to maintain a set of rename directives - */ -export class Renames { - constructor(private readonly renameTable: RenameTable, private readonly defaultRename?: string) { - } - - /** - * Check the selected stacks against the renames, to see that they make sense - * - * We explicitly don't check that renamed stacks are in the passed set, so - * that people may use a default rename table even when only selecting - * subsets of stacks. - * - * We DO check that if there's a default rename (simple syntax) they - * only selected one stack. - */ - public validateSelectedStacks(stacks: cxapi.CloudFormationStackArtifact[]) { - if (this.hasDefaultRename && stacks.length > 1) { - throw new Error("When selecting multiple stacks, you must use the 'ORIGINALNAME:RENAME' pattern for renames."); - } - } - - /** - * Whether this rename has a single rename that renames any stack - */ - public get hasDefaultRename() { - return this.defaultRename != null; - } - - /** - * Return the target name for a given stack name - * - * Returns either the renamed name or the original name. - */ - public finalName(name: string): string { - const rename = this.lookupRename(name); - - if (rename != null) { - return rename; - } - - return name; - } - - private lookupRename(name: string): string | undefined { - if (this.hasDefaultRename) { - return this.defaultRename; - } - return this.renameTable[name]; - } - -} - -/** - * Parse a rename expression string and construct a Renames object from it - * - * The rename expression looks like: - * - * [OLD:]NEW[,OLD:NEW[,...]] - * - * If there is more than one rename, every entry must have an OLD name. - */ -export function parseRenames(renameExpr: string|undefined): Renames { - if (renameExpr == null || renameExpr.trim().length === 0) { return new Renames({}); } - - const clauses = renameExpr.split(','); - if (clauses.length === 1 && clauses[0].indexOf(':') === -1) { - return new Renames({}, clauses[0]); - } - - const table = util.makeObject(clauses - .map(s => s.trim()) - .filter(s => s.length > 0) - .map(clause => clause.split(':', 2) as [string, string])); - - return new Renames(table); -} diff --git a/packages/aws-cdk/lib/version.ts b/packages/aws-cdk/lib/version.ts index 7d9933b6b8f03..8e4f9128b56e2 100644 --- a/packages/aws-cdk/lib/version.ts +++ b/packages/aws-cdk/lib/version.ts @@ -1,17 +1,16 @@ import { exec as _exec } from 'child_process'; import colors = require('colors/safe'); -import { close as _close, open as _open, stat as _stat } from 'fs'; +import fs = require('fs-extra'); +import os = require('os'); +import path = require('path'); import semver = require('semver'); import { promisify } from 'util'; -import { debug, print, warning } from '../lib/logging'; +import { debug, print } from '../lib/logging'; import { formatAsBanner } from '../lib/util/console-formatters'; const ONE_DAY_IN_SECONDS = 1 * 24 * 60 * 60; -const close = promisify(_close); const exec = promisify(_exec); -const open = promisify(_open); -const stat = promisify(_stat); export const DISPLAY_VERSION = `${versionNumber()} (build ${commit()})`; @@ -23,23 +22,39 @@ function commit(): string { return require('../build-info.json').commit; } -export class TimestampFile { +export class VersionCheckTTL { + public static timestampFilePath(): string { + // Get the home directory from the OS, first. Fallback to $HOME. + const homedir = os.userInfo().homedir || os.homedir(); + if (!homedir || !homedir.trim()) { + throw new Error('Cannot determine home directory'); + } + // Using the same path from account-cache.ts + return path.join(homedir, '.cdk', 'cache', 'repo-version-ttl'); + } + private readonly file: string; - // File modify times are accurate only till the second, hence using seconds as precision + // File modify times are accurate only to the second private readonly ttlSecs: number; - constructor(file: string, ttlSecs: number) { - this.file = file; - this.ttlSecs = ttlSecs; + constructor(file?: string, ttlSecs?: number) { + this.file = file || VersionCheckTTL.timestampFilePath(); + try { + fs.mkdirsSync(path.dirname(this.file)); + fs.accessSync(path.dirname(this.file), fs.constants.W_OK); + } catch { + throw new Error(`Directory (${path.dirname(this.file)}) is not writable.`); + } + this.ttlSecs = ttlSecs || ONE_DAY_IN_SECONDS; } public async hasExpired(): Promise { try { - const lastCheckTime = (await stat(this.file)).mtimeMs; + const lastCheckTime = (await fs.stat(this.file)).mtimeMs; const today = new Date().getTime(); - if ((today - lastCheckTime) / 1000 > this.ttlSecs) { // convert ms to secs + if ((today - lastCheckTime) / 1000 > this.ttlSecs) { // convert ms to sec return true; } return false; @@ -52,15 +67,17 @@ export class TimestampFile { } } - public async update(): Promise { - const fd = await open(this.file, 'w'); - await close(fd); + public async update(latestVersion?: string): Promise { + if (!latestVersion) { + latestVersion = ''; + } + await fs.writeFile(this.file, latestVersion); } } // Export for unit testing only. // Don't use directly, use displayVersionMessage() instead. -export async function latestVersionIfHigher(currentVersion: string, cacheFile: TimestampFile): Promise { +export async function latestVersionIfHigher(currentVersion: string, cacheFile: VersionCheckTTL): Promise { if (!(await cacheFile.hasExpired())) { return null; } @@ -74,7 +91,7 @@ export async function latestVersionIfHigher(currentVersion: string, cacheFile: T throw new Error(`npm returned an invalid semver ${latestVersion}`); } const isNewer = semver.gt(latestVersion, currentVersion); - await cacheFile.update(); + await cacheFile.update(latestVersion); if (isNewer) { return latestVersion; @@ -83,14 +100,13 @@ export async function latestVersionIfHigher(currentVersion: string, cacheFile: T } } -const versionCheckCache = new TimestampFile(`${__dirname}/../.LAST_VERSION_CHECK`, ONE_DAY_IN_SECONDS); - export async function displayVersionMessage(): Promise { if (!process.stdout.isTTY) { return; } try { + const versionCheckCache = new VersionCheckTTL(); const laterVersion = await latestVersionIfHigher(versionNumber(), versionCheckCache); if (laterVersion) { const bannerMsg = formatAsBanner([ @@ -100,6 +116,6 @@ export async function displayVersionMessage(): Promise { bannerMsg.forEach((e) => print(e)); } } catch (err) { - warning(`Could not run version check due to error ${err.message}`); + debug(`Could not run version check - ${err.message}`); } -} \ No newline at end of file +} diff --git a/packages/aws-cdk/test/api/test.stacks.ts b/packages/aws-cdk/test/api/test.stacks.ts index 54ad8355379e1..032914a5f5003 100644 --- a/packages/aws-cdk/test/api/test.stacks.ts +++ b/packages/aws-cdk/test/api/test.stacks.ts @@ -2,7 +2,6 @@ import cxapi = require('@aws-cdk/cx-api'); import { Test } from 'nodeunit'; import { SDK } from '../../lib'; import { AppStacks, ExtendedStackSelection } from '../../lib/api/cxapp/stacks'; -import { Renames } from '../../lib/renames'; import { Configuration } from '../../lib/settings'; import { testAssembly } from '../util'; @@ -59,23 +58,4 @@ export = { test.done(); }, - - async 'renames get applied when stacks are selected'(test: Test) { - // GIVEN - const stacks = new AppStacks({ - configuration: new Configuration(), - aws: new SDK(), - synthesizer: async () => FIXED_RESULT, - renames: new Renames({ withouterrors: 'withoutbananas' }), - }); - - // WHEN - const synthed = await stacks.selectStacks(['withouterrors'], ExtendedStackSelection.None); - - // THEN - test.equal(synthed[0].name, 'withoutbananas'); - test.equal(synthed[0].originalName, 'withouterrors'); - - test.done(); - }, }; diff --git a/packages/aws-cdk/test/test.version.ts b/packages/aws-cdk/test/test.version.ts index e0df94382b900..d4c31b67be7b6 100644 --- a/packages/aws-cdk/test/test.version.ts +++ b/packages/aws-cdk/test/test.version.ts @@ -1,7 +1,11 @@ +import fs = require('fs-extra'); import { Test } from 'nodeunit'; +import os = require('os'); +import path = require('path'); +import sinon = require('sinon'); import { setTimeout as _setTimeout } from 'timers'; import { promisify } from 'util'; -import { latestVersionIfHigher, TimestampFile } from '../lib/version'; +import { latestVersionIfHigher, VersionCheckTTL } from '../lib/version'; const setTimeout = promisify(_setTimeout); @@ -10,14 +14,29 @@ function tmpfile(): string { } export = { + 'tearDown'(callback: () => void) { + sinon.restore(); + callback(); + }, + + 'initialization fails on unwritable directory'(test: Test) { + test.expect(1); + const cacheFile = tmpfile(); + sinon.stub(fs, 'mkdirsSync').withArgs(path.dirname(cacheFile)).throws('Cannot make directory'); + test.throws(() => new VersionCheckTTL(cacheFile), /not writable/); + test.done(); + }, + async 'cache file responds correctly when file is not present'(test: Test) { - const cache = new TimestampFile(tmpfile(), 1); + test.expect(1); + const cache = new VersionCheckTTL(tmpfile(), 1); test.strictEqual(await cache.hasExpired(), true); test.done(); }, async 'cache file honours the specified TTL'(test: Test) { - const cache = new TimestampFile(tmpfile(), 1); + test.expect(2); + const cache = new VersionCheckTTL(tmpfile(), 1); await cache.update(); test.strictEqual(await cache.hasExpired(), false); await setTimeout(1001); // Just above 1 sec in ms @@ -26,14 +45,16 @@ export = { }, async 'Skip version check if cache has not expired'(test: Test) { - const cache = new TimestampFile(tmpfile(), 100); + test.expect(1); + const cache = new VersionCheckTTL(tmpfile(), 100); await cache.update(); test.equal(await latestVersionIfHigher('0.0.0', cache), null); test.done(); }, async 'Return later version when exists & skip recent re-check'(test: Test) { - const cache = new TimestampFile(tmpfile(), 100); + test.expect(3); + const cache = new VersionCheckTTL(tmpfile(), 100); const result = await latestVersionIfHigher('0.0.0', cache); test.notEqual(result, null); test.ok((result as string).length > 0); @@ -44,9 +65,38 @@ export = { }, async 'Return null if version is higher than npm'(test: Test) { - const cache = new TimestampFile(tmpfile(), 100); + test.expect(1); + const cache = new VersionCheckTTL(tmpfile(), 100); const result = await latestVersionIfHigher('100.100.100', cache); test.equal(result, null); test.done(); }, + + 'No homedir for the given user'(test: Test) { + test.expect(1); + sinon.stub(os, 'homedir').returns(''); + sinon.stub(os, 'userInfo').returns({ username: '', uid: 10, gid: 11, shell: null, homedir: ''}); + test.throws(() => new VersionCheckTTL(), /Cannot determine home directory/); + test.done(); + }, + + async 'Version specified is stored in the TTL file'(test: Test) { + test.expect(1); + const cacheFile = tmpfile(); + const cache = new VersionCheckTTL(cacheFile, 1); + await cache.update('1.1.1'); + const storedVersion = fs.readFileSync(cacheFile, 'utf8'); + test.equal(storedVersion, '1.1.1'); + test.done(); + }, + + async 'No Version specified for storage in the TTL file'(test: Test) { + test.expect(1); + const cacheFile = tmpfile(); + const cache = new VersionCheckTTL(cacheFile, 1); + await cache.update(); + const storedVersion = fs.readFileSync(cacheFile, 'utf8'); + test.equal(storedVersion, ''); + test.done(); + }, };