diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d66ae0944d6..55ff1bfa44ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [1.88.0](https://github.com/aws/aws-cdk/compare/v1.87.1...v1.88.0) (2021-02-03) + + +### ⚠ BREAKING CHANGES TO EXPERIMENTAL FEATURES + +* **appmesh:** the properties virtualRouter and virtualNode of VirtualServiceProps have been replaced with the union-like class VirtualServiceProvider +* **appmesh**: the method `addVirtualService` has been removed from `IMesh` +* **cloudfront:** experimental EdgeFunction stack names have changed from 'edge-lambda-stack-${region}' to 'edge-lambda-stack-${stackid}' to support multiple independent CloudFront distributions with EdgeFunctions. + +### Features + +* **apigateway:** cognito user pool authorizer ([#12786](https://github.com/aws/aws-cdk/issues/12786)) ([ff1e5b3](https://github.com/aws/aws-cdk/commit/ff1e5b3c580119c107fe26c67fe3cc220f9ee7c9)), closes [#5618](https://github.com/aws/aws-cdk/issues/5618) +* **apigateway:** import an existing Resource ([#12785](https://github.com/aws/aws-cdk/issues/12785)) ([8a1a9b8](https://github.com/aws/aws-cdk/commit/8a1a9b82a36e681334fd45be595f6ecdf904ad34)), closes [#4432](https://github.com/aws/aws-cdk/issues/4432) +* **appmesh:** change VirtualService provider to a union-like class ([#11978](https://github.com/aws/aws-cdk/issues/11978)) ([dfc765a](https://github.com/aws/aws-cdk/commit/dfc765af44c755f10be8f6c1c2eae55f62e2aa08)), closes [#9490](https://github.com/aws/aws-cdk/issues/9490) +* **aws-route53:** cross account DNS delegations ([#12680](https://github.com/aws/aws-cdk/issues/12680)) ([126a693](https://github.com/aws/aws-cdk/commit/126a6935cacc1f68b1d1155e484912d4ed6978f2)), closes [#8776](https://github.com/aws/aws-cdk/issues/8776) +* **cloudfront:** add PublicKey and KeyGroup L2 constructs ([#12743](https://github.com/aws/aws-cdk/issues/12743)) ([59cb6d0](https://github.com/aws/aws-cdk/commit/59cb6d032a55515ec5e9903f899de588d18d4cb5)) +* **core:** `stack.exportValue()` can be used to solve "deadly embrace" ([#12778](https://github.com/aws/aws-cdk/issues/12778)) ([3b66088](https://github.com/aws/aws-cdk/commit/3b66088010b6f2315a215e92505d5279680f16d4)), closes [#7602](https://github.com/aws/aws-cdk/issues/7602) [#2036](https://github.com/aws/aws-cdk/issues/2036) +* **ecr:** Public Gallery authorization token ([#12775](https://github.com/aws/aws-cdk/issues/12775)) ([8434294](https://github.com/aws/aws-cdk/commit/84342943ad9f2ea8a83773f00816a0b8117c4d17)) +* **ecs-patterns:** Add PlatformVersion option to ScheduledFargateTask props ([#12676](https://github.com/aws/aws-cdk/issues/12676)) ([3cbf38b](https://github.com/aws/aws-cdk/commit/3cbf38b09a9e66a6c009f833481fb25b8c5fc26c)), closes [#12623](https://github.com/aws/aws-cdk/issues/12623) +* **elbv2:** support for 2020 SSL policy ([#12710](https://github.com/aws/aws-cdk/issues/12710)) ([1dd3d05](https://github.com/aws/aws-cdk/commit/1dd3d0518dc2a70c725f87dd5d4377338389125c)), closes [#12595](https://github.com/aws/aws-cdk/issues/12595) +* **iam:** Permissions Boundaries ([#12777](https://github.com/aws/aws-cdk/issues/12777)) ([415eb86](https://github.com/aws/aws-cdk/commit/415eb861c65829cc53eabbbb8706f83f08c74570)), closes [aws/aws-cdk-rfcs#5](https://github.com/aws/aws-cdk-rfcs/issues/5) [#3242](https://github.com/aws/aws-cdk/issues/3242) +* **lambda:** inline code for Python 3.8 ([#12788](https://github.com/aws/aws-cdk/issues/12788)) ([8d3aaba](https://github.com/aws/aws-cdk/commit/8d3aabaffe436e6a3eebc0a58fe361c5b4b93f08)), closes [#6503](https://github.com/aws/aws-cdk/issues/6503) + + +### Bug Fixes + +* **apigateway:** stack update fails to replace api key ([#12745](https://github.com/aws/aws-cdk/issues/12745)) ([ffe7e42](https://github.com/aws/aws-cdk/commit/ffe7e425e605144a465cea9befa68d4fe19f9d8c)), closes [#12698](https://github.com/aws/aws-cdk/issues/12698) +* **cfn-include:** AWS::CloudFormation resources fail in monocdk ([#12758](https://github.com/aws/aws-cdk/issues/12758)) ([5060782](https://github.com/aws/aws-cdk/commit/5060782b00e17bdf44e225f8f5ef03344be238c7)), closes [#11595](https://github.com/aws/aws-cdk/issues/11595) +* **cli, codepipeline:** renamed bootstrap stack still not supported ([#12771](https://github.com/aws/aws-cdk/issues/12771)) ([40b32bb](https://github.com/aws/aws-cdk/commit/40b32bbda272b6e2f92fd5dd8de7ca5bf405ce52)), closes [#12594](https://github.com/aws/aws-cdk/issues/12594) [#12732](https://github.com/aws/aws-cdk/issues/12732) +* **cloudfront:** use node addr for edgeStackId name ([#12702](https://github.com/aws/aws-cdk/issues/12702)) ([c429bb7](https://github.com/aws/aws-cdk/commit/c429bb7df2406346426dce22d716cabc484ec7e6)), closes [#12323](https://github.com/aws/aws-cdk/issues/12323) +* **codedeploy:** wrong syntax on Windows 'installAgent' flag ([#12736](https://github.com/aws/aws-cdk/issues/12736)) ([238742e](https://github.com/aws/aws-cdk/commit/238742e4323310ce850d8edc70abe4b0e9f53186)), closes [#12734](https://github.com/aws/aws-cdk/issues/12734) +* **codepipeline:** permission denied for Action-level environment variables ([#12761](https://github.com/aws/aws-cdk/issues/12761)) ([99fd074](https://github.com/aws/aws-cdk/commit/99fd074a07ead624f64d3fe64685ba67c798976e)), closes [#12742](https://github.com/aws/aws-cdk/issues/12742) +* **ec2:** ARM-backed bastion hosts try to run x86-based Amazon Linux AMI ([#12280](https://github.com/aws/aws-cdk/issues/12280)) ([1a73d76](https://github.com/aws/aws-cdk/commit/1a73d761ad2363842567a1b6e0488ceb093e70b2)), closes [#12279](https://github.com/aws/aws-cdk/issues/12279) +* **efs:** EFS fails to create when using a VPC with multiple subnets per availability zone ([#12097](https://github.com/aws/aws-cdk/issues/12097)) ([889d673](https://github.com/aws/aws-cdk/commit/889d6734c10174f2661e45057c345cd112a44187)), closes [#10170](https://github.com/aws/aws-cdk/issues/10170) +* **iam:** cannot use the same Role for multiple Config Rules ([#12724](https://github.com/aws/aws-cdk/issues/12724)) ([2f6521a](https://github.com/aws/aws-cdk/commit/2f6521a1d8670b2653f7dee281309351181cf918)), closes [#12714](https://github.com/aws/aws-cdk/issues/12714) +* **lambda:** codeguru profiler not set up for Node runtime ([#12712](https://github.com/aws/aws-cdk/issues/12712)) ([59db763](https://github.com/aws/aws-cdk/commit/59db763e7d05d68fd85b6fd37246d69d4670d7d5)), closes [#12624](https://github.com/aws/aws-cdk/issues/12624) + ## [1.87.1](https://github.com/aws/aws-cdk/compare/v1.87.0...v1.87.1) (2021-01-28) diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/appmesh.ts b/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/appmesh.ts index fb93375423798..35436d11ce691 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/appmesh.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/appmesh.ts @@ -316,8 +316,7 @@ export class AppMeshExtension extends ServiceExtension { // Now create a virtual service. Relationship goes like this: // virtual service -> virtual router -> virtual node this.virtualService = new appmesh.VirtualService(this.scope, `${this.parentService.id}-virtual-service`, { - mesh: this.mesh, - virtualRouter: this.virtualRouter, + virtualServiceProvider: appmesh.VirtualServiceProvider.virtualRouter(this.virtualRouter), virtualServiceName: serviceName, }); } diff --git a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts index 2f3352bee16ef..3e44013188b2c 100644 --- a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts +++ b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts @@ -66,7 +66,7 @@ export class HaveResourceAssertion extends JestFriendlyAssertion for (const logicalId of Object.keys(inspector.value.Resources || {})) { const resource = inspector.value.Resources[logicalId]; if (resource.Type === this.resourceType) { - const propsToCheck = this.part === ResourcePart.Properties ? resource.Properties : resource; + const propsToCheck = this.part === ResourcePart.Properties ? (resource.Properties ?? {}) : resource; // Pass inspection object as 2nd argument, initialize failure with default string, // to maintain backwards compatibility with old predicate API. diff --git a/packages/@aws-cdk/aws-apigateway/README.md b/packages/@aws-cdk/aws-apigateway/README.md index 60d2cdcfa3654..4d4d1a79eb797 100644 --- a/packages/@aws-cdk/aws-apigateway/README.md +++ b/packages/@aws-cdk/aws-apigateway/README.md @@ -32,6 +32,7 @@ running on AWS Lambda, or any web application. - [IAM-based authorizer](#iam-based-authorizer) - [Lambda-based token authorizer](#lambda-based-token-authorizer) - [Lambda-based request authorizer](#lambda-based-request-authorizer) + - [Cognito User Pools authorizer](#cognito-user-pools-authorizer) - [Mutual TLS](#mutal-tls-mtls) - [Deployments](#deployments) - [Deep dive: Invalidation of deployments](#deep-dive-invalidation-of-deployments) @@ -580,6 +581,25 @@ Authorizers can also be passed via the `defaultMethodOptions` property within th explicitly overridden, the specified defaults will be applied across all `Method`s across the `RestApi` or across all `Resource`s, depending on where the defaults were specified. +### Cognito User Pools authorizer + +API Gateway also allows [Amazon Cognito user pools as authorizer](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-integrate-with-cognito.html) + +The following snippet configures a Cognito user pool as an authorizer: + +```ts +const userPool = new cognito.UserPool(stack, 'UserPool'); + +const auth = new apigateway.CognitoUserPoolsAuthorizer(this, 'booksAuthorizer', { + cognitoUserPools: [userPool] +}); + +books.addMethod('GET', new apigateway.HttpIntegration('http://amazon.com'), { + authorizer: auth, + authorizationType: apigateway.AuthorizationType.COGNITO, +}); +``` + ## Mutual TLS (mTLS) Mutual TLS can be configured to limit access to your API based by using client certificates instead of (or as an extension of) using authorization headers. diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizers/cognito.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizers/cognito.ts new file mode 100644 index 0000000000000..a1d000189354c --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizers/cognito.ts @@ -0,0 +1,115 @@ +import * as cognito from '@aws-cdk/aws-cognito'; +import { Duration, Lazy, Names, Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnAuthorizer } from '../apigateway.generated'; +import { Authorizer, IAuthorizer } from '../authorizer'; +import { AuthorizationType } from '../method'; +import { IRestApi } from '../restapi'; + +/** + * Properties for CognitoUserPoolsAuthorizer + */ +export interface CognitoUserPoolsAuthorizerProps { + /** + * An optional human friendly name for the authorizer. Note that, this is not the primary identifier of the authorizer. + * + * @default - the unique construct ID + */ + readonly authorizerName?: string; + + /** + * The user pools to associate with this authorizer. + */ + readonly cognitoUserPools: cognito.IUserPool[]; + + /** + * How long APIGateway should cache the results. Max 1 hour. + * Disable caching by setting this to 0. + * + * @default Duration.minutes(5) + */ + readonly resultsCacheTtl?: Duration; + + /** + * The request header mapping expression for the bearer token. This is typically passed as part of the header, in which case + * this should be `method.request.header.Authorizer` where Authorizer is the header containing the bearer token. + * @see https://docs.aws.amazon.com/apigateway/api-reference/link-relation/authorizer-create/#identitySource + * @default `IdentitySource.header('Authorization')` + */ + readonly identitySource?: string; +} + +/** + * Cognito user pools based custom authorizer + * + * @resource AWS::ApiGateway::Authorizer + */ +export class CognitoUserPoolsAuthorizer extends Authorizer implements IAuthorizer { + /** + * The id of the authorizer. + * @attribute + */ + public readonly authorizerId: string; + + /** + * The ARN of the authorizer to be used in permission policies, such as IAM and resource-based grants. + * @attribute + */ + public readonly authorizerArn: string; + + /** + * The authorization type of this authorizer. + */ + public readonly authorizationType?: AuthorizationType; + + private restApiId?: string; + + constructor(scope: Construct, id: string, props: CognitoUserPoolsAuthorizerProps) { + super(scope, id); + + const restApiId = this.lazyRestApiId(); + const resource = new CfnAuthorizer(this, 'Resource', { + name: props.authorizerName ?? Names.uniqueId(this), + restApiId, + type: 'COGNITO_USER_POOLS', + providerArns: props.cognitoUserPools.map(userPool => userPool.userPoolArn), + authorizerResultTtlInSeconds: props.resultsCacheTtl?.toSeconds(), + identitySource: props.identitySource || 'method.request.header.Authorization', + }); + + this.authorizerId = resource.ref; + this.authorizerArn = Stack.of(this).formatArn({ + service: 'execute-api', + resource: restApiId, + resourceName: `authorizers/${this.authorizerId}`, + }); + this.authorizationType = AuthorizationType.COGNITO; + } + + /** + * Attaches this authorizer to a specific REST API. + * @internal + */ + public _attachToApi(restApi: IRestApi): void { + if (this.restApiId && this.restApiId !== restApi.restApiId) { + throw new Error('Cannot attach authorizer to two different rest APIs'); + } + + this.restApiId = restApi.restApiId; + } + + /** + * Returns a token that resolves to the Rest Api Id at the time of synthesis. + * Throws an error, during token resolution, if no RestApi is attached to this authorizer. + */ + private lazyRestApiId() { + return Lazy.string({ + produce: () => { + if (!this.restApiId) { + throw new Error(`Authorizer (${this.node.path}) must be attached to a RestApi`); + } + return this.restApiId; + }, + }); + } +} diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts index 57289c931f760..fd93db036fefe 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts @@ -1,2 +1,3 @@ export * from './lambda'; export * from './identity-source'; +export * from './cognito'; diff --git a/packages/@aws-cdk/aws-apigateway/lib/resource.ts b/packages/@aws-cdk/aws-apigateway/lib/resource.ts index 04d9598303ad8..33ec6f1d5f7fd 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/resource.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/resource.ts @@ -373,7 +373,51 @@ export abstract class ResourceBase extends ResourceConstruct implements IResourc } } +/** + * Attributes that can be specified when importing a Resource + */ +export interface ResourceAttributes { + /** + * The ID of the resource. + */ + readonly resourceId: string; + + /** + * The rest API that this resource is part of. + */ + readonly restApi: IRestApi; + + /** + * The full path of this resource. + */ + readonly path: string; +} + export class Resource extends ResourceBase { + /** + * Import an existing resource + */ + public static fromResourceAttributes(scope: Construct, id: string, attrs: ResourceAttributes): IResource { + class Import extends ResourceBase { + public readonly api = attrs.restApi; + public readonly resourceId = attrs.resourceId; + public readonly path = attrs.path; + public readonly defaultIntegration?: Integration = undefined; + public readonly defaultMethodOptions?: MethodOptions = undefined; + public readonly defaultCorsPreflightOptions?: CorsOptions = undefined; + + public get parentResource(): IResource { + throw new Error('parentResource is not configured for imported resource.'); + } + + public get restApi(): RestApi { + throw new Error('restApi is not configured for imported resource.'); + } + } + + return new Import(scope, id); + } + public readonly parentResource?: IResource; public readonly api: IRestApi; public readonly resourceId: string; diff --git a/packages/@aws-cdk/aws-apigateway/package.json b/packages/@aws-cdk/aws-apigateway/package.json index c9650deeb9a4b..3a0641e8b1162 100644 --- a/packages/@aws-cdk/aws-apigateway/package.json +++ b/packages/@aws-cdk/aws-apigateway/package.json @@ -80,6 +80,7 @@ "dependencies": { "@aws-cdk/aws-certificatemanager": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-cognito": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", "@aws-cdk/aws-elasticloadbalancingv2": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", @@ -95,6 +96,7 @@ "peerDependencies": { "@aws-cdk/aws-certificatemanager": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-cognito": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", "@aws-cdk/aws-elasticloadbalancingv2": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/cognito.test.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/cognito.test.ts new file mode 100644 index 0000000000000..e59339177d5d4 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/cognito.test.ts @@ -0,0 +1,66 @@ +import '@aws-cdk/assert/jest'; +import * as cognito from '@aws-cdk/aws-cognito'; +import { Duration, Stack } from '@aws-cdk/core'; +import { AuthorizationType, CognitoUserPoolsAuthorizer, RestApi } from '../../lib'; + +describe('Cognito Authorizer', () => { + test('default cognito authorizer', () => { + // GIVEN + const stack = new Stack(); + const userPool = new cognito.UserPool(stack, 'UserPool'); + + // WHEN + const authorizer = new CognitoUserPoolsAuthorizer(stack, 'myauthorizer', { + cognitoUserPools: [userPool], + }); + + const restApi = new RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer, + authorizationType: AuthorizationType.COGNITO, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGateway::Authorizer', { + Type: 'COGNITO_USER_POOLS', + RestApiId: stack.resolve(restApi.restApiId), + IdentitySource: 'method.request.header.Authorization', + ProviderARNs: [stack.resolve(userPool.userPoolArn)], + }); + + expect(authorizer.authorizerArn.endsWith(`/authorizers/${authorizer.authorizerId}`)).toBeTruthy(); + }); + + test('cognito authorizer with all parameters specified', () => { + // GIVEN + const stack = new Stack(); + const userPool1 = new cognito.UserPool(stack, 'UserPool1'); + const userPool2 = new cognito.UserPool(stack, 'UserPool2'); + + // WHEN + const authorizer = new CognitoUserPoolsAuthorizer(stack, 'myauthorizer', { + cognitoUserPools: [userPool1, userPool2], + identitySource: 'method.request.header.whoami', + authorizerName: 'myauthorizer', + resultsCacheTtl: Duration.minutes(1), + }); + + const restApi = new RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer, + authorizationType: AuthorizationType.COGNITO, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGateway::Authorizer', { + Type: 'COGNITO_USER_POOLS', + Name: 'myauthorizer', + RestApiId: stack.resolve(restApi.restApiId), + IdentitySource: 'method.request.header.whoami', + AuthorizerResultTtlInSeconds: 60, + ProviderARNs: [stack.resolve(userPool1.userPoolArn), stack.resolve(userPool2.userPoolArn)], + }); + + expect(authorizer.authorizerArn.endsWith(`/authorizers/${authorizer.authorizerId}`)).toBeTruthy(); + }); +}); diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.expected.json b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.expected.json new file mode 100644 index 0000000000000..990619cb495d4 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.expected.json @@ -0,0 +1,191 @@ +{ + "Resources": { + "UserPool6BA7E5F2": { + "Type": "AWS::Cognito::UserPool", + "Properties": { + "AccountRecoverySetting": { + "RecoveryMechanisms": [ + { + "Name": "verified_phone_number", + "Priority": 1 + }, + { + "Name": "verified_email", + "Priority": 2 + } + ] + }, + "AdminCreateUserConfig": { + "AllowAdminCreateUserOnly": true + }, + "EmailVerificationMessage": "The verification code to your new account is {####}", + "EmailVerificationSubject": "Verify your new account", + "SmsVerificationMessage": "The verification code to your new account is {####}", + "VerificationMessageTemplate": { + "DefaultEmailOption": "CONFIRM_WITH_CODE", + "EmailMessage": "The verification code to your new account is {####}", + "EmailSubject": "Verify your new account", + "SmsMessage": "The verification code to your new account is {####}" + } + } + }, + "myauthorizer23CB99DD": { + "Type": "AWS::ApiGateway::Authorizer", + "Properties": { + "RestApiId": { + "Ref": "myrestapi551C8392" + }, + "Type": "COGNITO_USER_POOLS", + "IdentitySource": "method.request.header.Authorization", + "Name": "CognitoUserPoolsAuthorizerIntegmyauthorizer10C804C1", + "ProviderARNs": [ + { + "Fn::GetAtt": [ + "UserPool6BA7E5F2", + "Arn" + ] + } + ] + } + }, + "myrestapi551C8392": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "myrestapi" + } + }, + "myrestapiCloudWatchRoleC48DA1DD": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + } + }, + "myrestapiAccountA49A05BE": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "myrestapiCloudWatchRoleC48DA1DD", + "Arn" + ] + } + }, + "DependsOn": [ + "myrestapi551C8392" + ] + }, + "myrestapiDeployment419B1464b903292b53d7532ca4296973bcb95b1a": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "myrestapi551C8392" + }, + "Description": "Automatically created by the RestApi construct" + }, + "DependsOn": [ + "myrestapiANY94B0497F" + ] + }, + "myrestapiDeploymentStageprodA9250EA4": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "RestApiId": { + "Ref": "myrestapi551C8392" + }, + "DeploymentId": { + "Ref": "myrestapiDeployment419B1464b903292b53d7532ca4296973bcb95b1a" + }, + "StageName": "prod" + } + }, + "myrestapiANY94B0497F": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Fn::GetAtt": [ + "myrestapi551C8392", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "myrestapi551C8392" + }, + "AuthorizationType": "COGNITO_USER_POOLS", + "AuthorizerId": { + "Ref": "myauthorizer23CB99DD" + }, + "Integration": { + "IntegrationResponses": [ + { + "StatusCode": "200" + } + ], + "PassthroughBehavior": "NEVER", + "RequestTemplates": { + "application/json": "{ \"statusCode\": 200 }" + }, + "Type": "MOCK" + }, + "MethodResponses": [ + { + "StatusCode": "200" + } + ] + } + } + }, + "Outputs": { + "myrestapiEndpointE06F9D98": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "myrestapi551C8392" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "myrestapiDeploymentStageprodA9250EA4" + }, + "/" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.ts new file mode 100644 index 0000000000000..4830dc83ae29f --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.ts @@ -0,0 +1,43 @@ +import * as cognito from '@aws-cdk/aws-cognito'; +import { App, Stack } from '@aws-cdk/core'; +import { AuthorizationType, CognitoUserPoolsAuthorizer, MockIntegration, PassthroughBehavior, RestApi } from '../../lib'; + +/* + * Stack verification steps: + * * 1. Get the IdToken for the created pool by adding user/app-client and using aws cognito-idp: + * * a. aws cognito-idp create-user-pool-client --user-pool-id --client-name --no-generate-secret + * * b. aws cognito-idp admin-create-user --user-pool-id --username --temporary-password + * * c. aws cognito-idp initiate-auth --client-id --auth-flow USER_PASSWORD_AUTH --auth-parameters USERNAME=,PASSWORD= + * * d. aws cognito-idp respond-to-auth-challenge --client-id --challenge-name --session + * * + * * 2. Verify the stack using above obtained token: + * * a. `curl -s -o /dev/null -w "%{http_code}" ` should return 401 + * * b. `curl -s -o /dev/null -w "%{http_code}" -H 'Authorization: ' ` should return 403 + * * c. `curl -s -o /dev/null -w "%{http_code}" -H 'Authorization: ' ` should return 200 + */ + +const app = new App(); +const stack = new Stack(app, 'CognitoUserPoolsAuthorizerInteg'); + +const userPool = new cognito.UserPool(stack, 'UserPool'); + +const authorizer = new CognitoUserPoolsAuthorizer(stack, 'myauthorizer', { + cognitoUserPools: [userPool], +}); + +const restApi = new RestApi(stack, 'myrestapi'); +restApi.root.addMethod('ANY', new MockIntegration({ + integrationResponses: [ + { statusCode: '200' }, + ], + passthroughBehavior: PassthroughBehavior.NEVER, + requestTemplates: { + 'application/json': '{ "statusCode": 200 }', + }, +}), { + methodResponses: [ + { statusCode: '200' }, + ], + authorizer, + authorizationType: AuthorizationType.COGNITO, +}); diff --git a/packages/@aws-cdk/aws-apigateway/test/resource.test.ts b/packages/@aws-cdk/aws-apigateway/test/resource.test.ts index ecad61cb1905c..0a20483f4261c 100644 --- a/packages/@aws-cdk/aws-apigateway/test/resource.test.ts +++ b/packages/@aws-cdk/aws-apigateway/test/resource.test.ts @@ -236,6 +236,27 @@ describe('resource', () => { }); + test('fromResourceAttributes()', () => { + // GIVEN + const stack = new Stack(); + const resourceId = 'resource-id'; + const api = new apigw.RestApi(stack, 'MyRestApi'); + + // WHEN + const imported = apigw.Resource.fromResourceAttributes(stack, 'imported-resource', { + resourceId, + restApi: api, + path: 'some-path', + }); + imported.addMethod('GET'); + + // THEN + expect(stack).toHaveResource('AWS::ApiGateway::Method', { + HttpMethod: 'GET', + ResourceId: resourceId, + }); + }); + describe('getResource', () => { describe('root resource', () => { diff --git a/packages/@aws-cdk/aws-appmesh/README.md b/packages/@aws-cdk/aws-appmesh/README.md index 2253b12d2987d..c400cbb0af05d 100644 --- a/packages/@aws-cdk/aws-appmesh/README.md +++ b/packages/@aws-cdk/aws-appmesh/README.md @@ -109,23 +109,21 @@ When creating a virtual service: Adding a virtual router as the provider: ```ts -mesh.addVirtualService('virtual-service', { - virtualRouter: router, - virtualServiceName: 'my-service.default.svc.cluster.local', +new appmesh.VirtualService('virtual-service', { + virtualServiceName: 'my-service.default.svc.cluster.local', // optional + virtualServiceProvider: appmesh.VirtualServiceProvider.virtualRouter(router), }); ``` Adding a virtual node as the provider: ```ts -mesh.addVirtualService('virtual-service', { - virtualNode: node, - virtualServiceName: `my-service.default.svc.cluster.local`, +new appmesh.VirtualService('virtual-service', { + virtualServiceName: `my-service.default.svc.cluster.local`, // optional + virtualServiceProvider: appmesh.VirtualServiceProvider.virtualNode(node), }); ``` -**Note** that only one must of `virtualNode` or `virtualRouter` must be chosen. - ## Adding a VirtualNode A `virtual node` acts as a logical pointer to a particular task group, such as an Amazon ECS service or a Kubernetes deployment. diff --git a/packages/@aws-cdk/aws-appmesh/lib/mesh.ts b/packages/@aws-cdk/aws-appmesh/lib/mesh.ts index cc0596695aae2..a1cd5b49904a5 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/mesh.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/mesh.ts @@ -4,7 +4,6 @@ import { CfnMesh } from './appmesh.generated'; import { VirtualGateway, VirtualGatewayBaseProps } from './virtual-gateway'; import { VirtualNode, VirtualNodeBaseProps } from './virtual-node'; import { VirtualRouter, VirtualRouterBaseProps } from './virtual-router'; -import { VirtualService, VirtualServiceBaseProps } from './virtual-service'; /** * A utility enum defined for the egressFilter type property, the default of DROP_ALL, @@ -46,11 +45,6 @@ export interface IMesh extends cdk.IResource { */ addVirtualRouter(id: string, props?: VirtualRouterBaseProps): VirtualRouter; - /** - * Adds a VirtualService with the given id - */ - addVirtualService(id: string, props?: VirtualServiceBaseProps): VirtualService; - /** * Adds a VirtualNode to the Mesh */ @@ -86,16 +80,6 @@ abstract class MeshBase extends cdk.Resource implements IMesh { }); } - /** - * Adds a VirtualService with the given id - */ - public addVirtualService(id: string, props: VirtualServiceBaseProps = {}): VirtualService { - return new VirtualService(this, id, { - ...props, - mesh: this, - }); - } - /** * Adds a VirtualNode to the Mesh */ diff --git a/packages/@aws-cdk/aws-appmesh/lib/virtual-service.ts b/packages/@aws-cdk/aws-appmesh/lib/virtual-service.ts index 9ca5d5010b7f4..5685b8b08c1f8 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/virtual-service.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/virtual-service.ts @@ -36,9 +36,9 @@ export interface IVirtualService extends cdk.IResource { } /** - * The base properties which all classes in VirtualService will inherit from + * The properties applied to the VirtualService being defined */ -export interface VirtualServiceBaseProps { +export interface VirtualServiceProps { /** * The name of the VirtualService. * @@ -50,36 +50,17 @@ export interface VirtualServiceBaseProps { */ readonly virtualServiceName?: string; - /** - * The VirtualRouter which the VirtualService uses as provider - * - * @default - At most one of virtualRouter and virtualNode is allowed. - */ - readonly virtualRouter?: IVirtualRouter; - - /** - * The VirtualNode attached to the virtual service - * - * @default - At most one of virtualRouter and virtualNode is allowed. - */ - readonly virtualNode?: IVirtualNode; - /** * Client policy for this Virtual Service * * @default - none */ readonly clientPolicy?: ClientPolicy; -} -/** - * The properties applied to the VirtualService being define - */ -export interface VirtualServiceProps extends VirtualServiceBaseProps { /** - * The Mesh which the VirtualService belongs to + * The VirtualNode or VirtualRouter which the VirtualService uses as its provider */ - readonly mesh: IMesh; + readonly virtualServiceProvider: VirtualServiceProvider; } /** @@ -135,59 +116,35 @@ export class VirtualService extends cdk.Resource implements IVirtualService { public readonly clientPolicy?: ClientPolicy; - private readonly virtualServiceProvider?: CfnVirtualService.VirtualServiceProviderProperty; - constructor(scope: Construct, id: string, props: VirtualServiceProps) { super(scope, id, { physicalName: props.virtualServiceName || cdk.Lazy.string({ produce: () => cdk.Names.uniqueId(this) }), }); - if (props.virtualNode && props.virtualRouter) { - throw new Error('Must provide only one of virtualNode or virtualRouter for the provider'); - } - - this.mesh = props.mesh; this.clientPolicy = props.clientPolicy; - - // Check which provider to use node or router (or neither) - if (props.virtualRouter) { - this.virtualServiceProvider = this.addVirtualRouter(props.virtualRouter.virtualRouterName); - } - if (props.virtualNode) { - this.virtualServiceProvider = this.addVirtualNode(props.virtualNode.virtualNodeName); - } + const providerConfig = props.virtualServiceProvider.bind(this); + this.mesh = providerConfig.mesh; const svc = new CfnVirtualService(this, 'Resource', { meshName: this.mesh.meshName, virtualServiceName: this.physicalName, spec: { - provider: this.virtualServiceProvider, + provider: providerConfig.virtualNodeProvider || providerConfig.virtualRouterProvider + ? { + virtualNode: providerConfig.virtualNodeProvider, + virtualRouter: providerConfig.virtualRouterProvider, + } + : undefined, }, }); this.virtualServiceName = this.getResourceNameAttribute(svc.attrVirtualServiceName); this.virtualServiceArn = this.getResourceArnAttribute(svc.ref, { service: 'appmesh', - resource: `mesh/${props.mesh.meshName}/virtualService`, + resource: `mesh/${this.mesh.meshName}/virtualService`, resourceName: this.physicalName, }); } - - private addVirtualRouter(name: string): CfnVirtualService.VirtualServiceProviderProperty { - return { - virtualRouter: { - virtualRouterName: name, - }, - }; - } - - private addVirtualNode(name: string): CfnVirtualService.VirtualServiceProviderProperty { - return { - virtualNode: { - virtualNodeName: name, - }, - }; - } } /** @@ -211,3 +168,91 @@ export interface VirtualServiceAttributes { */ readonly clientPolicy?: ClientPolicy; } + +/** + * Properties for a VirtualService provider + */ +export interface VirtualServiceProviderConfig { + /** + * Virtual Node based provider + * + * @default - none + */ + readonly virtualNodeProvider?: CfnVirtualService.VirtualNodeServiceProviderProperty; + + /** + * Virtual Router based provider + * + * @default - none + */ + readonly virtualRouterProvider?: CfnVirtualService.VirtualRouterServiceProviderProperty; + + /** + * Mesh the Provider is using + * + * @default - none + */ + readonly mesh: IMesh; +} + +/** + * Represents the properties needed to define the provider for a VirtualService + */ +export abstract class VirtualServiceProvider { + /** + * Returns a VirtualNode based Provider for a VirtualService + */ + public static virtualNode(virtualNode: IVirtualNode): VirtualServiceProvider { + return new VirtualServiceProviderImpl(virtualNode, undefined); + } + + /** + * Returns a VirtualRouter based Provider for a VirtualService + */ + public static virtualRouter(virtualRouter: IVirtualRouter): VirtualServiceProvider { + return new VirtualServiceProviderImpl(undefined, virtualRouter); + } + + /** + * Returns an Empty Provider for a VirtualService. This provides no routing capabilities + * and should only be used as a placeholder + */ + public static none(mesh: IMesh): VirtualServiceProvider { + return new VirtualServiceProviderImpl(undefined, undefined, mesh); + } + + /** + * Enforces mutual exclusivity for VirtualService provider types. + */ + public abstract bind(_construct: Construct): VirtualServiceProviderConfig; +} + +class VirtualServiceProviderImpl extends VirtualServiceProvider { + private readonly virtualNode?: IVirtualNode; + private readonly virtualRouter?: IVirtualRouter; + private readonly mesh: IMesh; + + constructor(virtualNode?: IVirtualNode, virtualRouter?: IVirtualRouter, mesh?: IMesh) { + super(); + this.virtualNode = virtualNode; + this.virtualRouter = virtualRouter; + const providedMesh = this.virtualNode?.mesh ?? this.virtualRouter?.mesh ?? mesh!; + this.mesh = providedMesh; + } + + public bind(_construct: Construct): VirtualServiceProviderConfig { + return { + mesh: this.mesh, + virtualNodeProvider: this.virtualNode + ? { + virtualNodeName: this.virtualNode.virtualNodeName, + } + : undefined, + virtualRouterProvider: this.virtualRouter + ? { + virtualRouterName: this.virtualRouter.virtualRouterName, + } + : undefined, + }; + } +} diff --git a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json index db9277d071432..5f4a9ca206725 100644 --- a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json +++ b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json @@ -625,41 +625,6 @@ } } }, - "meshserviceE06ECED5": { - "Type": "AWS::AppMesh::VirtualService", - "Properties": { - "MeshName": { - "Fn::GetAtt": [ - "meshACDFE68E", - "MeshName" - ] - }, - "Spec": { - "Provider": { - "VirtualRouter": { - "VirtualRouterName": { - "Fn::GetAtt": [ - "meshrouter81B8087E", - "VirtualRouterName" - ] - } - } - } - }, - "VirtualServiceName": "service1.domain.local" - } - }, - "cert56CA94EB": { - "Type": "AWS::CertificateManager::Certificate", - "Properties": { - "DomainName":"node1.domain.local", - "DomainValidationOptions": [{ - "DomainName":"node1.domain.local", - "ValidationDomain":"local" - }], - "ValidationMethod": "EMAIL" - } - }, "meshnode726C787D": { "Type": "AWS::AppMesh::VirtualNode", "Properties": { @@ -675,7 +640,7 @@ "VirtualService": { "VirtualServiceName": { "Fn::GetAtt": [ - "meshserviceE06ECED5", + "service6D174F83", "VirtualServiceName" ] } @@ -706,16 +671,6 @@ "PortMapping": { "Port": 8080, "Protocol": "http" - }, - "TLS": { - "Certificate": { - "ACM": { - "CertificateArn": { - "Ref": "cert56CA94EB" - } - } - }, - "Mode": "STRICT" } } ], @@ -743,8 +698,8 @@ "TLS": { "Validation": { "Trust": { - "ACM": { - "CertificateAuthorityArns": ["arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012"] + "File": { + "CertificateChain": "path/to/cert" } } } @@ -891,7 +846,7 @@ "VirtualService": { "VirtualServiceName": { "Fn::GetAtt": [ - "meshserviceE06ECED5", + "service6D174F83", "VirtualServiceName" ] } @@ -928,7 +883,7 @@ "VirtualService": { "VirtualServiceName": { "Fn::GetAtt": [ - "meshserviceE06ECED5", + "service6D174F83", "VirtualServiceName" ] } @@ -965,7 +920,7 @@ "VirtualService": { "VirtualServiceName": { "Fn::GetAtt": [ - "meshserviceE06ECED5", + "service6D174F83", "VirtualServiceName" ] } @@ -975,7 +930,7 @@ "Match": { "ServiceName": { "Fn::GetAtt": [ - "meshserviceE06ECED5", + "service6D174F83", "VirtualServiceName" ] } @@ -990,6 +945,30 @@ } } }, + "service6D174F83": { + "Type": "AWS::AppMesh::VirtualService", + "Properties": { + "MeshName": { + "Fn::GetAtt": [ + "meshACDFE68E", + "MeshName" + ] + }, + "Spec": { + "Provider": { + "VirtualRouter": { + "VirtualRouterName": { + "Fn::GetAtt": [ + "meshrouter81B8087E", + "VirtualRouterName" + ] + } + } + } + }, + "VirtualServiceName": "service1.domain.local" + } + }, "service27C65CF7D": { "Type": "AWS::AppMesh::VirtualService", "Properties": { diff --git a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts index 730418aef7970..90e54586f7f51 100644 --- a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts +++ b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts @@ -1,5 +1,3 @@ -import * as acmpca from '@aws-cdk/aws-acmpca'; -import * as acm from '@aws-cdk/aws-certificatemanager'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as cloudmap from '@aws-cdk/aws-servicediscovery'; import * as cdk from '@aws-cdk/core'; @@ -25,15 +23,11 @@ const router = mesh.addVirtualRouter('router', { ], }); -const virtualService = mesh.addVirtualService('service', { - virtualRouter: router, +const virtualService = new appmesh.VirtualService(stack, 'service', { + virtualServiceProvider: appmesh.VirtualServiceProvider.virtualRouter(router), virtualServiceName: 'service1.domain.local', }); -const cert = new acm.Certificate(stack, 'cert', { - domainName: `node1.${namespace.namespaceName}`, -}); - const node = mesh.addVirtualNode('node', { serviceDiscovery: appmesh.ServiceDiscovery.dns(`node1.${namespace.namespaceName}`), listeners: [appmesh.VirtualNodeListener.http({ @@ -41,10 +35,6 @@ const node = mesh.addVirtualNode('node', { healthyThreshold: 3, path: '/check-path', }, - tlsCertificate: appmesh.TlsCertificate.acm({ - certificate: cert, - tlsMode: appmesh.TlsMode.STRICT, - }), })], backends: [ virtualService, @@ -53,7 +43,7 @@ const node = mesh.addVirtualNode('node', { node.addBackend(new appmesh.VirtualService(stack, 'service-2', { virtualServiceName: 'service2.domain.local', - mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }), ); @@ -75,8 +65,6 @@ router.addRoute('route-1', { }), }); -const certificateAuthorityArn = 'arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012'; - const node2 = mesh.addVirtualNode('node2', { serviceDiscovery: appmesh.ServiceDiscovery.dns(`node2.${namespace.namespaceName}`), listeners: [appmesh.VirtualNodeListener.http({ @@ -90,13 +78,13 @@ const node2 = mesh.addVirtualNode('node2', { unhealthyThreshold: 2, }, })], - backendsDefaultClientPolicy: appmesh.ClientPolicy.acmTrust({ - certificateAuthorities: [acmpca.CertificateAuthority.fromCertificateAuthorityArn(stack, 'certificate', certificateAuthorityArn)], + backendsDefaultClientPolicy: appmesh.ClientPolicy.fileTrust({ + certificateChain: 'path/to/cert', }), backends: [ new appmesh.VirtualService(stack, 'service-3', { virtualServiceName: 'service3.domain.local', - mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }), ], }); diff --git a/packages/@aws-cdk/aws-appmesh/test/test.gateway-route.ts b/packages/@aws-cdk/aws-appmesh/test/test.gateway-route.ts index a741ec0b0d1d8..fed290d36a3e2 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.gateway-route.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.gateway-route.ts @@ -21,7 +21,7 @@ export = { }); const virtualService = new appmesh.VirtualService(stack, 'vs-1', { - mesh: mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), virtualServiceName: 'target.local', }); @@ -121,7 +121,9 @@ export = { meshName: 'test-mesh', }); - const virtualService = mesh.addVirtualService('testVirtualService'); + const virtualService = new appmesh.VirtualService(stack, 'testVirtualService', { + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), + }); test.throws(() => appmesh.GatewayRouteSpec.http({ routeTarget: virtualService, match: { diff --git a/packages/@aws-cdk/aws-appmesh/test/test.mesh.ts b/packages/@aws-cdk/aws-appmesh/test/test.mesh.ts index c6d2fbfbbb294..ce50c1402a7c3 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.mesh.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.mesh.ts @@ -125,120 +125,6 @@ export = { test.done(); }, - 'When adding a VirtualService to a mesh': { - 'with VirtualRouter and VirtualNode as providers': { - 'should throw error'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - - // WHEN - const mesh = new appmesh.Mesh(stack, 'mesh', { - meshName: 'test-mesh', - }); - - const testNode = new appmesh.VirtualNode(stack, 'test-node', { - mesh, - serviceDiscovery: appmesh.ServiceDiscovery.dns('test-node'), - }); - - const testRouter = mesh.addVirtualRouter('router', { - listeners: [ - appmesh.VirtualRouterListener.http(), - ], - }); - - // THEN - test.throws(() => { - mesh.addVirtualService('service', { - virtualServiceName: 'test-service.domain.local', - virtualNode: testNode, - virtualRouter: testRouter, - }); - }); - - test.done(); - }, - }, - 'with single virtual router provider resource': { - 'should create service'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - - // WHEN - const mesh = new appmesh.Mesh(stack, 'mesh', { - meshName: 'test-mesh', - }); - - const testRouter = mesh.addVirtualRouter('test-router', { - listeners: [ - appmesh.VirtualRouterListener.http(), - ], - }); - - mesh.addVirtualService('service', { - virtualServiceName: 'test-service.domain.local', - virtualRouter: testRouter, - }); - - // THEN - expect(stack).to( - haveResource('AWS::AppMesh::VirtualService', { - Spec: { - Provider: { - VirtualRouter: { - VirtualRouterName: { - 'Fn::GetAtt': ['meshtestrouterF78D72DD', 'VirtualRouterName'], - }, - }, - }, - }, - }), - ); - - test.done(); - }, - }, - 'with single virtual node provider resource': { - 'should create service'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - - // WHEN - const mesh = new appmesh.Mesh(stack, 'mesh', { - meshName: 'test-mesh', - }); - - const node = mesh.addVirtualNode('test-node', { - serviceDiscovery: appmesh.ServiceDiscovery.dns('test.domain.local'), - listeners: [appmesh.VirtualNodeListener.http({ - port: 8080, - })], - }); - - mesh.addVirtualService('service2', { - virtualServiceName: 'test-service.domain.local', - virtualNode: node, - }); - - // THEN - expect(stack).to( - haveResource('AWS::AppMesh::VirtualService', { - Spec: { - Provider: { - VirtualNode: { - VirtualNodeName: { - 'Fn::GetAtt': ['meshtestnodeF93946D4', 'VirtualNodeName'], - }, - }, - }, - }, - }), - ); - - test.done(); - }, - }, - }, 'When adding a VirtualNode to a mesh': { 'with empty default listeners and backends': { 'should create default resource'(test: Test) { @@ -376,7 +262,7 @@ export = { const service1 = new appmesh.VirtualService(stack, 'service-1', { virtualServiceName: 'service1.domain.local', - mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }); mesh.addVirtualNode('test-node', { @@ -415,8 +301,9 @@ export = { const stack2 = new cdk.Stack(); const mesh2 = appmesh.Mesh.fromMeshName(stack2, 'imported-mesh', 'abc'); - mesh2.addVirtualService('service', { + new appmesh.VirtualService(stack2, 'service', { virtualServiceName: 'test.domain.local', + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh2), }); // THEN diff --git a/packages/@aws-cdk/aws-appmesh/test/test.virtual-gateway.ts b/packages/@aws-cdk/aws-appmesh/test/test.virtual-gateway.ts index 7b4a563c90d34..b9d3ed70cae43 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.virtual-gateway.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.virtual-gateway.ts @@ -306,7 +306,9 @@ export = { mesh: mesh, }); - const virtualService = mesh.addVirtualService('virtualService', {}); + const virtualService = new appmesh.VirtualService(stack, 'virtualService', { + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), + }); virtualGateway.addGatewayRoute('testGatewayRoute', { gatewayRouteName: 'test-gateway-route', @@ -324,7 +326,7 @@ export = { Target: { VirtualService: { VirtualServiceName: { - 'Fn::GetAtt': ['meshvirtualService93460D43', 'VirtualServiceName'], + 'Fn::GetAtt': ['virtualService03A04B87', 'VirtualServiceName'], }, }, }, @@ -349,7 +351,9 @@ export = { meshName: 'test-mesh', }); - const virtualService = mesh.addVirtualService('virtualService', {}); + const virtualService = new appmesh.VirtualService(stack, 'virtualService', { + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), + }); const virtualGateway = mesh.addVirtualGateway('gateway'); virtualGateway.addGatewayRoute('testGatewayRoute', { diff --git a/packages/@aws-cdk/aws-appmesh/test/test.virtual-node.ts b/packages/@aws-cdk/aws-appmesh/test/test.virtual-node.ts index 9fb05931a2a44..4337973230854 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.virtual-node.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.virtual-node.ts @@ -19,11 +19,11 @@ export = { const service1 = new appmesh.VirtualService(stack, 'service-1', { virtualServiceName: 'service1.domain.local', - mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }); const service2 = new appmesh.VirtualService(stack, 'service-2', { virtualServiceName: 'service2.domain.local', - mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }); const node = new appmesh.VirtualNode(stack, 'test-node', { @@ -319,7 +319,7 @@ export = { const service1 = new appmesh.VirtualService(stack, 'service-1', { virtualServiceName: 'service1.domain.local', - mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), clientPolicy: appmesh.ClientPolicy.fileTrust({ certificateChain: 'path-to-certificate', ports: [8080, 8081], diff --git a/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts b/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts index aafe3dff8ce7f..2732adb4cba17 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts @@ -101,7 +101,7 @@ export = { const service1 = new appmesh.VirtualService(stack, 'service-1', { virtualServiceName: 'service1.domain.local', - mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }); const node = mesh.addVirtualNode('test-node', { @@ -170,11 +170,11 @@ export = { const service1 = new appmesh.VirtualService(stack, 'service-1', { virtualServiceName: 'service1.domain.local', - mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }); const service2 = new appmesh.VirtualService(stack, 'service-2', { virtualServiceName: 'service2.domain.local', - mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }); const node = mesh.addVirtualNode('test-node', { @@ -332,7 +332,7 @@ export = { const service1 = new appmesh.VirtualService(stack, 'service-1', { virtualServiceName: 'service1.domain.local', - mesh, + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }); const node = mesh.addVirtualNode('test-node', { diff --git a/packages/@aws-cdk/aws-appmesh/test/test.virtual-service.ts b/packages/@aws-cdk/aws-appmesh/test/test.virtual-service.ts index c09c156ac75ea..c60c98f8e7b94 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.virtual-service.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.virtual-service.ts @@ -1,3 +1,4 @@ +import { expect, haveResource } from '@aws-cdk/assert'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; @@ -19,6 +20,7 @@ export = { test.done(); }, + 'Can import Virtual Services using attributes'(test: Test) { // GIVEN const stack = new cdk.Stack(); @@ -34,6 +36,90 @@ export = { // THEN test.equal(virtualService.mesh.meshName, meshName); test.equal(virtualService.virtualServiceName, virtualServiceName); + test.done(); }, + + 'When adding a VirtualService to a mesh': { + 'with single virtual router provider resource': { + 'should create service'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const testRouter = mesh.addVirtualRouter('test-router', { + listeners: [ + appmesh.VirtualRouterListener.http(), + ], + }); + + new appmesh.VirtualService(stack, 'service', { + virtualServiceName: 'test-service.domain.local', + virtualServiceProvider: appmesh.VirtualServiceProvider.virtualRouter(testRouter), + }); + + // THEN + expect(stack).to( + haveResource('AWS::AppMesh::VirtualService', { + Spec: { + Provider: { + VirtualRouter: { + VirtualRouterName: { + 'Fn::GetAtt': ['meshtestrouterF78D72DD', 'VirtualRouterName'], + }, + }, + }, + }, + }), + ); + + test.done(); + }, + }, + + 'with single virtual node provider resource': { + 'should create service'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const node = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test.domain.local'), + listeners: [appmesh.VirtualNodeListener.http({ + port: 8080, + })], + }); + + new appmesh.VirtualService(stack, 'service2', { + virtualServiceName: 'test-service.domain.local', + virtualServiceProvider: appmesh.VirtualServiceProvider.virtualNode(node), + }); + + // THEN + expect(stack).to( + haveResource('AWS::AppMesh::VirtualService', { + Spec: { + Provider: { + VirtualNode: { + VirtualNodeName: { + 'Fn::GetAtt': ['meshtestnodeF93946D4', 'VirtualNodeName'], + }, + }, + }, + }, + }), + ); + + test.done(); + }, + }, + }, }; diff --git a/packages/@aws-cdk/aws-cloudfront/README.md b/packages/@aws-cdk/aws-cloudfront/README.md index f42d6a15f7abc..1355d7a64d31d 100644 --- a/packages/@aws-cdk/aws-cloudfront/README.md +++ b/packages/@aws-cdk/aws-cloudfront/README.md @@ -520,3 +520,40 @@ new CloudFrontWebDistribution(stack, 'ADistribution', { ], }); ``` + +## KeyGroup & PublicKey API + +Now you can create a key group to use with CloudFront signed URLs and signed cookies. You can add public keys to use with CloudFront features such as signed URLs, signed cookies, and field-level encryption. + +The following example command uses OpenSSL to generate an RSA key pair with a length of 2048 bits and save to the file named `private_key.pem`. + +```bash +openssl genrsa -out private_key.pem 2048 +``` + +The resulting file contains both the public and the private key. The following example command extracts the public key from the file named `private_key.pem` and stores it in `public_key.pem`. + +```bash +openssl rsa -pubout -in private_key.pem -out public_key.pem +``` + +Note: Don't forget to copy/paste the contents of `public_key.pem` file including `-----BEGIN PUBLIC KEY-----` and `-----END PUBLIC KEY-----` lines into `encodedKey` parameter when creating a `PublicKey`. + +Example: + +```ts + new cloudfront.KeyGroup(stack, 'MyKeyGroup', { + items: [ + new cloudfront.PublicKey(stack, 'MyPublicKey', { + encodedKey: '...', // contents of public_key.pem file + // comment: 'Key is expiring on ...', + }), + ], + // comment: 'Key group containing public keys ...', + }); +``` + +See: + +* https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PrivateContent.html +* https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html diff --git a/packages/@aws-cdk/aws-cloudfront/lib/experimental/edge-function.ts b/packages/@aws-cdk/aws-cloudfront/lib/experimental/edge-function.ts index e1d7a5dd9c960..f97b7e176d874 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/experimental/edge-function.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/experimental/edge-function.ts @@ -214,7 +214,7 @@ export class EdgeFunction extends Resource implements lambda.IVersion { throw new Error('stacks which use EdgeFunctions must have an explicitly set region'); } - const edgeStackId = stackId ?? `edge-lambda-stack-${region}`; + const edgeStackId = stackId ?? `edge-lambda-stack-${this.stack.node.addr}`; let edgeStack = stage.node.tryFindChild(edgeStackId) as Stack; if (!edgeStack) { edgeStack = new Stack(stage, edgeStackId, { diff --git a/packages/@aws-cdk/aws-cloudfront/lib/index.ts b/packages/@aws-cdk/aws-cloudfront/lib/index.ts index b0bd550231be3..7de2aa62b4412 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/index.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/index.ts @@ -1,10 +1,12 @@ export * from './cache-policy'; export * from './distribution'; export * from './geo-restriction'; +export * from './key-group'; export * from './origin'; -export * from './origin_access_identity'; +export * from './origin-access-identity'; export * from './origin-request-policy'; -export * from './web_distribution'; +export * from './public-key'; +export * from './web-distribution'; export * as experimental from './experimental'; diff --git a/packages/@aws-cdk/aws-cloudfront/lib/key-group.ts b/packages/@aws-cdk/aws-cloudfront/lib/key-group.ts new file mode 100644 index 0000000000000..aea7bf451f305 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/lib/key-group.ts @@ -0,0 +1,75 @@ +import { IResource, Names, Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnKeyGroup } from './cloudfront.generated'; +import { IPublicKey } from './public-key'; + +/** + * Represents a Key Group + */ +export interface IKeyGroup extends IResource { + /** + * The ID of the key group. + * @attribute + */ + readonly keyGroupId: string; +} + +/** + * Properties for creating a Public Key + */ +export interface KeyGroupProps { + /** + * A name to identify the key group. + * @default - generated from the `id` + */ + readonly keyGroupName?: string; + + /** + * A comment to describe the key group. + * @default - no comment + */ + readonly comment?: string; + + /** + * A list of public keys to add to the key group. + */ + readonly items: IPublicKey[]; +} + +/** + * A Key Group configuration + * + * @resource AWS::CloudFront::KeyGroup + */ +export class KeyGroup extends Resource implements IKeyGroup { + + /** Imports a Key Group from its id. */ + public static fromKeyGroupId(scope: Construct, id: string, keyGroupId: string): IKeyGroup { + return new class extends Resource implements IKeyGroup { + public readonly keyGroupId = keyGroupId; + }(scope, id); + } + public readonly keyGroupId: string; + + constructor(scope: Construct, id: string, props: KeyGroupProps) { + super(scope, id); + + const resource = new CfnKeyGroup(this, 'Resource', { + keyGroupConfig: { + name: props.keyGroupName ?? this.generateName(), + comment: props.comment, + items: props.items.map(key => key.publicKeyId), + }, + }); + + this.keyGroupId = resource.ref; + } + + private generateName(): string { + const name = Names.uniqueId(this); + if (name.length > 80) { + return name.substring(0, 40) + name.substring(name.length - 40); + } + return name; + } +} diff --git a/packages/@aws-cdk/aws-cloudfront/lib/origin_access_identity.ts b/packages/@aws-cdk/aws-cloudfront/lib/origin-access-identity.ts similarity index 100% rename from packages/@aws-cdk/aws-cloudfront/lib/origin_access_identity.ts rename to packages/@aws-cdk/aws-cloudfront/lib/origin-access-identity.ts diff --git a/packages/@aws-cdk/aws-cloudfront/lib/public-key.ts b/packages/@aws-cdk/aws-cloudfront/lib/public-key.ts new file mode 100644 index 0000000000000..e2c2b6e044cdb --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/lib/public-key.ts @@ -0,0 +1,83 @@ +import { IResource, Names, Resource, Token } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnPublicKey } from './cloudfront.generated'; + +/** + * Represents a Public Key + */ +export interface IPublicKey extends IResource { + /** + * The ID of the key group. + * @attribute + */ + readonly publicKeyId: string; +} + +/** + * Properties for creating a Public Key + */ +export interface PublicKeyProps { + /** + * A name to identify the public key. + * @default - generated from the `id` + */ + readonly publicKeyName?: string; + + /** + * A comment to describe the public key. + * @default - no comment + */ + readonly comment?: string; + + /** + * The public key that you can use with signed URLs and signed cookies, or with field-level encryption. + * The `encodedKey` parameter must include `-----BEGIN PUBLIC KEY-----` and `-----END PUBLIC KEY-----` lines. + * @see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PrivateContent.html + * @see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/field-level-encryption.html + */ + readonly encodedKey: string; +} + +/** + * A Public Key Configuration + * + * @resource AWS::CloudFront::PublicKey + */ +export class PublicKey extends Resource implements IPublicKey { + + /** Imports a Public Key from its id. */ + public static fromPublicKeyId(scope: Construct, id: string, publicKeyId: string): IPublicKey { + return new class extends Resource implements IPublicKey { + public readonly publicKeyId = publicKeyId; + }(scope, id); + } + + public readonly publicKeyId: string; + + constructor(scope: Construct, id: string, props: PublicKeyProps) { + super(scope, id); + + if (!Token.isUnresolved(props.encodedKey) && !/^-----BEGIN PUBLIC KEY-----/.test(props.encodedKey)) { + throw new Error(`Public key must be in PEM format (with the BEGIN/END PUBLIC KEY lines); got ${props.encodedKey}`); + } + + const resource = new CfnPublicKey(this, 'Resource', { + publicKeyConfig: { + name: props.publicKeyName ?? this.generateName(), + callerReference: this.node.addr, + encodedKey: props.encodedKey, + comment: props.comment, + }, + }); + + this.publicKeyId = resource.ref; + } + + private generateName(): string { + const name = Names.uniqueId(this); + if (name.length > 80) { + return name.substring(0, 40) + name.substring(name.length - 40); + } + return name; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudfront/lib/web_distribution.ts b/packages/@aws-cdk/aws-cloudfront/lib/web-distribution.ts similarity index 99% rename from packages/@aws-cdk/aws-cloudfront/lib/web_distribution.ts rename to packages/@aws-cdk/aws-cloudfront/lib/web-distribution.ts index dad54c3b1488a..c62e1e09ed3f4 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/web_distribution.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/web-distribution.ts @@ -7,7 +7,7 @@ import { Construct } from 'constructs'; import { CfnDistribution } from './cloudfront.generated'; import { HttpVersion, IDistribution, LambdaEdgeEventType, OriginProtocolPolicy, PriceClass, ViewerProtocolPolicy, SSLMethod, SecurityPolicyProtocol } from './distribution'; import { GeoRestriction } from './geo-restriction'; -import { IOriginAccessIdentity } from './origin_access_identity'; +import { IOriginAccessIdentity } from './origin-access-identity'; /** * HTTP status code to failover to second origin diff --git a/packages/@aws-cdk/aws-cloudfront/package.json b/packages/@aws-cdk/aws-cloudfront/package.json index 651d22e95e668..c781a065bbc0a 100644 --- a/packages/@aws-cdk/aws-cloudfront/package.json +++ b/packages/@aws-cdk/aws-cloudfront/package.json @@ -153,7 +153,9 @@ "resource-attribute:@aws-cdk/aws-cloudfront.CachePolicy.cachePolicyLastModifiedTime", "construct-interface-extends-iconstruct:@aws-cdk/aws-cloudfront.IOriginRequestPolicy", "resource-interface-extends-resource:@aws-cdk/aws-cloudfront.IOriginRequestPolicy", - "resource-attribute:@aws-cdk/aws-cloudfront.OriginRequestPolicy.originRequestPolicyLastModifiedTime" + "resource-attribute:@aws-cdk/aws-cloudfront.OriginRequestPolicy.originRequestPolicyLastModifiedTime", + "resource-attribute:@aws-cdk/aws-cloudfront.KeyGroup.keyGroupLastModifiedTime", + "resource-attribute:@aws-cdk/aws-cloudfront.PublicKey.publicKeyCreatedTime" ] }, "awscdkio": { diff --git a/packages/@aws-cdk/aws-cloudfront/test/experimental/edge-function.test.ts b/packages/@aws-cdk/aws-cloudfront/test/experimental/edge-function.test.ts index 372caea5c3fb6..4154f79cabff2 100644 --- a/packages/@aws-cdk/aws-cloudfront/test/experimental/edge-function.test.ts +++ b/packages/@aws-cdk/aws-cloudfront/test/experimental/edge-function.test.ts @@ -250,10 +250,10 @@ function defaultEdgeFunctionProps(stackId?: string) { code: lambda.Code.fromInline('foo'), handler: 'index.handler', runtime: lambda.Runtime.NODEJS_12_X, - stackId: stackId ?? 'edge-lambda-stack-testregion', + stackId: stackId, }; } -function getFnStack(region: string = 'testregion'): cdk.Stack { - return app.node.findChild(`edge-lambda-stack-${region}`) as cdk.Stack; +function getFnStack(): cdk.Stack { + return app.node.findChild(`edge-lambda-stack-${stack.node.addr}`) as cdk.Stack; } diff --git a/packages/@aws-cdk/aws-cloudfront/test/integ.cloudfront-key-group.expected.json b/packages/@aws-cdk/aws-cloudfront/test/integ.cloudfront-key-group.expected.json new file mode 100644 index 0000000000000..45191bad86cff --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/test/integ.cloudfront-key-group.expected.json @@ -0,0 +1,27 @@ +{ + "Resources": { + "AwesomePublicKeyED3E7F55": { + "Type": "AWS::CloudFront::PublicKey", + "Properties": { + "PublicKeyConfig": { + "CallerReference": "c88e460888c5762c9c47ac0cdc669370d787fb2d9f", + "EncodedKey": "-----BEGIN PUBLIC KEY-----\n MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAudf8/iNkQgdvjEdm6xYS\n JAyxd/kGTbJfQNg9YhInb7TSm0dGu0yx8yZ3fnpmxuRPqJIlaVr+fT4YRl71gEYa\n dlhHmnVegyPNjP9dNqZ7zwNqMEPOPnS/NOHbJj1KYKpn1f8pPNycQ5MQCntKGnSj\n 6fc+nbcC0joDvGz80xuy1W4hLV9oC9c3GT26xfZb2jy9MVtA3cppNuTwqrFi3t6e\n 0iGpraxZlT5wewjZLpQkngqYr6s3aucPAZVsGTEYPo4nD5mswmtZOm+tgcOrivtD\n /3sD/qZLQ6c5siqyS8aTraD6y+VXugujfarTU65IeZ6QAUbLMsWuZOIi5Jn8zAwx\n NQIDAQAB\n -----END PUBLIC KEY-----\n ", + "Name": "awscdkcloudfrontcustomAwesomePublicKey0E83393B" + } + } + }, + "AwesomeKeyGroup3EF8348B": { + "Type": "AWS::CloudFront::KeyGroup", + "Properties": { + "KeyGroupConfig": { + "Items": [ + { + "Ref": "AwesomePublicKeyED3E7F55" + } + ], + "Name": "awscdkcloudfrontcustomAwesomeKeyGroup73FD4DCA" + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudfront/test/integ.cloudfront-key-group.ts b/packages/@aws-cdk/aws-cloudfront/test/integ.cloudfront-key-group.ts new file mode 100644 index 0000000000000..7bfdbbe645446 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/test/integ.cloudfront-key-group.ts @@ -0,0 +1,25 @@ +import * as cdk from '@aws-cdk/core'; +import * as cloudfront from '../lib'; + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-cloudfront-custom'); + +new cloudfront.KeyGroup(stack, 'AwesomeKeyGroup', { + items: [ + new cloudfront.PublicKey(stack, 'AwesomePublicKey', { + encodedKey: `-----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAudf8/iNkQgdvjEdm6xYS + JAyxd/kGTbJfQNg9YhInb7TSm0dGu0yx8yZ3fnpmxuRPqJIlaVr+fT4YRl71gEYa + dlhHmnVegyPNjP9dNqZ7zwNqMEPOPnS/NOHbJj1KYKpn1f8pPNycQ5MQCntKGnSj + 6fc+nbcC0joDvGz80xuy1W4hLV9oC9c3GT26xfZb2jy9MVtA3cppNuTwqrFi3t6e + 0iGpraxZlT5wewjZLpQkngqYr6s3aucPAZVsGTEYPo4nD5mswmtZOm+tgcOrivtD + /3sD/qZLQ6c5siqyS8aTraD6y+VXugujfarTU65IeZ6QAUbLMsWuZOIi5Jn8zAwx + NQIDAQAB + -----END PUBLIC KEY----- + `, + }), + ], +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-cloudfront/test/key-group.test.ts b/packages/@aws-cdk/aws-cloudfront/test/key-group.test.ts new file mode 100644 index 0000000000000..f5a0ae43c0855 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/test/key-group.test.ts @@ -0,0 +1,187 @@ +import '@aws-cdk/assert/jest'; +import { expect as expectStack } from '@aws-cdk/assert'; +import { App, Stack } from '@aws-cdk/core'; +import { KeyGroup, PublicKey } from '../lib'; + +const publicKey1 = `-----BEGIN PUBLIC KEY----- +FIRST_KEYgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAudf8/iNkQgdvjEdm6xYS +JAyxd/kGTbJfQNg9YhInb7TSm0dGu0yx8yZ3fnpmxuRPqJIlaVr+fT4YRl71gEYa +dlhHmnVegyPNjP9dNqZ7zwNqMEPOPnS/NOHbJj1KYKpn1f8pPNycQ5MQCntKGnSj +6fc+nbcC0joDvGz80xuy1W4hLV9oC9c3GT26xfZb2jy9MVtA3cppNuTwqrFi3t6e +0iGpraxZlT5wewjZLpQkngqYr6s3aucPAZVsGTEYPo4nD5mswmtZOm+tgcOrivtD +/3sD/qZLQ6c5siqyS8aTraD6y+VXugujfarTU65IeZ6QAUbLMsWuZOIi5Jn8zAwx +NQIDAQAB +-----END PUBLIC KEY-----`; + +const publicKey2 = `-----BEGIN PUBLIC KEY----- +SECOND_KEYkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAudf8/iNkQgdvjEdm6xYS +JAyxd/kGTbJfQNg9YhInb7TSm0dGu0yx8yZ3fnpmxuRPqJIlaVr+fT4YRl71gEYa +dlhHmnVegyPNjP9dNqZ7zwNqMEPOPnS/NOHbJj1KYKpn1f8pPNycQ5MQCntKGnSj +6fc+nbcC0joDvGz80xuy1W4hLV9oC9c3GT26xfZb2jy9MVtA3cppNuTwqrFi3t6e +0iGpraxZlT5wewjZLpQkngqYr6s3aucPAZVsGTEYPo4nD5mswmtZOm+tgcOrivtD +/3sD/qZLQ6c5siqyS8aTraD6y+VXugujfarTU65IeZ6QAUbLMsWuZOIi5Jn8zAwx +NQIDAQAB +-----END PUBLIC KEY-----`; + +describe('KeyGroup', () => { + let app: App; + let stack: Stack; + + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '123456789012', region: 'testregion' }, + }); + }); + + test('import existing key group by id', () => { + const keyGroupId = '344f6fe5-7ce5-4df0-a470-3f14177c549c'; + const keyGroup = KeyGroup.fromKeyGroupId(stack, 'MyKeyGroup', keyGroupId); + expect(keyGroup.keyGroupId).toEqual(keyGroupId); + }); + + test('minimal example', () => { + new KeyGroup(stack, 'MyKeyGroup', { + items: [ + new PublicKey(stack, 'MyPublicKey', { + encodedKey: publicKey1, + }), + ], + }); + + expectStack(stack).toMatch({ + Resources: { + MyPublicKey78071F3D: { + Type: 'AWS::CloudFront::PublicKey', + Properties: { + PublicKeyConfig: { + CallerReference: 'c872d91ae0d2943aad25d4b31f1304d0a62c658ace', + EncodedKey: publicKey1, + Name: 'StackMyPublicKey36EDA6AB', + }, + }, + }, + MyKeyGroupAF22FD35: { + Type: 'AWS::CloudFront::KeyGroup', + Properties: { + KeyGroupConfig: { + Items: [ + { + Ref: 'MyPublicKey78071F3D', + }, + ], + Name: 'StackMyKeyGroupC9D82374', + }, + }, + }, + }, + }); + }); + + test('maximum example', () => { + new KeyGroup(stack, 'MyKeyGroup', { + keyGroupName: 'AcmeKeyGroup', + comment: 'Key group created on 1/1/1984', + items: [ + new PublicKey(stack, 'MyPublicKey', { + publicKeyName: 'pub-key', + encodedKey: publicKey1, + comment: 'Key expiring on 1/1/1984', + }), + ], + }); + + expectStack(stack).toMatch({ + Resources: { + MyPublicKey78071F3D: { + Type: 'AWS::CloudFront::PublicKey', + Properties: { + PublicKeyConfig: { + CallerReference: 'c872d91ae0d2943aad25d4b31f1304d0a62c658ace', + EncodedKey: publicKey1, + Name: 'pub-key', + Comment: 'Key expiring on 1/1/1984', + }, + }, + }, + MyKeyGroupAF22FD35: { + Type: 'AWS::CloudFront::KeyGroup', + Properties: { + KeyGroupConfig: { + Items: [ + { + Ref: 'MyPublicKey78071F3D', + }, + ], + Name: 'AcmeKeyGroup', + Comment: 'Key group created on 1/1/1984', + }, + }, + }, + }, + }); + }); + + test('multiple keys example', () => { + new KeyGroup(stack, 'MyKeyGroup', { + keyGroupName: 'AcmeKeyGroup', + comment: 'Key group created on 1/1/1984', + items: [ + new PublicKey(stack, 'BingoKey', { + publicKeyName: 'Bingo-Key', + encodedKey: publicKey1, + comment: 'Key expiring on 1/1/1984', + }), + new PublicKey(stack, 'RollyKey', { + publicKeyName: 'Rolly-Key', + encodedKey: publicKey2, + comment: 'Key expiring on 1/1/1984', + }), + ], + }); + + expectStack(stack).toMatch({ + Resources: { + BingoKeyCBEC786C: { + Type: 'AWS::CloudFront::PublicKey', + Properties: { + PublicKeyConfig: { + CallerReference: 'c847cb3dc23f619c0a1e400a44afaf1060d27a1d1a', + EncodedKey: publicKey1, + Name: 'Bingo-Key', + Comment: 'Key expiring on 1/1/1984', + }, + }, + }, + RollyKey83F8BC5B: { + Type: 'AWS::CloudFront::PublicKey', + Properties: { + PublicKeyConfig: { + CallerReference: 'c83a16945c386bf6cd88a3aaa1aa603eeb4b6c6c57', + EncodedKey: publicKey2, + Name: 'Rolly-Key', + Comment: 'Key expiring on 1/1/1984', + }, + }, + }, + MyKeyGroupAF22FD35: { + Type: 'AWS::CloudFront::KeyGroup', + Properties: { + KeyGroupConfig: { + Items: [ + { + Ref: 'BingoKeyCBEC786C', + }, + { + Ref: 'RollyKey83F8BC5B', + }, + ], + Name: 'AcmeKeyGroup', + Comment: 'Key group created on 1/1/1984', + }, + }, + }, + }, + }); + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudfront/test/public-key.test.ts b/packages/@aws-cdk/aws-cloudfront/test/public-key.test.ts new file mode 100644 index 0000000000000..934b1a9dc8107 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/test/public-key.test.ts @@ -0,0 +1,85 @@ +import '@aws-cdk/assert/jest'; +import { expect as expectStack } from '@aws-cdk/assert'; +import { App, Stack } from '@aws-cdk/core'; +import { PublicKey } from '../lib'; + +const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAudf8/iNkQgdvjEdm6xYS +JAyxd/kGTbJfQNg9YhInb7TSm0dGu0yx8yZ3fnpmxuRPqJIlaVr+fT4YRl71gEYa +dlhHmnVegyPNjP9dNqZ7zwNqMEPOPnS/NOHbJj1KYKpn1f8pPNycQ5MQCntKGnSj +6fc+nbcC0joDvGz80xuy1W4hLV9oC9c3GT26xfZb2jy9MVtA3cppNuTwqrFi3t6e +0iGpraxZlT5wewjZLpQkngqYr6s3aucPAZVsGTEYPo4nD5mswmtZOm+tgcOrivtD +/3sD/qZLQ6c5siqyS8aTraD6y+VXugujfarTU65IeZ6QAUbLMsWuZOIi5Jn8zAwx +NQIDAQAB +-----END PUBLIC KEY-----`; + +describe('PublicKey', () => { + let app: App; + let stack: Stack; + + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '123456789012', region: 'testregion' }, + }); + }); + + test('import existing key group by id', () => { + const publicKeyId = 'K36X4X2EO997HM'; + const pubKey = PublicKey.fromPublicKeyId(stack, 'MyPublicKey', publicKeyId); + expect(pubKey.publicKeyId).toEqual(publicKeyId); + }); + + test('minimal example', () => { + new PublicKey(stack, 'MyPublicKey', { + encodedKey: publicKey, + }); + + expectStack(stack).toMatch({ + Resources: { + MyPublicKey78071F3D: { + Type: 'AWS::CloudFront::PublicKey', + Properties: { + PublicKeyConfig: { + CallerReference: 'c872d91ae0d2943aad25d4b31f1304d0a62c658ace', + EncodedKey: publicKey, + Name: 'StackMyPublicKey36EDA6AB', + }, + }, + }, + }, + }); + }); + + test('maximum example', () => { + new PublicKey(stack, 'MyPublicKey', { + publicKeyName: 'pub-key', + encodedKey: publicKey, + comment: 'Key expiring on 1/1/1984', + }); + + expectStack(stack).toMatch({ + Resources: { + MyPublicKey78071F3D: { + Type: 'AWS::CloudFront::PublicKey', + Properties: { + PublicKeyConfig: { + CallerReference: 'c872d91ae0d2943aad25d4b31f1304d0a62c658ace', + Comment: 'Key expiring on 1/1/1984', + EncodedKey: publicKey, + Name: 'pub-key', + }, + }, + }, + }, + }); + }); + + test('bad key example', () => { + expect(() => new PublicKey(stack, 'MyPublicKey', { + publicKeyName: 'pub-key', + encodedKey: 'bad-key', + comment: 'Key expiring on 1/1/1984', + })).toThrow(/Public key must be in PEM format [(]with the BEGIN\/END PUBLIC KEY lines[)]; got (.*?)/); + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudfront/test/web_distribution.test.ts b/packages/@aws-cdk/aws-cloudfront/test/web-distribution.test.ts similarity index 100% rename from packages/@aws-cdk/aws-cloudfront/test/web_distribution.test.ts rename to packages/@aws-cdk/aws-cloudfront/test/web-distribution.test.ts diff --git a/packages/@aws-cdk/aws-codebuild/lib/index.ts b/packages/@aws-cdk/aws-codebuild/lib/index.ts index 96731b2130043..5c2de5f3119c2 100644 --- a/packages/@aws-cdk/aws-codebuild/lib/index.ts +++ b/packages/@aws-cdk/aws-codebuild/lib/index.ts @@ -10,6 +10,7 @@ export * from './cache'; export * from './build-spec'; export * from './file-location'; export * from './linux-gpu-build-image'; +export * from './untrusted-code-boundary-policy'; // AWS::CodeBuild CloudFormation Resources: export * from './codebuild.generated'; diff --git a/packages/@aws-cdk/aws-codebuild/lib/project.ts b/packages/@aws-cdk/aws-codebuild/lib/project.ts index c98718bd744bf..2f31bc897f9f8 100644 --- a/packages/@aws-cdk/aws-codebuild/lib/project.ts +++ b/packages/@aws-cdk/aws-codebuild/lib/project.ts @@ -695,9 +695,11 @@ export class Project extends ProjectBase { * @returns an array of {@link CfnProject.EnvironmentVariableProperty} instances */ public static serializeEnvVariables(environmentVariables: { [name: string]: BuildEnvironmentVariable }, - validateNoPlainTextSecrets: boolean = false): CfnProject.EnvironmentVariableProperty[] { + validateNoPlainTextSecrets: boolean = false, principal?: iam.IGrantable): CfnProject.EnvironmentVariableProperty[] { const ret = new Array(); + const ssmVariables = new Array(); + const secretsManagerSecrets = new Array(); for (const [name, envVariable] of Object.entries(environmentVariables)) { const cfnEnvVariable: CfnProject.EnvironmentVariableProperty = { @@ -720,6 +722,46 @@ export class Project extends ProjectBase { } } } + + if (principal) { + // save the SSM env variables + if (envVariable.type === BuildEnvironmentVariableType.PARAMETER_STORE) { + const envVariableValue = envVariable.value.toString(); + ssmVariables.push(Stack.of(principal).formatArn({ + service: 'ssm', + resource: 'parameter', + // If the parameter name starts with / the resource name is not separated with a double '/' + // arn:aws:ssm:region:1111111111:parameter/PARAM_NAME + resourceName: envVariableValue.startsWith('/') + ? envVariableValue.substr(1) + : envVariableValue, + })); + } + + // save SecretsManager env variables + if (envVariable.type === BuildEnvironmentVariableType.SECRETS_MANAGER) { + secretsManagerSecrets.push(Stack.of(principal).formatArn({ + service: 'secretsmanager', + resource: 'secret', + // we don't know the exact ARN of the Secret just from its name, but we can get close + resourceName: `${envVariable.value}-??????`, + sep: ':', + })); + } + } + } + + if (ssmVariables.length !== 0) { + principal?.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['ssm:GetParameters'], + resources: ssmVariables, + })); + } + if (secretsManagerSecrets.length !== 0) { + principal?.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: secretsManagerSecrets, + })); } return ret; @@ -854,7 +896,6 @@ export class Project extends ProjectBase { this.projectName = this.getResourceNameAttribute(resource.ref); this.addToRolePolicy(this.createLoggingPermission()); - this.addEnvVariablesPermissions(props.environmentVariables); // add permissions to create and use test report groups // with names starting with the project's name, // unless the customer explicitly opts out of it @@ -1007,57 +1048,6 @@ export class Project extends ProjectBase { }); } - private addEnvVariablesPermissions(environmentVariables: { [name: string]: BuildEnvironmentVariable } | undefined): void { - this.addParameterStorePermissions(environmentVariables); - this.addSecretsManagerPermissions(environmentVariables); - } - - private addParameterStorePermissions(environmentVariables: { [name: string]: BuildEnvironmentVariable } | undefined): void { - const resources = Object.values(environmentVariables || {}) - .filter(envVariable => envVariable.type === BuildEnvironmentVariableType.PARAMETER_STORE) - .map(envVariable => - // If the parameter name starts with / the resource name is not separated with a double '/' - // arn:aws:ssm:region:1111111111:parameter/PARAM_NAME - (envVariable.value as string).startsWith('/') - ? (envVariable.value as string).substr(1) - : envVariable.value) - .map(envVariable => Stack.of(this).formatArn({ - service: 'ssm', - resource: 'parameter', - resourceName: envVariable, - })); - - if (resources.length === 0) { - return; - } - - this.addToRolePolicy(new iam.PolicyStatement({ - actions: ['ssm:GetParameters'], - resources, - })); - } - - private addSecretsManagerPermissions(environmentVariables: { [name: string]: BuildEnvironmentVariable } | undefined): void { - const resources = Object.values(environmentVariables || {}) - .filter(envVariable => envVariable.type === BuildEnvironmentVariableType.SECRETS_MANAGER) - .map(envVariable => Stack.of(this).formatArn({ - service: 'secretsmanager', - resource: 'secret', - // we don't know the exact ARN of the Secret just from its name, but we can get close - resourceName: `${envVariable.value}-??????`, - sep: ':', - })); - - if (resources.length === 0) { - return; - } - - this.addToRolePolicy(new iam.PolicyStatement({ - actions: ['secretsmanager:GetSecretValue'], - resources, - })); - } - private renderEnvironment( props: ProjectProps, projectVars: { [name: string]: BuildEnvironmentVariable } = {}): CfnProject.EnvironmentProperty { @@ -1118,7 +1108,7 @@ export class Project extends ProjectBase { privilegedMode: env.privileged || false, computeType: env.computeType || this.buildImage.defaultComputeType, environmentVariables: hasEnvironmentVars - ? Project.serializeEnvVariables(vars, props.checkSecretsInPlainTextEnvVariables ?? true) + ? Project.serializeEnvVariables(vars, props.checkSecretsInPlainTextEnvVariables ?? true, this) : undefined, }; } diff --git a/packages/@aws-cdk/aws-codebuild/lib/untrusted-code-boundary-policy.ts b/packages/@aws-cdk/aws-codebuild/lib/untrusted-code-boundary-policy.ts new file mode 100644 index 0000000000000..229cb547e7c1f --- /dev/null +++ b/packages/@aws-cdk/aws-codebuild/lib/untrusted-code-boundary-policy.ts @@ -0,0 +1,94 @@ +import * as iam from '@aws-cdk/aws-iam'; +import { Construct } from 'constructs'; + +/** + * Construction properties for UntrustedCodeBoundaryPolicy + */ +export interface UntrustedCodeBoundaryPolicyProps { + /** + * The name of the managed policy. + * + * @default - A name is automatically generated. + */ + readonly managedPolicyName?: string; + + /** + * Additional statements to add to the default set of statements + * + * @default - No additional statements + */ + readonly additionalStatements?: iam.PolicyStatement[]; +} + +/** + * Permissions Boundary for a CodeBuild Project running untrusted code + * + * This class is a Policy, intended to be used as a Permissions Boundary + * for a CodeBuild project. It allows most of the actions necessary to run + * the CodeBuild project, but disallows reading from Parameter Store + * and Secrets Manager. + * + * Use this when your CodeBuild project is running untrusted code (for + * example, if you are using one to automatically build Pull Requests + * that anyone can submit), and you want to prevent your future self + * from accidentally exposing Secrets to this build. + * + * (The reason you might want to do this is because otherwise anyone + * who can submit a Pull Request to your project can write a script + * to email those secrets to themselves). + * + * @example + * + * iam.PermissionsBoundary.of(project).apply(new UntrustedCodeBoundaryPolicy(this, 'Boundary')); + */ +export class UntrustedCodeBoundaryPolicy extends iam.ManagedPolicy { + constructor(scope: Construct, id: string, props: UntrustedCodeBoundaryPolicyProps = {}) { + super(scope, id, { + managedPolicyName: props.managedPolicyName, + description: 'Permissions Boundary Policy for CodeBuild Projects running untrusted code', + statements: [ + new iam.PolicyStatement({ + actions: [ + // For logging + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:PutLogEvents', + + // For test reports + 'codebuild:CreateReportGroup', + 'codebuild:CreateReport', + 'codebuild:UpdateReport', + 'codebuild:BatchPutTestCases', + 'codebuild:BatchPutCodeCoverages', + + // For batch builds + 'codebuild:StartBuild', + 'codebuild:StopBuild', + 'codebuild:RetryBuild', + + // For pulling ECR images + 'ecr:GetDownloadUrlForLayer', + 'ecr:BatchGetImage', + 'ecr:BatchCheckLayerAvailability', + + // For running in a VPC + 'ec2:CreateNetworkInterfacePermission', + 'ec2:CreateNetworkInterface', + 'ec2:DescribeNetworkInterfaces', + 'ec2:DeleteNetworkInterface', + 'ec2:DescribeSubnets', + 'ec2:DescribeSecurityGroups', + 'ec2:DescribeDhcpOptions', + 'ec2:DescribeVpcs', + + // NOTABLY MISSING: + // - Reading secrets + // - Reading parameterstore + ], + resources: ['*'], + }), + ...props.additionalStatements ?? [], + ], + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codebuild/test/test.untrusted-code-boundary.ts b/packages/@aws-cdk/aws-codebuild/test/test.untrusted-code-boundary.ts new file mode 100644 index 0000000000000..04196e631f5c8 --- /dev/null +++ b/packages/@aws-cdk/aws-codebuild/test/test.untrusted-code-boundary.ts @@ -0,0 +1,56 @@ +import { expect, haveResourceLike, arrayWith } from '@aws-cdk/assert'; +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import * as codebuild from '../lib'; + +export = { + 'can attach permissions boundary to Project'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const project = new codebuild.Project(stack, 'Project', { + source: codebuild.Source.gitHub({ owner: 'a', repo: 'b' }), + }); + iam.PermissionsBoundary.of(project).apply(new codebuild.UntrustedCodeBoundaryPolicy(stack, 'Boundary')); + + // THEN + expect(stack).to(haveResourceLike('AWS::IAM::Role', { + PermissionsBoundary: { Ref: 'BoundaryEA298153' }, + })); + + test.done(); + }, + + 'can add additional statements Boundary'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const project = new codebuild.Project(stack, 'Project', { + source: codebuild.Source.gitHub({ owner: 'a', repo: 'b' }), + }); + iam.PermissionsBoundary.of(project).apply(new codebuild.UntrustedCodeBoundaryPolicy(stack, 'Boundary', { + additionalStatements: [ + new iam.PolicyStatement({ + actions: ['a:a'], + resources: ['b'], + }), + ], + })); + + // THEN + expect(stack).to(haveResourceLike('AWS::IAM::ManagedPolicy', { + PolicyDocument: { + Statement: arrayWith({ + Effect: 'Allow', + Action: 'a:a', + Resource: 'b', + }), + }, + })); + + test.done(); + }, +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codedeploy/lib/server/deployment-group.ts b/packages/@aws-cdk/aws-codedeploy/lib/server/deployment-group.ts index eb4bbafade216..0864d603d2e28 100644 --- a/packages/@aws-cdk/aws-codedeploy/lib/server/deployment-group.ts +++ b/packages/@aws-cdk/aws-codedeploy/lib/server/deployment-group.ts @@ -372,7 +372,8 @@ export class ServerDeploymentGroup extends ServerDeploymentGroupBase { asg.addUserData( 'Set-Variable -Name TEMPDIR -Value (New-TemporaryFile).DirectoryName', `aws s3 cp s3://aws-codedeploy-${cdk.Stack.of(this).region}/latest/codedeploy-agent.msi $TEMPDIR\\codedeploy-agent.msi`, - '$TEMPDIR\\codedeploy-agent.msi /quiet /l c:\\temp\\host-agent-install-log.txt', + 'cd $TEMPDIR', + '.\\codedeploy-agent.msi /quiet /l c:\\temp\\host-agent-install-log.txt', ); break; } diff --git a/packages/@aws-cdk/aws-codedeploy/test/server/test.deployment-group.ts b/packages/@aws-cdk/aws-codedeploy/test/server/test.deployment-group.ts index fd486f20f1eb2..761d0e38eb6a9 100644 --- a/packages/@aws-cdk/aws-codedeploy/test/server/test.deployment-group.ts +++ b/packages/@aws-cdk/aws-codedeploy/test/server/test.deployment-group.ts @@ -42,6 +42,78 @@ export = { test.done(); }, + 'uses good linux install agent script'(test: Test) { + const stack = new cdk.Stack(); + + const asg = new autoscaling.AutoScalingGroup(stack, 'ASG', { + instanceType: ec2.InstanceType.of(ec2.InstanceClass.STANDARD3, ec2.InstanceSize.SMALL), + machineImage: new ec2.AmazonLinuxImage(), + vpc: new ec2.Vpc(stack, 'VPC'), + }); + + new codedeploy.ServerDeploymentGroup(stack, 'DeploymentGroup', { + autoScalingGroups: [asg], + installAgent: true, + }); + + expect(stack).to(haveResource('AWS::AutoScaling::LaunchConfiguration', { + 'UserData': { + 'Fn::Base64': { + 'Fn::Join': [ + '', + [ + '#!/bin/bash\nPKG_CMD=`which yum 2>/dev/null`\nif [ -z "$PKG_CMD" ]; then\nPKG_CMD=apt-get\nelse\nPKG=CMD=yum\nfi\n$PKG_CMD update -y\n$PKG_CMD install -y ruby2.0\nif [ $? -ne 0 ]; then\n$PKG_CMD install -y ruby\nfi\n$PKG_CMD install -y awscli\nTMP_DIR=`mktemp -d`\ncd $TMP_DIR\naws s3 cp s3://aws-codedeploy-', + { + 'Ref': 'AWS::Region', + }, + '/latest/install . --region ', + { + 'Ref': 'AWS::Region', + }, + '\nchmod +x ./install\n./install auto\nrm -fr $TMP_DIR', + ], + ], + }, + }, + })); + + test.done(); + }, + + 'uses good windows install agent script'(test: Test) { + const stack = new cdk.Stack(); + + const asg = new autoscaling.AutoScalingGroup(stack, 'ASG', { + instanceType: ec2.InstanceType.of(ec2.InstanceClass.STANDARD3, ec2.InstanceSize.SMALL), + machineImage: new ec2.WindowsImage(ec2.WindowsVersion.WINDOWS_SERVER_2019_ENGLISH_FULL_BASE, {}), + vpc: new ec2.Vpc(stack, 'VPC'), + }); + + new codedeploy.ServerDeploymentGroup(stack, 'DeploymentGroup', { + autoScalingGroups: [asg], + installAgent: true, + }); + + expect(stack).to(haveResource('AWS::AutoScaling::LaunchConfiguration', { + 'UserData': { + 'Fn::Base64': { + 'Fn::Join': [ + '', + [ + 'Set-Variable -Name TEMPDIR -Value (New-TemporaryFile).DirectoryName\naws s3 cp s3://aws-codedeploy-', + { + 'Ref': 'AWS::Region', + }, + '/latest/codedeploy-agent.msi $TEMPDIR\\codedeploy-agent.msi\ncd $TEMPDIR\n.\\codedeploy-agent.msi /quiet /l c:\\temp\\host-agent-install-log.txt', + ], + ], + }, + }, + })); + + test.done(); + }, + 'created with ASGs contains the ASG names'(test: Test) { const stack = new cdk.Stack(); diff --git a/packages/@aws-cdk/aws-codepipeline-actions/lib/codebuild/build-action.ts b/packages/@aws-cdk/aws-codepipeline-actions/lib/codebuild/build-action.ts index 238efebae4658..1fc1611bd9ff2 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/lib/codebuild/build-action.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/lib/codebuild/build-action.ts @@ -207,7 +207,7 @@ export class CodeBuildAction extends Action { ProjectName: this.props.project.projectName, EnvironmentVariables: this.props.environmentVariables && cdk.Stack.of(scope).toJsonString(codebuild.Project.serializeEnvVariables(this.props.environmentVariables, - this.props.checkSecretsInPlainTextEnvVariables ?? true)), + this.props.checkSecretsInPlainTextEnvVariables ?? true, this.props.project)), }; if ((this.actionProperties.inputs || []).length > 1) { // lazy, because the Artifact name might be generated lazily diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-commit-build.expected.json b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-commit-build.expected.json index 7fe649e0c2f8c..dbf8c85f91394 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-commit-build.expected.json +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-commit-build.expected.json @@ -149,6 +149,30 @@ ] } }, + { + "Action": "ssm:GetParameters", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":ssm:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":parameter/param_store" + ] + ] + } + }, { "Action": [ "s3:GetObject*", @@ -898,4 +922,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-codepipeline/test/cross-env.test.ts b/packages/@aws-cdk/aws-codepipeline/test/cross-env.test.ts index 917f4c833858f..08fd0160b90f4 100644 --- a/packages/@aws-cdk/aws-codepipeline/test/cross-env.test.ts +++ b/packages/@aws-cdk/aws-codepipeline/test/cross-env.test.ts @@ -6,130 +6,136 @@ import * as codepipeline from '../lib'; import { FakeBuildAction } from './fake-build-action'; import { FakeSourceAction } from './fake-source-action'; -let app: App; -let stack: Stack; -let sourceArtifact: codepipeline.Artifact; -let initialStages: codepipeline.StageProps[]; - -beforeEach(() => { - app = new App(); - stack = new Stack(app, 'PipelineStack', { env: { account: '2222', region: 'us-east-1' } }); - sourceArtifact = new codepipeline.Artifact(); - initialStages = [ - { - stageName: 'Source', - actions: [new FakeSourceAction({ - actionName: 'Source', - output: sourceArtifact, - })], - }, - { - stageName: 'Build', - actions: [new FakeBuildAction({ - actionName: 'Build', - input: sourceArtifact, - })], - }, - ]; -}); - -describe('crossAccountKeys=false', () => { - let pipeline: codepipeline.Pipeline; +describe.each(['legacy', 'modern'])('with %s synthesis', (synthesisStyle: string) => { + let app: App; + let stack: Stack; + let sourceArtifact: codepipeline.Artifact; + let initialStages: codepipeline.StageProps[]; + beforeEach(() => { - pipeline = new codepipeline.Pipeline(stack, 'Pipeline', { - crossAccountKeys: false, - stages: initialStages, + app = new App({ + context: { + ...synthesisStyle === 'modern' ? { '@aws-cdk/core:newStyleStackSynthesis': true } : undefined, + }, }); + stack = new Stack(app, 'PipelineStack', { env: { account: '2222', region: 'us-east-1' } }); + sourceArtifact = new codepipeline.Artifact(); + initialStages = [ + { + stageName: 'Source', + actions: [new FakeSourceAction({ + actionName: 'Source', + output: sourceArtifact, + })], + }, + { + stageName: 'Build', + actions: [new FakeBuildAction({ + actionName: 'Build', + input: sourceArtifact, + })], + }, + ]; }); - test('creates a bucket but no keys', () => { - // THEN - expect(stack).not.toHaveResource('AWS::KMS::Key'); - expect(stack).toHaveResource('AWS::S3::Bucket'); - }); - - describe('prevents adding a cross-account action', () => { - const expectedError = 'crossAccountKeys: true'; - - let stage: codepipeline.IStage; + describe('crossAccountKeys=false', () => { + let pipeline: codepipeline.Pipeline; beforeEach(() => { - stage = pipeline.addStage({ stageName: 'Deploy' }); + pipeline = new codepipeline.Pipeline(stack, 'Pipeline', { + crossAccountKeys: false, + stages: initialStages, + }); }); - test('by role', () => { - // WHEN - expect(() => { - stage.addAction(new FakeBuildAction({ - actionName: 'Deploy', - input: sourceArtifact, - role: iam.Role.fromRoleArn(stack, 'Role', 'arn:aws:iam::1111:role/some-role'), - })); - }).toThrow(expectedError); + test('creates a bucket but no keys', () => { + // THEN + expect(stack).not.toHaveResource('AWS::KMS::Key'); + expect(stack).toHaveResource('AWS::S3::Bucket'); }); - test('by resource', () => { - // WHEN - expect(() => { - stage.addAction(new FakeBuildAction({ - actionName: 'Deploy', - input: sourceArtifact, - resource: s3.Bucket.fromBucketAttributes(stack, 'Bucket', { - bucketName: 'foo', + describe('prevents adding a cross-account action', () => { + const expectedError = 'crossAccountKeys: true'; + + let stage: codepipeline.IStage; + beforeEach(() => { + stage = pipeline.addStage({ stageName: 'Deploy' }); + }); + + test('by role', () => { + // WHEN + expect(() => { + stage.addAction(new FakeBuildAction({ + actionName: 'Deploy', + input: sourceArtifact, + role: iam.Role.fromRoleArn(stack, 'Role', 'arn:aws:iam::1111:role/some-role'), + })); + }).toThrow(expectedError); + }); + + test('by resource', () => { + // WHEN + expect(() => { + stage.addAction(new FakeBuildAction({ + actionName: 'Deploy', + input: sourceArtifact, + resource: s3.Bucket.fromBucketAttributes(stack, 'Bucket', { + bucketName: 'foo', + account: '1111', + }), + })); + }).toThrow(expectedError); + }); + + test('by declared account', () => { + // WHEN + expect(() => { + stage.addAction(new FakeBuildAction({ + actionName: 'Deploy', + input: sourceArtifact, account: '1111', - }), - })); - }).toThrow(expectedError); + })); + }).toThrow(expectedError); + }); }); - test('by declared account', () => { - // WHEN - expect(() => { + describe('also affects cross-region support stacks', () => { + let stage: codepipeline.IStage; + beforeEach(() => { + stage = pipeline.addStage({ stageName: 'Deploy' }); + }); + + test('when making a support stack', () => { + // WHEN stage.addAction(new FakeBuildAction({ actionName: 'Deploy', input: sourceArtifact, - account: '1111', + // No resource to grab onto forces creating a fresh support stack + region: 'eu-west-1', })); - }).toThrow(expectedError); - }); - }); - describe('also affects cross-region support stacks', () => { - let stage: codepipeline.IStage; - beforeEach(() => { - stage = pipeline.addStage({ stageName: 'Deploy' }); - }); - - test('when making a support stack', () => { - // WHEN - stage.addAction(new FakeBuildAction({ - actionName: 'Deploy', - input: sourceArtifact, - // No resource to grab onto forces creating a fresh support stack - region: 'eu-west-1', - })); - - // THEN - const asm = app.synth(); - const supportStack = asm.getStack(`${stack.stackName}-support-eu-west-1`); + // THEN + const asm = app.synth(); + const supportStack = asm.getStack(`${stack.stackName}-support-eu-west-1`); - // THEN - expect(supportStack).not.toHaveResource('AWS::KMS::Key'); - expect(supportStack).toHaveResource('AWS::S3::Bucket'); - }); + // THEN + expect(supportStack).not.toHaveResource('AWS::KMS::Key'); + expect(supportStack).toHaveResource('AWS::S3::Bucket'); + }); - test('when twiddling another stack', () => { - const stack2 = new Stack(app, 'Stack2', { env: { account: '2222', region: 'eu-west-1' } }); + test('when twiddling another stack', () => { + const stack2 = new Stack(app, 'Stack2', { env: { account: '2222', region: 'eu-west-1' } }); - // WHEN - stage.addAction(new FakeBuildAction({ - actionName: 'Deploy', - input: sourceArtifact, - resource: new iam.User(stack2, 'DoesntMatterWhatThisIs'), - })); + // WHEN + stage.addAction(new FakeBuildAction({ + actionName: 'Deploy', + input: sourceArtifact, + resource: new iam.User(stack2, 'DoesntMatterWhatThisIs'), + })); - // THEN - expect(stack2).not.toHaveResource('AWS::KMS::Key'); - expect(stack2).toHaveResource('AWS::S3::Bucket'); + // THEN + expect(stack2).not.toHaveResource('AWS::KMS::Key'); + expect(stack2).toHaveResource('AWS::S3::Bucket'); + }); }); }); }); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/lib/bastion-host.ts b/packages/@aws-cdk/aws-ec2/lib/bastion-host.ts index dbc6c45fe415a..0656440ea3ff4 100644 --- a/packages/@aws-cdk/aws-ec2/lib/bastion-host.ts +++ b/packages/@aws-cdk/aws-ec2/lib/bastion-host.ts @@ -1,10 +1,10 @@ import { IPrincipal, IRole, PolicyStatement } from '@aws-cdk/aws-iam'; import { CfnOutput, Resource, Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; -import { AmazonLinuxGeneration, InstanceClass, InstanceSize, InstanceType } from '.'; +import { AmazonLinuxGeneration, InstanceArchitecture, InstanceClass, InstanceSize, InstanceType } from '.'; import { Connections } from './connections'; import { IInstance, Instance } from './instance'; -import { IMachineImage, MachineImage } from './machine-image'; +import { AmazonLinuxCpuType, IMachineImage, MachineImage } from './machine-image'; import { IPeer } from './peer'; import { Port } from './port'; import { ISecurityGroup } from './security-group'; @@ -60,10 +60,10 @@ export interface BastionHostLinuxProps { readonly instanceType?: InstanceType; /** - * The machine image to use + * The machine image to use, assumed to have SSM Agent preinstalled. * * @default - An Amazon Linux 2 image which is kept up-to-date automatically (the instance - * may be replaced on every deployment). + * may be replaced on every deployment) and already has SSM Agent installed. */ readonly machineImage?: IMachineImage; @@ -146,14 +146,17 @@ export class BastionHostLinux extends Resource implements IInstance { constructor(scope: Construct, id: string, props: BastionHostLinuxProps) { super(scope, id); this.stack = Stack.of(scope); - + const instanceType = props.instanceType ?? InstanceType.of(InstanceClass.T3, InstanceSize.NANO); this.instance = new Instance(this, 'Resource', { vpc: props.vpc, availabilityZone: props.availabilityZone, securityGroup: props.securityGroup, instanceName: props.instanceName ?? 'BastionHost', - instanceType: props.instanceType ?? InstanceType.of(InstanceClass.T3, InstanceSize.NANO), - machineImage: props.machineImage ?? MachineImage.latestAmazonLinux({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }), + instanceType, + machineImage: props.machineImage ?? MachineImage.latestAmazonLinux({ + generation: AmazonLinuxGeneration.AMAZON_LINUX_2, + cpuType: this.toAmazonLinuxCpuType(instanceType.architecture), + }), vpcSubnets: props.subnetSelection ?? {}, blockDevices: props.blockDevices ?? undefined, }); @@ -165,8 +168,6 @@ export class BastionHostLinux extends Resource implements IInstance { ], resources: ['*'], })); - this.instance.addUserData('yum install -y https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm'); - this.connections = this.instance.connections; this.role = this.instance.role; this.grantPrincipal = this.instance.role; @@ -183,6 +184,20 @@ export class BastionHostLinux extends Resource implements IInstance { }); } + /** + * Returns the AmazonLinuxCpuType corresponding to the given instance architecture + * @param architecture the instance architecture value to convert + */ + private toAmazonLinuxCpuType(architecture: InstanceArchitecture): AmazonLinuxCpuType { + if (architecture === InstanceArchitecture.ARM_64) { + return AmazonLinuxCpuType.ARM_64; + } else if (architecture === InstanceArchitecture.X86_64) { + return AmazonLinuxCpuType.X86_64; + } + + throw new Error(`Unsupported instance architecture '${architecture}'`); + } + /** * Allow SSH access from the given peer or peers * diff --git a/packages/@aws-cdk/aws-ec2/lib/instance-types.ts b/packages/@aws-cdk/aws-ec2/lib/instance-types.ts index be2a58561b181..c9a7f8ac74509 100644 --- a/packages/@aws-cdk/aws-ec2/lib/instance-types.ts +++ b/packages/@aws-cdk/aws-ec2/lib/instance-types.ts @@ -473,6 +473,21 @@ export enum InstanceClass { INF1 = 'inf1' } +/** + * Identifies an instance's CPU architecture + */ +export enum InstanceArchitecture { + /** + * ARM64 architecture + */ + ARM_64 = 'arm64', + + /** + * x86-64 architecture + */ + X86_64 = 'x86_64', +} + /** * What size of instance to use */ @@ -597,4 +612,26 @@ export class InstanceType { public toString(): string { return this.instanceTypeIdentifier; } + + /** + * The instance's CPU architecture + */ + public get architecture(): InstanceArchitecture { + // capture the family, generation, capabilities, and size portions of the instance type id + const instanceTypeComponents = this.instanceTypeIdentifier.match(/^([a-z]+)(\d{1,2})([a-z]*)\.([a-z0-9]+)$/); + if (instanceTypeComponents == null) { + throw new Error('Malformed instance type identifier'); + } + + const family = instanceTypeComponents[1]; + const capabilities = instanceTypeComponents[3]; + + // Instance family `a` are first-gen Graviton instances + // Capability `g` indicates the instance is Graviton2 powered + if (family === 'a' || capabilities.includes('g')) { + return InstanceArchitecture.ARM_64; + } + + return InstanceArchitecture.X86_64; + } } diff --git a/packages/@aws-cdk/aws-ec2/test/bastion-host.test.ts b/packages/@aws-cdk/aws-ec2/test/bastion-host.test.ts index e950a397707b8..6c547d7859c1a 100644 --- a/packages/@aws-cdk/aws-ec2/test/bastion-host.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/bastion-host.test.ts @@ -1,7 +1,7 @@ import { expect, haveResource } from '@aws-cdk/assert'; import { Stack } from '@aws-cdk/core'; import { nodeunitShim, Test } from 'nodeunit-shim'; -import { BastionHostLinux, BlockDeviceVolume, SubnetType, Vpc } from '../lib'; +import { BastionHostLinux, BlockDeviceVolume, InstanceClass, InstanceSize, InstanceType, SubnetType, Vpc } from '../lib'; nodeunitShim({ 'default instance is created in basic'(test: Test) { @@ -83,6 +83,45 @@ nodeunitShim({ ], })); + test.done(); + }, + 'x86-64 instances use x86-64 image by default'(test: Test) { + // GIVEN + const stack = new Stack(); + const vpc = new Vpc(stack, 'VPC'); + + // WHEN + new BastionHostLinux(stack, 'Bastion', { + vpc, + }); + + // THEN + expect(stack).to(haveResource('AWS::EC2::Instance', { + ImageId: { + Ref: 'SsmParameterValueawsserviceamiamazonlinuxlatestamzn2amihvmx8664gp2C96584B6F00A464EAD1953AFF4B05118Parameter', + }, + })); + + test.done(); + }, + 'arm instances use arm image by default'(test: Test) { + // GIVEN + const stack = new Stack(); + const vpc = new Vpc(stack, 'VPC'); + + // WHEN + new BastionHostLinux(stack, 'Bastion', { + vpc, + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.NANO), + }); + + // THEN + expect(stack).to(haveResource('AWS::EC2::Instance', { + ImageId: { + Ref: 'SsmParameterValueawsserviceamiamazonlinuxlatestamzn2amihvmarm64gp2C96584B6F00A464EAD1953AFF4B05118Parameter', + }, + })); + test.done(); }, }); diff --git a/packages/@aws-cdk/aws-ec2/test/instance.test.ts b/packages/@aws-cdk/aws-ec2/test/instance.test.ts index 6d002e38af568..a2049fb31e86b 100644 --- a/packages/@aws-cdk/aws-ec2/test/instance.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/instance.test.ts @@ -8,7 +8,7 @@ import { Stack } from '@aws-cdk/core'; import { nodeunitShim, Test } from 'nodeunit-shim'; import { AmazonLinuxImage, BlockDeviceVolume, CloudFormationInit, - EbsDeviceVolumeType, InitCommand, Instance, InstanceClass, InstanceSize, InstanceType, UserData, Vpc, + EbsDeviceVolumeType, InitCommand, Instance, InstanceArchitecture, InstanceClass, InstanceSize, InstanceType, UserData, Vpc, } from '../lib'; @@ -107,7 +107,56 @@ nodeunitShim({ test.done(); }, + 'instance architecture is correctly discerned for arm instances'(test: Test) { + // GIVEN + const sampleInstanceClasses = [ + 'a1', 't4g', 'c6g', 'c6gd', 'c6gn', 'm6g', 'm6gd', 'r6g', 'r6gd', // current Graviton-based instance classes + 'a13', 't11g', 'y10ng', 'z11ngd', // theoretical future Graviton-based instance classes + ]; + + for (const instanceClass of sampleInstanceClasses) { + // WHEN + const instanceType = InstanceType.of(instanceClass as InstanceClass, InstanceSize.XLARGE18); + + // THEN + expect(instanceType.architecture).toBe(InstanceArchitecture.ARM_64); + } + + test.done(); + }, + 'instance architecture is correctly discerned for x86-64 instance'(test: Test) { + // GIVEN + const sampleInstanceClasses = ['c5', 'm5ad', 'r5n', 'm6', 't3a']; // A sample of x86-64 instance classes + for (const instanceClass of sampleInstanceClasses) { + // WHEN + const instanceType = InstanceType.of(instanceClass as InstanceClass, InstanceSize.XLARGE18); + + // THEN + expect(instanceType.architecture).toBe(InstanceArchitecture.X86_64); + } + + test.done(); + }, + 'instance architecture throws an error when instance type is invalid'(test: Test) { + // GIVEN + const malformedInstanceTypes = ['t4', 't4g.nano.', 't4gnano', '']; + + for (const malformedInstanceType of malformedInstanceTypes) { + // WHEN + const instanceType = new InstanceType(malformedInstanceType); + + // THEN + try { + instanceType.architecture; + expect(true).toBe(false); // The line above should have thrown an error + } catch (err) { + expect(err.message).toBe('Malformed instance type identifier'); + } + } + + test.done(); + }, blockDeviceMappings: { 'can set blockDeviceMappings'(test: Test) { // WHEN diff --git a/packages/@aws-cdk/aws-ec2/test/integ.bastion-host-arm-support.expected.json b/packages/@aws-cdk/aws-ec2/test/integ.bastion-host-arm-support.expected.json new file mode 100644 index 0000000000000..81f4ae3377d40 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/integ.bastion-host-arm-support.expected.json @@ -0,0 +1,659 @@ +{ + "Resources": { + "VPCB9E5F0B4": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC" + } + ] + } + }, + "VPCPublicSubnet1SubnetB4246D30": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1RouteTableFEE4B781": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1RouteTableAssociation0B0896DC": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet1RouteTableFEE4B781" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + } + } + }, + "VPCPublicSubnet1DefaultRoute91CEF279": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet1RouteTableFEE4B781" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet1EIP6AD938E8": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1NATGatewayE0556630": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet1EIP6AD938E8", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet2Subnet74179F39": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.32.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2RouteTable6F1A15F1": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2RouteTableAssociation5A808732": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet2RouteTable6F1A15F1" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + } + } + }, + "VPCPublicSubnet2DefaultRouteB7481BBA": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet2RouteTable6F1A15F1" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet2EIP4947BC00": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2NATGateway3C070193": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet2EIP4947BC00", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet3Subnet631C5E25": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3RouteTable98AE0E14": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3RouteTableAssociation427FE0C6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet3RouteTable98AE0E14" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + } + } + }, + "VPCPublicSubnet3DefaultRouteA0D29D46": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet3RouteTable98AE0E14" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet3EIPAD4BC883": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3NATGatewayD3048F5C": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet3EIPAD4BC883", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPrivateSubnet1Subnet8BCA10E0": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.96.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "TestStack/VPC/PrivateSubnet1" + } + ] + } + }, + "VPCPrivateSubnet1RouteTableBE8A6027": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PrivateSubnet1" + } + ] + } + }, + "VPCPrivateSubnet1RouteTableAssociation347902D1": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet1RouteTableBE8A6027" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + } + } + }, + "VPCPrivateSubnet1DefaultRouteAE1D6490": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet1RouteTableBE8A6027" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet1NATGatewayE0556630" + } + } + }, + "VPCPrivateSubnet2SubnetCFCDAA7A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "TestStack/VPC/PrivateSubnet2" + } + ] + } + }, + "VPCPrivateSubnet2RouteTable0A19E10E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PrivateSubnet2" + } + ] + } + }, + "VPCPrivateSubnet2RouteTableAssociation0C73D413": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet2RouteTable0A19E10E" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" + } + } + }, + "VPCPrivateSubnet2DefaultRouteF4F5CFD2": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet2RouteTable0A19E10E" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet2NATGateway3C070193" + } + } + }, + "VPCPrivateSubnet3Subnet3EDCD457": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.160.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "TestStack/VPC/PrivateSubnet3" + } + ] + } + }, + "VPCPrivateSubnet3RouteTable192186F8": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC/PrivateSubnet3" + } + ] + } + }, + "VPCPrivateSubnet3RouteTableAssociationC28D144E": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet3RouteTable192186F8" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet3Subnet3EDCD457" + } + } + }, + "VPCPrivateSubnet3DefaultRoute27F311AE": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet3RouteTable192186F8" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet3NATGatewayD3048F5C" + } + } + }, + "VPCIGWB7E252D3": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "TestStack/VPC" + } + ] + } + }, + "VPCVPCGW99B986DC": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "InternetGatewayId": { + "Ref": "VPCIGWB7E252D3" + } + } + }, + "BastionHostInstanceSecurityGroupE75D4274": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "TestStack/BastionHost/Resource/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "BastionHost" + } + ], + "VpcId": { + "Ref": "VPCB9E5F0B4" + } + } + }, + "BastionHostInstanceRoleDD3FA5F1": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "ec2.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "BastionHost" + } + ] + } + }, + "BastionHostInstanceRoleDefaultPolicy17347525": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ssmmessages:*", + "ssm:UpdateInstanceInformation", + "ec2messages:*" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "BastionHostInstanceRoleDefaultPolicy17347525", + "Roles": [ + { + "Ref": "BastionHostInstanceRoleDD3FA5F1" + } + ] + } + }, + "BastionHostInstanceProfile770FCA07": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "BastionHostInstanceRoleDD3FA5F1" + } + ] + } + }, + "BastionHost30F9ED05": { + "Type": "AWS::EC2::Instance", + "Properties": { + "AvailabilityZone": "test-region-1a", + "IamInstanceProfile": { + "Ref": "BastionHostInstanceProfile770FCA07" + }, + "ImageId": { + "Ref": "SsmParameterValueawsserviceamiamazonlinuxlatestamzn2amihvmarm64gp2C96584B6F00A464EAD1953AFF4B05118Parameter" + }, + "InstanceType": "t4g.nano", + "SecurityGroupIds": [ + { + "Fn::GetAtt": [ + "BastionHostInstanceSecurityGroupE75D4274", + "GroupId" + ] + } + ], + "SubnetId": { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + }, + "Tags": [ + { + "Key": "Name", + "Value": "BastionHost" + } + ], + "UserData": { + "Fn::Base64": "#!/bin/bash" + } + }, + "DependsOn": [ + "BastionHostInstanceRoleDefaultPolicy17347525", + "BastionHostInstanceRoleDD3FA5F1" + ] + } + }, + "Outputs": { + "BastionHostBastionHostIdC743CBD6": { + "Description": "Instance ID of the bastion host. Use this to connect via SSM Session Manager", + "Value": { + "Ref": "BastionHost30F9ED05" + } + } + }, + "Parameters": { + "SsmParameterValueawsserviceamiamazonlinuxlatestamzn2amihvmarm64gp2C96584B6F00A464EAD1953AFF4B05118Parameter": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-arm64-gp2" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/test/integ.bastion-host-arm-support.ts b/packages/@aws-cdk/aws-ec2/test/integ.bastion-host-arm-support.ts new file mode 100644 index 0000000000000..06d6d12557ba9 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/integ.bastion-host-arm-support.ts @@ -0,0 +1,26 @@ +/* + * Stack verification steps: + * * aws ssm start-session --target + * * lscpu # Architecture should be aarch64 + */ +import * as cdk from '@aws-cdk/core'; +import * as ec2 from '../lib'; + +const app = new cdk.App(); + +class TestStack extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const vpc = new ec2.Vpc(this, 'VPC'); + + new ec2.BastionHostLinux(this, 'BastionHost', { + vpc, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.NANO), + }); + } +} + +new TestStack(app, 'TestStack'); + +app.synth(); diff --git a/packages/@aws-cdk/aws-ec2/test/integ.bastion-host.expected.json b/packages/@aws-cdk/aws-ec2/test/integ.bastion-host.expected.json index 73b52ab630a76..4943873897e75 100644 --- a/packages/@aws-cdk/aws-ec2/test/integ.bastion-host.expected.json +++ b/packages/@aws-cdk/aws-ec2/test/integ.bastion-host.expected.json @@ -633,7 +633,7 @@ } ], "UserData": { - "Fn::Base64": "#!/bin/bash\nyum install -y https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm" + "Fn::Base64": "#!/bin/bash" } }, "DependsOn": [ diff --git a/packages/@aws-cdk/aws-ecr/README.md b/packages/@aws-cdk/aws-ecr/README.md index 6278238b84eab..9bce0c80e132c 100644 --- a/packages/@aws-cdk/aws-ecr/README.md +++ b/packages/@aws-cdk/aws-ecr/README.md @@ -51,11 +51,29 @@ grants an IAM user access to call this API. ```ts import * as iam from '@aws-cdk/aws-iam'; +import * as ecr from '@aws-cdk/aws-ecr'; const user = new iam.User(this, 'User', { ... }); -iam.AuthorizationToken.grantRead(user); +ecr.AuthorizationToken.grantRead(user); ``` +If you access images in the [Public ECR Gallery](https://gallery.ecr.aws/) as well, it is recommended you authenticate to the regsitry to benefit from +higher rate and bandwidth limits. + +> See `Pricing` in https://aws.amazon.com/blogs/aws/amazon-ecr-public-a-new-public-container-registry/ and [Service quotas](https://docs.aws.amazon.com/AmazonECR/latest/public/public-service-quotas.html). + +The following code snippet grants an IAM user access to retrieve an authorization token for the public gallery. + +```ts +import * as iam from '@aws-cdk/aws-iam'; +import * as ecr from '@aws-cdk/aws-ecr'; + +const user = new iam.User(this, 'User', { ... }); +ecr.PublicGalleryAuthorizationToken.grantRead(user); +``` + +This user can then proceed to login to the registry using one of the [authentication methods](https://docs.aws.amazon.com/AmazonECR/latest/public/public-registries.html#public-registry-auth). + ## Automatically clean up repositories You can set life cycle rules to automatically clean up old images from your diff --git a/packages/@aws-cdk/aws-ecr/lib/auth-token.ts b/packages/@aws-cdk/aws-ecr/lib/auth-token.ts index 52c10cc513d0a..63484bbed0199 100644 --- a/packages/@aws-cdk/aws-ecr/lib/auth-token.ts +++ b/packages/@aws-cdk/aws-ecr/lib/auth-token.ts @@ -1,7 +1,9 @@ import * as iam from '@aws-cdk/aws-iam'; /** - * Authorization token to access ECR repositories via Docker CLI. + * Authorization token to access private ECR repositories in the current environment via Docker CLI. + * + * @see https://docs.aws.amazon.com/AmazonECR/latest/userguide/registry_auth.html */ export class AuthorizationToken { /** @@ -18,3 +20,27 @@ export class AuthorizationToken { private constructor() { } } + +/** + * Authorization token to access the global public ECR Gallery via Docker CLI. + * + * @see https://docs.aws.amazon.com/AmazonECR/latest/public/public-registries.html#public-registry-auth + */ +export class PublicGalleryAuthorizationToken { + + /** + * Grant access to retrieve an authorization token. + */ + public static grantRead(grantee: iam.IGrantable) { + grantee.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['ecr-public:GetAuthorizationToken', 'sts:GetServiceBearerToken'], + // GetAuthorizationToken only allows '*'. See https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonelasticcontainerregistry.html#amazonelasticcontainerregistry-actions-as-permissions + // GetServiceBearerToken only allows '*'. See https://docs.aws.amazon.com/service-authorization/latest/reference/list_awssecuritytokenservice.html#awssecuritytokenservice-actions-as-permissions + resources: ['*'], + })); + } + + private constructor() { + } + +} diff --git a/packages/@aws-cdk/aws-ecr/test/test.auth-token.ts b/packages/@aws-cdk/aws-ecr/test/test.auth-token.ts index 4e9e12e4fb078..bb1e13c5566b4 100644 --- a/packages/@aws-cdk/aws-ecr/test/test.auth-token.ts +++ b/packages/@aws-cdk/aws-ecr/test/test.auth-token.ts @@ -2,10 +2,10 @@ import { expect, haveResourceLike } from '@aws-cdk/assert'; import * as iam from '@aws-cdk/aws-iam'; import { Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; -import { AuthorizationToken } from '../lib'; +import { AuthorizationToken, PublicGalleryAuthorizationToken } from '../lib'; export = { - 'grant()'(test: Test) { + 'AuthorizationToken.grantRead()'(test: Test) { // GIVEN const stack = new Stack(); const user = new iam.User(stack, 'User'); @@ -28,4 +28,32 @@ export = { test.done(); }, + + 'PublicGalleryAuthorizationToken.grantRead()'(test: Test) { + // GIVEN + const stack = new Stack(); + const user = new iam.User(stack, 'User'); + + // WHEN + PublicGalleryAuthorizationToken.grantRead(user); + + // THEN + expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 'ecr-public:GetAuthorizationToken', + 'sts:GetServiceBearerToken', + ], + Effect: 'Allow', + Resource: '*', + }, + ], + }, + })); + + test.done(); + }, + }; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs-patterns/README.md b/packages/@aws-cdk/aws-ecs-patterns/README.md index 7a80d93aad346..38915031f4346 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/README.md +++ b/packages/@aws-cdk/aws-ecs-patterns/README.md @@ -427,3 +427,17 @@ const loadBalancedFargateService = new ApplicationLoadBalancedFargateService(sta }, }); ``` + +### Set PlatformVersion for ScheduledFargateTask + +```ts +const scheduledFargateTask = new ScheduledFargateTask(stack, 'ScheduledFargateTask', { + cluster, + scheduledFargateTaskImageOptions: { + image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), + memoryLimitMiB: 512, + }, + schedule: events.Schedule.expression('rate(1 minute)'), + platformVersion: ecs.FargatePlatformVersion.VERSION1_4, +}); +``` diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/base/scheduled-task-base.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/base/scheduled-task-base.ts index 259e375b1973c..38ac4e30a79af 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/base/scheduled-task-base.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/base/scheduled-task-base.ts @@ -165,11 +165,20 @@ export abstract class ScheduledTaskBase extends CoreConstruct { subnetSelection: this.subnetSelection, }); - this.eventRule.addTarget(eventRuleTarget); + this.addTaskAsTarget(eventRuleTarget); return eventRuleTarget; } + /** + * Adds task as a target of the scheduled event rule. + * + * @param ecsTaskTarget the EcsTask to add to the event rule + */ + protected addTaskAsTarget(ecsTaskTarget: EcsTask) { + this.eventRule.addTarget(ecsTaskTarget); + } + /** * Returns the default cluster. */ diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/scheduled-fargate-task.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/scheduled-fargate-task.ts index 8ad898693b6bd..27b9d8b6ad224 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/scheduled-fargate-task.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/scheduled-fargate-task.ts @@ -1,4 +1,5 @@ -import { FargateTaskDefinition } from '@aws-cdk/aws-ecs'; +import { FargateTaskDefinition, FargatePlatformVersion } from '@aws-cdk/aws-ecs'; +import { EcsTask } from '@aws-cdk/aws-events-targets'; import { Construct } from 'constructs'; import { ScheduledTaskBase, ScheduledTaskBaseProps, ScheduledTaskImageProps } from '../base/scheduled-task-base'; @@ -21,6 +22,17 @@ export interface ScheduledFargateTaskProps extends ScheduledTaskBaseProps { * @default none */ readonly scheduledFargateTaskImageOptions?: ScheduledFargateTaskImageOptions; + + /** + * The platform version on which to run your service. + * + * If one is not specified, the LATEST platform version is used by default. For more information, see + * [AWS Fargate Platform Versions](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/platform_versions.html) + * in the Amazon Elastic Container Service Developer Guide. + * + * @default Latest + */ + readonly platformVersion?: FargatePlatformVersion; } /** @@ -109,6 +121,15 @@ export class ScheduledFargateTask extends ScheduledTaskBase { throw new Error('You must specify one of: taskDefinition or image'); } - this.addTaskDefinitionToEventTarget(this.taskDefinition); + // Use the EcsTask as the target of the EventRule + const eventRuleTarget = new EcsTask( { + cluster: this.cluster, + taskDefinition: this.taskDefinition, + taskCount: this.desiredTaskCount, + subnetSelection: this.subnetSelection, + platformVersion: props.platformVersion, + }); + + this.addTaskAsTarget(eventRuleTarget); } } diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/test.scheduled-fargate-task.ts b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/test.scheduled-fargate-task.ts index 27367249ee3cb..fa62f079862f1 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/test.scheduled-fargate-task.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/test.scheduled-fargate-task.ts @@ -294,6 +294,60 @@ export = { ], })); + test.done(); + }, + 'Scheduled Fargate Task - with platformVersion defined'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 1 }); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + + new ScheduledFargateTask(stack, 'ScheduledFargateTask', { + cluster, + scheduledFargateTaskImageOptions: { + image: ecs.ContainerImage.fromRegistry('henk'), + memoryLimitMiB: 512, + }, + schedule: events.Schedule.expression('rate(1 minute)'), + platformVersion: ecs.FargatePlatformVersion.VERSION1_4, + }); + + // THEN + expect(stack).to(haveResource('AWS::Events::Rule', { + Targets: [ + { + Arn: { 'Fn::GetAtt': ['EcsCluster97242B84', 'Arn'] }, + EcsParameters: { + LaunchType: 'FARGATE', + NetworkConfiguration: { + AwsVpcConfiguration: { + AssignPublicIp: 'DISABLED', + SecurityGroups: [ + { + 'Fn::GetAtt': [ + 'ScheduledFargateTaskScheduledTaskDefSecurityGroupE075BC19', + 'GroupId', + ], + }, + ], + Subnets: [ + { + Ref: 'VpcPrivateSubnet1Subnet536B997A', + }, + ], + }, + }, + PlatformVersion: '1.4.0', + TaskCount: 1, + TaskDefinitionArn: { Ref: 'ScheduledFargateTaskScheduledTaskDef521FA675' }, + }, + Id: 'Target0', + Input: '{}', + RoleArn: { 'Fn::GetAtt': ['ScheduledFargateTaskScheduledTaskDefEventsRole6CE19522', 'Arn'] }, + }, + ], + })); + test.done(); }, }; diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-task-definition.ts b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-task-definition.ts index 8581738c6da51..9264c47e4550c 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-task-definition.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-task-definition.ts @@ -543,7 +543,7 @@ export = { }); // THEN - expect(stack).notTo(haveResource('AWS::ECR::Repository', {})); + expect(stack).to(haveResource('AWS::ECR::Repository', {})); test.done(); }, diff --git a/packages/@aws-cdk/aws-ecs/test/test.aws-log-driver.ts b/packages/@aws-cdk/aws-ecs/test/test.aws-log-driver.ts index 1ea5942386ad0..a4d5c9af9c53d 100644 --- a/packages/@aws-cdk/aws-ecs/test/test.aws-log-driver.ts +++ b/packages/@aws-cdk/aws-ecs/test/test.aws-log-driver.ts @@ -126,7 +126,7 @@ export = { test.done(); }, - 'without a defined log group'(test: Test) { + 'without a defined log group: creates one anyway'(test: Test) { // GIVEN td.addContainer('Container', { image, @@ -136,7 +136,7 @@ export = { }); // THEN - expect(stack).notTo(haveResource('AWS::Logs::LogGroup', {})); + expect(stack).to(haveResource('AWS::Logs::LogGroup', {})); test.done(); }, diff --git a/packages/@aws-cdk/aws-efs/lib/efs-file-system.ts b/packages/@aws-cdk/aws-efs/lib/efs-file-system.ts index a93ea1b76d5a4..60af6fde51752 100644 --- a/packages/@aws-cdk/aws-efs/lib/efs-file-system.ts +++ b/packages/@aws-cdk/aws-efs/lib/efs-file-system.ts @@ -272,7 +272,7 @@ export class FileSystem extends Resource implements IFileSystem { defaultPort: ec2.Port.tcp(FileSystem.DEFAULT_PORT), }); - const subnets = props.vpc.selectSubnets(props.vpcSubnets); + const subnets = props.vpc.selectSubnets(props.vpcSubnets ?? { onePerAz: true }); // We now have to create the mount target for each of the mentioned subnet let mountTargetCount = 0; diff --git a/packages/@aws-cdk/aws-efs/test/efs-file-system.test.ts b/packages/@aws-cdk/aws-efs/test/efs-file-system.test.ts index 5b9f3c6539a45..d3868a841e0a5 100644 --- a/packages/@aws-cdk/aws-efs/test/efs-file-system.test.ts +++ b/packages/@aws-cdk/aws-efs/test/efs-file-system.test.ts @@ -1,4 +1,4 @@ -import { expect as expectCDK, haveResource, ResourcePart } from '@aws-cdk/assert'; +import { expect as expectCDK, haveResource, ResourcePart, countResources } from '@aws-cdk/assert'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as kms from '@aws-cdk/aws-kms'; import { RemovalPolicy, Size, Stack, Tags } from '@aws-cdk/core'; @@ -240,3 +240,17 @@ test('can specify backup policy', () => { }, })); }); + +test('can create when using a VPC with multiple subnets per availability zone', () => { + // create a vpc with two subnets in the same availability zone. + const oneAzVpc = new ec2.Vpc(stack, 'Vpc', { + maxAzs: 1, + subnetConfiguration: [{ name: 'One', subnetType: ec2.SubnetType.ISOLATED }, { name: 'Two', subnetType: ec2.SubnetType.ISOLATED }], + natGateways: 0, + }); + new FileSystem(stack, 'EfsFileSystem', { + vpc: oneAzVpc, + }); + // make sure only one mount target is created. + expectCDK(stack).to(countResources('AWS::EFS::MountTarget', 1)); +}); diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/shared/enums.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/shared/enums.ts index 89f2c88d7ad7e..be25c49b96513 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/shared/enums.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/shared/enums.ts @@ -82,6 +82,12 @@ export enum SslPolicy { */ RECOMMENDED = 'ELBSecurityPolicy-2016-08', + /** + * Strong foward secrecy ciphers and TLV1.2 only (2020 edition). + * Same as FORWARD_SECRECY_TLS12_RES, but only supports GCM versions of the TLS ciphers + */ + FORWARD_SECRECY_TLS12_RES_GCM = 'ELBSecurityPolicy-FS-1-2-Res-2020-10', + /** * Strong forward secrecy ciphers and TLS1.2 only */ diff --git a/packages/@aws-cdk/aws-iam/README.md b/packages/@aws-cdk/aws-iam/README.md index d9488e7d081c8..f2b86ccff2f59 100644 --- a/packages/@aws-cdk/aws-iam/README.md +++ b/packages/@aws-cdk/aws-iam/README.md @@ -264,6 +264,50 @@ const newPolicy = new Policy(stack, 'MyNewPolicy', { }); ``` +## Permissions Boundaries + +[Permissions +Boundaries](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) +can be used as a mechanism to prevent privilege esclation by creating new +`Role`s. Permissions Boundaries are a Managed Policy, attached to Roles or +Users, that represent the *maximum* set of permissions they can have. The +effective set of permissions of a Role (or User) will be the intersection of +the Identity Policy and the Permissions Boundary attached to the Role (or +User). Permissions Boundaries are typically created by account +Administrators, and their use on newly created `Role`s will be enforced by +IAM policies. + +It is possible to attach Permissions Boundaries to all Roles created in a construct +tree all at once: + +```ts +// This imports an existing policy. +const boundary = iam.ManagedPolicy.fromManagedPolicyArn(this, 'Boundary', 'arn:aws:iam::123456789012:policy/boundary'); + +// This creates a new boundary +const boundary2 = new iam.ManagedPolicy(this, 'Boundary2', { + statements: [ + new iam.PolicyStatement({ + effect: iam.Effect.DENY, + actions: ['iam:*'], + resources: ['*'], + }), + ], +}); + +// Directly apply the boundary to a Role you create +iam.PermissionsBoundary.of(role).apply(boundary); + +// Apply the boundary to an Role that was implicitly created for you +iam.PermissionsBoundary.of(lambdaFunction).apply(boundary); + +// Apply the boundary to all Roles in a stack +iam.PermissionsBoundary.of(stack).apply(boundary); + +// Remove a Permissions Boundary that is inherited, for example from the Stack level +iam.PermissionsBoundary.of(customResource).clear(); +``` + ## OpenID Connect Providers OIDC identity providers are entities in IAM that describe an external identity diff --git a/packages/@aws-cdk/aws-iam/lib/index.ts b/packages/@aws-cdk/aws-iam/lib/index.ts index ba9250ca1e08e..19b8a156ba598 100644 --- a/packages/@aws-cdk/aws-iam/lib/index.ts +++ b/packages/@aws-cdk/aws-iam/lib/index.ts @@ -11,6 +11,7 @@ export * from './identity-base'; export * from './grant'; export * from './unknown-principal'; export * from './oidc-provider'; +export * from './permissions-boundary'; // AWS::IAM CloudFormation Resources: export * from './iam.generated'; diff --git a/packages/@aws-cdk/aws-iam/lib/permissions-boundary.ts b/packages/@aws-cdk/aws-iam/lib/permissions-boundary.ts new file mode 100644 index 0000000000000..7b4320462a1ac --- /dev/null +++ b/packages/@aws-cdk/aws-iam/lib/permissions-boundary.ts @@ -0,0 +1,53 @@ +import { Node, IConstruct } from 'constructs'; +import { CfnRole, CfnUser } from './iam.generated'; +import { IManagedPolicy } from './managed-policy'; + +/** + * Modify the Permissions Boundaries of Users and Roles in a construct tree + * + * @example + * + * const policy = ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess'); + * PermissionsBoundary.of(stack).apply(policy); + */ +export class PermissionsBoundary { + /** + * Access the Permissions Boundaries of a construct tree + */ + public static of(scope: IConstruct): PermissionsBoundary { + return new PermissionsBoundary(scope); + } + + private constructor(private readonly scope: IConstruct) { + } + + /** + * Apply the given policy as Permissions Boundary to all Roles in the scope + * + * Will override any Permissions Boundaries configured previously; in case + * a Permission Boundary is applied in multiple scopes, the Boundary applied + * closest to the Role wins. + */ + public apply(boundaryPolicy: IManagedPolicy) { + Node.of(this.scope).applyAspect({ + visit(node: IConstruct) { + if (node instanceof CfnRole || node instanceof CfnUser) { + node.permissionsBoundary = boundaryPolicy.managedPolicyArn; + } + }, + }); + } + + /** + * Remove previously applied Permissions Boundaries + */ + public clear() { + Node.of(this.scope).applyAspect({ + visit(node: IConstruct) { + if (node instanceof CfnRole || node instanceof CfnUser) { + node.permissionsBoundary = undefined; + } + }, + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/lib/role.ts b/packages/@aws-cdk/aws-iam/lib/role.ts index 74e251bd7bc07..9b81e4f174152 100644 --- a/packages/@aws-cdk/aws-iam/lib/role.ts +++ b/packages/@aws-cdk/aws-iam/lib/role.ts @@ -1,4 +1,4 @@ -import { Duration, Lazy, Resource, Stack, Token, TokenComparison } from '@aws-cdk/core'; +import { Duration, Resource, Stack, Token, TokenComparison } from '@aws-cdk/core'; import { Construct, Node } from 'constructs'; import { Grant } from './grant'; import { CfnRole } from './iam.generated'; @@ -9,7 +9,7 @@ import { PolicyDocument } from './policy-document'; import { PolicyStatement } from './policy-statement'; import { AddToPrincipalPolicyResult, ArnPrincipal, IPrincipal, PrincipalPolicyFragment } from './principals'; import { ImmutableRole } from './private/immutable-role'; -import { AttachedPolicies } from './util'; +import { AttachedPolicies, UniqueStringSet } from './util'; /** * Properties for defining an IAM Role @@ -326,7 +326,7 @@ export class Role extends Resource implements IRole { const role = new CfnRole(this, 'Resource', { assumeRolePolicyDocument: this.assumeRolePolicy as any, - managedPolicyArns: Lazy.list({ produce: () => this.managedPolicies.map(p => p.managedPolicyArn) }, { omitEmpty: true }), + managedPolicyArns: UniqueStringSet.from(() => this.managedPolicies.map(p => p.managedPolicyArn)), policies: _flatten(this.inlinePolicies), path: props.path, permissionsBoundary: this.permissionsBoundary ? this.permissionsBoundary.managedPolicyArn : undefined, diff --git a/packages/@aws-cdk/aws-iam/lib/util.ts b/packages/@aws-cdk/aws-iam/lib/util.ts index b5f1700baefe7..19fcbffe09639 100644 --- a/packages/@aws-cdk/aws-iam/lib/util.ts +++ b/packages/@aws-cdk/aws-iam/lib/util.ts @@ -1,4 +1,4 @@ -import { DefaultTokenResolver, Lazy, StringConcat, Tokenization } from '@aws-cdk/core'; +import { captureStackTrace, DefaultTokenResolver, IPostProcessor, IResolvable, IResolveContext, Lazy, StringConcat, Token, Tokenization } from '@aws-cdk/core'; import { IConstruct } from 'constructs'; import { IPolicy } from './policy'; @@ -82,3 +82,45 @@ export function mergePrincipal(target: { [key: string]: string[] }, source: { [k return target; } + +/** + * Lazy string set token that dedupes entries + * + * Needs to operate post-resolve, because the inputs could be + * `[ '${Token[TOKEN.9]}', '${Token[TOKEN.10]}', '${Token[TOKEN.20]}' ]`, which + * still all resolve to the same string value. + * + * Needs to JSON.stringify() results because strings could resolve to literal + * strings but could also resolve to `{ Fn::Join: [...] }`. + */ +export class UniqueStringSet implements IResolvable, IPostProcessor { + public static from(fn: () => string[]) { + return Token.asList(new UniqueStringSet(fn)); + } + + public readonly creationStack: string[]; + + private constructor(private readonly fn: () => string[]) { + this.creationStack = captureStackTrace(); + } + + public resolve(context: IResolveContext) { + context.registerPostProcessor(this); + return this.fn(); + } + + public postProcess(input: any, _context: IResolveContext) { + if (!Array.isArray(input)) { return input; } + if (input.length === 0) { return undefined; } + + const uniq: Record = {}; + for (const el of input) { + uniq[JSON.stringify(el)] = el; + } + return Object.values(uniq); + } + + public toString(): string { + return Token.asString(this); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/permissions-boundary.test.ts b/packages/@aws-cdk/aws-iam/test/permissions-boundary.test.ts new file mode 100644 index 0000000000000..99a14de55f2e1 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/permissions-boundary.test.ts @@ -0,0 +1,101 @@ +import { ABSENT } from '@aws-cdk/assert'; +import '@aws-cdk/assert/jest'; +import { App, Stack } from '@aws-cdk/core'; +import * as iam from '../lib'; + +let app: App; +let stack: Stack; +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack'); +}); + +test('apply imported boundary to a role', () => { + // GIVEN + const role = new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('service.amazonaws.com'), + }); + + // WHEN + iam.PermissionsBoundary.of(role).apply(iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess')); + + // THEN + expect(stack).toHaveResource('AWS::IAM::Role', { + PermissionsBoundary: { + 'Fn::Join': ['', [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iam::aws:policy/ReadOnlyAccess', + ]], + }, + }); +}); + +test('apply imported boundary to a user', () => { + // GIVEN + const user = new iam.User(stack, 'User'); + + // WHEN + iam.PermissionsBoundary.of(user).apply(iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess')); + + // THEN + expect(stack).toHaveResource('AWS::IAM::User', { + PermissionsBoundary: { + 'Fn::Join': ['', [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iam::aws:policy/ReadOnlyAccess', + ]], + }, + }); +}); + +test('apply newly created boundary to a role', () => { + // GIVEN + const role = new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('service.amazonaws.com'), + }); + + // WHEN + iam.PermissionsBoundary.of(role).apply(new iam.ManagedPolicy(stack, 'Policy', { + statements: [ + new iam.PolicyStatement({ + actions: ['*'], + resources: ['*'], + }), + ], + })); + + // THEN + expect(stack).toHaveResource('AWS::IAM::Role', { + PermissionsBoundary: { Ref: 'Policy23B91518' }, + }); +}); + +test('unapply inherited boundary from a user: order 1', () => { + // GIVEN + const user = new iam.User(stack, 'User'); + + // WHEN + iam.PermissionsBoundary.of(stack).apply(iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess')); + iam.PermissionsBoundary.of(user).clear(); + + // THEN + expect(stack).toHaveResource('AWS::IAM::User', { + PermissionsBoundary: ABSENT, + }); +}); + +test('unapply inherited boundary from a user: order 2', () => { + // GIVEN + const user = new iam.User(stack, 'User'); + + // WHEN + iam.PermissionsBoundary.of(user).clear(); + iam.PermissionsBoundary.of(stack).apply(iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess')); + + // THEN + expect(stack).toHaveResource('AWS::IAM::User', { + PermissionsBoundary: ABSENT, + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/role.test.ts b/packages/@aws-cdk/aws-iam/test/role.test.ts index 510416f580f39..8f0415c45598d 100644 --- a/packages/@aws-cdk/aws-iam/test/role.test.ts +++ b/packages/@aws-cdk/aws-iam/test/role.test.ts @@ -535,3 +535,31 @@ describe('IAM role', () => { expect(() => app.synth()).toThrow(/A PolicyStatement used in a resource-based policy must specify at least one IAM principal/); }); }); + +test('managed policy ARNs are deduplicated', () => { + const app = new App(); + const stack = new Stack(app, 'my-stack'); + const role = new Role(stack, 'MyRole', { + assumedBy: new ServicePrincipal('sns.amazonaws.com'), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('SuperDeveloper'), + ManagedPolicy.fromAwsManagedPolicyName('SuperDeveloper'), + ], + }); + role.addManagedPolicy(ManagedPolicy.fromAwsManagedPolicyName('SuperDeveloper')); + + expect(stack).toHaveResource('AWS::IAM::Role', { + ManagedPolicyArns: [ + { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':iam::aws:policy/SuperDeveloper', + ], + ], + }, + ], + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/README.md b/packages/@aws-cdk/aws-lambda/README.md index 5b7ee7cd3240e..a59dc1a311936 100644 --- a/packages/@aws-cdk/aws-lambda/README.md +++ b/packages/@aws-cdk/aws-lambda/README.md @@ -143,6 +143,19 @@ of a new version resource. You can specify options for this version through the > of code providers (such as `lambda.Code.fromBucket`) require that you define a > `lambda.Version` resource directly since the CDK is unable to determine if > their contents had changed. +> +> An alternative to defining a `lambda.Version` is to set an environment variable +> which changes at least as often as your code does. This makes sure the function +> always has the latest code. +> +> ```ts +> const codeVersion = "stringOrMethodToGetCodeVersion"; +> const fn = new lambda.Function(this, 'MyFunction', { +> environment: { +> 'CodeVersionString': codeVersion +> } +> }); +> ``` The `version.addAlias()` method can be used to define an AWS Lambda alias that points to a specific version. @@ -270,17 +283,24 @@ to learn more about AWS Lambda's X-Ray support. ## Lambda with Profiling +The following code configures the lambda function with CodeGuru profiling. By default, this creates a new CodeGuru +profiling group - + ```ts import * as lambda from '@aws-cdk/aws-lambda'; const fn = new lambda.Function(this, 'MyFunction', { - runtime: lambda.Runtime.NODEJS_10_X, + runtime: lambda.Runtime.PYTHON_3_6, handler: 'index.handler', - code: lambda.Code.fromInline('exports.handler = function(event, ctx, cb) { return cb(null, "hi"); }'), + code: lambda.Code.fromAsset('lambda-handler'), profiling: true }); ``` +The `profilingGroup` property can be used to configure an existing CodeGuru profiler group. + +CodeGuru profiling is supported for all Java runtimes and Python3.6+ runtimes. + See [the AWS documentation](https://docs.aws.amazon.com/codeguru/latest/profiler-ug/setting-up-lambda.html) to learn more about AWS Lambda's Profiling support. diff --git a/packages/@aws-cdk/aws-lambda/lib/function.ts b/packages/@aws-cdk/aws-lambda/lib/function.ts index 0e56ee695d4e5..fdcf4b4e0ec24 100644 --- a/packages/@aws-cdk/aws-lambda/lib/function.ts +++ b/packages/@aws-cdk/aws-lambda/lib/function.ts @@ -574,7 +574,7 @@ export class Function extends FunctionBase { let profilingGroupEnvironmentVariables: { [key: string]: string } = {}; if (props.profilingGroup && props.profiling !== false) { - this.validateProfilingEnvironmentVariables(props); + this.validateProfiling(props); props.profilingGroup.grantPublish(this.role); profilingGroupEnvironmentVariables = { AWS_CODEGURU_PROFILER_GROUP_ARN: Stack.of(scope).formatArn({ @@ -585,7 +585,7 @@ export class Function extends FunctionBase { AWS_CODEGURU_PROFILER_ENABLED: 'TRUE', }; } else if (props.profiling) { - this.validateProfilingEnvironmentVariables(props); + this.validateProfiling(props); const profilingGroup = new ProfilingGroup(this, 'ProfilingGroup', { computePlatform: ComputePlatform.AWS_LAMBDA, }); @@ -941,7 +941,10 @@ Environment variables can be marked for removal when used in Lambda@Edge by sett }; } - private validateProfilingEnvironmentVariables(props: FunctionProps) { + private validateProfiling(props: FunctionProps) { + if (!props.runtime.supportsCodeGuruProfiling) { + throw new Error(`CodeGuru profiling is not supported by runtime ${props.runtime.name}`); + } if (props.environment && (props.environment.AWS_CODEGURU_PROFILER_GROUP_ARN || props.environment.AWS_CODEGURU_PROFILER_ENABLED)) { throw new Error('AWS_CODEGURU_PROFILER_GROUP_ARN and AWS_CODEGURU_PROFILER_ENABLED must not be set when profiling options enabled'); } diff --git a/packages/@aws-cdk/aws-lambda/lib/runtime.ts b/packages/@aws-cdk/aws-lambda/lib/runtime.ts index 1930641f45f04..05d4996ff5e4f 100644 --- a/packages/@aws-cdk/aws-lambda/lib/runtime.ts +++ b/packages/@aws-cdk/aws-lambda/lib/runtime.ts @@ -12,6 +12,12 @@ export interface LambdaRuntimeProps { * @default - the latest docker image "amazon/aws-sam-cli-build-image-" from https://hub.docker.com/u/amazon */ readonly bundlingDockerImage?: string; + + /** + * Whether this runtime is integrated with and supported for profiling using Amazon CodeGuru Profiler. + * @default false + */ + readonly supportsCodeGuruProfiling?: boolean; } export enum RuntimeFamily { @@ -37,28 +43,28 @@ export class Runtime { /** * The NodeJS runtime (nodejs) * - * @deprecated Use {@link NODEJS_10_X} + * @deprecated Use {@link NODEJS_12_X} */ public static readonly NODEJS = new Runtime('nodejs', RuntimeFamily.NODEJS, { supportsInlineCode: true }); /** * The NodeJS 4.3 runtime (nodejs4.3) * - * @deprecated Use {@link NODEJS_10_X} + * @deprecated Use {@link NODEJS_12_X} */ public static readonly NODEJS_4_3 = new Runtime('nodejs4.3', RuntimeFamily.NODEJS, { supportsInlineCode: true }); /** * The NodeJS 6.10 runtime (nodejs6.10) * - * @deprecated Use {@link NODEJS_10_X} + * @deprecated Use {@link NODEJS_12_X} */ public static readonly NODEJS_6_10 = new Runtime('nodejs6.10', RuntimeFamily.NODEJS, { supportsInlineCode: true }); /** * The NodeJS 8.10 runtime (nodejs8.10) * - * @deprecated Use {@link NODEJS_10_X} + * @deprecated Use {@link NODEJS_12_X} */ public static readonly NODEJS_8_10 = new Runtime('nodejs8.10', RuntimeFamily.NODEJS, { supportsInlineCode: true }); @@ -80,32 +86,47 @@ export class Runtime { /** * The Python 3.6 runtime (python3.6) */ - public static readonly PYTHON_3_6 = new Runtime('python3.6', RuntimeFamily.PYTHON, { supportsInlineCode: true }); + public static readonly PYTHON_3_6 = new Runtime('python3.6', RuntimeFamily.PYTHON, { + supportsInlineCode: true, + supportsCodeGuruProfiling: true, + }); /** * The Python 3.7 runtime (python3.7) */ - public static readonly PYTHON_3_7 = new Runtime('python3.7', RuntimeFamily.PYTHON, { supportsInlineCode: true }); + public static readonly PYTHON_3_7 = new Runtime('python3.7', RuntimeFamily.PYTHON, { + supportsInlineCode: true, + supportsCodeGuruProfiling: true, + }); /** * The Python 3.8 runtime (python3.8) */ - public static readonly PYTHON_3_8 = new Runtime('python3.8', RuntimeFamily.PYTHON); + public static readonly PYTHON_3_8 = new Runtime('python3.8', RuntimeFamily.PYTHON, { + supportsInlineCode: true, + supportsCodeGuruProfiling: true, + }); /** * The Java 8 runtime (java8) */ - public static readonly JAVA_8 = new Runtime('java8', RuntimeFamily.JAVA); + public static readonly JAVA_8 = new Runtime('java8', RuntimeFamily.JAVA, { + supportsCodeGuruProfiling: true, + }); /** * The Java 8 Corretto runtime (java8.al2) */ - public static readonly JAVA_8_CORRETTO = new Runtime('java8.al2', RuntimeFamily.JAVA); + public static readonly JAVA_8_CORRETTO = new Runtime('java8.al2', RuntimeFamily.JAVA, { + supportsCodeGuruProfiling: true, + }); /** * The Java 11 runtime (java11) */ - public static readonly JAVA_11 = new Runtime('java11', RuntimeFamily.JAVA); + public static readonly JAVA_11 = new Runtime('java11', RuntimeFamily.JAVA, { + supportsCodeGuruProfiling: true, + }); /** * The .NET Core 1.0 runtime (dotnetcore1.0) @@ -178,6 +199,11 @@ export class Runtime { */ public readonly supportsInlineCode: boolean; + /** + * Whether this runtime is integrated with and supported for profiling using Amazon CodeGuru Profiler. + */ + public readonly supportsCodeGuruProfiling: boolean; + /** * The runtime family. */ @@ -194,6 +220,7 @@ export class Runtime { this.family = family; const imageName = props.bundlingDockerImage ?? `amazon/aws-sam-cli-build-image-${name}`; this.bundlingDockerImage = BundlingDockerImage.fromRegistry(imageName); + this.supportsCodeGuruProfiling = props.supportsCodeGuruProfiling ?? false; Runtime.ALL.push(this); } diff --git a/packages/@aws-cdk/aws-lambda/test/function.test.ts b/packages/@aws-cdk/aws-lambda/test/function.test.ts index 707b32428912b..51cfe70fd878c 100644 --- a/packages/@aws-cdk/aws-lambda/test/function.test.ts +++ b/packages/@aws-cdk/aws-lambda/test/function.test.ts @@ -1611,7 +1611,7 @@ describe('function', () => { new lambda.Function(stack, 'MyLambda', { code: new lambda.InlineCode('foo'), handler: 'index.handler', - runtime: lambda.Runtime.NODEJS_10_X, + runtime: lambda.Runtime.PYTHON_3_6, profiling: true, }); @@ -1660,7 +1660,7 @@ describe('function', () => { new lambda.Function(stack, 'MyLambda', { code: new lambda.InlineCode('foo'), handler: 'index.handler', - runtime: lambda.Runtime.NODEJS_10_X, + runtime: lambda.Runtime.PYTHON_3_6, profilingGroup: new ProfilingGroup(stack, 'ProfilingGroup'), }); @@ -1712,7 +1712,7 @@ describe('function', () => { new lambda.Function(stack, 'MyLambda', { code: new lambda.InlineCode('foo'), handler: 'index.handler', - runtime: lambda.Runtime.NODEJS_10_X, + runtime: lambda.Runtime.PYTHON_3_6, profiling: false, profilingGroup: new ProfilingGroup(stack, 'ProfilingGroup'), }); @@ -1743,7 +1743,7 @@ describe('function', () => { expect(() => new lambda.Function(stack, 'MyLambda', { code: new lambda.InlineCode('foo'), handler: 'index.handler', - runtime: lambda.Runtime.NODEJS_10_X, + runtime: lambda.Runtime.PYTHON_3_6, profiling: true, environment: { AWS_CODEGURU_PROFILER_GROUP_ARN: 'profiler_group_arn', @@ -1758,7 +1758,7 @@ describe('function', () => { expect(() => new lambda.Function(stack, 'MyLambda', { code: new lambda.InlineCode('foo'), handler: 'index.handler', - runtime: lambda.Runtime.NODEJS_10_X, + runtime: lambda.Runtime.PYTHON_3_6, profilingGroup: new ProfilingGroup(stack, 'ProfilingGroup'), environment: { AWS_CODEGURU_PROFILER_GROUP_ARN: 'profiler_group_arn', @@ -1766,6 +1766,20 @@ describe('function', () => { }, })).toThrow(/AWS_CODEGURU_PROFILER_GROUP_ARN and AWS_CODEGURU_PROFILER_ENABLED must not be set when profiling options enabled/); }); + + test('throws an error when used with an unsupported runtime', () => { + const stack = new cdk.Stack(); + expect(() => new lambda.Function(stack, 'MyLambda', { + code: new lambda.InlineCode('foo'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_10_X, + profilingGroup: new ProfilingGroup(stack, 'ProfilingGroup'), + environment: { + AWS_CODEGURU_PROFILER_GROUP_ARN: 'profiler_group_arn', + AWS_CODEGURU_PROFILER_ENABLED: 'yes', + }, + })).toThrow(/not supported by runtime/); + }); }); describe('currentVersion', () => { diff --git a/packages/@aws-cdk/aws-lambda/test/integ.runtime.inlinecode.expected.json b/packages/@aws-cdk/aws-lambda/test/integ.runtime.inlinecode.expected.json index 8bbe8cdef572a..30d39828cc39d 100644 --- a/packages/@aws-cdk/aws-lambda/test/integ.runtime.inlinecode.expected.json +++ b/packages/@aws-cdk/aws-lambda/test/integ.runtime.inlinecode.expected.json @@ -37,13 +37,13 @@ "Code": { "ZipFile": "exports.handler = async function(event) { return \"success\" }" }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "NODEJS10XServiceRole2FD24B65", "Arn" ] }, + "Handler": "index.handler", "Runtime": "nodejs10.x" }, "DependsOn": [ @@ -87,13 +87,13 @@ "Code": { "ZipFile": "exports.handler = async function(event) { return \"success\" }" }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "NODEJS12XServiceRole59E71436", "Arn" ] }, + "Handler": "index.handler", "Runtime": "nodejs12.x" }, "DependsOn": [ @@ -137,13 +137,13 @@ "Code": { "ZipFile": "def handler(event, context):\n return \"success\"" }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "PYTHON27ServiceRoleF484A17D", "Arn" ] }, + "Handler": "index.handler", "Runtime": "python2.7" }, "DependsOn": [ @@ -187,13 +187,13 @@ "Code": { "ZipFile": "def handler(event, context):\n return \"success\"" }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "PYTHON36ServiceRole814B3AD9", "Arn" ] }, + "Handler": "index.handler", "Runtime": "python3.6" }, "DependsOn": [ @@ -237,18 +237,68 @@ "Code": { "ZipFile": "def handler(event, context):\n return \"success\"" }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "PYTHON37ServiceRoleDE7E561E", "Arn" ] }, + "Handler": "index.handler", "Runtime": "python3.7" }, "DependsOn": [ "PYTHON37ServiceRoleDE7E561E" ] + }, + "PYTHON38ServiceRole3EA86BBE": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "PYTHON38A180AE47": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "def handler(event, context):\n return \"success\"" + }, + "Role": { + "Fn::GetAtt": [ + "PYTHON38ServiceRole3EA86BBE", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "python3.8" + }, + "DependsOn": [ + "PYTHON38ServiceRole3EA86BBE" + ] } }, "Outputs": { @@ -276,6 +326,11 @@ "Value": { "Ref": "PYTHON37D3A10E04" } + }, + "PYTHON38functionName": { + "Value": { + "Ref": "PYTHON38A180AE47" + } } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/test/integ.runtime.inlinecode.ts b/packages/@aws-cdk/aws-lambda/test/integ.runtime.inlinecode.ts index aa4ef06e6a5e1..56f5bd27f7746 100644 --- a/packages/@aws-cdk/aws-lambda/test/integ.runtime.inlinecode.ts +++ b/packages/@aws-cdk/aws-lambda/test/integ.runtime.inlinecode.ts @@ -50,4 +50,11 @@ const python37 = new Function(stack, 'PYTHON_3_7', { }); new CfnOutput(stack, 'PYTHON_3_7-functionName', { value: python37.functionName }); -app.synth(); \ No newline at end of file +const python38 = new Function(stack, 'PYTHON_3_8', { + code: new InlineCode('def handler(event, context):\n return "success"'), + handler: 'index.handler', + runtime: Runtime.PYTHON_3_8, +}); +new CfnOutput(stack, 'PYTHON_3_8-functionName', { value: python38.functionName }); + +app.synth(); diff --git a/packages/@aws-cdk/aws-route53/.gitignore b/packages/@aws-cdk/aws-route53/.gitignore index 86fc837df8fca..a82230b5888d0 100644 --- a/packages/@aws-cdk/aws-route53/.gitignore +++ b/packages/@aws-cdk/aws-route53/.gitignore @@ -15,4 +15,5 @@ nyc.config.js *.snk !.eslintrc.js -junit.xml \ No newline at end of file +junit.xml +!jest.config.js \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53/.npmignore b/packages/@aws-cdk/aws-route53/.npmignore index a94c531529866..9e88226921c33 100644 --- a/packages/@aws-cdk/aws-route53/.npmignore +++ b/packages/@aws-cdk/aws-route53/.npmignore @@ -23,4 +23,5 @@ tsconfig.json # exclude cdk artifacts **/cdk.out junit.xml -test/ \ No newline at end of file +test/ +jest.config.js \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53/README.md b/packages/@aws-cdk/aws-route53/README.md index cc517c5ad6937..b9f75fb1b9d4e 100644 --- a/packages/@aws-cdk/aws-route53/README.md +++ b/packages/@aws-cdk/aws-route53/README.md @@ -109,6 +109,29 @@ Constructs are available for A, AAAA, CAA, CNAME, MX, NS, SRV and TXT records. Use the `CaaAmazonRecord` construct to easily restrict certificate authorities allowed to issue certificates for a domain to Amazon only. +To add a NS record to a HostedZone in different account + +```ts +import * as route53 from '@aws-cdk/aws-route53'; + +// In the account containing the HostedZone +const parentZone = new route53.PublicHostedZone(this, 'HostedZone', { + zoneName: 'someexample.com', + crossAccountZoneDelegationPrinciple: new iam.AccountPrincipal('12345678901') +}); + +// In this account +const subZone = new route53.PublicHostedZone(this, 'SubZone', { + zoneName: 'sub.someexample.com' +}); + +new route53.CrossAccountZoneDelegationRecord(this, 'delegate', { + delegatedZone: subZone, + parentHostedZoneId: parentZone.hostedZoneId, + delegationRole: parentZone.crossAccountDelegationRole +}); +``` + ## Imports If you don't know the ID of the Hosted Zone to import, you can use the @@ -146,9 +169,7 @@ Alternatively, use the `HostedZone.fromHostedZoneId` to import hosted zones if you know the ID and the retrieval for the `zoneName` is undesirable. ```ts -const zone = HostedZone.fromHostedZoneId(this, 'MyZone', { - hostedZoneId: 'ZOJJZC49E0EPZ', -}); +const zone = HostedZone.fromHostedZoneId(this, 'MyZone', 'ZOJJZC49E0EPZ'); ``` ## VPC Endpoint Service Private DNS diff --git a/packages/@aws-cdk/aws-route53/jest.config.js b/packages/@aws-cdk/aws-route53/jest.config.js new file mode 100644 index 0000000000000..54e28beb9798b --- /dev/null +++ b/packages/@aws-cdk/aws-route53/jest.config.js @@ -0,0 +1,2 @@ +const baseConfig = require('cdk-build-tools/config/jest.config'); +module.exports = baseConfig; diff --git a/packages/@aws-cdk/aws-route53/lib/cross-account-zone-delegation-handler/index.ts b/packages/@aws-cdk/aws-route53/lib/cross-account-zone-delegation-handler/index.ts new file mode 100644 index 0000000000000..3c711d283d7e5 --- /dev/null +++ b/packages/@aws-cdk/aws-route53/lib/cross-account-zone-delegation-handler/index.ts @@ -0,0 +1,66 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { Credentials, Route53, STS } from 'aws-sdk'; + +interface ResourceProperties { + AssumeRoleArn: string, + ParentZoneId: string, + DelegatedZoneName: string, + DelegatedZoneNameServers: string[], + TTL: number, +} + +export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) { + const resourceProps = event.ResourceProperties as unknown as ResourceProperties; + + switch (event.RequestType) { + case 'Create': + case 'Update': + return cfnEventHandler(resourceProps, false); + case 'Delete': + return cfnEventHandler(resourceProps, true); + } +} + +async function cfnEventHandler(props: ResourceProperties, isDeleteEvent: boolean) { + const { AssumeRoleArn, ParentZoneId, DelegatedZoneName, DelegatedZoneNameServers, TTL } = props; + + const credentials = await getCrossAccountCredentials(AssumeRoleArn); + const route53 = new Route53({ credentials }); + + await route53.changeResourceRecordSets({ + HostedZoneId: ParentZoneId, + ChangeBatch: { + Changes: [{ + Action: isDeleteEvent ? 'DELETE' : 'UPSERT', + ResourceRecordSet: { + Name: DelegatedZoneName, + Type: 'NS', + TTL, + ResourceRecords: DelegatedZoneNameServers.map(ns => ({ Value: ns })), + }, + }], + }, + }).promise(); +} + +async function getCrossAccountCredentials(roleArn: string): Promise { + const sts = new STS(); + const timestamp = (new Date()).getTime(); + + const { Credentials: assumedCredentials } = await sts + .assumeRole({ + RoleArn: roleArn, + RoleSessionName: `cross-account-zone-delegation-${timestamp}`, + }) + .promise(); + + if (!assumedCredentials) { + throw Error('Error getting assume role credentials'); + } + + return new Credentials({ + accessKeyId: assumedCredentials.AccessKeyId, + secretAccessKey: assumedCredentials.SecretAccessKey, + sessionToken: assumedCredentials.SessionToken, + }); +} diff --git a/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts b/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts index f11e9ae180e7f..1ca835cb258d9 100644 --- a/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts +++ b/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts @@ -1,4 +1,5 @@ import * as ec2 from '@aws-cdk/aws-ec2'; +import * as iam from '@aws-cdk/aws-iam'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import { ContextProvider, Duration, Lazy, Resource, Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; @@ -190,6 +191,13 @@ export interface PublicHostedZoneProps extends CommonHostedZoneProps { * @default false */ readonly caaAmazon?: boolean; + + /** + * A principal which is trusted to assume a role for zone delegation + * + * @default - No delegation configuration + */ + readonly crossAccountZoneDelegationPrincipal?: iam.IPrincipal; } /** @@ -222,6 +230,11 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone { return new Import(scope, id); } + /** + * Role for cross account zone delegation + */ + public readonly crossAccountZoneDelegationRole?: iam.Role; + constructor(scope: Construct, id: string, props: PublicHostedZoneProps) { super(scope, id, props); @@ -230,6 +243,22 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone { zone: this, }); } + + if (props.crossAccountZoneDelegationPrincipal) { + this.crossAccountZoneDelegationRole = new iam.Role(this, 'CrossAccountZoneDelegationRole', { + assumedBy: props.crossAccountZoneDelegationPrincipal, + inlinePolicies: { + delegation: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + actions: ['route53:ChangeResourceRecordSets'], + resources: [this.hostedZoneArn], + }), + ], + }), + }, + }); + } } public addVpc(_vpc: ec2.IVpc) { diff --git a/packages/@aws-cdk/aws-route53/lib/record-set.ts b/packages/@aws-cdk/aws-route53/lib/record-set.ts index 3111375a8682d..577af5c1a3a57 100644 --- a/packages/@aws-cdk/aws-route53/lib/record-set.ts +++ b/packages/@aws-cdk/aws-route53/lib/record-set.ts @@ -1,10 +1,18 @@ -import { Duration, IResource, Resource, Token } from '@aws-cdk/core'; +import * as path from 'path'; +import * as iam from '@aws-cdk/aws-iam'; +import { CustomResource, CustomResourceProvider, CustomResourceProviderRuntime, Duration, IResource, Resource, Token } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { IAliasRecordTarget } from './alias-record-target'; import { IHostedZone } from './hosted-zone-ref'; import { CfnRecordSet } from './route53.generated'; import { determineFullyQualifiedDomainName } from './util'; +const CROSS_ACCOUNT_ZONE_DELEGATION_RESOURCE_TYPE = 'Custom::CrossAccountZoneDelegation'; + +// v2 - keep this import as a separate section to reduce merge conflict when forward merging with the v2 branch. +// eslint-disable-next-line +import { Construct as CoreConstruct } from '@aws-cdk/core'; + /** * A record set */ @@ -559,3 +567,57 @@ export class ZoneDelegationRecord extends RecordSet { }); } } + +/** + * Construction properties for a CrossAccountZoneDelegationRecord + */ +export interface CrossAccountZoneDelegationRecordProps { + /** + * The zone to be delegated + */ + readonly delegatedZone: IHostedZone; + + /** + * The hosted zone id in the parent account + */ + readonly parentHostedZoneId: string; + + /** + * The delegation role in the parent account + */ + readonly delegationRole: iam.IRole; + + /** + * The resource record cache time to live (TTL). + * + * @default Duration.days(2) + */ + readonly ttl?: Duration; +} + +/** + * A Cross Account Zone Delegation record + */ +export class CrossAccountZoneDelegationRecord extends CoreConstruct { + constructor(scope: Construct, id: string, props: CrossAccountZoneDelegationRecordProps) { + super(scope, id); + + const serviceToken = CustomResourceProvider.getOrCreate(this, CROSS_ACCOUNT_ZONE_DELEGATION_RESOURCE_TYPE, { + codeDirectory: path.join(__dirname, 'cross-account-zone-delegation-handler'), + runtime: CustomResourceProviderRuntime.NODEJS_12, + policyStatements: [{ Effect: 'Allow', Action: 'sts:AssumeRole', Resource: props.delegationRole.roleArn }], + }); + + new CustomResource(this, 'CrossAccountZoneDelegationCustomResource', { + resourceType: CROSS_ACCOUNT_ZONE_DELEGATION_RESOURCE_TYPE, + serviceToken, + properties: { + AssumeRoleArn: props.delegationRole.roleArn, + ParentZoneId: props.parentHostedZoneId, + DelegatedZoneName: props.delegatedZone.zoneName, + DelegatedZoneNameServers: props.delegatedZone.hostedZoneNameServers!, + TTL: (props.ttl || Duration.days(2)).toSeconds(), + }, + }); + } +} diff --git a/packages/@aws-cdk/aws-route53/package.json b/packages/@aws-cdk/aws-route53/package.json index 89990b82cc61f..70aa8fc17f4a8 100644 --- a/packages/@aws-cdk/aws-route53/package.json +++ b/packages/@aws-cdk/aws-route53/package.json @@ -55,7 +55,8 @@ "cloudformation": "AWS::Route53", "env": { "AWSLINT_BASE_CONSTRUCT": true - } + }, + "jest": true }, "keywords": [ "aws", @@ -77,11 +78,12 @@ "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", "jest": "^26.6.0", - "nodeunit": "^0.11.3", + "nodeunit-shim": "0.0.0", "pkglint": "0.0.0" }, "dependencies": { "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cloud-assembly-schema": "0.0.0", @@ -91,6 +93,7 @@ "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cloud-assembly-schema": "0.0.0", diff --git a/packages/@aws-cdk/aws-route53/test/cross-account-zone-delegation-handler/index.test.ts b/packages/@aws-cdk/aws-route53/test/cross-account-zone-delegation-handler/index.test.ts new file mode 100644 index 0000000000000..8c8dcafcbd9c7 --- /dev/null +++ b/packages/@aws-cdk/aws-route53/test/cross-account-zone-delegation-handler/index.test.ts @@ -0,0 +1,119 @@ +import { handler } from '../../lib/cross-account-zone-delegation-handler'; + +const mockStsClient = { + assumeRole: jest.fn().mockReturnThis(), + promise: jest.fn(), +}; +const mockRoute53Client = { + changeResourceRecordSets: jest.fn().mockReturnThis(), + promise: jest.fn(), +}; + +jest.mock('aws-sdk', () => { + return { + ...(jest.requireActual('aws-sdk') as any), + STS: jest.fn(() => mockStsClient), + Route53: jest.fn(() => mockRoute53Client), + }; +}); + +beforeEach(() => { + mockStsClient.assumeRole.mockReturnThis(); + mockRoute53Client.changeResourceRecordSets.mockReturnThis(); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('throws error if getting credentials fails', async () => { + // GIVEN + mockStsClient.promise.mockResolvedValueOnce({ Credentials: undefined }); + + // WHEN + const event= getCfnEvent(); + + // THEN + await expect(invokeHandler(event)).rejects.toThrow(/Error getting assume role credentials/); + + expect(mockStsClient.assumeRole).toHaveBeenCalledTimes(1); + expect(mockStsClient.assumeRole).toHaveBeenCalledWith({ + RoleArn: 'roleArn', + RoleSessionName: expect.any(String), + }); +}); + +test('calls create resouce record set with Upsert for Create event', async () => { + // GIVEN + mockStsClient.promise.mockResolvedValueOnce({ Credentials: { AccessKeyId: 'K', SecretAccessKey: 'S', SessionToken: 'T' } }); + mockRoute53Client.promise.mockResolvedValueOnce({}); + + // WHEN + const event= getCfnEvent(); + await invokeHandler(event); + + // THEN + expect(mockRoute53Client.changeResourceRecordSets).toHaveBeenCalledTimes(1); + expect(mockRoute53Client.changeResourceRecordSets).toHaveBeenCalledWith({ + HostedZoneId: '1', + ChangeBatch: { + Changes: [{ + Action: 'UPSERT', + ResourceRecordSet: { + Name: 'recordName', + Type: 'NS', + TTL: 172800, + ResourceRecords: [{ Value: 'one' }, { Value: 'two' }], + }, + }], + }, + }); +}); + +test('calls create resouce record set with DELETE for Delete event', async () => { + // GIVEN + mockStsClient.promise.mockResolvedValueOnce({ Credentials: { AccessKeyId: 'K', SecretAccessKey: 'S', SessionToken: 'T' } }); + mockRoute53Client.promise.mockResolvedValueOnce({}); + + // WHEN + const event= getCfnEvent({ RequestType: 'Delete' }); + await invokeHandler(event); + + // THEN + expect(mockRoute53Client.changeResourceRecordSets).toHaveBeenCalledTimes(1); + expect(mockRoute53Client.changeResourceRecordSets).toHaveBeenCalledWith({ + HostedZoneId: '1', + ChangeBatch: { + Changes: [{ + Action: 'DELETE', + ResourceRecordSet: { + Name: 'recordName', + Type: 'NS', + TTL: 172800, + ResourceRecords: [{ Value: 'one' }, { Value: 'two' }], + }, + }], + }, + }); +}); + +function getCfnEvent(event?: Partial): Partial { + return { + RequestType: 'Create', + ResourceProperties: { + ServiceToken: 'Foo', + AssumeRoleArn: 'roleArn', + ParentZoneId: '1', + DelegatedZoneName: 'recordName', + DelegatedZoneNameServers: ['one', 'two'], + TTL: 172800, + }, + ...event, + }; +} + +// helper function to get around TypeScript expecting a complete event object, +// even though our tests only need some of the fields +async function invokeHandler(event: Partial) { + return handler(event as AWSLambda.CloudFormationCustomResourceEvent); +} diff --git a/packages/@aws-cdk/aws-route53/test/test.hosted-zone-provider.ts b/packages/@aws-cdk/aws-route53/test/hosted-zone-provider.test.ts similarity index 97% rename from packages/@aws-cdk/aws-route53/test/test.hosted-zone-provider.ts rename to packages/@aws-cdk/aws-route53/test/hosted-zone-provider.test.ts index 98778fe9d5107..07917bb83ba40 100644 --- a/packages/@aws-cdk/aws-route53/test/test.hosted-zone-provider.ts +++ b/packages/@aws-cdk/aws-route53/test/hosted-zone-provider.test.ts @@ -1,9 +1,9 @@ import { SynthUtils } from '@aws-cdk/assert'; import * as cdk from '@aws-cdk/core'; -import { Test } from 'nodeunit'; +import { nodeunitShim, Test } from 'nodeunit-shim'; import { HostedZone } from '../lib'; -export = { +nodeunitShim({ 'Hosted Zone Provider': { 'HostedZoneProvider will return context values if available'(test: Test) { // GIVEN @@ -82,4 +82,4 @@ export = { test.done(); }, }, -}; +}); diff --git a/packages/@aws-cdk/aws-route53/test/hosted-zone.test.ts b/packages/@aws-cdk/aws-route53/test/hosted-zone.test.ts new file mode 100644 index 0000000000000..6e7810824c2bf --- /dev/null +++ b/packages/@aws-cdk/aws-route53/test/hosted-zone.test.ts @@ -0,0 +1,149 @@ +import { expect } from '@aws-cdk/assert'; +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import { nodeunitShim, Test } from 'nodeunit-shim'; +import { HostedZone, PublicHostedZone } from '../lib'; + +nodeunitShim({ + 'Hosted Zone': { + 'Hosted Zone constructs the ARN'(test: Test) { + // GIVEN + const stack = new cdk.Stack(undefined, 'TestStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + + const testZone = new HostedZone(stack, 'HostedZone', { + zoneName: 'testZone', + }); + + test.deepEqual(stack.resolve(testZone.hostedZoneArn), { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':route53:::hostedzone/', + { Ref: 'HostedZoneDB99F866' }, + ], + ], + }); + + test.done(); + }, + }, + + 'Supports tags'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const hostedZone = new HostedZone(stack, 'HostedZone', { + zoneName: 'test.zone', + }); + cdk.Tags.of(hostedZone).add('zoneTag', 'inMyZone'); + + // THEN + expect(stack).toMatch({ + Resources: { + HostedZoneDB99F866: { + Type: 'AWS::Route53::HostedZone', + Properties: { + Name: 'test.zone.', + HostedZoneTags: [ + { + Key: 'zoneTag', + Value: 'inMyZone', + }, + ], + }, + }, + }, + }); + + test.done(); + }, + + 'with crossAccountZoneDelegationPrinciple'(test: Test) { + // GIVEN + const stack = new cdk.Stack(undefined, 'TestStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + + // WHEN + new PublicHostedZone(stack, 'HostedZone', { + zoneName: 'testZone', + crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal('223456789012'), + }); + + // THEN + expect(stack).toMatch({ + Resources: { + HostedZoneDB99F866: { + Type: 'AWS::Route53::HostedZone', + Properties: { + Name: 'testZone.', + }, + }, + HostedZoneCrossAccountZoneDelegationRole685DF755: { + Type: 'AWS::IAM::Role', + Properties: { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + AWS: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::223456789012:root', + ], + ], + }, + }, + }, + ], + Version: '2012-10-17', + }, + Policies: [ + { + PolicyDocument: { + Statement: [ + { + Action: 'route53:ChangeResourceRecordSets', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':route53:::hostedzone/', + { + Ref: 'HostedZoneDB99F866', + }, + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'delegation', + }, + ], + }, + }, + }, + }); + + test.done(); + }, +}); diff --git a/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.expected.json b/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.expected.json new file mode 100644 index 0000000000000..919a54f8b5051 --- /dev/null +++ b/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.expected.json @@ -0,0 +1,219 @@ +{ + "Resources": { + "ParentHostedZoneC2BD86E1": { + "Type": "AWS::Route53::HostedZone", + "Properties": { + "Name": "myzone.com." + } + }, + "ParentHostedZoneCrossAccountZoneDelegationRole95B1C36E": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": "route53:ChangeResourceRecordSets", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":route53:::hostedzone/", + { + "Ref": "ParentHostedZoneC2BD86E1" + } + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "delegation" + } + ] + } + }, + "ChildHostedZone4B14AC71": { + "Type": "AWS::Route53::HostedZone", + "Properties": { + "Name": "sub.myzone.com." + } + }, + "DelegationCrossAccountZoneDelegationCustomResourceFADC27F0": { + "Type": "Custom::CrossAccountZoneDelegation", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomCrossAccountZoneDelegationCustomResourceProviderHandler44A84265", + "Arn" + ] + }, + "AssumeRoleArn": { + "Fn::GetAtt": [ + "ParentHostedZoneCrossAccountZoneDelegationRole95B1C36E", + "Arn" + ] + }, + "ParentZoneId": { + "Ref": "ParentHostedZoneC2BD86E1" + }, + "DelegatedZoneName": "sub.myzone.com", + "DelegatedZoneNameServers": { + "Fn::GetAtt": [ + "ChildHostedZone4B14AC71", + "NameServers" + ] + }, + "TTL": 172800 + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "CustomCrossAccountZoneDelegationCustomResourceProviderRoleED64687B": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ], + "Policies": [ + { + "PolicyName": "Inline", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": { + "Fn::GetAtt": [ + "ParentHostedZoneCrossAccountZoneDelegationRole95B1C36E", + "Arn" + ] + } + } + ] + } + } + ] + } + }, + "CustomCrossAccountZoneDelegationCustomResourceProviderHandler44A84265": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aS3Bucket8B462894" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aS3VersionKeyFDEC5E1D" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aS3VersionKeyFDEC5E1D" + } + ] + } + ] + } + ] + ] + } + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "CustomCrossAccountZoneDelegationCustomResourceProviderRoleED64687B", + "Arn" + ] + }, + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "CustomCrossAccountZoneDelegationCustomResourceProviderRoleED64687B" + ] + } + }, + "Parameters": { + "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aS3Bucket8B462894": { + "Type": "String", + "Description": "S3 bucket for asset \"3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113a\"" + }, + "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aS3VersionKeyFDEC5E1D": { + "Type": "String", + "Description": "S3 key for asset version \"3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113a\"" + }, + "AssetParameters3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113aArtifactHash4F367D8C": { + "Type": "String", + "Description": "Artifact hash for asset \"3c971020239d152fff59dc3bdbabbbc6d3d9140574e45fd4eb7313ced117113a\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.ts b/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.ts new file mode 100644 index 0000000000000..75f9e86152eb0 --- /dev/null +++ b/packages/@aws-cdk/aws-route53/test/integ.cross-account-zone-delegation.ts @@ -0,0 +1,23 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import { PublicHostedZone, CrossAccountZoneDelegationRecord } from '../lib'; + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-route53-cross-account-integ'); + +const parentZone = new PublicHostedZone(stack, 'ParentHostedZone', { + zoneName: 'myzone.com', + crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal(cdk.Aws.ACCOUNT_ID), +}); + +const childZone = new PublicHostedZone(stack, 'ChildHostedZone', { + zoneName: 'sub.myzone.com', +}); +new CrossAccountZoneDelegationRecord(stack, 'Delegation', { + delegatedZone: childZone, + parentHostedZoneId: parentZone.hostedZoneId, + delegationRole: parentZone.crossAccountZoneDelegationRole!, +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-route53/test/test.record-set.ts b/packages/@aws-cdk/aws-route53/test/record-set.test.ts similarity index 88% rename from packages/@aws-cdk/aws-route53/test/test.record-set.ts rename to packages/@aws-cdk/aws-route53/test/record-set.test.ts index 8da38f60d9720..373464f455992 100644 --- a/packages/@aws-cdk/aws-route53/test/test.record-set.ts +++ b/packages/@aws-cdk/aws-route53/test/record-set.test.ts @@ -1,9 +1,10 @@ import { expect, haveResource } from '@aws-cdk/assert'; +import * as iam from '@aws-cdk/aws-iam'; import { Duration, Stack } from '@aws-cdk/core'; -import { Test } from 'nodeunit'; +import { nodeunitShim, Test } from 'nodeunit-shim'; import * as route53 from '../lib'; -export = { +nodeunitShim({ 'with default ttl'(test: Test) { // GIVEN const stack = new Stack(); @@ -513,4 +514,52 @@ export = { })); test.done(); }, -}; + + 'Cross account zone delegation record'(test: Test) { + // GIVEN + const stack = new Stack(); + const parentZone = new route53.PublicHostedZone(stack, 'ParentHostedZone', { + zoneName: 'myzone.com', + crossAccountZoneDelegationPrincipal: new iam.AccountPrincipal('123456789012'), + }); + + // WHEN + const childZone = new route53.PublicHostedZone(stack, 'ChildHostedZone', { + zoneName: 'sub.myzone.com', + }); + new route53.CrossAccountZoneDelegationRecord(stack, 'Delegation', { + delegatedZone: childZone, + parentHostedZoneId: parentZone.hostedZoneId, + delegationRole: parentZone.crossAccountZoneDelegationRole!, + ttl: Duration.seconds(60), + }); + + // THEN + expect(stack).to(haveResource('Custom::CrossAccountZoneDelegation', { + ServiceToken: { + 'Fn::GetAtt': [ + 'CustomCrossAccountZoneDelegationCustomResourceProviderHandler44A84265', + 'Arn', + ], + }, + AssumeRoleArn: { + 'Fn::GetAtt': [ + 'ParentHostedZoneCrossAccountZoneDelegationRole95B1C36E', + 'Arn', + ], + }, + ParentZoneId: { + Ref: 'ParentHostedZoneC2BD86E1', + }, + DelegatedZoneName: 'sub.myzone.com', + DelegatedZoneNameServers: { + 'Fn::GetAtt': [ + 'ChildHostedZone4B14AC71', + 'NameServers', + ], + }, + TTL: 60, + })); + test.done(); + }, +}); diff --git a/packages/@aws-cdk/aws-route53/test/test.route53.ts b/packages/@aws-cdk/aws-route53/test/route53.test.ts similarity index 98% rename from packages/@aws-cdk/aws-route53/test/test.route53.ts rename to packages/@aws-cdk/aws-route53/test/route53.test.ts index 4655e1c10fda8..8f58486bebcbc 100644 --- a/packages/@aws-cdk/aws-route53/test/test.route53.ts +++ b/packages/@aws-cdk/aws-route53/test/route53.test.ts @@ -1,10 +1,10 @@ import { beASupersetOfTemplate, exactlyMatchTemplate, expect, haveResource } from '@aws-cdk/assert'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as cdk from '@aws-cdk/core'; -import { Test } from 'nodeunit'; +import { nodeunitShim, Test } from 'nodeunit-shim'; import { HostedZone, PrivateHostedZone, PublicHostedZone, TxtRecord } from '../lib'; -export = { +nodeunitShim({ 'default properties': { 'public hosted zone'(test: Test) { const app = new TestApp(); @@ -215,7 +215,7 @@ export = { })); test.done(); }, -}; +}); class TestApp { public readonly stack: cdk.Stack; diff --git a/packages/@aws-cdk/aws-route53/test/test.hosted-zone.ts b/packages/@aws-cdk/aws-route53/test/test.hosted-zone.ts deleted file mode 100644 index 37d80a5908a9b..0000000000000 --- a/packages/@aws-cdk/aws-route53/test/test.hosted-zone.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect } from '@aws-cdk/assert'; -import * as cdk from '@aws-cdk/core'; -import { Test } from 'nodeunit'; -import { HostedZone } from '../lib'; - -export = { - 'Hosted Zone': { - 'Hosted Zone constructs the ARN'(test: Test) { - // GIVEN - const stack = new cdk.Stack(undefined, 'TestStack', { - env: { account: '123456789012', region: 'us-east-1' }, - }); - - const testZone = new HostedZone(stack, 'HostedZone', { - zoneName: 'testZone', - }); - - test.deepEqual(stack.resolve(testZone.hostedZoneArn), { - 'Fn::Join': [ - '', - [ - 'arn:', - { Ref: 'AWS::Partition' }, - ':route53:::hostedzone/', - { Ref: 'HostedZoneDB99F866' }, - ], - ], - }); - - test.done(); - }, - }, - - 'Supports tags'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - - // WHEN - const hostedZone = new HostedZone(stack, 'HostedZone', { - zoneName: 'test.zone', - }); - cdk.Tags.of(hostedZone).add('zoneTag', 'inMyZone'); - - // THEN - expect(stack).toMatch({ - Resources: { - HostedZoneDB99F866: { - Type: 'AWS::Route53::HostedZone', - Properties: { - Name: 'test.zone.', - HostedZoneTags: [ - { - Key: 'zoneTag', - Value: 'inMyZone', - }, - ], - }, - }, - }, - }); - - test.done(); - }, -}; diff --git a/packages/@aws-cdk/aws-route53/test/test.util.ts b/packages/@aws-cdk/aws-route53/test/util.test.ts similarity index 97% rename from packages/@aws-cdk/aws-route53/test/test.util.ts rename to packages/@aws-cdk/aws-route53/test/util.test.ts index d589b058e40cc..c6ded4e74f7b4 100644 --- a/packages/@aws-cdk/aws-route53/test/test.util.ts +++ b/packages/@aws-cdk/aws-route53/test/util.test.ts @@ -1,9 +1,9 @@ import * as cdk from '@aws-cdk/core'; -import { Test } from 'nodeunit'; +import { nodeunitShim, Test } from 'nodeunit-shim'; import { HostedZone } from '../lib'; import * as util from '../lib/util'; -export = { +nodeunitShim({ 'throws when zone name ending with a \'.\''(test: Test) { test.throws(() => util.validateZoneName('zone.name.'), /trailing dot/); test.done(); @@ -78,4 +78,4 @@ export = { test.equal(qualified, 'test.domain.com.'); test.done(); }, -}; +}); diff --git a/packages/@aws-cdk/aws-route53/test/vpc-endpoint-service-domain-name.test.ts b/packages/@aws-cdk/aws-route53/test/vpc-endpoint-service-domain-name.test.ts index 70ba07c201d5f..86edd9992776d 100644 --- a/packages/@aws-cdk/aws-route53/test/vpc-endpoint-service-domain-name.test.ts +++ b/packages/@aws-cdk/aws-route53/test/vpc-endpoint-service-domain-name.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-disabled-tests */ import { expect as cdkExpect, haveResource, haveResourceLike, ResourcePart } from '@aws-cdk/assert'; import '@aws-cdk/assert/jest'; import { IVpcEndpointServiceLoadBalancer, VpcEndpointService } from '@aws-cdk/aws-ec2'; @@ -56,7 +57,7 @@ test('create domain name resource', () => { }, }, physicalResourceId: { - id: 'EndpointDomain', + id: 'VPCES', }, }, Update: { @@ -69,7 +70,7 @@ test('create domain name resource', () => { }, }, physicalResourceId: { - id: 'EndpointDomain', + id: 'VPCES', }, }, Delete: { @@ -236,6 +237,9 @@ test('create domain name resource', () => { test('throws if creating multiple domains for a single service', () => { // GIVEN + vpces = new VpcEndpointService(stack, 'VPCES-2', { + vpcEndpointServiceLoadBalancers: [nlb], + }); new VpcEndpointServiceDomainName(stack, 'EndpointDomain', { endpointService: vpces, @@ -250,5 +254,5 @@ test('throws if creating multiple domains for a single service', () => { domainName: 'my-stuff-2.aws-cdk.dev', publicHostedZone: zone, }); - }).toThrow(); + }).toThrow(/Cannot create a VpcEndpointServiceDomainName for service/); }); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3/README.md b/packages/@aws-cdk/aws-s3/README.md index 1ce00f56ba0a9..12ae88bb02d2e 100644 --- a/packages/@aws-cdk/aws-s3/README.md +++ b/packages/@aws-cdk/aws-s3/README.md @@ -67,8 +67,6 @@ assert(bucket.encryptionKey === myKmsKey); Enable KMS-SSE encryption via [S3 Bucket Keys](https://docs.aws.amazon.com/AmazonS3/latest/dev/bucket-key.html): ```ts -const myKmsKey = new kms.Key(this, 'MyKey'); - const bucket = new Bucket(this, 'MyEncryptedBucket', { encryption: BucketEncryption.KMS, bucketKeyEnabled: true diff --git a/packages/@aws-cdk/aws-s3/test/bucket.test.ts b/packages/@aws-cdk/aws-s3/test/bucket.test.ts index 0d4e5b03f8258..adf4858bae8c8 100644 --- a/packages/@aws-cdk/aws-s3/test/bucket.test.ts +++ b/packages/@aws-cdk/aws-s3/test/bucket.test.ts @@ -1,22 +1,22 @@ +import '@aws-cdk/assert/jest'; import { EOL } from 'os'; -import { countResources, expect, haveResource, haveResourceLike, ResourcePart, SynthUtils, arrayWith, objectLike } from '@aws-cdk/assert'; +import { ResourcePart, SynthUtils, arrayWith, objectLike } from '@aws-cdk/assert'; import * as iam from '@aws-cdk/aws-iam'; import * as kms from '@aws-cdk/aws-kms'; import * as cdk from '@aws-cdk/core'; import * as cxapi from '@aws-cdk/cx-api'; -import { nodeunitShim, Test } from 'nodeunit-shim'; import * as s3 from '../lib'; // to make it easy to copy & paste from output: /* eslint-disable quote-props */ -nodeunitShim({ - 'default bucket'(test: Test) { +describe('bucket', () => { + test('default bucket', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket'); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -26,29 +26,29 @@ nodeunitShim({ }, }); - test.done(); - }, - 'CFN properties are type-validated during resolution'(test: Test) { + }); + + test('CFN properties are type-validated during resolution', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { bucketName: cdk.Token.asString(5), // Oh no }); - test.throws(() => { + expect(() => { SynthUtils.synthesize(stack); - }, /bucketName: 5 should be a string/); + }).toThrow(/bucketName: 5 should be a string/); + - test.done(); - }, + }); - 'bucket without encryption'(test: Test) { + test('bucket without encryption', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.UNENCRYPTED, }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -58,16 +58,16 @@ nodeunitShim({ }, }); - test.done(); - }, - 'bucket with managed encryption'(test: Test) { + }); + + test('bucket with managed encryption', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.KMS_MANAGED, }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -87,34 +87,34 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'valid bucket names'(test: Test) { + }); + + test('valid bucket names', () => { const stack = new cdk.Stack(); - test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket1', { + expect(() => new s3.Bucket(stack, 'MyBucket1', { bucketName: 'abc.xyz-34ab', - })); + })).not.toThrow(); - test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket2', { + expect(() => new s3.Bucket(stack, 'MyBucket2', { bucketName: '124.pp--33', - })); + })).not.toThrow(); + - test.done(); - }, + }); - 'bucket validation skips tokenized values'(test: Test) { + test('bucket validation skips tokenized values', () => { const stack = new cdk.Stack(); - test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket', { + expect(() => new s3.Bucket(stack, 'MyBucket', { bucketName: cdk.Lazy.string({ produce: () => '_BUCKET' }), - })); + })).not.toThrow(); + - test.done(); - }, + }); - 'fails with message on invalid bucket names'(test: Test) { + test('fails with message on invalid bucket names', () => { const stack = new cdk.Stack(); const bucket = `-buckEt.-${new Array(65).join('$')}`; const expectedErrors = [ @@ -126,135 +126,135 @@ nodeunitShim({ 'Bucket name must not have dash next to period, or period next to dash, or consecutive periods (offset: 7)', ].join(EOL); - test.throws(() => new s3.Bucket(stack, 'MyBucket', { + expect(() => new s3.Bucket(stack, 'MyBucket', { bucketName: bucket, - }), expectedErrors); + })).toThrow(expectedErrors); - test.done(); - }, - 'fails if bucket name has less than 3 or more than 63 characters'(test: Test) { + }); + + test('fails if bucket name has less than 3 or more than 63 characters', () => { const stack = new cdk.Stack(); - test.throws(() => new s3.Bucket(stack, 'MyBucket1', { + expect(() => new s3.Bucket(stack, 'MyBucket1', { bucketName: 'a', - }), /at least 3/); + })).toThrow(/at least 3/); - test.throws(() => new s3.Bucket(stack, 'MyBucket2', { + expect(() => new s3.Bucket(stack, 'MyBucket2', { bucketName: new Array(65).join('x'), - }), /no more than 63/); + })).toThrow(/no more than 63/); + - test.done(); - }, + }); - 'fails if bucket name has invalid characters'(test: Test) { + test('fails if bucket name has invalid characters', () => { const stack = new cdk.Stack(); - test.throws(() => new s3.Bucket(stack, 'MyBucket1', { + expect(() => new s3.Bucket(stack, 'MyBucket1', { bucketName: 'b@cket', - }), /offset: 1/); + })).toThrow(/offset: 1/); - test.throws(() => new s3.Bucket(stack, 'MyBucket2', { + expect(() => new s3.Bucket(stack, 'MyBucket2', { bucketName: 'bucKet', - }), /offset: 3/); + })).toThrow(/offset: 3/); - test.throws(() => new s3.Bucket(stack, 'MyBucket3', { + expect(() => new s3.Bucket(stack, 'MyBucket3', { bucketName: 'bučket', - }), /offset: 2/); + })).toThrow(/offset: 2/); + - test.done(); - }, + }); - 'fails if bucket name does not start or end with lowercase character or number'(test: Test) { + test('fails if bucket name does not start or end with lowercase character or number', () => { const stack = new cdk.Stack(); - test.throws(() => new s3.Bucket(stack, 'MyBucket1', { + expect(() => new s3.Bucket(stack, 'MyBucket1', { bucketName: '-ucket', - }), /offset: 0/); + })).toThrow(/offset: 0/); - test.throws(() => new s3.Bucket(stack, 'MyBucket2', { + expect(() => new s3.Bucket(stack, 'MyBucket2', { bucketName: 'bucke.', - }), /offset: 5/); + })).toThrow(/offset: 5/); - test.done(); - }, - 'fails only if bucket name has the consecutive symbols (..), (.-), (-.)'(test: Test) { + }); + + test('fails only if bucket name has the consecutive symbols (..), (.-), (-.)', () => { const stack = new cdk.Stack(); - test.throws(() => new s3.Bucket(stack, 'MyBucket1', { + expect(() => new s3.Bucket(stack, 'MyBucket1', { bucketName: 'buc..ket', - }), /offset: 3/); + })).toThrow(/offset: 3/); - test.throws(() => new s3.Bucket(stack, 'MyBucket2', { + expect(() => new s3.Bucket(stack, 'MyBucket2', { bucketName: 'buck.-et', - }), /offset: 4/); + })).toThrow(/offset: 4/); - test.throws(() => new s3.Bucket(stack, 'MyBucket3', { + expect(() => new s3.Bucket(stack, 'MyBucket3', { bucketName: 'b-.ucket', - }), /offset: 1/); + })).toThrow(/offset: 1/); - test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket4', { + expect(() => new s3.Bucket(stack, 'MyBucket4', { bucketName: 'bu--cket', - })); + })).not.toThrow(); + - test.done(); - }, + }); - 'fails only if bucket name resembles IP address'(test: Test) { + test('fails only if bucket name resembles IP address', () => { const stack = new cdk.Stack(); - test.throws(() => new s3.Bucket(stack, 'MyBucket1', { + expect(() => new s3.Bucket(stack, 'MyBucket1', { bucketName: '1.2.3.4', - }), /must not resemble an IP address/); + })).toThrow(/must not resemble an IP address/); - test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket2', { + expect(() => new s3.Bucket(stack, 'MyBucket2', { bucketName: '1.2.3', - })); + })).not.toThrow(); - test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket3', { + expect(() => new s3.Bucket(stack, 'MyBucket3', { bucketName: '1.2.3.a', - })); + })).not.toThrow(); - test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket4', { + expect(() => new s3.Bucket(stack, 'MyBucket4', { bucketName: '1000.2.3.4', - })); + })).not.toThrow(); - test.done(); - }, - 'fails if encryption key is used with managed encryption'(test: Test) { + }); + + test('fails if encryption key is used with managed encryption', () => { const stack = new cdk.Stack(); const myKey = new kms.Key(stack, 'MyKey'); - test.throws(() => new s3.Bucket(stack, 'MyBucket', { + expect(() => new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.KMS_MANAGED, encryptionKey: myKey, - }), /encryptionKey is specified, so 'encryption' must be set to KMS/); + })).toThrow(/encryptionKey is specified, so 'encryption' must be set to KMS/); + - test.done(); - }, + }); - 'fails if encryption key is used with encryption set to unencrypted'(test: Test) { + test('fails if encryption key is used with encryption set to unencrypted', () => { const stack = new cdk.Stack(); const myKey = new kms.Key(stack, 'MyKey'); - test.throws(() => new s3.Bucket(stack, 'MyBucket', { + expect(() => new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.UNENCRYPTED, encryptionKey: myKey, - }), /encryptionKey is specified, so 'encryption' must be set to KMS/); + })).toThrow(/encryptionKey is specified, so 'encryption' must be set to KMS/); + - test.done(); - }, + }); - 'encryptionKey can specify kms key'(test: Test) { + test('encryptionKey can specify kms key', () => { const stack = new cdk.Stack(); const encryptionKey = new kms.Key(stack, 'MyKey', { description: 'hello, world' }); new s3.Bucket(stack, 'MyBucket', { encryptionKey, encryption: s3.BucketEncryption.KMS }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyKey6AB29FA6': { 'Type': 'AWS::KMS::Key', @@ -332,15 +332,15 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'bucketKeyEnabled can be enabled'(test: Test) { + }); + + test('bucketKeyEnabled can be enabled', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { bucketKeyEnabled: true, encryption: s3.BucketEncryption.KMS }); - expect(stack).to(haveResource('AWS::S3::Bucket', { + expect(stack).toHaveResource('AWS::S3::Bucket', { 'BucketEncryption': { 'ServerSideEncryptionConfiguration': [ { @@ -357,30 +357,30 @@ nodeunitShim({ }, ], }, - }), - ); - test.done(); - }, + }); + - 'throws error if bucketKeyEnabled is set, but encryption is not KMS'(test: Test) { + }); + + test('throws error if bucketKeyEnabled is set, but encryption is not KMS', () => { const stack = new cdk.Stack(); - test.throws(() => { + expect(() => { new s3.Bucket(stack, 'MyBucket', { bucketKeyEnabled: true, encryption: s3.BucketEncryption.S3_MANAGED }); - }, "bucketKeyEnabled is specified, so 'encryption' must be set to KMS (value: S3MANAGED)"); - test.throws(() => { + }).toThrow("bucketKeyEnabled is specified, so 'encryption' must be set to KMS (value: S3MANAGED)"); + expect(() => { new s3.Bucket(stack, 'MyBucket3', { bucketKeyEnabled: true }); - }, "bucketKeyEnabled is specified, so 'encryption' must be set to KMS (value: NONE)"); - test.done(); - }, + }).toThrow("bucketKeyEnabled is specified, so 'encryption' must be set to KMS (value: NONE)"); + + }); - 'bucket with versioning turned on'(test: Test) { + test('bucket with versioning turned on', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { versioned: true, }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -394,16 +394,16 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'bucket with block public access set to BlockAll'(test: Test) { + }); + + test('bucket with block public access set to BlockAll', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -420,16 +420,16 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'bucket with block public access set to BlockAcls'(test: Test) { + }); + + test('bucket with block public access set to BlockAcls', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS, }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -444,16 +444,16 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'bucket with custom block public access setting'(test: Test) { + }); + + test('bucket with custom block public access setting', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { blockPublicAccess: new s3.BlockPublicAccess({ restrictPublicBuckets: true }), }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -467,16 +467,16 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'bucket with custom canned access control'(test: Test) { + }); + + test('bucket with custom canned access control', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { accessControl: s3.BucketAccessControl.LOG_DELIVERY_WRITE, }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -488,12 +488,12 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'permissions': { + }); + + describe('permissions', () => { - 'addPermission creates a bucket policy'(test: Test) { + test('addPermission creates a bucket policy', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.UNENCRYPTED }); @@ -503,7 +503,7 @@ nodeunitShim({ principals: [new iam.AnyPrincipal()], })); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -532,10 +532,10 @@ nodeunitShim({ }, }); - test.done(); - }, - 'forBucket returns a permission statement associated with the bucket\'s ARN'(test: Test) { + }); + + test('forBucket returns a permission statement associated with the bucket\'s ARN', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.UNENCRYPTED }); @@ -546,17 +546,17 @@ nodeunitShim({ principals: [new iam.AnyPrincipal()], }); - test.deepEqual(stack.resolve(x.toStatementJson()), { + expect(stack.resolve(x.toStatementJson())).toEqual({ Action: 's3:ListBucket', Effect: 'Allow', Principal: '*', Resource: { 'Fn::GetAtt': ['MyBucketF68F3FF0', 'Arn'] }, }); - test.done(); - }, - 'arnForObjects returns a permission statement associated with objects in the bucket'(test: Test) { + }); + + test('arnForObjects returns a permission statement associated with objects in the bucket', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.UNENCRYPTED }); @@ -567,7 +567,7 @@ nodeunitShim({ principals: [new iam.AnyPrincipal()], }); - test.deepEqual(stack.resolve(p.toStatementJson()), { + expect(stack.resolve(p.toStatementJson())).toEqual({ Action: 's3:GetObject', Effect: 'Allow', Principal: '*', @@ -579,10 +579,10 @@ nodeunitShim({ }, }); - test.done(); - }, - 'arnForObjects accepts multiple arguments and FnConcats them'(test: Test) { + }); + + test('arnForObjects accepts multiple arguments and FnConcats them', () => { const stack = new cdk.Stack(); @@ -598,7 +598,7 @@ nodeunitShim({ principals: [new iam.AnyPrincipal()], }); - test.deepEqual(stack.resolve(p.toStatementJson()), { + expect(stack.resolve(p.toStatementJson())).toEqual({ Action: 's3:GetObject', Effect: 'Allow', Principal: '*', @@ -617,18 +617,18 @@ nodeunitShim({ }, }); - test.done(); - }, - }, - 'removal policy can be used to specify behavior upon delete'(test: Test) { + }); + }); + + test('removal policy can be used to specify behavior upon delete', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { removalPolicy: cdk.RemovalPolicy.RETAIN, encryption: s3.BucketEncryption.UNENCRYPTED, }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ Resources: { MyBucketF68F3FF0: { Type: 'AWS::S3::Bucket', @@ -638,12 +638,12 @@ nodeunitShim({ }, }); - test.done(); - }, - 'import/export': { + }); + + describe('import/export', () => { - 'static import(ref) allows importing an external/existing bucket'(test: Test) { + test('static import(ref) allows importing an external/existing bucket', () => { const stack = new cdk.Stack(); const bucketArn = 'arn:aws:s3:::my-bucket'; @@ -663,21 +663,21 @@ nodeunitShim({ }); // it is possible to obtain a permission statement for a ref - test.deepEqual(p.toStatementJson(), { + expect(p.toStatementJson()).toEqual({ Action: 's3:ListBucket', Effect: 'Allow', Principal: '*', Resource: 'arn:aws:s3:::my-bucket', }); - test.deepEqual(bucket.bucketArn, bucketArn); - test.deepEqual(stack.resolve(bucket.bucketName), 'my-bucket'); + expect(bucket.bucketArn).toEqual(bucketArn); + expect(stack.resolve(bucket.bucketName)).toEqual('my-bucket'); - test.deepEqual(SynthUtils.synthesize(stack).template, {}, 'the ref is not a real resource'); - test.done(); - }, + expect(SynthUtils.synthesize(stack).template).toEqual({}); - 'import does not create any resources'(test: Test) { + }); + + test('import does not create any resources', () => { const stack = new cdk.Stack(); const bucket = s3.Bucket.fromBucketAttributes(stack, 'ImportedBucket', { bucketArn: 'arn:aws:s3:::my-bucket' }); bucket.addToResourcePolicy(new iam.PolicyStatement({ @@ -687,11 +687,11 @@ nodeunitShim({ })); // at this point we technically didn't create any resources in the consuming stack. - expect(stack).toMatch({}); - test.done(); - }, + expect(stack).toMatchTemplate({}); + + }); - 'import can also be used to import arbitrary ARNs'(test: Test) { + test('import can also be used to import arbitrary ARNs', () => { const stack = new cdk.Stack(); const bucket = s3.Bucket.fromBucketAttributes(stack, 'ImportedBucket', { bucketArn: 'arn:aws:s3:::my-bucket' }); bucket.addToResourcePolicy(new iam.PolicyStatement({ resources: ['*'], actions: ['*'] })); @@ -704,7 +704,7 @@ nodeunitShim({ actions: ['s3:*'], })); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyUserDC45028B': { 'Type': 'AWS::IAM::User', @@ -733,10 +733,10 @@ nodeunitShim({ }, }); - test.done(); - }, - 'import can explicitly set bucket region'(test: Test) { + }); + + test('import can explicitly set bucket region', () => { const stack = new cdk.Stack(undefined, undefined, { env: { region: 'us-east-1' }, }); @@ -746,19 +746,19 @@ nodeunitShim({ region: 'eu-west-1', }); - test.equals(bucket.bucketRegionalDomainName, `myBucket.s3.eu-west-1.${stack.urlSuffix}`); - test.equals(bucket.bucketWebsiteDomainName, `myBucket.s3-website-eu-west-1.${stack.urlSuffix}`); + expect(bucket.bucketRegionalDomainName).toEqual(`myBucket.s3.eu-west-1.${stack.urlSuffix}`); + expect(bucket.bucketWebsiteDomainName).toEqual(`myBucket.s3-website-eu-west-1.${stack.urlSuffix}`); - test.done(); - }, - }, - 'grantRead'(test: Test) { + }); + }); + + test('grantRead', () => { const stack = new cdk.Stack(); const reader = new iam.User(stack, 'Reader'); const bucket = new s3.Bucket(stack, 'MyBucket'); bucket.grantRead(reader); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'ReaderF7BF189D': { 'Type': 'AWS::IAM::User', @@ -816,17 +816,17 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'grantReadWrite': { - 'can be used to grant reciprocal permissions to an identity'(test: Test) { + }); + + describe('grantReadWrite', () => { + test('can be used to grant reciprocal permissions to an identity', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket'); const user = new iam.User(stack, 'MyUser'); bucket.grantReadWrite(user); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -888,10 +888,10 @@ nodeunitShim({ }, }); - test.done(); - }, - 'grant permissions to non-identity principal'(test: Test) { + }); + + test('grant permissions to non-identity principal', () => { // GIVEN const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.KMS }); @@ -900,7 +900,7 @@ nodeunitShim({ bucket.grantRead(new iam.OrganizationPrincipal('o-1234')); // THEN - expect(stack).to(haveResource('AWS::S3::BucketPolicy', { + expect(stack).toHaveResource('AWS::S3::BucketPolicy', { PolicyDocument: { 'Version': '2012-10-17', 'Statement': [ @@ -916,9 +916,9 @@ nodeunitShim({ }, ], }, - })); + }); - expect(stack).to(haveResource('AWS::KMS::Key', { + expect(stack).toHaveResource('AWS::KMS::Key', { 'KeyPolicy': { 'Statement': [ { @@ -946,18 +946,18 @@ nodeunitShim({ 'Version': '2012-10-17', }, - })); + }); + - test.done(); - }, + }); - 'if an encryption key is included, encrypt/decrypt permissions are also added both ways'(test: Test) { + test('if an encryption key is included, encrypt/decrypt permissions are also added both ways', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.KMS }); const user = new iam.User(stack, 'MyUser'); bucket.grantReadWrite(user); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketKeyC17130CF': { 'Type': 'AWS::KMS::Key', @@ -1123,10 +1123,10 @@ nodeunitShim({ }, }); - test.done(); - }, - 'does not grant PutObjectAcl when the S3_GRANT_WRITE_WITHOUT_ACL feature is enabled'(test: Test) { + }); + + test('does not grant PutObjectAcl when the S3_GRANT_WRITE_WITHOUT_ACL feature is enabled', () => { const app = new cdk.App({ context: { [cxapi.S3_GRANT_WRITE_WITHOUT_ACL]: true, @@ -1138,7 +1138,7 @@ nodeunitShim({ bucket.grantReadWrite(user); - expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + expect(stack).toHaveResourceLike('AWS::IAM::Policy', { 'PolicyDocument': { 'Statement': [ { @@ -1162,20 +1162,20 @@ nodeunitShim({ }, ], }, - })); + }); + - test.done(); - }, - }, + }); + }); - 'grantWrite': { - 'with KMS key has appropriate permissions for multipart uploads'(test: Test) { + describe('grantWrite', () => { + test('with KMS key has appropriate permissions for multipart uploads', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.KMS }); const user = new iam.User(stack, 'MyUser'); bucket.grantWrite(user); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketKeyC17130CF': { 'Type': 'AWS::KMS::Key', @@ -1336,10 +1336,10 @@ nodeunitShim({ }, }); - test.done(); - }, - 'does not grant PutObjectAcl when the S3_GRANT_WRITE_WITHOUT_ACL feature is enabled'(test: Test) { + }); + + test('does not grant PutObjectAcl when the S3_GRANT_WRITE_WITHOUT_ACL feature is enabled', () => { const app = new cdk.App({ context: { [cxapi.S3_GRANT_WRITE_WITHOUT_ACL]: true, @@ -1351,7 +1351,7 @@ nodeunitShim({ bucket.grantWrite(user); - expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + expect(stack).toHaveResourceLike('AWS::IAM::Policy', { 'PolicyDocument': { 'Statement': [ { @@ -1372,14 +1372,14 @@ nodeunitShim({ }, ], }, - })); + }); - test.done(); - }, - }, - 'grantPut': { - 'does not grant PutObjectAcl when the S3_GRANT_WRITE_WITHOUT_ACL feature is enabled'(test: Test) { + }); + }); + + describe('grantPut', () => { + test('does not grant PutObjectAcl when the S3_GRANT_WRITE_WITHOUT_ACL feature is enabled', () => { const app = new cdk.App({ context: { [cxapi.S3_GRANT_WRITE_WITHOUT_ACL]: true, @@ -1391,7 +1391,7 @@ nodeunitShim({ bucket.grantPut(user); - expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + expect(stack).toHaveResourceLike('AWS::IAM::Policy', { 'PolicyDocument': { 'Statement': [ { @@ -1408,13 +1408,13 @@ nodeunitShim({ }, ], }, - })); + }); - test.done(); - }, - }, - 'more grants'(test: Test) { + }); + }); + + test('more grants', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket', { encryption: s3.BucketEncryption.KMS }); const putter = new iam.User(stack, 'Putter'); @@ -1428,13 +1428,13 @@ nodeunitShim({ const resources = SynthUtils.synthesize(stack).template.Resources; const actions = (id: string) => resources[id].Properties.PolicyDocument.Statement[0].Action; - test.deepEqual(actions('WriterDefaultPolicyDC585BCE'), ['s3:DeleteObject*', 's3:PutObject*', 's3:Abort*']); - test.deepEqual(actions('PutterDefaultPolicyAB138DD3'), ['s3:PutObject*', 's3:Abort*']); - test.deepEqual(actions('DeleterDefaultPolicyCD33B8A0'), 's3:DeleteObject*'); - test.done(); - }, + expect(actions('WriterDefaultPolicyDC585BCE')).toEqual(['s3:DeleteObject*', 's3:PutObject*', 's3:Abort*']); + expect(actions('PutterDefaultPolicyAB138DD3')).toEqual(['s3:PutObject*', 's3:Abort*']); + expect(actions('DeleterDefaultPolicyCD33B8A0')).toEqual('s3:DeleteObject*'); - 'grantDelete, with a KMS Key'(test: Test) { + }); + + test('grantDelete, with a KMS Key', () => { // given const stack = new cdk.Stack(); const key = new kms.Key(stack, 'MyKey'); @@ -1449,7 +1449,7 @@ nodeunitShim({ bucket.grantDelete(deleter); // then - expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + expect(stack).toHaveResourceLike('AWS::IAM::Policy', { 'PolicyDocument': { 'Statement': [ { @@ -1473,13 +1473,13 @@ nodeunitShim({ ], 'Version': '2012-10-17', }, - })); + }); + - test.done(); - }, + }); - 'cross-stack permissions': { - 'in the same account and region'(test: Test) { + describe('cross-stack permissions', () => { + test('in the same account and region', () => { const app = new cdk.App(); const stackA = new cdk.Stack(app, 'stackA'); const bucketFromStackA = new s3.Bucket(stackA, 'MyBucket'); @@ -1488,7 +1488,7 @@ nodeunitShim({ const user = new iam.User(stackB, 'UserWhoNeedsAccess'); bucketFromStackA.grantRead(user); - expect(stackA).toMatch({ + expect(stackA).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -1511,7 +1511,7 @@ nodeunitShim({ }, }); - expect(stackB).toMatch({ + expect(stackB).toMatchTemplate({ 'Resources': { 'UserWhoNeedsAccessF8959C3D': { 'Type': 'AWS::IAM::User', @@ -1559,10 +1559,10 @@ nodeunitShim({ }, }); - test.done(); - }, - 'in different accounts'(test: Test) { + }); + + test('in different accounts', () => { // given const stackA = new cdk.Stack(undefined, 'StackA', { env: { account: '123456789012' } }); const bucketFromStackA = new s3.Bucket(stackA, 'MyBucket', { @@ -1579,7 +1579,7 @@ nodeunitShim({ bucketFromStackA.grantRead(roleFromStackB); // then - expect(stackA).to(haveResourceLike('AWS::S3::BucketPolicy', { + expect(stackA).toHaveResourceLike('AWS::S3::BucketPolicy', { 'PolicyDocument': { 'Statement': [ { @@ -1606,9 +1606,9 @@ nodeunitShim({ }, ], }, - })); + }); - expect(stackB).to(haveResourceLike('AWS::IAM::Policy', { + expect(stackB).toHaveResourceLike('AWS::IAM::Policy', { 'PolicyDocument': { 'Statement': [ { @@ -1647,12 +1647,12 @@ nodeunitShim({ }, ], }, - })); + }); - test.done(); - }, - 'in different accounts, with a KMS Key'(test: Test) { + }); + + test('in different accounts, with a KMS Key', () => { // given const stackA = new cdk.Stack(undefined, 'StackA', { env: { account: '123456789012' } }); const key = new kms.Key(stackA, 'MyKey'); @@ -1672,7 +1672,7 @@ nodeunitShim({ bucketFromStackA.grantRead(roleFromStackB); // then - expect(stackA).to(haveResourceLike('AWS::KMS::Key', { + expect(stackA).toHaveResourceLike('AWS::KMS::Key', { 'KeyPolicy': { 'Statement': [ { @@ -1701,9 +1701,9 @@ nodeunitShim({ }, ], }, - })); + }); - expect(stackB).to(haveResourceLike('AWS::IAM::Policy', { + expect(stackB).toHaveResourceLike('AWS::IAM::Policy', { 'PolicyDocument': { 'Statement': [ { @@ -1719,13 +1719,13 @@ nodeunitShim({ }, ], }, - })); + }); + - test.done(); - }, - }, + }); + }); - 'urlForObject returns a token with the S3 URL of the token'(test: Test) { + test('urlForObject returns a token with the S3 URL of the token', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket'); @@ -1733,7 +1733,7 @@ nodeunitShim({ new cdk.CfnOutput(stack, 'MyFileURL', { value: bucket.urlForObject('my/file.txt') }); new cdk.CfnOutput(stack, 'YourFileURL', { value: bucket.urlForObject('/your/file.txt') }); // "/" is optional - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -1810,10 +1810,10 @@ nodeunitShim({ }, }); - test.done(); - }, - 's3UrlForObject returns a token with the S3 URL of the token'(test: Test) { + }); + + test('s3UrlForObject returns a token with the S3 URL of the token', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket'); @@ -1821,7 +1821,7 @@ nodeunitShim({ new cdk.CfnOutput(stack, 'MyFileS3URL', { value: bucket.s3UrlForObject('my/file.txt') }); new cdk.CfnOutput(stack, 'YourFileS3URL', { value: bucket.s3UrlForObject('/your/file.txt') }); // "/" is optional - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -1874,11 +1874,11 @@ nodeunitShim({ }, }); - test.done(); - }, - 'grantPublicAccess': { - 'by default, grants s3:GetObject to all objects'(test: Test) { + }); + + describe('grantPublicAccess', () => { + test('by default, grants s3:GetObject to all objects', () => { // GIVEN const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'b'); @@ -1887,7 +1887,7 @@ nodeunitShim({ bucket.grantPublicAccess(); // THEN - expect(stack).to(haveResource('AWS::S3::BucketPolicy', { + expect(stack).toHaveResource('AWS::S3::BucketPolicy', { 'PolicyDocument': { 'Statement': [ { @@ -1899,11 +1899,11 @@ nodeunitShim({ ], 'Version': '2012-10-17', }, - })); - test.done(); - }, + }); - '"keyPrefix" can be used to only grant access to certain objects'(test: Test) { + }); + + test('"keyPrefix" can be used to only grant access to certain objects', () => { // GIVEN const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'b'); @@ -1912,7 +1912,7 @@ nodeunitShim({ bucket.grantPublicAccess('only/access/these/*'); // THEN - expect(stack).to(haveResource('AWS::S3::BucketPolicy', { + expect(stack).toHaveResource('AWS::S3::BucketPolicy', { 'PolicyDocument': { 'Statement': [ { @@ -1924,11 +1924,11 @@ nodeunitShim({ ], 'Version': '2012-10-17', }, - })); - test.done(); - }, + }); + + }); - '"allowedActions" can be used to specify actions explicitly'(test: Test) { + test('"allowedActions" can be used to specify actions explicitly', () => { // GIVEN const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'b'); @@ -1937,7 +1937,7 @@ nodeunitShim({ bucket.grantPublicAccess('*', 's3:GetObject', 's3:PutObject'); // THEN - expect(stack).to(haveResource('AWS::S3::BucketPolicy', { + expect(stack).toHaveResource('AWS::S3::BucketPolicy', { 'PolicyDocument': { 'Statement': [ { @@ -1949,11 +1949,11 @@ nodeunitShim({ ], 'Version': '2012-10-17', }, - })); - test.done(); - }, + }); - 'returns the PolicyStatement which can be then customized'(test: Test) { + }); + + test('returns the PolicyStatement which can be then customized', () => { // GIVEN const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'b'); @@ -1963,7 +1963,7 @@ nodeunitShim({ result.resourceStatement!.addCondition('IpAddress', { 'aws:SourceIp': '54.240.143.0/24' }); // THEN - expect(stack).to(haveResource('AWS::S3::BucketPolicy', { + expect(stack).toHaveResource('AWS::S3::BucketPolicy', { 'PolicyDocument': { 'Statement': [ { @@ -1978,11 +1978,11 @@ nodeunitShim({ ], 'Version': '2012-10-17', }, - })); - test.done(); - }, + }); + + }); - 'throws when blockPublicPolicy is set to true'(test: Test) { + test('throws when blockPublicPolicy is set to true', () => { // GIVEN const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'MyBucket', { @@ -1990,62 +1990,62 @@ nodeunitShim({ }); // THEN - test.throws(() => bucket.grantPublicAccess(), /blockPublicPolicy/); + expect(() => bucket.grantPublicAccess()).toThrow(/blockPublicPolicy/); - test.done(); - }, - }, - 'website configuration': { - 'only index doc'(test: Test) { + }); + }); + + describe('website configuration', () => { + test('only index doc', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'Website', { websiteIndexDocument: 'index2.html', }); - expect(stack).to(haveResource('AWS::S3::Bucket', { + expect(stack).toHaveResource('AWS::S3::Bucket', { WebsiteConfiguration: { IndexDocument: 'index2.html', }, - })); - test.done(); - }, - 'fails if only error doc is specified'(test: Test) { + }); + + }); + test('fails if only error doc is specified', () => { const stack = new cdk.Stack(); - test.throws(() => { + expect(() => { new s3.Bucket(stack, 'Website', { websiteErrorDocument: 'error.html', }); - }, /"websiteIndexDocument" is required if "websiteErrorDocument" is set/); - test.done(); - }, - 'error and index docs'(test: Test) { + }).toThrow(/"websiteIndexDocument" is required if "websiteErrorDocument" is set/); + + }); + test('error and index docs', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'Website', { websiteIndexDocument: 'index2.html', websiteErrorDocument: 'error.html', }); - expect(stack).to(haveResource('AWS::S3::Bucket', { + expect(stack).toHaveResource('AWS::S3::Bucket', { WebsiteConfiguration: { IndexDocument: 'index2.html', ErrorDocument: 'error.html', }, - })); - test.done(); - }, - 'exports the WebsiteURL'(test: Test) { + }); + + }); + test('exports the WebsiteURL', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'Website', { websiteIndexDocument: 'index.html', }); - test.deepEqual(stack.resolve(bucket.bucketWebsiteUrl), { 'Fn::GetAtt': ['Website32962D0B', 'WebsiteURL'] }); - test.done(); - }, - 'exports the WebsiteDomain'(test: Test) { + expect(stack.resolve(bucket.bucketWebsiteUrl)).toEqual({ 'Fn::GetAtt': ['Website32962D0B', 'WebsiteURL'] }); + + }); + test('exports the WebsiteDomain', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'Website', { websiteIndexDocument: 'index.html', }); - test.deepEqual(stack.resolve(bucket.bucketWebsiteDomainName), { + expect(stack.resolve(bucket.bucketWebsiteDomainName)).toEqual({ 'Fn::Select': [ 2, { @@ -2053,12 +2053,12 @@ nodeunitShim({ }, ], }); - test.done(); - }, - 'exports the WebsiteURL for imported buckets'(test: Test) { + + }); + test('exports the WebsiteURL for imported buckets', () => { const stack = new cdk.Stack(); const bucket = s3.Bucket.fromBucketName(stack, 'Website', 'my-test-bucket'); - test.deepEqual(stack.resolve(bucket.bucketWebsiteUrl), { + expect(stack.resolve(bucket.bucketWebsiteUrl)).toEqual({ 'Fn::Join': [ '', [ @@ -2069,7 +2069,7 @@ nodeunitShim({ ], ], }); - test.deepEqual(stack.resolve(bucket.bucketWebsiteDomainName), { + expect(stack.resolve(bucket.bucketWebsiteDomainName)).toEqual({ 'Fn::Join': [ '', [ @@ -2080,19 +2080,19 @@ nodeunitShim({ ], ], }); - test.done(); - }, - 'exports the WebsiteURL for imported buckets with url'(test: Test) { + + }); + test('exports the WebsiteURL for imported buckets with url', () => { const stack = new cdk.Stack(); const bucket = s3.Bucket.fromBucketAttributes(stack, 'Website', { bucketName: 'my-test-bucket', bucketWebsiteUrl: 'http://my-test-bucket.my-test.suffix', }); - test.deepEqual(stack.resolve(bucket.bucketWebsiteUrl), 'http://my-test-bucket.my-test.suffix'); - test.deepEqual(stack.resolve(bucket.bucketWebsiteDomainName), 'my-test-bucket.my-test.suffix'); - test.done(); - }, - 'adds RedirectAllRequestsTo property'(test: Test) { + expect(stack.resolve(bucket.bucketWebsiteUrl)).toEqual('http://my-test-bucket.my-test.suffix'); + expect(stack.resolve(bucket.bucketWebsiteDomainName)).toEqual('my-test-bucket.my-test.suffix'); + + }); + test('adds RedirectAllRequestsTo property', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'Website', { websiteRedirect: { @@ -2100,19 +2100,19 @@ nodeunitShim({ protocol: s3.RedirectProtocol.HTTPS, }, }); - expect(stack).to(haveResource('AWS::S3::Bucket', { + expect(stack).toHaveResource('AWS::S3::Bucket', { WebsiteConfiguration: { RedirectAllRequestsTo: { HostName: 'www.example.com', Protocol: 'https', }, }, - })); - test.done(); - }, - 'fails if websiteRedirect and websiteIndex and websiteError are specified'(test: Test) { + }); + + }); + test('fails if websiteRedirect and websiteIndex and websiteError are specified', () => { const stack = new cdk.Stack(); - test.throws(() => { + expect(() => { new s3.Bucket(stack, 'Website', { websiteIndexDocument: 'index.html', websiteErrorDocument: 'error.html', @@ -2120,22 +2120,22 @@ nodeunitShim({ hostName: 'www.example.com', }, }); - }, /"websiteIndexDocument", "websiteErrorDocument" and, "websiteRoutingRules" cannot be set if "websiteRedirect" is used/); - test.done(); - }, - 'fails if websiteRedirect and websiteRoutingRules are specified'(test: Test) { + }).toThrow(/"websiteIndexDocument", "websiteErrorDocument" and, "websiteRoutingRules" cannot be set if "websiteRedirect" is used/); + + }); + test('fails if websiteRedirect and websiteRoutingRules are specified', () => { const stack = new cdk.Stack(); - test.throws(() => { + expect(() => { new s3.Bucket(stack, 'Website', { websiteRoutingRules: [], websiteRedirect: { hostName: 'www.example.com', }, }); - }, /"websiteIndexDocument", "websiteErrorDocument" and, "websiteRoutingRules" cannot be set if "websiteRedirect" is used/); - test.done(); - }, - 'adds RedirectRules property'(test: Test) { + }).toThrow(/"websiteIndexDocument", "websiteErrorDocument" and, "websiteRoutingRules" cannot be set if "websiteRedirect" is used/); + + }); + test('adds RedirectRules property', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'Website', { websiteRoutingRules: [{ @@ -2149,7 +2149,7 @@ nodeunitShim({ }, }], }); - expect(stack).to(haveResource('AWS::S3::Bucket', { + expect(stack).toHaveResource('AWS::S3::Bucket', { WebsiteConfiguration: { RoutingRules: [{ RedirectRule: { @@ -2164,40 +2164,40 @@ nodeunitShim({ }, }], }, - })); - test.done(); - }, - 'fails if routingRule condition object is empty'(test: Test) { + }); + + }); + test('fails if routingRule condition object is empty', () => { const stack = new cdk.Stack(); - test.throws(() => { + expect(() => { new s3.Bucket(stack, 'Website', { websiteRoutingRules: [{ httpRedirectCode: '303', condition: {}, }], }); - }, /The condition property cannot be an empty object/); - test.done(); - }, - 'isWebsite set properly with': { - 'only index doc'(test: Test) { + }).toThrow(/The condition property cannot be an empty object/); + + }); + describe('isWebsite set properly with', () => { + test('only index doc', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'Website', { websiteIndexDocument: 'index2.html', }); - test.equal(bucket.isWebsite, true); - test.done(); - }, - 'error and index docs'(test: Test) { + expect(bucket.isWebsite).toEqual(true); + + }); + test('error and index docs', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'Website', { websiteIndexDocument: 'index2.html', websiteErrorDocument: 'error.html', }); - test.equal(bucket.isWebsite, true); - test.done(); - }, - 'redirects'(test: Test) { + expect(bucket.isWebsite).toEqual(true); + + }); + test('redirects', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'Website', { websiteRedirect: { @@ -2205,36 +2205,36 @@ nodeunitShim({ protocol: s3.RedirectProtocol.HTTPS, }, }); - test.equal(bucket.isWebsite, true); - test.done(); - }, - 'no website properties set'(test: Test) { + expect(bucket.isWebsite).toEqual(true); + + }); + test('no website properties set', () => { const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'Website'); - test.equal(bucket.isWebsite, false); - test.done(); - }, - 'imported website buckets'(test: Test) { + expect(bucket.isWebsite).toEqual(false); + + }); + test('imported website buckets', () => { const stack = new cdk.Stack(); const bucket = s3.Bucket.fromBucketAttributes(stack, 'Website', { bucketArn: 'arn:aws:s3:::my-bucket', isWebsite: true, }); - test.equal(bucket.isWebsite, true); - test.done(); - }, - 'imported buckets'(test: Test) { + expect(bucket.isWebsite).toEqual(true); + + }); + test('imported buckets', () => { const stack = new cdk.Stack(); const bucket = s3.Bucket.fromBucketAttributes(stack, 'NotWebsite', { bucketArn: 'arn:aws:s3:::my-bucket', }); - test.equal(bucket.isWebsite, false); - test.done(); - }, - }, - }, + expect(bucket.isWebsite).toEqual(false); - 'Bucket.fromBucketArn'(test: Test) { + }); + }); + }); + + test('Bucket.fromBucketArn', () => { // GIVEN const stack = new cdk.Stack(); @@ -2242,12 +2242,12 @@ nodeunitShim({ const bucket = s3.Bucket.fromBucketArn(stack, 'my-bucket', 'arn:aws:s3:::my_corporate_bucket'); // THEN - test.deepEqual(bucket.bucketName, 'my_corporate_bucket'); - test.deepEqual(bucket.bucketArn, 'arn:aws:s3:::my_corporate_bucket'); - test.done(); - }, + expect(bucket.bucketName).toEqual('my_corporate_bucket'); + expect(bucket.bucketArn).toEqual('arn:aws:s3:::my_corporate_bucket'); + + }); - 'Bucket.fromBucketName'(test: Test) { + test('Bucket.fromBucketName', () => { // GIVEN const stack = new cdk.Stack(); @@ -2255,24 +2255,24 @@ nodeunitShim({ const bucket = s3.Bucket.fromBucketName(stack, 'imported-bucket', 'my-bucket-name'); // THEN - test.deepEqual(bucket.bucketName, 'my-bucket-name'); - test.deepEqual(stack.resolve(bucket.bucketArn), { + expect(bucket.bucketName).toEqual('my-bucket-name'); + expect(stack.resolve(bucket.bucketArn)).toEqual({ 'Fn::Join': ['', ['arn:', { Ref: 'AWS::Partition' }, ':s3:::my-bucket-name']], }); - test.done(); - }, - 'if a kms key is specified, it implies bucket is encrypted with kms (dah)'(test: Test) { + }); + + test('if a kms key is specified, it implies bucket is encrypted with kms (dah)', () => { // GIVEN const stack = new cdk.Stack(); const key = new kms.Key(stack, 'k'); // THEN new s3.Bucket(stack, 'b', { encryptionKey: key }); - test.done(); - }, - 'Bucket with Server Access Logs'(test: Test) { + }); + + test('Bucket with Server Access Logs', () => { // GIVEN const stack = new cdk.Stack(); @@ -2283,18 +2283,18 @@ nodeunitShim({ }); // THEN - expect(stack).to(haveResource('AWS::S3::Bucket', { + expect(stack).toHaveResource('AWS::S3::Bucket', { LoggingConfiguration: { DestinationBucketName: { Ref: 'AccessLogs8B620ECA', }, }, - })); + }); + - test.done(); - }, + }); - 'Bucket with Server Access Logs with Prefix'(test: Test) { + test('Bucket with Server Access Logs with Prefix', () => { // GIVEN const stack = new cdk.Stack(); @@ -2306,19 +2306,19 @@ nodeunitShim({ }); // THEN - expect(stack).to(haveResource('AWS::S3::Bucket', { + expect(stack).toHaveResource('AWS::S3::Bucket', { LoggingConfiguration: { DestinationBucketName: { Ref: 'AccessLogs8B620ECA', }, LogFilePrefix: 'hello', }, - })); + }); + - test.done(); - }, + }); - 'Access log prefix given without bucket'(test: Test) { + test('Access log prefix given without bucket', () => { // GIVEN const stack = new cdk.Stack(); @@ -2327,15 +2327,15 @@ nodeunitShim({ }); // THEN - expect(stack).to(haveResource('AWS::S3::Bucket', { + expect(stack).toHaveResource('AWS::S3::Bucket', { LoggingConfiguration: { LogFilePrefix: 'hello', }, - })); - test.done(); - }, + }); - 'Bucket Allow Log delivery changes bucket Access Control should fail'(test: Test) { + }); + + test('Bucket Allow Log delivery changes bucket Access Control should fail', () => { // GIVEN const stack = new cdk.Stack(); @@ -2343,18 +2343,18 @@ nodeunitShim({ const accessLogBucket = new s3.Bucket(stack, 'AccessLogs', { accessControl: s3.BucketAccessControl.AUTHENTICATED_READ, }); - test.throws(() => + expect(() => new s3.Bucket(stack, 'MyBucket', { serverAccessLogsBucket: accessLogBucket, serverAccessLogsPrefix: 'hello', accessControl: s3.BucketAccessControl.AUTHENTICATED_READ, - }) - , /Cannot enable log delivery to this bucket because the bucket's ACL has been set and can't be changed/); + }), + ).toThrow(/Cannot enable log delivery to this bucket because the bucket's ACL has been set and can't be changed/); + - test.done(); - }, + }); - 'Defaults for an inventory bucket'(test: Test) { + test('Defaults for an inventory bucket', () => { // Given const stack = new cdk.Stack(); @@ -2369,7 +2369,7 @@ nodeunitShim({ ], }); - expect(stack).to(haveResourceLike('AWS::S3::Bucket', { + expect(stack).toHaveResourceLike('AWS::S3::Bucket', { InventoryConfigurations: [ { Enabled: true, @@ -2382,9 +2382,9 @@ nodeunitShim({ Id: 'MyBucketInventory0', }, ], - })); + }); - expect(stack).to(haveResourceLike('AWS::S3::BucketPolicy', { + expect(stack).toHaveResourceLike('AWS::S3::BucketPolicy', { Bucket: { Ref: 'InventoryBucketA869B8CB' }, PolicyDocument: { Statement: arrayWith(objectLike({ @@ -2400,17 +2400,17 @@ nodeunitShim({ ], })), }, - })); + }); + - test.done(); - }, + }); - 'Bucket with objectOwnership set to BUCKET_OWNER_PREFERRED'(test: Test) { + test('Bucket with objectOwnership set to BUCKET_OWNER_PREFERRED', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED, }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -2428,15 +2428,15 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'Bucket with objectOwnership set to OBJECT_WRITER'(test: Test) { + }); + + test('Bucket with objectOwnership set to OBJECT_WRITER', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { objectOwnership: s3.ObjectOwnership.OBJECT_WRITER, }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -2454,15 +2454,15 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'Bucket with objectOwnerships set to undefined'(test: Test) { + }); + + test('Bucket with objectOwnerships set to undefined', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { objectOwnership: undefined, }); - expect(stack).toMatch({ + expect(stack).toMatchTemplate({ 'Resources': { 'MyBucketF68F3FF0': { 'Type': 'AWS::S3::Bucket', @@ -2471,10 +2471,10 @@ nodeunitShim({ }, }, }); - test.done(); - }, - 'with autoDeleteObjects'(test: Test) { + }); + + test('with autoDeleteObjects', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { @@ -2482,12 +2482,12 @@ nodeunitShim({ autoDeleteObjects: true, }); - expect(stack).to(haveResource('AWS::S3::Bucket', { + expect(stack).toHaveResource('AWS::S3::Bucket', { UpdateReplacePolicy: 'Delete', DeletionPolicy: 'Delete', - }, ResourcePart.CompleteDefinition)); + }, ResourcePart.CompleteDefinition); - expect(stack).to(haveResource('AWS::S3::BucketPolicy', { + expect(stack).toHaveResource('AWS::S3::BucketPolicy', { Bucket: { Ref: 'MyBucketF68F3FF0', }, @@ -2535,9 +2535,9 @@ nodeunitShim({ ], 'Version': '2012-10-17', }, - })); + }); - expect(stack).to(haveResource('Custom::S3AutoDeleteObjects', { + expect(stack).toHaveResource('Custom::S3AutoDeleteObjects', { 'Properties': { 'ServiceToken': { 'Fn::GetAtt': [ @@ -2552,12 +2552,12 @@ nodeunitShim({ 'DependsOn': [ 'MyBucketPolicyE7FBAC7B', ], - }, ResourcePart.CompleteDefinition)); + }, ResourcePart.CompleteDefinition); + - test.done(); - }, + }); - 'with autoDeleteObjects on multiple buckets'(test: Test) { + test('with autoDeleteObjects on multiple buckets', () => { const stack = new cdk.Stack(); new s3.Bucket(stack, 'Bucket1', { @@ -2570,18 +2570,18 @@ nodeunitShim({ autoDeleteObjects: true, }); - expect(stack).to(countResources('AWS::Lambda::Function', 1)); + expect(stack).toCountResources('AWS::Lambda::Function', 1); - test.done(); - }, - 'autoDeleteObjects throws if RemovalPolicy is not DESTROY'(test: Test) { + }); + + test('autoDeleteObjects throws if RemovalPolicy is not DESTROY', () => { const stack = new cdk.Stack(); - test.throws(() => new s3.Bucket(stack, 'MyBucket', { + expect(() => new s3.Bucket(stack, 'MyBucket', { autoDeleteObjects: true, - }), /Cannot use \'autoDeleteObjects\' property on a bucket without setting removal policy to \'DESTROY\'/); + })).toThrow(/Cannot use \'autoDeleteObjects\' property on a bucket without setting removal policy to \'DESTROY\'/); + - test.done(); - }, + }); }); diff --git a/packages/@aws-cdk/aws-stepfunctions/README.md b/packages/@aws-cdk/aws-stepfunctions/README.md index 1fb164cc6e8e2..ae6635358d0ec 100644 --- a/packages/@aws-cdk/aws-stepfunctions/README.md +++ b/packages/@aws-cdk/aws-stepfunctions/README.md @@ -84,9 +84,9 @@ definition. The definition is specified by its start state, and encompasses all states reachable from the start state: ```ts -const startState = new stepfunctions.Pass(this, 'StartState'); +const startState = new sfn.Pass(this, 'StartState'); -new stepfunctions.StateMachine(this, 'StateMachine', { +new sfn.StateMachine(this, 'StateMachine', { definition: startState }); ``` @@ -138,8 +138,8 @@ will be passed as the state's output. ```ts // Makes the current JSON state { ..., "subObject": { "hello": "world" } } -const pass = new stepfunctions.Pass(this, 'Add Hello World', { - result: stepfunctions.Result.fromObject({ hello: 'world' }), +const pass = new sfn.Pass(this, 'Add Hello World', { + result: sfn.Result.fromObject({ hello: 'world' }), resultPath: '$.subObject', }); @@ -154,9 +154,9 @@ The following example filters the `greeting` field from the state input and also injects a field called `otherData`. ```ts -const pass = new stepfunctions.Pass(this, 'Filter input and inject data', { +const pass = new sfn.Pass(this, 'Filter input and inject data', { parameters: { // input to the pass state - input: stepfunctions.JsonPath.stringAt('$.input.greeting'), + input: sfn.JsonPath.stringAt('$.input.greeting'), otherData: 'some-extra-stuff' }, }); @@ -177,8 +177,8 @@ state. ```ts // Wait until it's the time mentioned in the the state object's "triggerTime" // field. -const wait = new stepfunctions.Wait(this, 'Wait For Trigger Time', { - time: stepfunctions.WaitTime.timestampPath('$.triggerTime'), +const wait = new sfn.Wait(this, 'Wait For Trigger Time', { + time: sfn.WaitTime.timestampPath('$.triggerTime'), }); // Set the next state @@ -191,11 +191,11 @@ A `Choice` state can take a different path through the workflow based on the values in the execution's JSON state: ```ts -const choice = new stepfunctions.Choice(this, 'Did it work?'); +const choice = new sfn.Choice(this, 'Did it work?'); // Add conditions with .when() -choice.when(stepfunctions.Condition.stringEqual('$.status', 'SUCCESS'), successState); -choice.when(stepfunctions.Condition.numberGreaterThan('$.attempts', 5), failureState); +choice.when(sfn.Condition.stringEquals('$.status', 'SUCCESS'), successState); +choice.when(sfn.Condition.numberGreaterThan('$.attempts', 5), failureState); // Use .otherwise() to indicate what should be done if none of the conditions match choice.otherwise(tryAgainState); @@ -206,9 +206,9 @@ all branches come together and continuing as one (similar to how an `if ... then ... else` works in a programming language), use the `.afterwards()` method: ```ts -const choice = new stepfunctions.Choice(this, 'What color is it?'); -choice.when(stepfunctions.Condition.stringEqual('$.color', 'BLUE'), handleBlueItem); -choice.when(stepfunctions.Condition.stringEqual('$.color', 'RED'), handleRedItem); +const choice = new sfn.Choice(this, 'What color is it?'); +choice.when(sfn.Condition.stringEquals('$.color', 'BLUE'), handleBlueItem); +choice.when(sfn.Condition.stringEquals('$.color', 'RED'), handleRedItem); choice.otherwise(handleOtherItemColor); // Use .afterwards() to join all possible paths back together and continue @@ -275,7 +275,7 @@ A `Parallel` state executes one or more subworkflows in parallel. It can also be used to catch and recover from errors in subworkflows. ```ts -const parallel = new stepfunctions.Parallel(this, 'Do the work in parallel'); +const parallel = new sfn.Parallel(this, 'Do the work in parallel'); // Add branches to be executed in parallel parallel.branch(shipItem); @@ -298,7 +298,7 @@ Reaching a `Succeed` state terminates the state machine execution with a succesful status. ```ts -const success = new stepfunctions.Succeed(this, 'We did it!'); +const success = new sfn.Succeed(this, 'We did it!'); ``` ### Fail @@ -308,7 +308,7 @@ failure status. The fail state should report the reason for the failure. Failures can be caught by encompassing `Parallel` states. ```ts -const success = new stepfunctions.Fail(this, 'Fail', { +const success = new sfn.Fail(this, 'Fail', { error: 'WorkflowFailure', cause: "Something went wrong" }); @@ -323,11 +323,11 @@ While the `Parallel` state executes multiple branches of steps using the same in execute the same steps for multiple entries of an array in the state input. ```ts -const map = new stepfunctions.Map(this, 'Map State', { +const map = new sfn.Map(this, 'Map State', { maxConcurrency: 1, - itemsPath: stepfunctions.JsonPath.stringAt('$.inputForMap') + itemsPath: sfn.JsonPath.stringAt('$.inputForMap') }); -map.iterator(new stepfunctions.Pass(this, 'Pass State')); +map.iterator(new sfn.Pass(this, 'Pass State')); ``` ### Custom State @@ -420,7 +420,7 @@ const definition = step1 .branch(step9.next(step10))) .next(finish); -new stepfunctions.StateMachine(this, 'StateMachine', { +new sfn.StateMachine(this, 'StateMachine', { definition, }); ``` @@ -429,14 +429,13 @@ If you don't like the visual look of starting a chain directly off the first step, you can use `Chain.start`: ```ts -const definition = stepfunctions.Chain +const definition = sfn.Chain .start(step1) .next(step2) .next(step3) // ... ``` - ## State Machine Fragments It is possible to define reusable (or abstracted) mini-state machines by @@ -461,16 +460,16 @@ interface MyJobProps { jobFlavor: string; } -class MyJob extends stepfunctions.StateMachineFragment { - public readonly startState: State; - public readonly endStates: INextable[]; +class MyJob extends sfn.StateMachineFragment { + public readonly startState: sfn.State; + public readonly endStates: sfn.INextable[]; constructor(parent: cdk.Construct, id: string, props: MyJobProps) { super(parent, id); - const first = new stepfunctions.Task(this, 'First', { ... }); + const first = new sfn.Task(this, 'First', { ... }); // ... - const last = new stepfunctions.Task(this, 'Last', { ... }); + const last = new sfn.Task(this, 'Last', { ... }); this.startState = first; this.endStates = [last]; @@ -478,7 +477,7 @@ class MyJob extends stepfunctions.StateMachineFragment { } // Do 3 different variants of MyJob in parallel -new stepfunctions.Parallel(this, 'All jobs') +new sfn.Parallel(this, 'All jobs') .branch(new MyJob(this, 'Quick', { jobFlavor: 'quick' }).prefixStates()) .branch(new MyJob(this, 'Medium', { jobFlavor: 'medium' }).prefixStates()) .branch(new MyJob(this, 'Slow', { jobFlavor: 'slow' }).prefixStates()); @@ -500,7 +499,7 @@ You need the ARN to do so, so if you use Activities be sure to pass the Activity ARN into your worker pool: ```ts -const activity = new stepfunctions.Activity(this, 'Activity'); +const activity = new sfn.Activity(this, 'Activity'); // Read this CloudFormation Output from your application and use it to poll for work on // the activity. @@ -512,7 +511,7 @@ new cdk.CfnOutput(this, 'ActivityArn', { value: activity.activityArn }); Granting IAM permissions to an activity can be achieved by calling the `grant(principal, actions)` API: ```ts -const activity = new stepfunctions.Activity(this, 'Activity'); +const activity = new sfn.Activity(this, 'Activity'); const role = new iam.Role(stack, 'Role', { assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), @@ -564,11 +563,11 @@ destination LogGroup: ```ts const logGroup = new logs.LogGroup(stack, 'MyLogGroup'); -new stepfunctions.StateMachine(stack, 'MyStateMachine', { - definition: stepfunctions.Chain.start(new stepfunctions.Pass(stack, 'Pass')), +new sfn.StateMachine(stack, 'MyStateMachine', { + definition: sfn.Chain.start(new sfn.Pass(stack, 'Pass')), logs: { destination: logGroup, - level: stepfunctions.LogLevel.ALL, + level: sfn.LogLevel.ALL, } }); ``` @@ -580,8 +579,8 @@ Enable X-Ray tracing for StateMachine: ```ts const logGroup = new logs.LogGroup(stack, 'MyLogGroup'); -new stepfunctions.StateMachine(stack, 'MyStateMachine', { - definition: stepfunctions.Chain.start(new stepfunctions.Pass(stack, 'Pass')), +new sfn.StateMachine(stack, 'MyStateMachine', { + definition: sfn.Chain.start(new sfn.Pass(stack, 'Pass')), tracingEnabled: true }); ``` diff --git a/packages/@aws-cdk/core/README.md b/packages/@aws-cdk/core/README.md index 8c5ce270d8dc1..880aea79a55fc 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -92,6 +92,65 @@ nested stack and referenced using `Fn::GetAtt "Outputs.Xxx"` from the parent. Nested stacks also support the use of Docker image and file assets. +## Accessing resources in a different stack + +You can access resources in a different stack, as long as they are in the +same account and AWS Region. The following example defines the stack `stack1`, +which defines an Amazon S3 bucket. Then it defines a second stack, `stack2`, +which takes the bucket from stack1 as a constructor property. + +```ts +const prod = { account: '123456789012', region: 'us-east-1' }; + +const stack1 = new StackThatProvidesABucket(app, 'Stack1' , { env: prod }); + +// stack2 will take a property { bucket: IBucket } +const stack2 = new StackThatExpectsABucket(app, 'Stack2', { + bucket: stack1.bucket, + env: prod +}); +``` + +If the AWS CDK determines that the resource is in the same account and +Region, but in a different stack, it automatically synthesizes AWS +CloudFormation +[Exports](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-stack-exports.html) +in the producing stack and an +[Fn::ImportValue](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-importvalue.html) +in the consuming stack to transfer that information from one stack to the +other. + +### Removing automatic cross-stack references + +The automatic references created by CDK when you use resources across stacks +are convenient, but may block your deployments if you want to remove the +resources that are referenced in this way. You will see an error like: + +```text +Export Stack1:ExportsOutputFnGetAtt-****** cannot be deleted as it is in use by Stack1 +``` + +Let's say there is a Bucket in the `stack1`, and the `stack2` references its +`bucket.bucketName`. You now want to remove the bucket and run into the error above. + +It's not safe to remove `stack1.bucket` while `stack2` is still using it, so +unblocking yourself from this is a two-step process. This is how it works: + +DEPLOYMENT 1: break the relationship + +- Make sure `stack2` no longer references `bucket.bucketName` (maybe the consumer + stack now uses its own bucket, or it writes to an AWS DynamoDB table, or maybe you just + remove the Lambda Function altogether). +- In the `stack1` class, call `this.exportAttribute(this.bucket.bucketName)`. This + will make sure the CloudFormation Export continues to exist while the relationship + between the two stacks is being broken. +- Deploy (this will effectively only change the `stack2`, but it's safe to deploy both). + +DEPLOYMENT 2: remove the resource + +- You are now free to remove the `bucket` resource from `stack1`. +- Don't forget to remove the `exportAttribute()` call as well. +- Deploy again (this time only the `stack1` will be changed -- the bucket will be deleted). ## Durations diff --git a/packages/@aws-cdk/core/lib/private/refs.ts b/packages/@aws-cdk/core/lib/private/refs.ts index 46d44563b4a96..27618d6776f21 100644 --- a/packages/@aws-cdk/core/lib/private/refs.ts +++ b/packages/@aws-cdk/core/lib/private/refs.ts @@ -1,22 +1,19 @@ // ---------------------------------------------------- // CROSS REFERENCES // ---------------------------------------------------- -import * as cxapi from '@aws-cdk/cx-api'; import { CfnElement } from '../cfn-element'; import { CfnOutput } from '../cfn-output'; import { CfnParameter } from '../cfn-parameter'; -import { Construct, IConstruct } from '../construct-compat'; -import { FeatureFlags } from '../feature-flags'; +import { IConstruct } from '../construct-compat'; import { Names } from '../names'; import { Reference } from '../reference'; import { IResolvable } from '../resolvable'; import { Stack } from '../stack'; -import { Token } from '../token'; +import { Token, Tokenization } from '../token'; import { CfnReference } from './cfn-reference'; import { Intrinsic } from './intrinsic'; import { findTokens } from './resolve'; -import { makeUniqueId } from './uniqueid'; /** * This is called from the App level to resolve all references defined. Each @@ -167,55 +164,10 @@ function findAllReferences(root: IConstruct) { function createImportValue(reference: Reference): Intrinsic { const exportingStack = Stack.of(reference.target); - // Ensure a singleton "Exports" scoping Construct - // This mostly exists to trigger LogicalID munging, which would be - // disabled if we parented constructs directly under Stack. - // Also it nicely prevents likely construct name clashes - const exportsScope = getCreateExportsScope(exportingStack); + const importExpr = exportingStack.exportValue(reference); - // Ensure a singleton CfnOutput for this value - const resolved = exportingStack.resolve(reference); - const id = 'Output' + JSON.stringify(resolved); - const exportName = generateExportName(exportsScope, id); - - if (Token.isUnresolved(exportName)) { - throw new Error(`unresolved token in generated export name: ${JSON.stringify(exportingStack.resolve(exportName))}`); - } - - const output = exportsScope.node.tryFindChild(id) as CfnOutput; - if (!output) { - new CfnOutput(exportsScope, id, { value: Token.asString(reference), exportName }); - } - - // We want to return an actual FnImportValue Token here, but Fn.importValue() returns a 'string', - // so construct one in-place. - return new Intrinsic({ 'Fn::ImportValue': exportName }); -} - -function getCreateExportsScope(stack: Stack) { - const exportsName = 'Exports'; - let stackExports = stack.node.tryFindChild(exportsName) as Construct; - if (stackExports === undefined) { - stackExports = new Construct(stack, exportsName); - } - - return stackExports; -} - -function generateExportName(stackExports: Construct, id: string) { - const stackRelativeExports = FeatureFlags.of(stackExports).isEnabled(cxapi.STACK_RELATIVE_EXPORTS_CONTEXT); - const stack = Stack.of(stackExports); - - const components = [ - ...stackExports.node.scopes - .slice(stackRelativeExports ? stack.node.scopes.length : 2) - .map(c => c.node.id), - id, - ]; - const prefix = stack.stackName ? stack.stackName + ':' : ''; - const localPart = makeUniqueId(components); - const maxLength = 255; - return prefix + localPart.slice(Math.max(0, localPart.length - maxLength + prefix.length)); + // I happen to know this returns a Fn.importValue() which implements Intrinsic. + return Tokenization.reverseCompleteString(importExpr) as Intrinsic; } // ------------------------------------------------------------------------------------------------ @@ -262,6 +214,25 @@ function createNestedStackOutput(producer: Stack, reference: Reference): CfnRefe return producer.nestedStackResource.getAtt(`Outputs.${output.logicalId}`) as CfnReference; } +/** + * Translate a Reference into a nested stack into a value in the parent stack + * + * Will create Outputs along the chain of Nested Stacks, and return the final `{ Fn::GetAtt }`. + */ +export function referenceNestedStackValueInParent(reference: Reference, targetStack: Stack) { + let currentStack = Stack.of(reference.target); + if (currentStack !== targetStack && !isNested(currentStack, targetStack)) { + throw new Error(`Referenced resource must be in stack '${targetStack.node.path}', got '${reference.target.node.path}'`); + } + + while (currentStack !== targetStack) { + reference = createNestedStackOutput(Stack.of(reference.target), reference); + currentStack = Stack.of(reference.target); + } + + return reference; +} + /** * @returns true if this stack is a direct or indirect parent of the nested * stack `nested`. @@ -282,4 +253,4 @@ function isNested(nested: Stack, parent: Stack): boolean { // recurse with the child's direct parent return isNested(nested.nestedStackParent, parent); -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts index 16ea69c1b2302..1a9a2ab8ee0cc 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts @@ -57,7 +57,6 @@ export class BootstraplessSynthesizer extends DefaultStackSynthesizer { this.emitStackArtifact(this.stack, session, { assumeRoleArn: this.deployRoleArn, cloudFormationExecutionRoleArn: this.cloudFormationExecutionRoleArn, - requiresBootstrapStackVersion: 1, }); } } diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index c6a8a56916f2f..53e7adfdc206e 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -775,6 +775,93 @@ export class Stack extends CoreConstruct implements ITaggable { } } + /** + * Create a CloudFormation Export for a value + * + * Returns a string representing the corresponding `Fn.importValue()` + * expression for this Export. You can control the name for the export by + * passing the `name` option. + * + * If you don't supply a value for `name`, the value you're exporting must be + * a Resource attribute (for example: `bucket.bucketName`) and it will be + * given the same name as the automatic cross-stack reference that would be created + * if you used the attribute in another Stack. + * + * One of the uses for this method is to *remove* the relationship between + * two Stacks established by automatic cross-stack references. It will + * temporarily ensure that the CloudFormation Export still exists while you + * remove the reference from the consuming stack. After that, you can remove + * the resource and the manual export. + * + * ## Example + * + * Here is how the process works. Let's say there are two stacks, + * `producerStack` and `consumerStack`, and `producerStack` has a bucket + * called `bucket`, which is referenced by `consumerStack` (perhaps because + * an AWS Lambda Function writes into it, or something like that). + * + * It is not safe to remove `producerStack.bucket` because as the bucket is being + * deleted, `consumerStack` might still be using it. + * + * Instead, the process takes two deployments: + * + * ### Deployment 1: break the relationship + * + * - Make sure `consumerStack` no longer references `bucket.bucketName` (maybe the consumer + * stack now uses its own bucket, or it writes to an AWS DynamoDB table, or maybe you just + * remove the Lambda Function altogether). + * - In the `ProducerStack` class, call `this.exportValue(this.bucket.bucketName)`. This + * will make sure the CloudFormation Export continues to exist while the relationship + * between the two stacks is being broken. + * - Deploy (this will effectively only change the `consumerStack`, but it's safe to deploy both). + * + * ### Deployment 2: remove the bucket resource + * + * - You are now free to remove the `bucket` resource from `producerStack`. + * - Don't forget to remove the `exportValue()` call as well. + * - Deploy again (this time only the `producerStack` will be changed -- the bucket will be deleted). + */ + public exportValue(exportedValue: any, options: ExportValueOptions = {}) { + if (options.name) { + new CfnOutput(this, `Export${options.name}`, { + value: exportedValue, + exportName: options.name, + }); + return Fn.importValue(options.name); + } + + const resolvable = Tokenization.reverse(exportedValue); + if (!resolvable || !Reference.isReference(resolvable)) { + throw new Error('exportValue: either supply \'name\' or make sure to export a resource attribute (like \'bucket.bucketName\')'); + } + + // "teleport" the value here, in case it comes from a nested stack. This will also + // ensure the value is from our own scope. + const exportable = referenceNestedStackValueInParent(resolvable, this); + + // Ensure a singleton "Exports" scoping Construct + // This mostly exists to trigger LogicalID munging, which would be + // disabled if we parented constructs directly under Stack. + // Also it nicely prevents likely construct name clashes + const exportsScope = getCreateExportsScope(this); + + // Ensure a singleton CfnOutput for this value + const resolved = this.resolve(exportable); + const id = 'Output' + JSON.stringify(resolved); + const exportName = generateExportName(exportsScope, id); + + if (Token.isUnresolved(exportName)) { + throw new Error(`unresolved token in generated export name: ${JSON.stringify(this.resolve(exportName))}`); + } + + const output = exportsScope.node.tryFindChild(id) as CfnOutput; + if (!output) { + new CfnOutput(exportsScope, id, { value: Token.asString(exportable), exportName }); + } + + return Fn.importValue(exportName); + } + /** * Returns the naming scheme used to allocate logical IDs. By default, uses * the `HashedAddressingScheme` but this method can be overridden to customize @@ -1143,18 +1230,58 @@ function makeStackName(components: string[]) { return makeUniqueId(components); } +function getCreateExportsScope(stack: Stack) { + const exportsName = 'Exports'; + let stackExports = stack.node.tryFindChild(exportsName) as CoreConstruct; + if (stackExports === undefined) { + stackExports = new CoreConstruct(stack, exportsName); + } + + return stackExports; +} + +function generateExportName(stackExports: CoreConstruct, id: string) { + const stackRelativeExports = FeatureFlags.of(stackExports).isEnabled(cxapi.STACK_RELATIVE_EXPORTS_CONTEXT); + const stack = Stack.of(stackExports); + + const components = [ + ...stackExports.node.scopes + .slice(stackRelativeExports ? stack.node.scopes.length : 2) + .map(c => c.node.id), + id, + ]; + const prefix = stack.stackName ? stack.stackName + ':' : ''; + const localPart = makeUniqueId(components); + const maxLength = 255; + return prefix + localPart.slice(Math.max(0, localPart.length - maxLength + prefix.length)); +} + +interface StackDependency { + stack: Stack; + reasons: string[]; +} + +/** + * Options for the `stack.exportValue()` method + */ +export interface ExportValueOptions { + /** + * The name of the export to create + * + * @default - A name is automatically chosen + */ + readonly name?: string; +} + // These imports have to be at the end to prevent circular imports +import { CfnOutput } from './cfn-output'; import { addDependency } from './deps'; +import { FileSystem } from './fs'; +import { Names } from './names'; import { Reference } from './reference'; import { IResolvable } from './resolvable'; import { DefaultStackSynthesizer, IStackSynthesizer, LegacyStackSynthesizer } from './stack-synthesizers'; import { Stage } from './stage'; import { ITaggable, TagManager } from './tag-manager'; -import { Token } from './token'; -import { FileSystem } from './fs'; -import { Names } from './names'; - -interface StackDependency { - stack: Stack; - reasons: string[]; -} +import { Token, Tokenization } from './token'; +import { referenceNestedStackValueInParent } from './private/refs'; diff --git a/packages/@aws-cdk/core/lib/token.ts b/packages/@aws-cdk/core/lib/token.ts index 5f98db7a4f11f..4bbcdb454f9bd 100644 --- a/packages/@aws-cdk/core/lib/token.ts +++ b/packages/@aws-cdk/core/lib/token.ts @@ -132,6 +132,19 @@ export class Tokenization { return TokenMap.instance().splitString(s); } + /** + * Un-encode a string which is either a complete encoded token, or doesn't contain tokens at all + * + * It's illegal for the string to be a concatenation of an encoded token and something else. + */ + public static reverseCompleteString(s: string): IResolvable | undefined { + const fragments = Tokenization.reverseString(s); + if (fragments.length !== 1) { + throw new Error(`Tokenzation.reverseCompleteString: argument must not be a concatentation, got '${s}'`); + } + return fragments.firstToken; + } + /** * Un-encode a Tokenized value from a number */ @@ -146,6 +159,19 @@ export class Tokenization { return TokenMap.instance().lookupList(l); } + /** + * Reverse any value into a Resolvable, if possible + * + * In case of a string, the string must not be a concatenation. + */ + public static reverse(x: any): IResolvable | undefined { + if (Tokenization.isResolvable(x)) { return x; } + if (typeof x === 'string') { return Tokenization.reverseCompleteString(x); } + if (Array.isArray(x)) { return Tokenization.reverseList(x); } + if (typeof x === 'number') { return Tokenization.reverseNumber(x); } + return undefined; + } + /** * Resolves an object by evaluating all tokens and removing any undefined or empty objects or arrays. * Values can only be primitives, arrays or tokens. Other objects (i.e. with methods) will be rejected. diff --git a/packages/@aws-cdk/core/test/stack.test.ts b/packages/@aws-cdk/core/test/stack.test.ts index ceaf6a7c96e99..8891dbaa138c6 100644 --- a/packages/@aws-cdk/core/test/stack.test.ts +++ b/packages/@aws-cdk/core/test/stack.test.ts @@ -2,7 +2,9 @@ import * as cxapi from '@aws-cdk/cx-api'; import { testFutureBehavior, testLegacyBehavior } from 'cdk-build-tools/lib/feature-flag'; import { App, CfnCondition, CfnInclude, CfnOutput, CfnParameter, - CfnResource, Construct, Lazy, ScopedAws, Stack, validateString, ISynthesisSession, Tags, LegacyStackSynthesizer, DefaultStackSynthesizer, + CfnResource, Construct, Lazy, ScopedAws, Stack, validateString, + ISynthesisSession, Tags, LegacyStackSynthesizer, DefaultStackSynthesizer, + NestedStack, } from '../lib'; import { Intrinsic } from '../lib/private/intrinsic'; import { resolveReferences } from '../lib/private/refs'; @@ -535,7 +537,69 @@ describe('stack', () => { expect(assembly.getStackArtifact(child1.artifactId).dependencies.map((x: { id: any; }) => x.id)).toEqual([]); expect(assembly.getStackArtifact(child2.artifactId).dependencies.map((x: { id: any; }) => x.id)).toEqual(['ParentChild18FAEF419']); + }); + + test('automatic cross-stack references and manual exports look the same', () => { + // GIVEN: automatic + const appA = new App(); + const producerA = new Stack(appA, 'Producer'); + const consumerA = new Stack(appA, 'Consumer'); + const resourceA = new CfnResource(producerA, 'Resource', { type: 'AWS::Resource' }); + new CfnOutput(consumerA, 'SomeOutput', { value: `${resourceA.getAtt('Att')}` }); + + // GIVEN: manual + const appM = new App(); + const producerM = new Stack(appM, 'Producer'); + const resourceM = new CfnResource(producerM, 'Resource', { type: 'AWS::Resource' }); + producerM.exportValue(resourceM.getAtt('Att')); + + // THEN - producers are the same + const templateA = appA.synth().getStackByName(producerA.stackName).template; + const templateM = appM.synth().getStackByName(producerM.stackName).template; + + expect(templateA).toEqual(templateM); + }); + + test('automatic cross-stack references and manual exports look the same: nested stack edition', () => { + // GIVEN: automatic + const appA = new App(); + const producerA = new Stack(appA, 'Producer'); + const nestedA = new NestedStack(producerA, 'Nestor'); + const resourceA = new CfnResource(nestedA, 'Resource', { type: 'AWS::Resource' }); + + const consumerA = new Stack(appA, 'Consumer'); + new CfnOutput(consumerA, 'SomeOutput', { value: `${resourceA.getAtt('Att')}` }); + + // GIVEN: manual + const appM = new App(); + const producerM = new Stack(appM, 'Producer'); + const nestedM = new NestedStack(producerM, 'Nestor'); + const resourceM = new CfnResource(nestedM, 'Resource', { type: 'AWS::Resource' }); + producerM.exportValue(resourceM.getAtt('Att')); + + // THEN - producers are the same + const templateA = appA.synth().getStackByName(producerA.stackName).template; + const templateM = appM.synth().getStackByName(producerM.stackName).template; + + expect(templateA).toEqual(templateM); + }); + + test('manual exports require a name if not supplying a resource attribute', () => { + const app = new App(); + const stack = new Stack(app, 'Stack'); + + expect(() => { + stack.exportValue('someValue'); + }).toThrow(/or make sure to export a resource attribute/); + }); + + test('manual exports can also just be used to create an export of anything', () => { + const app = new App(); + const stack = new Stack(app, 'Stack'); + + const importV = stack.exportValue('someValue', { name: 'MyExport' }); + expect(stack.resolve(importV)).toEqual({ 'Fn::ImportValue': 'MyExport' }); }); test('CfnSynthesisError is ignored when preparing cross references', () => { diff --git a/packages/aws-cdk/lib/api/cloudformation-deployments.ts b/packages/aws-cdk/lib/api/cloudformation-deployments.ts index 2eec90afdb790..3fe5fed118a76 100644 --- a/packages/aws-cdk/lib/api/cloudformation-deployments.ts +++ b/packages/aws-cdk/lib/api/cloudformation-deployments.ts @@ -282,10 +282,6 @@ export class CloudFormationDeployments { if (requiresBootstrapStackVersion === undefined) { return; } - if (!toolkitInfo.found) { - throw new Error(`${stackName}: publishing assets requires bootstrap stack version '${requiresBootstrapStackVersion}', no bootstrap stack found. Please run 'cdk bootstrap'.`); - } - try { await toolkitInfo.validateVersion(requiresBootstrapStackVersion, bootstrapStackVersionSsmParameter); } catch (e) { diff --git a/packages/aws-cdk/lib/api/cxapp/exec.ts b/packages/aws-cdk/lib/api/cxapp/exec.ts index 376ee542f4580..facaf24a3bfe0 100644 --- a/packages/aws-cdk/lib/api/cxapp/exec.ts +++ b/packages/aws-cdk/lib/api/cxapp/exec.ts @@ -92,7 +92,7 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom } async function exec() { - return new Promise((ok, fail) => { + return new Promise((ok, fail) => { // We use a slightly lower-level interface to: // // - Pass arguments in an array instead of a string, to get around a diff --git a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts index 7afea33be6a0b..7a4e29628564c 100644 --- a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts +++ b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts @@ -76,7 +76,7 @@ test('deployment fails if bootstrap stack is missing', async () => { requiresBootstrapStackVersion: 99, }, }), - })).rejects.toThrow(/no bootstrap stack found/); + })).rejects.toThrow(/requires a bootstrap stack/); }); test('deployment fails if bootstrap stack is too old', async () => { diff --git a/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts b/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts index e11e3b48bfcc0..c6ba2e53ec2d8 100644 --- a/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts +++ b/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts @@ -259,8 +259,9 @@ integTest('can deploy modern-synthesized stack even if bootstrap stack name is u // Deploy stack that uses file assets await fixture.cdkDeploy('lambda', { options: [ - // Next line explicitly commented to show that we don't pass it! - // '--toolkit-stack-name', bootstrapStackName, + // Explicity pass a name that's sure to not exist, otherwise the CLI might accidentally find a + // default bootstracp stack if that happens to be in the account already. + '--toolkit-stack-name', 'DefinitelyDoesNotExist', '--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`, '--context', '@aws-cdk/core:newStyleStackSynthesis=1', ], diff --git a/packages/aws-cdk/test/integ/uberpackage/cfn-include-app/example-template.json b/packages/aws-cdk/test/integ/uberpackage/cfn-include-app/example-template.json index 0385b58961413..8ad9310b8fd4d 100644 --- a/packages/aws-cdk/test/integ/uberpackage/cfn-include-app/example-template.json +++ b/packages/aws-cdk/test/integ/uberpackage/cfn-include-app/example-template.json @@ -1,5 +1,8 @@ { "Resources": { + "NoopHandle": { + "Type": "AWS::CloudFormation::WaitConditionHandle" + }, "Bucket": { "Type": "AWS::S3::Bucket", "Properties": { diff --git a/tools/eslint-plugin-cdk/test/rules/eslintrc.js b/tools/eslint-plugin-cdk/test/rules/eslintrc.js deleted file mode 100644 index c68b2066acce3..0000000000000 --- a/tools/eslint-plugin-cdk/test/rules/eslintrc.js +++ /dev/null @@ -1,12 +0,0 @@ -const path = require('path'); -const rulesDirPlugin = require('eslint-plugin-rulesdir'); -rulesDirPlugin.RULES_DIR = path.join(__dirname, '../../lib/rules'); - -module.exports = { - parser: '@typescript-eslint/parser', - plugins: ['rulesdir'], - rules: { - quotes: [ 'error', 'single', { avoidEscape: true }], - 'rulesdir/no-core-construct': [ 'error' ], - } -} diff --git a/tools/eslint-plugin-cdk/test/rules/fixtures.test.ts b/tools/eslint-plugin-cdk/test/rules/fixtures.test.ts new file mode 100644 index 0000000000000..b65670f43b429 --- /dev/null +++ b/tools/eslint-plugin-cdk/test/rules/fixtures.test.ts @@ -0,0 +1,63 @@ +import { ESLint } from 'eslint'; +import * as fs from 'fs-extra'; +import * as path from 'path'; + +const rulesDirPlugin = require('eslint-plugin-rulesdir'); +rulesDirPlugin.RULES_DIR = path.join(__dirname, '../../lib/rules'); + +let linter: ESLint; + +const outputRoot = path.join(process.cwd(), '.test-output'); +fs.mkdirpSync(outputRoot); + +const fixturesRoot = path.join(__dirname, 'fixtures'); + +fs.readdirSync(fixturesRoot).filter(f => fs.lstatSync(path.join(fixturesRoot, f)).isDirectory()).forEach(d => { + describe(d, () => { + const fixturesDir = path.join(fixturesRoot, d); + + beforeAll(() => { + linter = new ESLint({ + baseConfig: { + parser: '@typescript-eslint/parser', + }, + overrideConfigFile: path.join(fixturesDir, 'eslintrc.js'), + rulePaths: [ + path.join(__dirname, '../../lib/rules'), + ], + fix: true, + }); + }); + + const outputDir = path.join(outputRoot, d); + fs.mkdirpSync(outputDir); + + const fixtureFiles = fs.readdirSync(fixturesDir).filter(f => f.endsWith('.ts') && !f.endsWith('.expected.ts')); + + fixtureFiles.forEach(f => { + test(f, async (done) => { + const actualFile = await lintAndFix(path.join(fixturesDir, f), outputDir); + const expectedFile = path.join(fixturesDir, `${path.basename(f, '.ts')}.expected.ts`); + if (!fs.existsSync(expectedFile)) { + done.fail(`Expected file not found. Generated output at ${actualFile}`); + } + const actual = await fs.readFile(actualFile, { encoding: 'utf8' }); + const expected = await fs.readFile(expectedFile, { encoding: 'utf8' }); + if (actual !== expected) { + done.fail(`Linted file did not match expectations. Expected: ${expectedFile}. Actual: ${actualFile}`); + } + done(); + }); + }); + }); +}); + +async function lintAndFix(file: string, outputDir: string) { + const newPath = path.join(outputDir, path.basename(file)) + let result = await linter.lintFiles(file); + await ESLint.outputFixes(result.map(r => { + r.filePath = newPath; + return r; + })); + return newPath; +} diff --git a/tools/eslint-plugin-cdk/test/rules/fixtures/no-core-construct/eslintrc.js b/tools/eslint-plugin-cdk/test/rules/fixtures/no-core-construct/eslintrc.js new file mode 100644 index 0000000000000..3bd78e797f728 --- /dev/null +++ b/tools/eslint-plugin-cdk/test/rules/fixtures/no-core-construct/eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['rulesdir'], + rules: { + 'rulesdir/no-core-construct': [ 'error' ], + } +} \ No newline at end of file diff --git a/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/both-constructs.expected.ts b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/both-constructs.expected.ts new file mode 100644 index 0000000000000..20caf8244cd1b --- /dev/null +++ b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/both-constructs.expected.ts @@ -0,0 +1,9 @@ +import { Construct } from 'constructs' +import * as cdk from '@aws-cdk/core'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +let x: CoreConstruct; +let y: Construct; \ No newline at end of file diff --git a/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/both-constructs.ts b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/both-constructs.ts new file mode 100644 index 0000000000000..bd92a909af763 --- /dev/null +++ b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/both-constructs.ts @@ -0,0 +1,5 @@ +import { Construct } from 'constructs' +import * as cdk from '@aws-cdk/core'; + +let x: cdk.Construct; +let y: Construct; \ No newline at end of file diff --git a/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/eslintrc.js b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/eslintrc.js new file mode 100644 index 0000000000000..30de0b87a63df --- /dev/null +++ b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['rulesdir'], + rules: { + 'rulesdir/no-qualified-construct': [ 'error' ], + } +} \ No newline at end of file diff --git a/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-heritage.expected.ts b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-heritage.expected.ts new file mode 100644 index 0000000000000..6510d7dd5542f --- /dev/null +++ b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-heritage.expected.ts @@ -0,0 +1,8 @@ +import * as cdk from '@aws-cdk/core'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '@aws-cdk/core'; + +class MyConstruct extends Construct { +} \ No newline at end of file diff --git a/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-heritage.ts b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-heritage.ts new file mode 100644 index 0000000000000..3f8b877e32c2e --- /dev/null +++ b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-heritage.ts @@ -0,0 +1,4 @@ +import * as cdk from '@aws-cdk/core'; + +class MyConstruct extends cdk.Construct { +} \ No newline at end of file diff --git a/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-usage.expected.ts b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-usage.expected.ts new file mode 100644 index 0000000000000..bba5c3ae8aa50 --- /dev/null +++ b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-usage.expected.ts @@ -0,0 +1,7 @@ +import * as cdk from '@aws-cdk/core'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '@aws-cdk/core'; + +let x: Construct; \ No newline at end of file diff --git a/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-usage.ts b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-usage.ts new file mode 100644 index 0000000000000..d2ebc12dc01ff --- /dev/null +++ b/tools/eslint-plugin-cdk/test/rules/fixtures/no-qualified-construct/qualified-usage.ts @@ -0,0 +1,3 @@ +import * as cdk from '@aws-cdk/core'; + +let x: cdk.Construct; \ No newline at end of file diff --git a/tools/eslint-plugin-cdk/test/rules/no-core-construct.test.ts b/tools/eslint-plugin-cdk/test/rules/no-core-construct.test.ts deleted file mode 100644 index c2272cfd39353..0000000000000 --- a/tools/eslint-plugin-cdk/test/rules/no-core-construct.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ESLint } from 'eslint'; -import * as fs from 'fs-extra'; -import * as path from 'path'; - -const linter = new ESLint({ - overrideConfigFile: path.join(__dirname, 'eslintrc.js'), - rulePaths: [ - path.join(__dirname, '../../lib/rules'), - ], - fix: true, -}); - -const outputDir = path.join(process.cwd(), '.test-output'); -fs.mkdirpSync(outputDir); -const fixturesDir = path.join(__dirname, 'fixtures', 'no-core-construct'); - -describe('no-core-construct', () => { - const fixtureFiles = fs.readdirSync(fixturesDir).filter(f => f.endsWith('.ts') && !f.endsWith('.expected.ts')); - fixtureFiles.forEach(f => { - test(f, async (done) => { - const actualFile = await lintAndFix(path.join(fixturesDir, f)); - const expectedFile = path.join(fixturesDir, `${path.basename(f, '.ts')}.expected.ts`); - if (!fs.existsSync(expectedFile)) { - done.fail(`Expected file not found. Generated output at ${actualFile}`); - } - const actual = await fs.readFile(actualFile, { encoding: 'utf8' }); - const expected = await fs.readFile(expectedFile, { encoding: 'utf8' }); - if (actual !== expected) { - done.fail(`Linted file did not match expectations. Expected: ${expectedFile}. Actual: ${actualFile}`); - } - done(); - }); - }); -}); - -async function lintAndFix(file: string) { - const newPath = path.join(outputDir, path.basename(file)) - let result = await linter.lintFiles(file); - await ESLint.outputFixes(result.map(r => { - r.filePath = newPath; - return r; - })); - return newPath; -} diff --git a/tools/ubergen/bin/ubergen.ts b/tools/ubergen/bin/ubergen.ts index ea5c529f77f3e..9047fc28c371a 100644 --- a/tools/ubergen/bin/ubergen.ts +++ b/tools/ubergen/bin/ubergen.ts @@ -333,8 +333,11 @@ async function copyOrTransformFiles(from: string, to: string, libraries: readonl const cfnTypes2Classes: { [key: string]: string } = await fs.readJson(source); for (const cfnType of Object.keys(cfnTypes2Classes)) { const fqn = cfnTypes2Classes[cfnType]; - // replace @aws-cdk/aws- with /aws- - cfnTypes2Classes[cfnType] = fqn.replace('@aws-cdk', uberPackageJson.name); + // replace @aws-cdk/aws- with /aws-, + // except for @aws-cdk/core, which maps just to the name of the uberpackage + cfnTypes2Classes[cfnType] = fqn.startsWith('@aws-cdk/core.') + ? fqn.replace('@aws-cdk/core', uberPackageJson.name) + : fqn.replace('@aws-cdk', uberPackageJson.name); } await fs.writeJson(destination, cfnTypes2Classes, { spaces: 2 }); } else { diff --git a/version.v1.json b/version.v1.json index 816b141bbbf4a..f804856ae4ccf 100644 --- a/version.v1.json +++ b/version.v1.json @@ -1,3 +1,3 @@ { - "version": "1.87.1" + "version": "1.88.0" }