diff --git a/.github/workflows/pr-linter.yml b/.github/workflows/pr-linter.yml index bb8c6dd66943b..5702b254d4a0b 100644 --- a/.github/workflows/pr-linter.yml +++ b/.github/workflows/pr-linter.yml @@ -2,7 +2,15 @@ # https://github.com/actions/toolkit/blob/master/packages/github/src/context.ts name: PR Linter -on: pull_request +on: + pull_request: + types: + - labeled + - unlabeled + - edited + - opened + - synchronize + - reopened jobs: validate-pr: diff --git a/.github/workflows/yarn-upgrade.yml b/.github/workflows/yarn-upgrade.yml index 17617a75537d3..91daf74640bb8 100644 --- a/.github/workflows/yarn-upgrade.yml +++ b/.github/workflows/yarn-upgrade.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v2 - name: Set up Node - uses: actions/setup-node@v2.1.4 + uses: actions/setup-node@v2.1.5 with: node-version: 10 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd8c2c1991b1..fad4611dfad6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,88 @@ 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.92.0](https://github.com/aws/aws-cdk/compare/v1.91.0...v1.92.0) (2021-03-06) + +* **ecs-patterns**: the `desiredCount` property stored on the above constructs will be optional, allowing them to be undefined. This is enabled through the `@aws-cdk/aws-ecs-patterns:removeDefaultDesiredCount` feature flag. We would recommend all CDK users to set the `@aws-cdk/aws-ecs-patterns:removeDefaultDesiredCount` flag to `true` for all of their existing applications. + +### ⚠ BREAKING CHANGES TO EXPERIMENTAL FEATURES + +* **apigatewayv2:** `HttpApiMapping` (and related interfaces for `Attributed` and `Props`) has been renamed to `ApiMapping` +* **apigatewayv2:** `CommonStageOptions` has been renamed to `StageOptions` +* **apigatewayv2:** `HttpStage.fromStageName` has been removed in favour of `HttpStage.fromHttpStageAttributes` +* **apigatewayv2:** `DefaultDomainMappingOptions` has been removed in favour of `DomainMappingOptions` +* **apigatewayv2:** `HttpApiProps.defaultDomainMapping` has been changed from `DefaultDomainMappingOptions` to `DomainMappingOptions` +* **apigatewayv2:** `HttpApi.defaultStage` has been changed from `HttpStage` to `IStage` +* **apigatewayv2:** `IHttpApi.defaultStage` has been removed +* **aws-appsync:** RdsDataSource now takes a ServerlessCluster instead of a DatabaseCluster +* **aws-appsync:** graphqlapi.addRdsDataSource now takes databaseName as its fourth argument + +### Features + +* **apigateway:** integrate with aws services in a different region ([#13251](https://github.com/aws/aws-cdk/issues/13251)) ([d942699](https://github.com/aws/aws-cdk/commit/d9426996c07ff909993594ed91cfcf2b5761414b)), closes [#7009](https://github.com/aws/aws-cdk/issues/7009) +* **apigatewayv2:** websocket api ([#13031](https://github.com/aws/aws-cdk/issues/13031)) ([fe1c839](https://github.com/aws/aws-cdk/commit/fe1c8393e0840fb273c4a5f325cb3cebc784bf4b)), closes [#2872](https://github.com/aws/aws-cdk/issues/2872) +* **aws-appsync:** add databaseName to rdsDataSource ([#12575](https://github.com/aws/aws-cdk/issues/12575)) ([f92b65e](https://github.com/aws/aws-cdk/commit/f92b65e2a158f918d8f05132ed12a4bb85228997)), closes [#12572](https://github.com/aws/aws-cdk/issues/12572) +* **aws-events:** Event Bus target ([#12926](https://github.com/aws/aws-cdk/issues/12926)) ([ea91aa3](https://github.com/aws/aws-cdk/commit/ea91aa31db9e2f31c734ad6d7e1f64d5d432dfd4)), closes [#9473](https://github.com/aws/aws-cdk/issues/9473) +* **aws-route53-targets:** add global accelerator target to route53 alias targets ([#13407](https://github.com/aws/aws-cdk/issues/13407)) ([2672a55](https://github.com/aws/aws-cdk/commit/2672a55c393e5ce7dd9a230d921ec1be1a23e32a)), closes [#12839](https://github.com/aws/aws-cdk/issues/12839) +* **aws-s3:** adds s3 bucket AWS FSBP option ([#12804](https://github.com/aws/aws-cdk/issues/12804)) ([b9cdd52](https://github.com/aws/aws-cdk/commit/b9cdd52274eca55940c65b830939132d0e074365)), closes [#10969](https://github.com/aws/aws-cdk/issues/10969) +* **cfnspec:** cloudformation spec v28.0.0 ([#13101](https://github.com/aws/aws-cdk/issues/13101)) ([13c9859](https://github.com/aws/aws-cdk/commit/13c9859cc62b3d472ba1be84b12d478f61f02ec9)) +* **cfnspec:** cloudformation spec v29.0.0 ([#13249](https://github.com/aws/aws-cdk/issues/13249)) ([6318e26](https://github.com/aws/aws-cdk/commit/6318e2632297783bc8b5b2609bba096dd83a1113)) +* **cfnspec:** cloudformation spec v30.0.0 ([#13365](https://github.com/aws/aws-cdk/issues/13365)) ([ae0185d](https://github.com/aws/aws-cdk/commit/ae0185dd089e3bb7c5639ebc1bce3f95e126f71c)) +* **cli:** Configurable --change-set-name CLI flag ([#13024](https://github.com/aws/aws-cdk/issues/13024)) ([18184df](https://github.com/aws/aws-cdk/commit/18184df05f5b8478ef9cae1285e45e61a0833822)), closes [#11075](https://github.com/aws/aws-cdk/issues/11075) [#12683](https://github.com/aws/aws-cdk/pull/12683) +* **cloudwatch:** EC2 actions ([#13281](https://github.com/aws/aws-cdk/issues/13281)) ([319cfcd](https://github.com/aws/aws-cdk/commit/319cfcdaaf92e4e6edb8c2388d04dce0971aaf86)), closes [#13228](https://github.com/aws/aws-cdk/issues/13228) +* **cognito:** user pools - sign in with apple ([#13160](https://github.com/aws/aws-cdk/issues/13160)) ([b965589](https://github.com/aws/aws-cdk/commit/b965589358f4c281aea36404276f08128e6ff3db)) +* **core:** `description` parameter in the CustomResourceProvider ([#13275](https://github.com/aws/aws-cdk/issues/13275)) ([78831cf](https://github.com/aws/aws-cdk/commit/78831cf9dec0407e7d827711183ac47be070f480)), closes [#13277](https://github.com/aws/aws-cdk/issues/13277) [#13276](https://github.com/aws/aws-cdk/issues/13276) +* **core:** customize bundling output packaging ([#13152](https://github.com/aws/aws-cdk/issues/13152)) ([6eca979](https://github.com/aws/aws-cdk/commit/6eca979f65542f3e44461588d8220e8c0bf76a6e)) +* **ec2:** Add VPC endpoint for RDS ([#12497](https://github.com/aws/aws-cdk/issues/12497)) ([fc87574](https://github.com/aws/aws-cdk/commit/fc8757437c37a0947cced720ff363b8858850f72)), closes [#12402](https://github.com/aws/aws-cdk/issues/12402) +* **ecs:** add port mappings to containers with props ([#13262](https://github.com/aws/aws-cdk/issues/13262)) ([f511639](https://github.com/aws/aws-cdk/commit/f511639bba156f6edd15896a4dd8e27b07671ea1)), closes [#13261](https://github.com/aws/aws-cdk/issues/13261) +* **ecs:** allow selection of container and port for SRV service discovery records ([#12798](https://github.com/aws/aws-cdk/issues/12798)) ([a452bc3](https://github.com/aws/aws-cdk/commit/a452bc385640762a043392a717d49de29abcc64e)), closes [#12796](https://github.com/aws/aws-cdk/issues/12796) +* **ecs-patterns:** Add support for assignPublicIp for QueueProcessingFargateService ([#13122](https://github.com/aws/aws-cdk/issues/13122)) ([3fb4600](https://github.com/aws/aws-cdk/commit/3fb46001a7345cbefa6df70893999bcb304ed40d)), closes [#12815](https://github.com/aws/aws-cdk/issues/12815) +* **ecs-patterns:** remove default desiredCount to align with cfn behaviour (under feature flag) ([#13130](https://github.com/aws/aws-cdk/issues/13130)) ([a9caa45](https://github.com/aws/aws-cdk/commit/a9caa455b708e08f1cf2d366ac32892d4faa59b4)) +* **elasticloadbalancingv2:** Add support for application cookies ([#13142](https://github.com/aws/aws-cdk/issues/13142)) ([23385dd](https://github.com/aws/aws-cdk/commit/23385ddeb0decd227a0104d7b0aff06939acaad9)) +* **elbv2:** allow control of ingress rules on redirect listener ([#12768](https://github.com/aws/aws-cdk/issues/12768)) ([b7b441f](https://github.com/aws/aws-cdk/commit/b7b441f74a07d26fd8de23df84e7ab4663c89c0c)), closes [#12766](https://github.com/aws/aws-cdk/issues/12766) +* **events:** archive events ([#12060](https://github.com/aws/aws-cdk/issues/12060)) ([465cd9c](https://github.com/aws/aws-cdk/commit/465cd9c434acff74070ca6d33891e1481e253128)), closes [#11531](https://github.com/aws/aws-cdk/issues/11531) +* **events:** dead letter queue for Lambda Targets ([#11617](https://github.com/aws/aws-cdk/issues/11617)) ([1bb3650](https://github.com/aws/aws-cdk/commit/1bb3650c5dd2087b05793a5e903cdfb80fc5c1ad)), closes [#11612](https://github.com/aws/aws-cdk/issues/11612) +* **lambda:** code signing config ([#12656](https://github.com/aws/aws-cdk/issues/12656)) ([778ea27](https://github.com/aws/aws-cdk/commit/778ea2759a8a4504dc232eb6b1d77a38f8ee7aef)), closes [#12216](https://github.com/aws/aws-cdk/issues/12216) +* **lambda:** Code.fromDockerBuild ([#13318](https://github.com/aws/aws-cdk/issues/13318)) ([ad01099](https://github.com/aws/aws-cdk/commit/ad01099d5b8f835c3b87d7d20fd2dc1a5df2fd6f)), closes [#13273](https://github.com/aws/aws-cdk/issues/13273) +* **lambda:** Code.fromDockerBuildAsset ([#12258](https://github.com/aws/aws-cdk/issues/12258)) ([09afed5](https://github.com/aws/aws-cdk/commit/09afed5ca2b39919c1c84d200370d490110cd0d1)), closes [#11914](https://github.com/aws/aws-cdk/issues/11914) +* **neptune:** high level constructs for db clusters and instances ([#12763](https://github.com/aws/aws-cdk/issues/12763)) ([c366837](https://github.com/aws/aws-cdk/commit/c36683701d88eb0c53fdd2add66b10c47c05f56b)), closes [aws#12762](https://github.com/aws/aws/issues/12762) +* **stepfunctions-tasks:** add EKS call to SFN-tasks ([#12779](https://github.com/aws/aws-cdk/issues/12779)) ([296a10d](https://github.com/aws/aws-cdk/commit/296a10d76a9f6fc2a374d1a6461c460bcc3eeb79)) +* **synthetics:** Update CloudWatch Synthetics NodeJS runtimes ([#12907](https://github.com/aws/aws-cdk/issues/12907)) ([6aac3b6](https://github.com/aws/aws-cdk/commit/6aac3b6a9bb1586ee16e7a85ca657b544d0f8304)), closes [#12906](https://github.com/aws/aws-cdk/issues/12906) + + +### Bug Fixes + +* **appsync:** revert to allow resolver creation from data source ([#12973](https://github.com/aws/aws-cdk/issues/12973)) ([d35f032](https://github.com/aws/aws-cdk/commit/d35f03226d6d7fb5be246b4d3584ee9205b0ef2d)), closes [#12635](https://github.com/aws/aws-cdk/issues/12635) [#11522](https://github.com/aws/aws-cdk/issues/11522) +* **aws-appsync:** use serverlessCluster on rdsDataSource ([#13206](https://github.com/aws/aws-cdk/issues/13206)) ([45cf387](https://github.com/aws/aws-cdk/commit/45cf3873fb48d4043e7a22284d36695ea6bde6ef)), closes [#12567](https://github.com/aws/aws-cdk/issues/12567) +* **cfn-diff:** handle Fn::If inside policies and statements ([#12975](https://github.com/aws/aws-cdk/issues/12975)) ([daf4e47](https://github.com/aws/aws-cdk/commit/daf4e47a790ab99639e471f6792f22e3e4f8ee73)), closes [#12887](https://github.com/aws/aws-cdk/issues/12887) +* **cfn-include:** allow dynamic mappings to be used in Fn::FindInMap ([#13428](https://github.com/aws/aws-cdk/issues/13428)) ([623675d](https://github.com/aws/aws-cdk/commit/623675d2f8fb2786f23beb87994e687e8a7c6612)) +* **cloudfront:** cannot add two EdgeFunctions with same aliases ([#13324](https://github.com/aws/aws-cdk/issues/13324)) ([1f35351](https://github.com/aws/aws-cdk/commit/1f3535145d22b2b13ebbcbfe31a3bfd73519352d)), closes [#13237](https://github.com/aws/aws-cdk/issues/13237) +* **cloudwatch:** MathExpression period of <5 minutes is not respected ([#13078](https://github.com/aws/aws-cdk/issues/13078)) ([d9ee914](https://github.com/aws/aws-cdk/commit/d9ee91432918aa113f728abdd61295096ed1512f)), closes [#9156](https://github.com/aws/aws-cdk/issues/9156) +* **cloudwatch:** metric `label` not rendered into Alarms ([#13070](https://github.com/aws/aws-cdk/issues/13070)) ([cbcc712](https://github.com/aws/aws-cdk/commit/cbcc712e0c4c44c83c7f4d1e8a544bccfa26bb56)) +* **codebuild:** allow FILE_PATH webhook filter for BitBucket ([#13186](https://github.com/aws/aws-cdk/issues/13186)) ([cbed348](https://github.com/aws/aws-cdk/commit/cbed3488f03bdfba16f3950bda653535c8999db1)), closes [#13175](https://github.com/aws/aws-cdk/issues/13175) +* **core:** custom resource provider NODEJS_12 now looks like Lambda's NODEJS_12_X, add Node 14 ([#13301](https://github.com/aws/aws-cdk/issues/13301)) ([3413b2f](https://github.com/aws/aws-cdk/commit/3413b2f887596d11dfb53c0e99c2a1788095a2ad)) +* **core:** ENOTDIR invalid cwd on "cdk deploy" ([#13145](https://github.com/aws/aws-cdk/issues/13145)) ([cd7a3ed](https://github.com/aws/aws-cdk/commit/cd7a3ed333570a3b26446e1e3a054ca886cd3906)), closes [#12258](https://github.com/aws/aws-cdk/issues/12258) [#13076](https://github.com/aws/aws-cdk/issues/13076) [#13131](https://github.com/aws/aws-cdk/issues/13131) +* **custom-resources:** unable to use a resource attributes as dictionary keys in AwsCustomResource ([#13074](https://github.com/aws/aws-cdk/issues/13074)) ([3cb3104](https://github.com/aws/aws-cdk/commit/3cb31043a42b035f6dcd2a318836d4bfc4973151)), closes [#13063](https://github.com/aws/aws-cdk/issues/13063) +* **dynamodb:** replicas not created on table replacement ([#13300](https://github.com/aws/aws-cdk/issues/13300)) ([c7c424f](https://github.com/aws/aws-cdk/commit/c7c424fec42f1f14ab8bdc3011f5bdb602918aa3)), closes [#12332](https://github.com/aws/aws-cdk/issues/12332) +* **ec2:** NAT provider's default outbound rules cannot be disabled ([#12674](https://github.com/aws/aws-cdk/issues/12674)) ([664133a](https://github.com/aws/aws-cdk/commit/664133a35da2bd096a237971ce662f3dd38b297f)), closes [#12673](https://github.com/aws/aws-cdk/issues/12673) +* **ec2:** readme grammar ([#13180](https://github.com/aws/aws-cdk/issues/13180)) ([fe4f056](https://github.com/aws/aws-cdk/commit/fe4f05678c06d634d3fe9e1b608e444a57f67b9c)) +* **ec2:** Throw error on empty InitFile content ([#13009](https://github.com/aws/aws-cdk/issues/13009)) ([#13119](https://github.com/aws/aws-cdk/issues/13119)) ([81a78a3](https://github.com/aws/aws-cdk/commit/81a78a31408276ebb020e45b15ddca7a2c57ae50)) +* **ecr:** Allow referencing an EcrImage by digest instead of tag ([#13299](https://github.com/aws/aws-cdk/issues/13299)) ([266a621](https://github.com/aws/aws-cdk/commit/266a621abfc34c62ff1e26de9cb8cf0687588f89)), closes [#5082](https://github.com/aws/aws-cdk/issues/5082) +* **ecr:** Generate valid CloudFormation for imageScanOnPush ([#13420](https://github.com/aws/aws-cdk/issues/13420)) ([278fba5](https://github.com/aws/aws-cdk/commit/278fba5df4a3d785e49bdb57ccf88fd34bacacbb)), closes [#13418](https://github.com/aws/aws-cdk/issues/13418) +* **ecs:** services essential container exceptions thrown too soon ([#13240](https://github.com/aws/aws-cdk/issues/13240)) ([c174f6c](https://github.com/aws/aws-cdk/commit/c174f6c2f4dd909e07be34b66bd6b3a92d5e8484)), closes [#13239](https://github.com/aws/aws-cdk/issues/13239) +* **eks:** `KubectlProvider` creates un-necessary security group ([#13178](https://github.com/aws/aws-cdk/issues/13178)) ([c5e8b6d](https://github.com/aws/aws-cdk/commit/c5e8b6df1e5f0359d51d025edcc68508ab5daef1)) +* UserPool, Volume, ElasticSearch, FSx are now RETAIN by default ([#12920](https://github.com/aws/aws-cdk/issues/12920)) ([5a54741](https://github.com/aws/aws-cdk/commit/5a54741a414d3f8b7913163f4785759b984b41d8)), closes [#12563](https://github.com/aws/aws-cdk/issues/12563) +* **eks:** Deployment fails for the first deployment in an account ([#13103](https://github.com/aws/aws-cdk/issues/13103)) ([e042879](https://github.com/aws/aws-cdk/commit/e042879851f8ddd558d20941019c9a6692a1c2bf)), closes [#9027](https://github.com/aws/aws-cdk/issues/9027) +* incorrect peerDependency on "constructs" ([#13255](https://github.com/aws/aws-cdk/issues/13255)) ([17244af](https://github.com/aws/aws-cdk/commit/17244af0d181a28b908fa161250c5a3285521c53)) +* **elasticloadbalancingv2:** should allow more than 2 certificates ([#13332](https://github.com/aws/aws-cdk/issues/13332)) ([d3155e9](https://github.com/aws/aws-cdk/commit/d3155e97fd9331a4732396941ce4ad20613fe81c)), closes [#13150](https://github.com/aws/aws-cdk/issues/13150) +* **events:** cannot trigger multiple Lambdas from the same Rule ([#13260](https://github.com/aws/aws-cdk/issues/13260)) ([c8c1762](https://github.com/aws/aws-cdk/commit/c8c1762c213aad1062c3a0bc48b22b05c3a0a185)), closes [#13231](https://github.com/aws/aws-cdk/issues/13231) +* **events:** imported ECS Task Definition cannot be used as target ([#13293](https://github.com/aws/aws-cdk/issues/13293)) ([6f7cebd](https://github.com/aws/aws-cdk/commit/6f7cebdf61073cc1fb358fcac5f5b2156389cb81)), closes [#12811](https://github.com/aws/aws-cdk/issues/12811) +* **lambda-nodejs:** 'must use "outdir"' error with spaces in paths ([#13268](https://github.com/aws/aws-cdk/issues/13268)) ([09723f5](https://github.com/aws/aws-cdk/commit/09723f58ed3034fc2cb46316e6d798cb8f2bf96e)), closes [#13210](https://github.com/aws/aws-cdk/issues/13210) +* **lambda-nodejs:** invalid sample in documentation ([#12404](https://github.com/aws/aws-cdk/issues/12404)) ([520c263](https://github.com/aws/aws-cdk/commit/520c263ca3c6b0ea7d9c09c23e509a3373ee2b8a)) +* **lambda-nodejs:** paths with spaces break esbuild ([#13312](https://github.com/aws/aws-cdk/issues/13312)) ([f983fbb](https://github.com/aws/aws-cdk/commit/f983fbb474ecd6727b0c5a35333718cc55d78bf1)), closes [#13311](https://github.com/aws/aws-cdk/issues/13311) +* **lambda-python:** asset hash is non-deterministic ([#12984](https://github.com/aws/aws-cdk/issues/12984)) ([37debc0](https://github.com/aws/aws-cdk/commit/37debc0513c5174ca3d918fce94a138d5d34b586)), closes [#12770](https://github.com/aws/aws-cdk/issues/12770) [#12684](https://github.com/aws/aws-cdk/issues/12684) +* **stepfunctions:** `SageMakeUpdateEndpoint` adds insufficient permissions ([#13170](https://github.com/aws/aws-cdk/issues/13170)) ([6126e49](https://github.com/aws/aws-cdk/commit/6126e499e5ca22b5f751af4f4f05d74f696829f1)), closes [#11594](https://github.com/aws/aws-cdk/issues/11594) + ## [1.91.0](https://github.com/aws/aws-cdk/compare/v1.90.1...v1.91.0) (2021-02-23) diff --git a/pack.sh b/pack.sh index 727c6faaf63cd..b85c92f3e68a5 100755 --- a/pack.sh +++ b/pack.sh @@ -63,6 +63,16 @@ for dir in $(find packages -name dist | grep -v node_modules | grep -v run-wrapp rsync -a $dir/ ${distdir}/ done +# Record the dependency order of NPM packages into a file +# (This file will be opportunistically used during publishing) +# +# Manually sort 'aws-cdk' to the end, as the 'cdk init' command has implicit dependencies +# on other packages (that should not appear in 'package.json' and so +# there is no way to tell lerna about these). +for dir in $(lerna ls --toposort -p | grep -v packages/aws-cdk) $PWD/packages/aws-cdk; do + (cd $dir/dist/js && ls >> ${distdir}/js/npm-publish-order.txt) || true +done + # Remove a JSII aggregate POM that may have snuk past rm -rf dist/java/software/amazon/jsii diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/README.md b/packages/@aws-cdk/aws-apigatewayv2-integrations/README.md index 6dd9de9e4e475..cce77fd6398e6 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-integrations/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/README.md @@ -21,6 +21,8 @@ - [Lambda Integration](#lambda) - [HTTP Proxy Integration](#http-proxy) - [Private Integration](#private-integration) +- [WebSocket APIs](#websocket-apis) + - [Lambda WebSocket Integration](#lambda-websocket-integration) ## HTTP APIs @@ -146,3 +148,32 @@ const httpEndpoint = new HttpApi(stack, 'HttpProxyPrivateApi', { }), }); ``` + +## WebSocket APIs + +WebSocket integrations connect a route to backend resources. The following integrations are supported in the CDK. + +### Lambda WebSocket Integration + +Lambda integrations enable integrating a WebSocket API route with a Lambda function. When a client connects/disconnects +or sends message specific to a route, the API Gateway service forwards the request to the Lambda function + +The API Gateway service will invoke the lambda function with an event payload of a specific format. + +The following code configures a `sendmessage` route with a Lambda integration + +```ts +const webSocketApi = new WebSocketApi(stack, 'mywsapi'); +new WebSocketStage(stack, 'mystage', { + webSocketApi, + stageName: 'dev', + autoDeploy: true, +}); + +const messageHandler = new lambda.Function(stack, 'MessageHandler', {...}); +webSocketApi.addRoute('sendmessage', { + integration: new LambdaWebSocketIntegration({ + handler: connectHandler, + }), +}); +``` diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/http/lambda.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/http/lambda.ts index a962b268d7165..220d3dca57210 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/http/lambda.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/http/lambda.ts @@ -41,7 +41,7 @@ export class LambdaProxyIntegration implements IHttpRouteIntegration { principal: new ServicePrincipal('apigateway.amazonaws.com'), sourceArn: Stack.of(route).formatArn({ service: 'execute-api', - resource: route.httpApi.httpApiId, + resource: route.httpApi.apiId, resourceName: `*/*${route.path ?? ''}`, // empty string in the case of the catch-all route $default }), }); diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/index.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/index.ts index c202386ae710e..fd16aff655ff2 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/index.ts @@ -1 +1,2 @@ export * from './http'; +export * from './websocket'; diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/index.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/index.ts new file mode 100644 index 0000000000000..04a64da0c7540 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/index.ts @@ -0,0 +1 @@ +export * from './lambda'; diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/lambda.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/lambda.ts new file mode 100644 index 0000000000000..85e199a71c3d7 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/lambda.ts @@ -0,0 +1,44 @@ +import { + IWebSocketRouteIntegration, + WebSocketIntegrationType, + WebSocketRouteIntegrationBindOptions, + WebSocketRouteIntegrationConfig, +} from '@aws-cdk/aws-apigatewayv2'; +import { ServicePrincipal } from '@aws-cdk/aws-iam'; +import { IFunction } from '@aws-cdk/aws-lambda'; +import { Names, Stack } from '@aws-cdk/core'; + +/** + * Lambda WebSocket Integration props + */ +export interface LambdaWebSocketIntegrationProps { + /** + * The handler for this integration. + */ + readonly handler: IFunction +} + +/** + * Lambda WebSocket Integration + */ +export class LambdaWebSocketIntegration implements IWebSocketRouteIntegration { + constructor(private props: LambdaWebSocketIntegrationProps) {} + + bind(options: WebSocketRouteIntegrationBindOptions): WebSocketRouteIntegrationConfig { + const route = options.route; + this.props.handler.addPermission(`${Names.nodeUniqueId(route.node)}-Permission`, { + scope: options.scope, + principal: new ServicePrincipal('apigateway.amazonaws.com'), + sourceArn: Stack.of(route).formatArn({ + service: 'execute-api', + resource: route.webSocketApi.apiId, + resourceName: `*/*${route.routeKey}`, + }), + }); + + return { + type: WebSocketIntegrationType.AWS_PROXY, + uri: this.props.handler.functionArn, + }; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.expected.json b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.expected.json new file mode 100644 index 0000000000000..48bf164ada435 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.expected.json @@ -0,0 +1,534 @@ +{ + "Resources": { + "ConnectHandlerServiceRole7E4A9B1F": { + "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" + ] + ] + } + ] + } + }, + "ConnectHandler2FFD52D8": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: \"connected\" }; };" + }, + "Role": { + "Fn::GetAtt": [ + "ConnectHandlerServiceRole7E4A9B1F", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "ConnectHandlerServiceRole7E4A9B1F" + ] + }, + "DisconnectHandlerServiceRoleE54F14F9": { + "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" + ] + ] + } + ] + } + }, + "DisconnectHandlerCB7ED6F7": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: \"disconnected\" }; };" + }, + "Role": { + "Fn::GetAtt": [ + "DisconnectHandlerServiceRoleE54F14F9", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "DisconnectHandlerServiceRoleE54F14F9" + ] + }, + "DefaultHandlerServiceRoleDF00569C": { + "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" + ] + ] + } + ] + } + }, + "DefaultHandler604DF7AC": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: \"default\" }; };" + }, + "Role": { + "Fn::GetAtt": [ + "DefaultHandlerServiceRoleDF00569C", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "DefaultHandlerServiceRoleDF00569C" + ] + }, + "MessageHandlerServiceRoleDF05266A": { + "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" + ] + ] + } + ] + } + }, + "MessageHandlerDFBBCD6B": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: \"received\" }; };" + }, + "Role": { + "Fn::GetAtt": [ + "MessageHandlerServiceRoleDF05266A", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "MessageHandlerServiceRoleDF05266A" + ] + }, + "mywsapi32E6CE11": { + "Type": "AWS::ApiGatewayV2::Api", + "Properties": { + "Name": "mywsapi", + "ProtocolType": "WEBSOCKET", + "RouteSelectionExpression": "$request.body.action" + } + }, + "mywsapiconnectRouteWebSocketApiIntegmywsapiconnectRoute456CB290Permission2D0BC294": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ConnectHandler2FFD52D8", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "mywsapi32E6CE11" + }, + "/*/*$connect" + ] + ] + } + } + }, + "mywsapiconnectRouteWebSocketIntegration50b017444a02be00a0b575d123314581176017EE": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "ConnectHandler2FFD52D8", + "Arn" + ] + } + } + }, + "mywsapiconnectRoute45A0ED6A": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "RouteKey": "$connect", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "mywsapiconnectRouteWebSocketIntegration50b017444a02be00a0b575d123314581176017EE" + } + ] + ] + } + } + }, + "mywsapidisconnectRouteWebSocketApiIntegmywsapidisconnectRoute26B84CF3PermissionB3F6D0A8": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "DisconnectHandlerCB7ED6F7", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "mywsapi32E6CE11" + }, + "/*/*$disconnect" + ] + ] + } + } + }, + "mywsapidisconnectRouteWebSocketIntegrationcd3bacb451e82549501e141cc094d7ba1F7F68BC": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "DisconnectHandlerCB7ED6F7", + "Arn" + ] + } + } + }, + "mywsapidisconnectRoute421A8CB9": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "RouteKey": "$disconnect", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "mywsapidisconnectRouteWebSocketIntegrationcd3bacb451e82549501e141cc094d7ba1F7F68BC" + } + ] + ] + } + } + }, + "mywsapidefaultRouteWebSocketApiIntegmywsapidefaultRouteA13D926BPermission58B64FCE": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "DefaultHandler604DF7AC", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "mywsapi32E6CE11" + }, + "/*/*$default" + ] + ] + } + } + }, + "mywsapidefaultRouteWebSocketIntegration640ac0772c157aa8b9a56aa99adbd9d7A2B7F2FA": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "DefaultHandler604DF7AC", + "Arn" + ] + } + } + }, + "mywsapidefaultRouteE9382DF8": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "RouteKey": "$default", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "mywsapidefaultRouteWebSocketIntegration640ac0772c157aa8b9a56aa99adbd9d7A2B7F2FA" + } + ] + ] + } + } + }, + "mywsapisendmessageRouteWebSocketApiIntegmywsapisendmessageRoute8A775F3CPermission660FB575": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "MessageHandlerDFBBCD6B", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "mywsapi32E6CE11" + }, + "/*/*sendmessage" + ] + ] + } + } + }, + "mywsapisendmessageRouteWebSocketIntegrationcf58a195e318f43f52c4d9ac6d6d2430786B6471": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "MessageHandlerDFBBCD6B", + "Arn" + ] + } + } + }, + "mywsapisendmessageRouteAE873328": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "RouteKey": "sendmessage", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "mywsapisendmessageRouteWebSocketIntegrationcf58a195e318f43f52c4d9ac6d6d2430786B6471" + } + ] + ] + } + } + }, + "mystage114C35EC": { + "Type": "AWS::ApiGatewayV2::Stage", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "StageName": "dev", + "AutoDeploy": true + } + } + }, + "Outputs": { + "ApiEndpoint": { + "Value": { + "Fn::Join": [ + "", + [ + "wss://", + { + "Ref": "mywsapi32E6CE11" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/dev" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.ts new file mode 100644 index 0000000000000..01e25f906b0f8 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.ts @@ -0,0 +1,54 @@ +import { WebSocketApi, WebSocketStage } from '@aws-cdk/aws-apigatewayv2'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { App, CfnOutput, Stack } from '@aws-cdk/core'; +import { LambdaWebSocketIntegration } from '../../lib'; + +/* + * Stack verification steps: + * 1. Connect: 'wscat -c '. Should connect successfully and print event data containing connectionId in cloudwatch + * 2. SendMessage: '> {"action": "sendmessage", "data": "some-data"}'. Should send the message successfully and print the data in cloudwatch + * 3. Default: '> {"data": "some-data"}'. Should send the message successfully and print the data in cloudwatch + * 4. Disconnect: disconnect from the wscat. Should print event data containing connectionId in cloudwatch + */ + +const app = new App(); +const stack = new Stack(app, 'WebSocketApiInteg'); + +const connectHandler = new lambda.Function(stack, 'ConnectHandler', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: "connected" }; };'), +}); + +const disconnetHandler = new lambda.Function(stack, 'DisconnectHandler', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: "disconnected" }; };'), +}); + +const defaultHandler = new lambda.Function(stack, 'DefaultHandler', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: "default" }; };'), +}); + +const messageHandler = new lambda.Function(stack, 'MessageHandler', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: "received" }; };'), +}); + +const webSocketApi = new WebSocketApi(stack, 'mywsapi', { + connectRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: connectHandler }) }, + disconnectRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: disconnetHandler }) }, + defaultRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: defaultHandler }) }, +}); +const stage = new WebSocketStage(stack, 'mystage', { + webSocketApi, + stageName: 'dev', + autoDeploy: true, +}); + +webSocketApi.addRoute('sendmessage', { integration: new LambdaWebSocketIntegration({ handler: messageHandler }) }); + +new CfnOutput(stack, 'ApiEndpoint', { value: stage.url }); diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/lambda.test.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/lambda.test.ts new file mode 100644 index 0000000000000..5f431ca28fc49 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/lambda.test.ts @@ -0,0 +1,35 @@ +import '@aws-cdk/assert/jest'; +import { WebSocketApi } from '@aws-cdk/aws-apigatewayv2'; +import { Code, Function, Runtime } from '@aws-cdk/aws-lambda'; +import { Stack } from '@aws-cdk/core'; +import { LambdaWebSocketIntegration } from '../../lib'; + + +describe('LambdaWebSocketIntegration', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + const fooFn = fooFunction(stack, 'Fn'); + + // WHEN + new WebSocketApi(stack, 'Api', { + connectRouteOptions: { + integration: new LambdaWebSocketIntegration({ handler: fooFn }), + }, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Integration', { + IntegrationType: 'AWS_PROXY', + IntegrationUri: stack.resolve(fooFn.functionArn), + }); + }); +}); + +function fooFunction(stack: Stack, id: string) { + return new Function(stack, id, { + code: Code.fromInline('foo'), + runtime: Runtime.NODEJS_12_X, + handler: 'index.handler', + }); +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/README.md b/packages/@aws-cdk/aws-apigatewayv2/README.md index 4da900f271e8f..d8278a800a00f 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2/README.md @@ -7,7 +7,7 @@ Features | Stability -------------------------------------------|-------------------------------------------------------- CFN Resources | ![Stable](https://img.shields.io/badge/stable-success.svg?style=for-the-badge) Higher level constructs for HTTP APIs | ![Experimental](https://img.shields.io/badge/experimental-important.svg?style=for-the-badge) -Higher level constructs for Websocket APIs | ![Not Implemented](https://img.shields.io/badge/not--implemented-black.svg?style=for-the-badge) +Higher level constructs for Websocket APIs | ![Experimental](https://img.shields.io/badge/experimental-important.svg?style=for-the-badge) > **CFN Resources:** All classes with the `Cfn` prefix in this module ([CFN Resources]) are always > stable and safe to use. @@ -38,6 +38,7 @@ Higher level constructs for Websocket APIs | ![Not Implemented](https://img.shie - [Metrics](#metrics) - [VPC Link](#vpc-link) - [Private Integration](#private-integration) +- [WebSocket API](#websocket-api) ## Introduction @@ -230,7 +231,7 @@ API](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-acces These authorizers can be found in the [APIGatewayV2-Authorizers](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-apigatewayv2-authorizers-readme.html) constructs library. -## Metrics +### Metrics The API Gateway v2 service sends metrics around the performance of HTTP APIs to Amazon CloudWatch. These metrics can be referred to using the metric APIs available on the `HttpApi` construct. @@ -277,3 +278,46 @@ Amazon ECS container-based applications. Using private integrations, resources clients outside of the VPC. These integrations can be found in the [APIGatewayV2-Integrations](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-apigatewayv2-integrations-readme.html) constructs library. + +## WebSocket API + +A WebSocket API in API Gateway is a collection of WebSocket routes that are integrated with backend HTTP endpoints, +Lambda functions, or other AWS services. You can use API Gateway features to help you with all aspects of the API +lifecycle, from creation through monitoring your production APIs. [Read more](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-overview.html) + +WebSocket APIs have two fundamental concepts - Routes and Integrations. + +WebSocket APIs direct JSON messages to backend integrations based on configured routes. (Non-JSON messages are directed +to the configured `$default` route.) + +Integrations define how the WebSocket API behaves when a client reaches a specific Route. Learn more at +[Configuring integrations](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-integration-requests.html). + +Integrations are available in the `aws-apigatewayv2-integrations` module and more information is available in that module. + +To add the default WebSocket routes supported by API Gateway (`$connect`, `$disconnect` and `$default`), configure them as part of api props: + +```ts +const webSocketApi = new WebSocketApi(stack, 'mywsapi', { + connectRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: connectHandler }) }, + disconnectRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: disconnetHandler }) }, + defaultRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: defaultHandler }) }, +}); + +new WebSocketStage(stack, 'mystage', { + webSocketApi, + stageName: 'dev', + autoDeploy: true, +}); +``` + +To add any other route: + +```ts +const webSocketApi = new WebSocketApi(stack, 'mywsapi'); +webSocketApi.addRoute('sendmessage', { + integration: new LambdaWebSocketIntegration({ + handler: messageHandler, + }), +}); +``` diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts index d843b51f8b315..adbe3fe3efc2c 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts @@ -1,4 +1,10 @@ -import { IResource } from '@aws-cdk/core'; +import { IResource, Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnApiMapping, CfnApiMappingProps } from '../apigatewayv2.generated'; +import { HttpApi } from '../http/api'; +import { IApi } from './api'; +import { IDomainName } from './domain-name'; +import { IStage } from './stage'; /** * Represents an ApiGatewayV2 ApiMapping resource @@ -11,3 +17,109 @@ export interface IApiMapping extends IResource { */ readonly apiMappingId: string; } + +/** + * Properties used to create the ApiMapping resource + */ +export interface ApiMappingProps { + /** + * Api mapping key. The path where this stage should be mapped to on the domain + * @default - undefined for the root path mapping. + */ + readonly apiMappingKey?: string; + + /** + * The Api to which this mapping is applied + */ + readonly api: IApi; + + /** + * custom domain name of the mapping target + */ + readonly domainName: IDomainName; + + /** + * stage for the ApiMapping resource + * required for WebSocket API + * defaults to default stage of an HTTP API + * + * @default - Default stage of the passed API for HTTP API, required for WebSocket API + */ + readonly stage?: IStage; +} + +/** + * The attributes used to import existing ApiMapping + */ +export interface ApiMappingAttributes { + /** + * The API mapping ID + */ + readonly apiMappingId: string; +} + +/** + * Create a new API mapping for API Gateway API endpoint. + * @resource AWS::ApiGatewayV2::ApiMapping + */ +export class ApiMapping extends Resource implements IApiMapping { + /** + * import from API ID + */ + public static fromApiMappingAttributes(scope: Construct, id: string, attrs: ApiMappingAttributes): IApiMapping { + class Import extends Resource implements IApiMapping { + public readonly apiMappingId = attrs.apiMappingId; + } + return new Import(scope, id); + } + /** + * ID of the API Mapping + */ + public readonly apiMappingId: string; + + /** + * API Mapping key + */ + public readonly mappingKey?: string; + + constructor(scope: Construct, id: string, props: ApiMappingProps) { + super(scope, id); + + let stage = props.stage; + if (!stage) { + if (props.api instanceof HttpApi) { + if (props.api.defaultStage) { + stage = props.api.defaultStage; + } else { + throw new Error('stage is required if default stage is not available'); + } + } else { + throw new Error('stage is required for WebSocket API'); + } + } + + const paramRe = '^[a-zA-Z0-9]*[-_.+!,$]?[a-zA-Z0-9]*$'; + if (props.apiMappingKey && !new RegExp(paramRe).test(props.apiMappingKey)) { + throw new Error('An ApiMapping key may contain only letters, numbers and one of $-_.+!*\'(),'); + } + + if (props.apiMappingKey === '') { + throw new Error('empty string for api mapping key not allowed'); + } + + const apiMappingProps: CfnApiMappingProps = { + apiId: props.api.apiId, + domainName: props.domainName.name, + stage: stage.stageName, + apiMappingKey: props.apiMappingKey, + }; + + const resource = new CfnApiMapping(this, 'Resource', apiMappingProps); + + // ensure the dependency on the provided stage + this.node.addDependency(stage); + + this.apiMappingId = resource.ref; + this.mappingKey = props.apiMappingKey; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/api.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/api.ts new file mode 100644 index 0000000000000..c632e6309083d --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/api.ts @@ -0,0 +1,71 @@ +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import { IResource } from '@aws-cdk/core'; + +/** + * Represents a API Gateway HTTP/WebSocket API + */ +export interface IApi extends IResource { + /** + * The identifier of this API Gateway API. + * @attribute + */ + readonly apiId: string; + + /** + * The default endpoint for an API + * @attribute + */ + readonly apiEndpoint: string; + + /** + * Return the given named metric for this Api Gateway + * + * @default - average over 5 minutes + */ + metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the number of client-side errors captured in a given period. + * + * @default - sum over 5 minutes + */ + metricClientError(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the number of server-side errors captured in a given period. + * + * @default - sum over 5 minutes + */ + metricServerError(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the amount of data processed in bytes. + * + * @default - sum over 5 minutes + */ + metricDataProcessed(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the total number API requests in a given period. + * + * @default - SampleCount over 5 minutes + */ + metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the time between when API Gateway relays a request to the backend + * and when it receives a response from the backend. + * + * @default - no statistic + */ + metricIntegrationLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * The time between when API Gateway receives a request from a client + * and when it returns a response to the client. + * The latency includes the integration latency and other API Gateway overhead. + * + * @default - no statistic + */ + metricLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/base.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/base.ts new file mode 100644 index 0000000000000..542fcfb16f8f4 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/base.ts @@ -0,0 +1,111 @@ +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import { Resource } from '@aws-cdk/core'; +import { IntegrationCache } from '../private/integration-cache'; +import { IApi } from './api'; +import { ApiMapping } from './api-mapping'; +import { DomainMappingOptions, IStage } from './stage'; + +/** + * Base class representing an API + * @internal + */ +export abstract class ApiBase extends Resource implements IApi { + abstract readonly apiId: string; + abstract readonly apiEndpoint: string; + /** + * @internal + */ + protected _integrationCache: IntegrationCache = new IntegrationCache(); + + public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace: 'AWS/ApiGateway', + metricName, + dimensions: { ApiId: this.apiId }, + ...props, + }).attachTo(this); + } + + public metricClientError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('4XXError', { statistic: 'Sum', ...props }); + } + + public metricServerError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('5XXError', { statistic: 'Sum', ...props }); + } + + public metricDataProcessed(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('DataProcessed', { statistic: 'Sum', ...props }); + } + + public metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('Count', { statistic: 'SampleCount', ...props }); + } + + public metricIntegrationLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('IntegrationLatency', props); + } + + public metricLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('Latency', props); + } +} + + +/** + * Base class representing a Stage + * @internal + */ +export abstract class StageBase extends Resource implements IStage { + public abstract readonly stageName: string; + protected abstract readonly baseApi: IApi; + + /** + * The URL to this stage. + */ + abstract get url(): string; + + /** + * @internal + */ + protected _addDomainMapping(domainMapping: DomainMappingOptions) { + new ApiMapping(this, `${domainMapping.domainName}${domainMapping.mappingKey}`, { + api: this.baseApi, + domainName: domainMapping.domainName, + stage: this, + apiMappingKey: domainMapping.mappingKey, + }); + // ensure the dependency + this.node.addDependency(domainMapping.domainName); + } + + public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.baseApi.metric(metricName, props).with({ + dimensions: { ApiId: this.baseApi.apiId, Stage: this.stageName }, + }).attachTo(this); + } + + public metricClientError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('4XXError', { statistic: 'Sum', ...props }); + } + + public metricServerError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('5XXError', { statistic: 'Sum', ...props }); + } + + public metricDataProcessed(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('DataProcessed', { statistic: 'Sum', ...props }); + } + + public metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('Count', { statistic: 'SampleCount', ...props }); + } + + public metricIntegrationLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('IntegrationLatency', props); + } + + public metricLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('Latency', props); + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts index eeb237a4e7f84..b0a0f1c0265eb 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts @@ -1,3 +1,4 @@ +export * from './api'; export * from './integration'; export * from './route'; export * from './stage'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/integration.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/integration.ts index 7255607639468..83e200aadb007 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/common/integration.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/integration.ts @@ -9,4 +9,4 @@ export interface IIntegration extends IResource { * @attribute */ readonly integrationId: string; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/stage.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/stage.ts index b608a7a34ad97..40b7832418633 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/common/stage.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/stage.ts @@ -1,4 +1,6 @@ +import { Metric, MetricOptions } from '@aws-cdk/aws-cloudwatch'; import { IResource } from '@aws-cdk/core'; +import { IDomainName } from './domain-name'; /** * Represents a Stage. @@ -9,22 +11,107 @@ export interface IStage extends IResource { * @attribute */ readonly stageName: string; + + /** + * The URL to this stage. + */ + readonly url: string; + + /** + * Return the given named metric for this HTTP Api Gateway Stage + * + * @default - average over 5 minutes + */ + metric(metricName: string, props?: MetricOptions): Metric + + /** + * Metric for the number of client-side errors captured in a given period. + * + * @default - sum over 5 minutes + */ + metricClientError(props?: MetricOptions): Metric + + /** + * Metric for the number of server-side errors captured in a given period. + * + * @default - sum over 5 minutes + */ + metricServerError(props?: MetricOptions): Metric + + /** + * Metric for the amount of data processed in bytes. + * + * @default - sum over 5 minutes + */ + metricDataProcessed(props?: MetricOptions): Metric + + /** + * Metric for the total number API requests in a given period. + * + * @default - SampleCount over 5 minutes + */ + metricCount(props?: MetricOptions): Metric + + /** + * Metric for the time between when API Gateway relays a request to the backend + * and when it receives a response from the backend. + * + * @default - no statistic + */ + metricIntegrationLatency(props?: MetricOptions): Metric + + /** + * The time between when API Gateway receives a request from a client + * and when it returns a response to the client. + * The latency includes the integration latency and other API Gateway overhead. + * + * @default - no statistic + */ + metricLatency(props?: MetricOptions): Metric } /** - * Options required to create a new stage. - * Options that are common between HTTP and Websocket APIs. + * Options for DomainMapping */ -export interface CommonStageOptions { +export interface DomainMappingOptions { /** - * The name of the stage. See `StageName` class for more details. - * @default '$default' the default stage of the API. This stage will have the URL at the root of the API endpoint. + * The domain name for the mapping + * */ - readonly stageName?: string; + readonly domainName: IDomainName; + /** + * The API mapping key. Leave it undefined for the root path mapping. + * @default - empty key for the root path mapping + */ + readonly mappingKey?: string; +} + +/** + * Options required to create a new stage. + * Options that are common between HTTP and Websocket APIs. + */ +export interface StageOptions { /** * Whether updates to an API automatically trigger a new deployment. * @default false */ readonly autoDeploy?: boolean; + + /** + * The options for custom domain and api mapping + * + * @default - no custom domain and api mapping configuration + */ + readonly domainMapping?: DomainMappingOptions; +} + +/** + * The attributes used to import existing Stage + */ +export interface StageAttributes { + /** + * The name of the stage + */ + readonly stageName: string; } diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api-mapping.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/api-mapping.ts deleted file mode 100644 index ee9323240833d..0000000000000 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api-mapping.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Resource } from '@aws-cdk/core'; -import { Construct } from 'constructs'; -import { CfnApiMapping, CfnApiMappingProps } from '../apigatewayv2.generated'; -import { IApiMapping, IDomainName } from '../common'; -import { IHttpApi } from '../http/api'; -import { IHttpStage } from './stage'; - -/** - * Properties used to create the HttpApiMapping resource - */ -export interface HttpApiMappingProps { - /** - * Api mapping key. The path where this stage should be mapped to on the domain - * @default - undefined for the root path mapping. - */ - readonly apiMappingKey?: string; - - /** - * The HttpApi to which this mapping is applied - */ - readonly api: IHttpApi; - - /** - * custom domain name of the mapping target - */ - readonly domainName: IDomainName; - - /** - * stage for the HttpApiMapping resource - * - * @default - the $default stage - */ - readonly stage?: IHttpStage; -} - -/** - * The attributes used to import existing HttpApiMapping - */ -export interface HttpApiMappingAttributes { - /** - * The API mapping ID - */ - readonly apiMappingId: string; -} - -/** - * Create a new API mapping for API Gateway HTTP API endpoint. - * @resource AWS::ApiGatewayV2::ApiMapping - */ -export class HttpApiMapping extends Resource implements IApiMapping { - /** - * import from API ID - */ - public static fromHttpApiMappingAttributes(scope: Construct, id: string, attrs: HttpApiMappingAttributes): IApiMapping { - class Import extends Resource implements IApiMapping { - public readonly apiMappingId = attrs.apiMappingId; - } - return new Import(scope, id); - } - /** - * ID of the API Mapping - */ - public readonly apiMappingId: string; - - /** - * API Mapping key - */ - public readonly mappingKey?: string; - - constructor(scope: Construct, id: string, props: HttpApiMappingProps) { - super(scope, id); - - if ((!props.stage?.stageName) && !props.api.defaultStage) { - throw new Error('stage is required if default stage is not available'); - } - - const paramRe = '^[a-zA-Z0-9]*[-_.+!,$]?[a-zA-Z0-9]*$'; - if (props.apiMappingKey && !new RegExp(paramRe).test(props.apiMappingKey)) { - throw new Error('An ApiMapping key may contain only letters, numbers and one of $-_.+!*\'(),'); - } - - if (props.apiMappingKey === '') { - throw new Error('empty string for api mapping key not allowed'); - } - - const apiMappingProps: CfnApiMappingProps = { - apiId: props.api.httpApiId, - domainName: props.domainName.name, - stage: props.stage?.stageName ?? props.api.defaultStage!.stageName, - apiMappingKey: props.apiMappingKey, - }; - - const resource = new CfnApiMapping(this, 'Resource', apiMappingProps); - - // ensure the dependency on the provided stage - if (props.stage) { - this.node.addDependency(props.stage); - } - - // if stage not specified, we ensure the default stage is ready before we create the api mapping - if (!props.stage?.stageName && props.api.defaultStage) { - this.node.addDependency(props.api.defaultStage!); - } - - this.apiMappingId = resource.ref; - this.mappingKey = props.apiMappingKey; - } - -} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts index 52e5f1fccbe07..b74c00e5824fb 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts @@ -1,9 +1,9 @@ -import * as crypto from 'crypto'; -import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; -import { Duration, IResource, Resource, Stack } from '@aws-cdk/core'; +import { Duration } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnApi, CfnApiProps } from '../apigatewayv2.generated'; -import { DefaultDomainMappingOptions } from '../http/stage'; +import { IApi } from '../common/api'; +import { ApiBase } from '../common/base'; +import { DomainMappingOptions, IStage } from '../common/stage'; import { IHttpRouteAuthorizer } from './authorizer'; import { IHttpRouteIntegration, HttpIntegration, HttpRouteIntegrationConfig } from './integration'; import { BatchHttpRouteOptions, HttpMethod, HttpRoute, HttpRouteKey } from './route'; @@ -13,76 +13,14 @@ import { VpcLink, VpcLinkProps } from './vpc-link'; /** * Represents an HTTP API */ -export interface IHttpApi extends IResource { +export interface IHttpApi extends IApi { /** * The identifier of this API Gateway HTTP API. * @attribute + * @deprecated - use apiId instead */ readonly httpApiId: string; - /** - * The default endpoint for an API - * @attribute - */ - readonly apiEndpoint: string; - - /** - * The default stage - */ - readonly defaultStage?: HttpStage; - - /** - * Return the given named metric for this HTTP Api Gateway - * - * @default - average over 5 minutes - */ - metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * Metric for the number of client-side errors captured in a given period. - * - * @default - sum over 5 minutes - */ - metricClientError(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * Metric for the number of server-side errors captured in a given period. - * - * @default - sum over 5 minutes - */ - metricServerError(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * Metric for the amount of data processed in bytes. - * - * @default - sum over 5 minutes - */ - metricDataProcessed(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * Metric for the total number API requests in a given period. - * - * @default - SampleCount over 5 minutes - */ - metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * Metric for the time between when API Gateway relays a request to the backend - * and when it receives a response from the backend. - * - * @default - no statistic - */ - metricIntegrationLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * The time between when API Gateway receives a request from a client - * and when it returns a response to the client. - * The latency includes the integration latency and other API Gateway overhead. - * - * @default - no statistic - */ - metricLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - /** * Add a new VpcLink */ @@ -135,7 +73,7 @@ export interface HttpApiProps { * * @default - no default domain mapping configured. meaningless if `createDefaultStage` is `false`. */ - readonly defaultDomainMapping?: DefaultDomainMappingOptions; + readonly defaultDomainMapping?: DomainMappingOptions; /** * Specifies whether clients can invoke your API using the default endpoint. @@ -218,45 +156,12 @@ export interface AddRoutesOptions extends BatchHttpRouteOptions { readonly authorizationScopes?: string[]; } -abstract class HttpApiBase extends Resource implements IHttpApi { // note that this is not exported +abstract class HttpApiBase extends ApiBase implements IHttpApi { // note that this is not exported + public abstract readonly apiId: string; public abstract readonly httpApiId: string; public abstract readonly apiEndpoint: string; private vpcLinks: Record = {}; - private httpIntegrations: Record = {}; - - public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return new cloudwatch.Metric({ - namespace: 'AWS/ApiGateway', - metricName, - dimensions: { ApiId: this.httpApiId }, - ...props, - }).attachTo(this); - } - - public metricClientError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('4XXError', { statistic: 'Sum', ...props }); - } - - public metricServerError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('5XXError', { statistic: 'Sum', ...props }); - } - - public metricDataProcessed(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('DataProcessed', { statistic: 'Sum', ...props }); - } - - public metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('Count', { statistic: 'SampleCount', ...props }); - } - - public metricIntegrationLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('IntegrationLatency', props); - } - - public metricLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('Latency', props); - } public addVpcLink(options: VpcLinkProps): VpcLink { const { vpcId } = options.vpc; @@ -275,11 +180,9 @@ abstract class HttpApiBase extends Resource implements IHttpApi { // note that t * @internal */ public _addIntegration(scope: Construct, config: HttpRouteIntegrationConfig): HttpIntegration { - const stringifiedConfig = JSON.stringify(Stack.of(scope).resolve(config)); - const configHash = crypto.createHash('md5').update(stringifiedConfig).digest('hex'); - - if (configHash in this.httpIntegrations) { - return this.httpIntegrations[configHash]; + const { configHash, integration: existingIntegration } = this._integrationCache.getIntegration(scope, config); + if (existingIntegration) { + return existingIntegration as HttpIntegration; } const integration = new HttpIntegration(scope, `HttpIntegration-${configHash}`, { @@ -291,7 +194,7 @@ abstract class HttpApiBase extends Resource implements IHttpApi { // note that t connectionType: config.connectionType, payloadFormatVersion: config.payloadFormatVersion, }); - this.httpIntegrations[configHash] = integration; + this._integrationCache.saveIntegration(scope, config, integration); return integration; } @@ -322,6 +225,7 @@ export class HttpApi extends HttpApiBase { */ public static fromHttpApiAttributes(scope: Construct, id: string, attrs: HttpApiAttributes): IHttpApi { class Import extends HttpApiBase { + public readonly apiId = attrs.httpApiId; public readonly httpApiId = attrs.httpApiId; private readonly _apiEndpoint = attrs.apiEndpoint; @@ -339,6 +243,7 @@ export class HttpApi extends HttpApiBase { * A human friendly name for this HTTP API. Note that this is different from `httpApiId`. */ public readonly httpApiName?: string; + public readonly apiId: string; public readonly httpApiId: string; /** @@ -347,9 +252,9 @@ export class HttpApi extends HttpApiBase { public readonly disableExecuteApiEndpoint?: boolean; /** - * default stage of the api resource + * The default stage of this API */ - public readonly defaultStage: HttpStage | undefined; + public readonly defaultStage: IStage | undefined; private readonly _apiEndpoint: string; @@ -392,6 +297,7 @@ export class HttpApi extends HttpApiBase { }; const resource = new CfnApi(this, 'Resource', apiProps); + this.apiId = resource.ref; this.httpApiId = resource.ref; this._apiEndpoint = resource.attrApiEndpoint; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts index aadfb630ba276..f63c2e3a96583 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts @@ -112,7 +112,7 @@ export class HttpAuthorizer extends Resource implements IHttpAuthorizer { const resource = new CfnAuthorizer(this, 'Resource', { name: props.authorizerName ?? id, - apiId: props.httpApi.httpApiId, + apiId: props.httpApi.apiId, authorizerType: props.type, identitySource: props.identitySource, jwtConfiguration: undefinedIfNoKeys({ diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts index efd60f9f24d7c..81ddfec695bc3 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts @@ -2,6 +2,5 @@ export * from './api'; export * from './route'; export * from './integration'; export * from './stage'; -export * from './api-mapping'; export * from './vpc-link'; export * from './authorizer'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/integration.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/integration.ts index e609c9396c08f..836b831550fb7 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/integration.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/integration.ts @@ -134,7 +134,7 @@ export class HttpIntegration extends Resource implements IHttpIntegration { constructor(scope: Construct, id: string, props: HttpIntegrationProps) { super(scope, id); const integ = new CfnIntegration(this, 'Resource', { - apiId: props.httpApi.httpApiId, + apiId: props.httpApi.apiId, integrationType: props.integrationType, integrationUri: props.integrationUri, integrationMethod: props.method, diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts index 4510d13ed6f2b..416e9bed973a3 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts @@ -157,7 +157,7 @@ export class HttpRoute extends Resource implements IHttpRoute { } const routeProps: CfnRouteProps = { - apiId: props.httpApi.httpApiId, + apiId: props.httpApi.apiId, routeKey: props.routeKey.key, target: `integrations/${integration.integrationId}`, authorizerId: authBindResult?.authorizerId, diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts index fbe54345e25e3..d6a5f96320e3b 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts @@ -1,11 +1,10 @@ -import { Metric, MetricOptions } from '@aws-cdk/aws-cloudwatch'; -import { Resource, Stack } from '@aws-cdk/core'; +import { Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnStage } from '../apigatewayv2.generated'; -import { CommonStageOptions, IDomainName, IStage } from '../common'; +import { StageOptions, IStage, StageAttributes } from '../common'; +import { IApi } from '../common/api'; +import { StageBase } from '../common/base'; import { IHttpApi } from './api'; -import { HttpApiMapping } from './api-mapping'; - const DEFAULT_STAGE_NAME = '$default'; @@ -13,18 +12,21 @@ const DEFAULT_STAGE_NAME = '$default'; * Represents the HttpStage */ export interface IHttpStage extends IStage { + /** + * The API this stage is associated to. + */ + readonly api: IHttpApi; } /** - * Options to create a new stage for an HTTP API. + * The options to create a new Stage for an HTTP API */ -export interface HttpStageOptions extends CommonStageOptions { +export interface HttpStageOptions extends StageOptions { /** - * The options for custom domain and api mapping - * - * @default - no custom domain and api mapping configuration + * The name of the stage. See `StageName` class for more details. + * @default '$default' the default stage of the API. This stage will have the URL at the root of the API endpoint. */ - readonly domainMapping?: DomainMappingOptions; + readonly stageName?: string; } /** @@ -38,51 +40,39 @@ export interface HttpStageProps extends HttpStageOptions { } /** - * Options for defaultDomainMapping + * The attributes used to import existing HttpStage */ -export interface DefaultDomainMappingOptions { - /** - * The domain name for the mapping - * - */ - readonly domainName: IDomainName; - +export interface HttpStageAttributes extends StageAttributes { /** - * The API mapping key. Leave it undefined for the root path mapping. - * @default - empty key for the root path mapping + * The API to which this stage is associated */ - readonly mappingKey?: string; -} - -/** - * Options for DomainMapping - */ -export interface DomainMappingOptions extends DefaultDomainMappingOptions { - /** - * The API Stage - * - * @default - the $default stage - */ - readonly stage?: IStage; + readonly api: IHttpApi; } /** * Represents a stage where an instance of the API is deployed. * @resource AWS::ApiGatewayV2::Stage */ -export class HttpStage extends Resource implements IStage { +export class HttpStage extends StageBase implements IHttpStage { /** * Import an existing stage into this CDK app. */ - public static fromStageName(scope: Construct, id: string, stageName: string): IStage { - class Import extends Resource implements IStage { - public readonly stageName = stageName; + public static fromHttpStageAttributes(scope: Construct, id: string, attrs: HttpStageAttributes): IHttpStage { + class Import extends StageBase implements IHttpStage { + protected readonly baseApi = attrs.api; + public readonly stageName = attrs.stageName; + public readonly api = attrs.api; + + get url(): string { + throw new Error('url is not available for imported stages.'); + } } return new Import(scope, id); } + protected readonly baseApi: IApi; public readonly stageName: string; - private httpApi: IHttpApi; + public readonly api: IHttpApi; constructor(scope: Construct, id: string, props: HttpStageProps) { super(scope, id, { @@ -90,25 +80,18 @@ export class HttpStage extends Resource implements IStage { }); new CfnStage(this, 'Resource', { - apiId: props.httpApi.httpApiId, + apiId: props.httpApi.apiId, stageName: this.physicalName, autoDeploy: props.autoDeploy, }); this.stageName = this.physicalName; - this.httpApi = props.httpApi; + this.baseApi = props.httpApi; + this.api = props.httpApi; if (props.domainMapping) { - new HttpApiMapping(this, `${props.domainMapping.domainName}${props.domainMapping.mappingKey}`, { - api: props.httpApi, - domainName: props.domainMapping.domainName, - stage: this, - apiMappingKey: props.domainMapping.mappingKey, - }); - // ensure the dependency - this.node.addDependency(props.domainMapping.domainName); + this._addDomainMapping(props.domainMapping); } - } /** @@ -117,75 +100,6 @@ export class HttpStage extends Resource implements IStage { public get url(): string { const s = Stack.of(this); const urlPath = this.stageName === DEFAULT_STAGE_NAME ? '' : this.stageName; - return `https://${this.httpApi.httpApiId}.execute-api.${s.region}.${s.urlSuffix}/${urlPath}`; - } - - /** - * Return the given named metric for this HTTP Api Gateway Stage - * - * @default - average over 5 minutes - */ - public metric(metricName: string, props?: MetricOptions): Metric { - var api = this.httpApi; - return api.metric(metricName, props).with({ - dimensions: { ApiId: this.httpApi.httpApiId, Stage: this.stageName }, - }).attachTo(this); - } - - /** - * Metric for the number of client-side errors captured in a given period. - * - * @default - sum over 5 minutes - */ - public metricClientError(props?: MetricOptions): Metric { - return this.metric('4XXError', { statistic: 'Sum', ...props }); - } - - /** - * Metric for the number of server-side errors captured in a given period. - * - * @default - sum over 5 minutes - */ - public metricServerError(props?: MetricOptions): Metric { - return this.metric('5XXError', { statistic: 'Sum', ...props }); - } - - /** - * Metric for the amount of data processed in bytes. - * - * @default - sum over 5 minutes - */ - public metricDataProcessed(props?: MetricOptions): Metric { - return this.metric('DataProcessed', { statistic: 'Sum', ...props }); - } - - /** - * Metric for the total number API requests in a given period. - * - * @default - SampleCount over 5 minutes - */ - public metricCount(props?: MetricOptions): Metric { - return this.metric('Count', { statistic: 'SampleCount', ...props }); - } - - /** - * Metric for the time between when API Gateway relays a request to the backend - * and when it receives a response from the backend. - * - * @default - no statistic - */ - public metricIntegrationLatency(props?: MetricOptions): Metric { - return this.metric('IntegrationLatency', props); - } - - /** - * The time between when API Gateway receives a request from a client - * and when it returns a response to the client. - * The latency includes the integration latency and other API Gateway overhead. - * - * @default - no statistic - */ - public metricLatency(props?: MetricOptions): Metric { - return this.metric('Latency', props); + return `https://${this.api.apiId}.execute-api.${s.region}.${s.urlSuffix}/${urlPath}`; } } diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/index.ts index 31ea86b4a91c2..12dd8113f8b4c 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/index.ts @@ -1,3 +1,4 @@ export * from './apigatewayv2.generated'; export * from './common'; -export * from './http'; \ No newline at end of file +export * from './http'; +export * from './websocket'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/private/integration-cache.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/private/integration-cache.ts new file mode 100644 index 0000000000000..2401d28e20d2d --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/private/integration-cache.ts @@ -0,0 +1,29 @@ +import * as crypto from 'crypto'; +import { Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { IIntegration } from '../common/integration'; +import { HttpRouteIntegrationConfig } from '../http'; +import { WebSocketRouteIntegrationConfig } from '../websocket'; + +type IntegrationConfig = HttpRouteIntegrationConfig | WebSocketRouteIntegrationConfig; + +export class IntegrationCache { + private integrations: Record = {}; + + getIntegration(scope: Construct, config: IntegrationConfig) { + const configHash = this.integrationConfigHash(scope, config); + const integration = this.integrations[configHash]; + return { configHash, integration }; + } + + saveIntegration(scope: Construct, config: IntegrationConfig, integration: IIntegration) { + const configHash = this.integrationConfigHash(scope, config); + this.integrations[configHash] = integration; + } + + private integrationConfigHash(scope: Construct, config: IntegrationConfig): string { + const stringifiedConfig = JSON.stringify(Stack.of(scope).resolve(config)); + const configHash = crypto.createHash('md5').update(stringifiedConfig).digest('hex'); + return configHash; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/api.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/api.ts new file mode 100644 index 0000000000000..f2f2653c94ee6 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/api.ts @@ -0,0 +1,130 @@ +import { Construct } from 'constructs'; +import { CfnApi } from '../apigatewayv2.generated'; +import { IApi } from '../common/api'; +import { ApiBase } from '../common/base'; +import { WebSocketRouteIntegrationConfig, WebSocketIntegration } from './integration'; +import { WebSocketRoute, WebSocketRouteOptions } from './route'; + +/** + * Represents a WebSocket API + */ +export interface IWebSocketApi extends IApi { + /** + * Add a websocket integration + * @internal + */ + _addIntegration(scope: Construct, config: WebSocketRouteIntegrationConfig): WebSocketIntegration +} + +/** + * Props for WebSocket API + */ +export interface WebSocketApiProps { + /** + * Name for the WebSocket API resoruce + * @default - id of the WebSocketApi construct. + */ + readonly apiName?: string; + + /** + * The description of the API. + * @default - none + */ + readonly description?: string; + + /** + * The route selection expression for the API + * @default '$request.body.action' + */ + readonly routeSelectionExpression?: string; + + /** + * Options to configure a '$connect' route + * + * @default - no '$connect' route configured + */ + readonly connectRouteOptions?: WebSocketRouteOptions; + + /** + * Options to configure a '$disconnect' route + * + * @default - no '$disconnect' route configured + */ + readonly disconnectRouteOptions?: WebSocketRouteOptions; + + /** + * Options to configure a '$default' route + * + * @default - no '$default' route configured + */ + readonly defaultRouteOptions?: WebSocketRouteOptions; +} + +/** + * Create a new API Gateway WebSocket API endpoint. + * @resource AWS::ApiGatewayV2::Api + */ +export class WebSocketApi extends ApiBase implements IWebSocketApi { + public readonly apiId: string; + public readonly apiEndpoint: string; + + /** + * A human friendly name for this WebSocket API. Note that this is different from `webSocketApiId`. + */ + public readonly webSocketApiName?: string; + + constructor(scope: Construct, id: string, props?: WebSocketApiProps) { + super(scope, id); + + this.webSocketApiName = props?.apiName ?? id; + + const resource = new CfnApi(this, 'Resource', { + name: this.webSocketApiName, + protocolType: 'WEBSOCKET', + description: props?.description, + routeSelectionExpression: props?.routeSelectionExpression ?? '$request.body.action', + }); + this.apiId = resource.ref; + this.apiEndpoint = resource.attrApiEndpoint; + + if (props?.connectRouteOptions) { + this.addRoute('$connect', props.connectRouteOptions); + } + if (props?.disconnectRouteOptions) { + this.addRoute('$disconnect', props.disconnectRouteOptions); + } + if (props?.defaultRouteOptions) { + this.addRoute('$default', props.defaultRouteOptions); + } + } + + /** + * @internal + */ + public _addIntegration(scope: Construct, config: WebSocketRouteIntegrationConfig): WebSocketIntegration { + const { configHash, integration: existingIntegration } = this._integrationCache.getIntegration(scope, config); + if (existingIntegration) { + return existingIntegration as WebSocketIntegration; + } + + const integration = new WebSocketIntegration(scope, `WebSocketIntegration-${configHash}`, { + webSocketApi: this, + integrationType: config.type, + integrationUri: config.uri, + }); + this._integrationCache.saveIntegration(scope, config, integration); + + return integration; + } + + /** + * Add a new route + */ + public addRoute(routeKey: string, options: WebSocketRouteOptions) { + return new WebSocketRoute(this, `${routeKey}-Route`, { + webSocketApi: this, + routeKey, + ...options, + }); + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/index.ts new file mode 100644 index 0000000000000..b0ce6a8a91419 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/index.ts @@ -0,0 +1,4 @@ +export * from './api'; +export * from './route'; +export * from './stage'; +export * from './integration'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/integration.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/integration.ts new file mode 100644 index 0000000000000..e75bd00b63d95 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/integration.ts @@ -0,0 +1,110 @@ +import { Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnIntegration } from '../apigatewayv2.generated'; +import { IIntegration } from '../common'; +import { IWebSocketApi } from './api'; +import { IWebSocketRoute } from './route'; + +// 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'; + +/** + * Represents an Integration for an WebSocket API. + */ +export interface IWebSocketIntegration extends IIntegration { + /** The WebSocket API associated with this integration */ + readonly webSocketApi: IWebSocketApi; +} + +/** + * WebSocket Integration Types + */ +export enum WebSocketIntegrationType { + /** + * AWS Proxy Integration Type + */ + AWS_PROXY = 'AWS_PROXY' +} + +/** + * The integration properties + */ +export interface WebSocketIntegrationProps { + /** + * The WebSocket API to which this integration should be bound. + */ + readonly webSocketApi: IWebSocketApi; + + /** + * Integration type + */ + readonly integrationType: WebSocketIntegrationType; + + /** + * Integration URI. + */ + readonly integrationUri: string; +} + +/** + * The integration for an API route. + * @resource AWS::ApiGatewayV2::Integration + */ +export class WebSocketIntegration extends Resource implements IWebSocketIntegration { + public readonly integrationId: string; + public readonly webSocketApi: IWebSocketApi; + + constructor(scope: Construct, id: string, props: WebSocketIntegrationProps) { + super(scope, id); + const integ = new CfnIntegration(this, 'Resource', { + apiId: props.webSocketApi.apiId, + integrationType: props.integrationType, + integrationUri: props.integrationUri, + }); + this.integrationId = integ.ref; + this.webSocketApi = props.webSocketApi; + } +} + +/** + * Options to the WebSocketRouteIntegration during its bind operation. + */ +export interface WebSocketRouteIntegrationBindOptions { + /** + * The route to which this is being bound. + */ + readonly route: IWebSocketRoute; + + /** + * The current scope in which the bind is occurring. + * If the `WebSocketRouteIntegration` being bound creates additional constructs, + * this will be used as their parent scope. + */ + readonly scope: CoreConstruct; +} + +/** + * The interface that various route integration classes will inherit. + */ +export interface IWebSocketRouteIntegration { + /** + * Bind this integration to the route. + */ + bind(options: WebSocketRouteIntegrationBindOptions): WebSocketRouteIntegrationConfig; +} + +/** + * Config returned back as a result of the bind. + */ +export interface WebSocketRouteIntegrationConfig { + /** + * Integration type. + */ + readonly type: WebSocketIntegrationType; + + /** + * Integration URI + */ + readonly uri: string; +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts new file mode 100644 index 0000000000000..0588889a603bc --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts @@ -0,0 +1,84 @@ +import { Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnRoute } from '../apigatewayv2.generated'; +import { IRoute } from '../common'; +import { IWebSocketApi } from './api'; +import { IWebSocketRouteIntegration } from './integration'; + +/** + * Represents a Route for an WebSocket API. + */ +export interface IWebSocketRoute extends IRoute { + /** + * The WebSocket API associated with this route. + */ + readonly webSocketApi: IWebSocketApi; + + /** + * The key to this route. + * @attribute + */ + readonly routeKey: string; +} + +/** + * Options used to add route to the API + */ +export interface WebSocketRouteOptions { + /** + * The integration to be configured on this route. + */ + readonly integration: IWebSocketRouteIntegration; +} + + +/** + * Properties to initialize a new Route + */ +export interface WebSocketRouteProps extends WebSocketRouteOptions { + /** + * the API the route is associated with + */ + readonly webSocketApi: IWebSocketApi; + + /** + * The key to this route. + */ + readonly routeKey: string; +} + +/** + * Route class that creates the Route for API Gateway WebSocket API + * @resource AWS::ApiGatewayV2::Route + */ +export class WebSocketRoute extends Resource implements IWebSocketRoute { + public readonly routeId: string; + public readonly webSocketApi: IWebSocketApi; + public readonly routeKey: string; + + /** + * Integration response ID + */ + public readonly integrationResponseId?: string; + + constructor(scope: Construct, id: string, props: WebSocketRouteProps) { + super(scope, id); + + this.webSocketApi = props.webSocketApi; + this.routeKey = props.routeKey; + + const config = props.integration.bind({ + route: this, + scope: this, + }); + + const integration = props.webSocketApi._addIntegration(this, config); + + const route = new CfnRoute(this, 'Resource', { + apiId: props.webSocketApi.apiId, + routeKey: props.routeKey, + target: `integrations/${integration.integrationId}`, + }); + this.routeId = route.ref; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/stage.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/stage.ts new file mode 100644 index 0000000000000..a50353a79ca2d --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/stage.ts @@ -0,0 +1,96 @@ +import { Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnStage } from '../apigatewayv2.generated'; +import { StageOptions, IApi, IStage, StageAttributes } from '../common'; +import { StageBase } from '../common/base'; +import { IWebSocketApi } from './api'; + +/** + * Represents the WebSocketStage + */ +export interface IWebSocketStage extends IStage { + /** + * The API this stage is associated to. + */ + readonly api: IWebSocketApi; +} + +/** + * Properties to initialize an instance of `WebSocketStage`. + */ +export interface WebSocketStageProps extends StageOptions { + /** + * The WebSocket API to which this stage is associated. + */ + readonly webSocketApi: IWebSocketApi; + + /** + * The name of the stage. + */ + readonly stageName: string; +} + +/** + * The attributes used to import existing WebSocketStage + */ +export interface WebSocketStageAttributes extends StageAttributes { + /** + * The API to which this stage is associated + */ + readonly api: IWebSocketApi; +} + +/** + * Represents a stage where an instance of the API is deployed. + * @resource AWS::ApiGatewayV2::Stage + */ +export class WebSocketStage extends StageBase implements IWebSocketStage { + /** + * Import an existing stage into this CDK app. + */ + public static fromWebSocketStageAttributes(scope: Construct, id: string, attrs: WebSocketStageAttributes): IWebSocketStage { + class Import extends StageBase implements IWebSocketStage { + public readonly baseApi = attrs.api; + public readonly stageName = attrs.stageName; + public readonly api = attrs.api; + + get url(): string { + throw new Error('url is not available for imported stages.'); + } + } + return new Import(scope, id); + } + + protected readonly baseApi: IApi; + public readonly stageName: string; + public readonly api: IWebSocketApi; + + constructor(scope: Construct, id: string, props: WebSocketStageProps) { + super(scope, id, { + physicalName: props.stageName, + }); + + this.baseApi = props.webSocketApi; + this.api = props.webSocketApi; + this.stageName = this.physicalName; + + new CfnStage(this, 'Resource', { + apiId: props.webSocketApi.apiId, + stageName: this.physicalName, + autoDeploy: props.autoDeploy, + }); + + if (props.domainMapping) { + this._addDomainMapping(props.domainMapping); + } + } + + /** + * The URL to this stage. + */ + public get url(): string { + const s = Stack.of(this); + const urlPath = this.stageName; + return `wss://${this.api.apiId}.execute-api.${s.region}.${s.urlSuffix}/${urlPath}`; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/package.json b/packages/@aws-cdk/aws-apigatewayv2/package.json index 19abb0ca10b3f..b1dd874b85f9e 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/package.json +++ b/packages/@aws-cdk/aws-apigatewayv2/package.json @@ -103,13 +103,16 @@ }, "awslint": { "exclude": [ + "props-physical-name:@aws-cdk/aws-apigatewayv2.ApiMappingProps", "from-method:@aws-cdk/aws-apigatewayv2.HttpIntegration", "from-method:@aws-cdk/aws-apigatewayv2.HttpRoute", - "from-method:@aws-cdk/aws-apigatewayv2.HttpStage", - "props-physical-name-type:@aws-cdk/aws-apigatewayv2.HttpStageProps.stageName", - "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpApiMappingProps", "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpIntegrationProps", - "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpRouteProps" + "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpRouteProps", + "from-method:@aws-cdk/aws-apigatewayv2.WebSocketApi", + "from-method:@aws-cdk/aws-apigatewayv2.WebSocketIntegration", + "from-method:@aws-cdk/aws-apigatewayv2.WebSocketRoute", + "props-physical-name:@aws-cdk/aws-apigatewayv2.WebSocketIntegrationProps", + "props-physical-name:@aws-cdk/aws-apigatewayv2.WebSocketRouteProps" ] }, "stability": "experimental", @@ -121,7 +124,7 @@ }, { "name": "Higher level constructs for Websocket APIs", - "stability": "Not Implemented" + "stability": "Experimental" } ], "awscdkio": { diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/api-mapping.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/common/api-mapping.test.ts similarity index 76% rename from packages/@aws-cdk/aws-apigatewayv2/test/http/api-mapping.test.ts rename to packages/@aws-cdk/aws-apigatewayv2/test/common/api-mapping.test.ts index fe113727c3f50..b917f19513a57 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/api-mapping.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/common/api-mapping.test.ts @@ -1,7 +1,7 @@ import '@aws-cdk/assert/jest'; import { Certificate } from '@aws-cdk/aws-certificatemanager'; import { Stack } from '@aws-cdk/core'; -import { DomainName, HttpApi, HttpApiMapping } from '../../lib'; +import { DomainName, HttpApi, ApiMapping, WebSocketApi } from '../../lib'; const domainName = 'example.com'; const certArn = 'arn:aws:acm:us-east-1:111111111111:certificate'; @@ -17,7 +17,7 @@ describe('ApiMapping', () => { certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), }); - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, }); @@ -47,7 +47,7 @@ describe('ApiMapping', () => { certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), }); - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, stage: beta, @@ -75,7 +75,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: '', @@ -94,7 +94,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: '/', @@ -113,7 +113,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: '/foo', @@ -132,7 +132,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: 'foo/bar', @@ -151,7 +151,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: 'foo/', @@ -170,7 +170,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: '^foo', @@ -189,7 +189,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: 'foo.*$', @@ -207,15 +207,53 @@ describe('ApiMapping', () => { certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), }); - const mapping = new HttpApiMapping(stack, 'Mapping', { + const mapping = new ApiMapping(stack, 'Mapping', { api, domainName: dn, }); - const imported = HttpApiMapping.fromHttpApiMappingAttributes(stack, 'ImportedMapping', { + const imported = ApiMapping.fromApiMappingAttributes(stack, 'ImportedMapping', { apiMappingId: mapping.apiMappingId, } ); expect(imported.apiMappingId).toEqual(mapping.apiMappingId); }); + + test('stage validation - throws if defaultStage not available for HttpApi', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'Api', { + createDefaultStage: false, + }); + const dn = new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + // WHEN + expect(() => { + new ApiMapping(stack, 'Mapping', { + api, + domainName: dn, + }); + }).toThrow(/stage is required if default stage is not available/); + }); + + test('stage validation - throws if stage not provided for WebSocketApi', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'api'); + const dn = new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + // WHEN + expect(() => { + new ApiMapping(stack, 'Mapping', { + api, + domainName: dn, + }); + }).toThrow(/stage is required for WebSocket API/); + }); }); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts index 01252be7d84f1..a8c5f418f7782 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts @@ -19,7 +19,7 @@ describe('HttpApi', () => { }); expect(stack).toHaveResource('AWS::ApiGatewayV2::Stage', { - ApiId: stack.resolve(api.httpApiId), + ApiId: stack.resolve(api.apiId), StageName: '$default', AutoDeploy: true, }); @@ -34,7 +34,7 @@ describe('HttpApi', () => { const stack = new Stack(); const imported = HttpApi.fromHttpApiAttributes(stack, 'imported', { httpApiId: 'http-1234', apiEndpoint: 'api-endpoint' }); - expect(imported.httpApiId).toEqual('http-1234'); + expect(imported.apiId).toEqual('http-1234'); expect(imported.apiEndpoint).toEqual('api-endpoint'); }); @@ -55,12 +55,12 @@ describe('HttpApi', () => { }); expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::Route', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), RouteKey: '$default', }); expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::Integration', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), }); }); @@ -75,12 +75,12 @@ describe('HttpApi', () => { }); expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::Route', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), RouteKey: 'GET /pets', }); expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::Route', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), RouteKey: 'PATCH /pets', }); }); @@ -95,7 +95,7 @@ describe('HttpApi', () => { }); expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::Route', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), RouteKey: 'ANY /pets', }); }); @@ -149,7 +149,7 @@ describe('HttpApi', () => { }); const metricName = '4xxError'; const statistic = 'Sum'; - const apiId = api.httpApiId; + const apiId = api.apiId; // WHEN const countMetric = api.metric(metricName, { statistic }); @@ -168,7 +168,7 @@ describe('HttpApi', () => { createDefaultStage: false, }); const color = '#00ff00'; - const apiId = api.httpApiId; + const apiId = api.apiId; // WHEN const metrics = new Array(); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts index bee2f7d05be1b..afb98fe733f19 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts @@ -18,7 +18,7 @@ describe('HttpAuthorizer', () => { }); expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), Name: 'HttpAuthorizer', AuthorizerType: 'JWT', IdentitySource: ['identitysource.1', 'identitysource.2'], diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts index a5ce1b7b64b64..41af3121dc893 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts @@ -17,7 +17,7 @@ describe('HttpRoute', () => { }); expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), RouteKey: 'GET /books', Target: { 'Fn::Join': [ @@ -33,7 +33,7 @@ describe('HttpRoute', () => { }); expect(stack).toHaveResource('AWS::ApiGatewayV2::Integration', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), }); }); @@ -48,7 +48,7 @@ describe('HttpRoute', () => { }); expect(stack).toHaveResource('AWS::ApiGatewayV2::Integration', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), IntegrationType: 'HTTP_PROXY', PayloadFormatVersion: '2.0', IntegrationUri: 'some-uri', @@ -209,7 +209,7 @@ describe('HttpRoute', () => { }); expect(stack).toHaveResource('AWS::ApiGatewayV2::Integration', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), IntegrationType: 'HTTP_PROXY', PayloadFormatVersion: '2.0', IntegrationUri: 'some-uri', diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts index 6c4359b5439c9..ff70ea026acb6 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts @@ -16,7 +16,7 @@ describe('HttpStage', () => { }); expect(stack).toHaveResource('AWS::ApiGatewayV2::Stage', { - ApiId: stack.resolve(api.httpApiId), + ApiId: stack.resolve(api.apiId), StageName: '$default', }); }); @@ -31,7 +31,10 @@ describe('HttpStage', () => { httpApi: api, }); - const imported = HttpStage.fromStageName(stack, 'Import', stage.stageName ); + const imported = HttpStage.fromHttpStageAttributes(stack, 'Import', { + stageName: stage.stageName, + api, + }); expect(imported.stageName).toEqual(stage.stageName); }); @@ -66,7 +69,7 @@ describe('HttpStage', () => { }); const metricName = '4xxError'; const statistic = 'Sum'; - const apiId = api.httpApiId; + const apiId = api.apiId; // WHEN const countMetric = stage.metric(metricName, { statistic }); @@ -91,7 +94,7 @@ describe('HttpStage', () => { httpApi: api, }); const color = '#00ff00'; - const apiId = api.httpApiId; + const apiId = api.apiId; // WHEN const metrics = new Array(); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/api.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/api.test.ts new file mode 100644 index 0000000000000..fcc65d4e18207 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/api.test.ts @@ -0,0 +1,92 @@ +import '@aws-cdk/assert/jest'; +import { Stack } from '@aws-cdk/core'; +import { + IWebSocketRouteIntegration, WebSocketApi, WebSocketIntegrationType, + WebSocketRouteIntegrationBindOptions, WebSocketRouteIntegrationConfig, +} from '../../lib'; + +describe('WebSocketApi', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new WebSocketApi(stack, 'api'); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Api', { + Name: 'api', + ProtocolType: 'WEBSOCKET', + }); + + expect(stack).not.toHaveResource('AWS::ApiGatewayV2::Stage'); + expect(stack).not.toHaveResource('AWS::ApiGatewayV2::Route'); + expect(stack).not.toHaveResource('AWS::ApiGatewayV2::Integration'); + }); + + test('addRoute: adds a route with passed key', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'api'); + + // WHEN + api.addRoute('myroute', { integration: new DummyIntegration() }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(api.apiId), + RouteKey: 'myroute', + }); + }); + + test('connectRouteOptions: adds a $connect route', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'api', { + connectRouteOptions: { integration: new DummyIntegration() }, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(api.apiId), + RouteKey: '$connect', + }); + }); + + test('disconnectRouteOptions: adds a $disconnect route', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'api', { + disconnectRouteOptions: { integration: new DummyIntegration() }, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(api.apiId), + RouteKey: '$disconnect', + }); + }); + + test('defaultRouteOptions: adds a $default route', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'api', { + defaultRouteOptions: { integration: new DummyIntegration() }, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(api.apiId), + RouteKey: '$default', + }); + }); +}); + +class DummyIntegration implements IWebSocketRouteIntegration { + bind(_options: WebSocketRouteIntegrationBindOptions): WebSocketRouteIntegrationConfig { + return { + type: WebSocketIntegrationType.AWS_PROXY, + uri: 'some-uri', + }; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/route.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/route.test.ts new file mode 100644 index 0000000000000..04e8e5fc7efac --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/route.test.ts @@ -0,0 +1,54 @@ +import '@aws-cdk/assert/jest'; +import { Stack } from '@aws-cdk/core'; +import { + IWebSocketRouteIntegration, WebSocketApi, WebSocketIntegrationType, + WebSocketRoute, WebSocketRouteIntegrationBindOptions, WebSocketRouteIntegrationConfig, +} from '../../lib'; + +describe('WebSocketRoute', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + const webSocketApi = new WebSocketApi(stack, 'Api'); + + // WHEN + new WebSocketRoute(stack, 'Route', { + webSocketApi, + integration: new DummyIntegration(), + routeKey: 'message', + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(webSocketApi.apiId), + RouteKey: 'message', + Target: { + 'Fn::Join': [ + '', + [ + 'integrations/', + { + Ref: 'RouteWebSocketIntegrationb7742333c7ab20d7b2b178df59bb17f20338431E', + }, + ], + ], + }, + }); + + expect(stack).toHaveResource('AWS::ApiGatewayV2::Integration', { + ApiId: stack.resolve(webSocketApi.apiId), + IntegrationType: 'AWS_PROXY', + IntegrationUri: 'some-uri', + }); + }); +}); + + +class DummyIntegration implements IWebSocketRouteIntegration { + bind(_options: WebSocketRouteIntegrationBindOptions): WebSocketRouteIntegrationConfig { + return { + type: WebSocketIntegrationType.AWS_PROXY, + uri: 'some-uri', + }; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/stage.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/stage.test.ts new file mode 100644 index 0000000000000..5ebdf0c61a980 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/stage.test.ts @@ -0,0 +1,44 @@ +import '@aws-cdk/assert/jest'; +import { Stack } from '@aws-cdk/core'; +import { WebSocketApi, WebSocketStage } from '../../lib'; + +describe('WebSocketStage', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'Api'); + + // WHEN + const defaultStage = new WebSocketStage(stack, 'Stage', { + webSocketApi: api, + stageName: 'dev', + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Stage', { + ApiId: stack.resolve(api.apiId), + StageName: 'dev', + }); + expect(defaultStage.url.endsWith('/dev')).toBe(true); + }); + + test('import', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'Api'); + + // WHEN + const stage = new WebSocketStage(stack, 'Stage', { + webSocketApi: api, + stageName: 'dev', + }); + + const imported = WebSocketStage.fromWebSocketStageAttributes(stack, 'Import', { + stageName: stage.stageName, + api, + }); + + // THEN + expect(imported.stageName).toEqual(stage.stageName); + }); +}); diff --git a/packages/@aws-cdk/aws-cloudfront/lib/origin-request-policy.ts b/packages/@aws-cdk/aws-cloudfront/lib/origin-request-policy.ts index 3d45cf2bc56fb..17e7894e6e84e 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/origin-request-policy.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/origin-request-policy.ts @@ -121,7 +121,7 @@ export class OriginRequestPolicy extends Resource implements IOriginRequestPolic } /** - * Ddetermines whether any cookies in viewer requests (and if so, which cookies) + * Determines whether any cookies in viewer requests (and if so, which cookies) * are included in requests that CloudFront sends to the origin. */ export class OriginRequestCookieBehavior { @@ -184,6 +184,12 @@ export class OriginRequestHeaderBehavior { if (headers.length === 0) { throw new Error('At least one header to allow must be provided'); } + if (headers.length > 10) { + throw new Error(`Maximum allowed headers in Origin Request Policy is 10; got ${headers.length}.`); + } + if (/Authorization/i.test(headers.join('|')) || /Accept-Encoding/i.test(headers.join('|'))) { + throw new Error('you cannot pass `Authorization` or `Accept-Encoding` as header values; use a CachePolicy to forward these headers instead'); + } return new OriginRequestHeaderBehavior('whitelist', headers); } diff --git a/packages/@aws-cdk/aws-cloudfront/test/origin-request-policy.test.ts b/packages/@aws-cdk/aws-cloudfront/test/origin-request-policy.test.ts index e719225c4845c..b342ac434e48e 100644 --- a/packages/@aws-cdk/aws-cloudfront/test/origin-request-policy.test.ts +++ b/packages/@aws-cdk/aws-cloudfront/test/origin-request-policy.test.ts @@ -77,6 +77,29 @@ describe('OriginRequestPolicy', () => { expect(() => new OriginRequestPolicy(stack, 'OriginRequestPolicy6', { originRequestPolicyName: 'My_Policy' })).not.toThrow(); }); + test('throws if prohibited headers are being passed', () => { + const errorMessage = /you cannot pass `Authorization` or `Accept-Encoding` as header values/; + expect(() => new OriginRequestPolicy(stack, 'OriginRequestPolicy1', { headerBehavior: OriginRequestHeaderBehavior.allowList('Authorization') })).toThrow(errorMessage); + expect(() => new OriginRequestPolicy(stack, 'OriginRequestPolicy2', { headerBehavior: OriginRequestHeaderBehavior.allowList('Accept-Encoding') })).toThrow(errorMessage); + expect(() => new OriginRequestPolicy(stack, 'OriginRequestPolicy3', { headerBehavior: OriginRequestHeaderBehavior.allowList('authorization') })).toThrow(errorMessage); + expect(() => new OriginRequestPolicy(stack, 'OriginRequestPolicy4', { headerBehavior: OriginRequestHeaderBehavior.allowList('accept-encoding') })).toThrow(errorMessage); + expect(() => new OriginRequestPolicy(stack, 'OriginRequestPolicy5', { headerBehavior: OriginRequestHeaderBehavior.allowList('Foo', 'Authorization', 'Bar') })).toThrow(errorMessage); + expect(() => new OriginRequestPolicy(stack, 'OriginRequestPolicy6', { headerBehavior: OriginRequestHeaderBehavior.allowList('Foo', 'Accept-Encoding', 'Bar') })).toThrow(errorMessage); + + expect(() => new OriginRequestPolicy(stack, 'OriginRequestPolicy7', { headerBehavior: OriginRequestHeaderBehavior.allowList('Foo', 'Bar') })).not.toThrow(); + }); + + test('throws if more than 10 OriginRequestHeaderBehavior headers are being passed', () => { + const errorMessage = /Maximum allowed headers in Origin Request Policy is 10; got (.*?)/; + expect(() => new OriginRequestPolicy(stack, 'OriginRequestPolicy1', { + headerBehavior: OriginRequestHeaderBehavior.allowList('Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing', 'elit', 'sed', 'do', 'eiusmod'), + })).toThrow(errorMessage); + + expect(() => new OriginRequestPolicy(stack, 'OriginRequestPolicy2', { + headerBehavior: OriginRequestHeaderBehavior.allowList('Lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing', 'elit', 'sed', 'do'), + })).not.toThrow(); + }); + test('does not throw if originRequestPolicyName is a token', () => { expect(() => new OriginRequestPolicy(stack, 'CachePolicy', { originRequestPolicyName: Aws.STACK_NAME, diff --git a/packages/@aws-cdk/aws-cloudwatch-actions/README.md b/packages/@aws-cdk/aws-cloudwatch-actions/README.md index 03d84220c5c08..f13861a8c1555 100644 --- a/packages/@aws-cdk/aws-cloudwatch-actions/README.md +++ b/packages/@aws-cdk/aws-cloudwatch-actions/README.md @@ -11,4 +11,19 @@ This library contains a set of classes which can be used as CloudWatch Alarm actions. +The currently implemented actions are: EC2 Actions, SNS Actions, Autoscaling Actions and Aplication Autoscaling Actions + + +## EC2 Action Example + +```ts +import * as cw from "@aws-cdk/aws-cloudwatch"; +// Alarm must be configured with an EC2 per-instance metric +let alarm: cw.Alarm; +// Attach a reboot when alarm triggers +alarm.addAlarmAction( + new Ec2Action(Ec2InstanceActions.REBOOT) +); +``` + See `@aws-cdk/aws-cloudwatch` for more information. diff --git a/packages/@aws-cdk/aws-cloudwatch-actions/lib/ec2.ts b/packages/@aws-cdk/aws-cloudwatch-actions/lib/ec2.ts new file mode 100644 index 0000000000000..57d5a4fb67501 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch-actions/lib/ec2.ts @@ -0,0 +1,47 @@ +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import { Stack } 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'; + +/** + * Types of EC2 actions available + */ +export enum Ec2InstanceAction { + /** + * Stop the instance + */ + STOP = 'stop', + /** + * Terminatethe instance + */ + TERMINATE = 'terminate', + /** + * Recover the instance + */ + RECOVER = 'recover', + /** + * Reboot the instance + */ + REBOOT = 'reboot' +} + +/** + * Use an EC2 action as an Alarm action + */ +export class Ec2Action implements cloudwatch.IAlarmAction { + private ec2Action: Ec2InstanceAction; + + constructor(instanceAction: Ec2InstanceAction) { + this.ec2Action = instanceAction; + } + + /** + * Returns an alarm action configuration to use an EC2 action as an alarm action + */ + bind(_scope: Construct, _alarm: cloudwatch.IAlarm): cloudwatch.AlarmActionConfig { + return { alarmActionArn: `arn:aws:automate:${Stack.of(_scope).region}:ec2:${this.ec2Action}` }; + } +} + diff --git a/packages/@aws-cdk/aws-cloudwatch-actions/lib/index.ts b/packages/@aws-cdk/aws-cloudwatch-actions/lib/index.ts index 3c446d47491aa..5a384eba01247 100644 --- a/packages/@aws-cdk/aws-cloudwatch-actions/lib/index.ts +++ b/packages/@aws-cdk/aws-cloudwatch-actions/lib/index.ts @@ -1,3 +1,4 @@ export * from './appscaling'; export * from './autoscaling'; export * from './sns'; +export * from './ec2'; diff --git a/packages/@aws-cdk/aws-cloudwatch-actions/test/ec2.test.ts b/packages/@aws-cdk/aws-cloudwatch-actions/test/ec2.test.ts new file mode 100644 index 0000000000000..8845588d533b4 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch-actions/test/ec2.test.ts @@ -0,0 +1,41 @@ +import '@aws-cdk/assert/jest'; +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import { Stack } from '@aws-cdk/core'; +import * as actions from '../lib'; + +test('can use instance reboot as alarm action', () => { + // GIVEN + const stack = new Stack(); + const alarm = new cloudwatch.Alarm(stack, 'Alarm', { + metric: new cloudwatch.Metric({ + namespace: 'AWS/EC2', + metricName: 'StatusCheckFailed', + dimensions: { + InstanceId: 'i-03cb889aaaafffeee', + }, + }), + evaluationPeriods: 3, + threshold: 100, + }); + + // WHEN + alarm.addAlarmAction(new actions.Ec2Action(actions.Ec2InstanceAction.REBOOT)); + + // THEN + expect(stack).toHaveResource('AWS::CloudWatch::Alarm', { + AlarmActions: [ + { + 'Fn::Join': [ + '', + [ + 'arn:aws:automate:', + { + Ref: 'AWS::Region', + }, + ':ec2:reboot', + ], + ], + }, + ], + }); +}); diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts b/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts index 73c1209733b5f..d8c93f66aa910 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts @@ -1,5 +1,6 @@ import { Lazy, Stack, Token } from '@aws-cdk/core'; import { Construct } from 'constructs'; +import { IAlarmAction } from './alarm-action'; import { AlarmBase, IAlarm } from './alarm-base'; import { CfnAlarm, CfnAlarmProps } from './cloudwatch.generated'; import { HorizontalAnnotation } from './graph'; @@ -224,6 +225,33 @@ export class Alarm extends AlarmBase { return this.annotation; } + /** + * Trigger this action if the alarm fires + * + * Typically the ARN of an SNS topic or ARN of an AutoScaling policy. + */ + public addAlarmAction(...actions: IAlarmAction[]) { + if (this.alarmActionArns === undefined) { + this.alarmActionArns = []; + } + + this.alarmActionArns.push(...actions.map(a => + this.validateActionArn(a.bind(this, this).alarmActionArn), + )); + } + + private validateActionArn(actionArn: string): string { + const ec2ActionsRegexp: RegExp = /arn:aws:automate:[a-z|\d|-]+:ec2:[a-z]+/; + if (ec2ActionsRegexp.test(actionArn)) { + // Check per-instance metric + const metricConfig = this.metric.toMetricConfig(); + if (metricConfig.metricStat?.dimensions?.length != 1 || metricConfig.metricStat?.dimensions![0].name != 'InstanceId') { + throw new Error(`EC2 alarm actions requires an EC2 Per-Instance Metric. (${JSON.stringify(metricConfig)} does not have an 'InstanceId' dimension)`); + } + } + return actionArn; + } + private renderMetric(metric: IMetric) { const self = this; return dispatchMetric(metric, { diff --git a/packages/@aws-cdk/aws-cloudwatch/test/test.alarm.ts b/packages/@aws-cdk/aws-cloudwatch/test/test.alarm.ts index c7c0f647c2e58..c6a727c023c16 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/test.alarm.ts +++ b/packages/@aws-cdk/aws-cloudwatch/test/test.alarm.ts @@ -42,6 +42,20 @@ export = { test.done(); }, + 'non ec2 instance related alarm does not accept EC2 action'(test: Test) { + + const stack = new Stack(); + const alarm = new Alarm(stack, 'Alarm', { + metric: testMetric, + threshold: 1000, + evaluationPeriods: 2, + }); + + test.throws(() => { + alarm.addAlarmAction(new Ec2TestAlarmAction('arn:aws:automate:us-east-1:ec2:reboot')); + }, /EC2 alarm actions requires an EC2 Per-Instance Metric. \(.+ does not have an 'InstanceId' dimension\)/); + test.done(); + }, 'can make simple alarm'(test: Test) { // GIVEN const stack = new Stack(); @@ -253,3 +267,12 @@ class TestAlarmAction implements IAlarmAction { return { alarmActionArn: this.arn }; } } + +class Ec2TestAlarmAction implements IAlarmAction { + constructor(private readonly arn: string) { + } + + public bind(_scope: Construct, _alarm: IAlarm) { + return { alarmActionArn: this.arn }; + } +} diff --git a/packages/@aws-cdk/aws-dynamodb/lib/replica-handler/index.ts b/packages/@aws-cdk/aws-dynamodb/lib/replica-handler/index.ts index 814bad346ece2..1554dcc84004d 100644 --- a/packages/@aws-cdk/aws-dynamodb/lib/replica-handler/index.ts +++ b/packages/@aws-cdk/aws-dynamodb/lib/replica-handler/index.ts @@ -5,27 +5,34 @@ import { DynamoDB } from 'aws-sdk'; // eslint-disable-line import/no-extraneous- export async function onEventHandler(event: OnEventRequest): Promise { console.log('Event: %j', event); - /** - * Process only Create and Delete requests. We shouldn't receive any - * update request and in case we do there is nothing to update. - */ + const dynamodb = new DynamoDB(); + + let updateTableAction: 'Create' | 'Update' | 'Delete'; if (event.RequestType === 'Create' || event.RequestType === 'Delete') { - const dynamodb = new DynamoDB(); - - const data = await dynamodb.updateTable({ - TableName: event.ResourceProperties.TableName, - ReplicaUpdates: [ - { - [event.RequestType]: { - RegionName: event.ResourceProperties.Region, - }, - }, - ], - }).promise(); - console.log('Update table: %j', data); + updateTableAction = event.RequestType; + } else { // Update + // This can only be a table replacement so we create a replica + // in the new table. The replica for the "old" table will be + // deleted when CF issues a Delete event on the old physical + // resource id. + updateTableAction = 'Create'; } - return { PhysicalResourceId: event.ResourceProperties.Region }; + const data = await dynamodb.updateTable({ + TableName: event.ResourceProperties.TableName, + ReplicaUpdates: [ + { + [updateTableAction]: { + RegionName: event.ResourceProperties.Region, + }, + }, + ], + }).promise(); + console.log('Update table: %j', data); + + return event.RequestType === 'Create' || event.RequestType === 'Update' + ? { PhysicalResourceId: `${event.ResourceProperties.TableName}-${event.ResourceProperties.Region}` } + : {}; } export async function isCompleteHandler(event: IsCompleteRequest): Promise { diff --git a/packages/@aws-cdk/aws-dynamodb/lib/table.ts b/packages/@aws-cdk/aws-dynamodb/lib/table.ts index 1b12eef42de1f..8f36894fc5df8 100644 --- a/packages/@aws-cdk/aws-dynamodb/lib/table.ts +++ b/packages/@aws-cdk/aws-dynamodb/lib/table.ts @@ -1670,12 +1670,19 @@ interface ScalableAttributePair { */ class SourceTableAttachedPolicy extends CoreConstruct implements iam.IGrantable { public readonly grantPrincipal: iam.IPrincipal; - public readonly policy: iam.IPolicy; + public readonly policy: iam.IManagedPolicy; public constructor(sourceTable: Table, role: iam.IRole) { - super(sourceTable, `SourceTableAttachedPolicy-${Names.nodeUniqueId(role.node)}`); - - const policy = new iam.Policy(this, 'Resource', { roles: [role] }); + super(sourceTable, `SourceTableAttachedManagedPolicy-${Names.nodeUniqueId(role.node)}`); + + const policy = new iam.ManagedPolicy(this, 'Resource', { + // A CF update of the description property of a managed policy requires + // a replacement. Use the table name in the description to force a managed + // policy replacement when the table name changes. This way we preserve permissions + // to delete old replicas in case of a table replacement. + description: `DynamoDB replication managed policy for table ${sourceTable.tableName}`, + roles: [role], + }); this.policy = policy; this.grantPrincipal = new SourceTableAttachedPrincipal(role, policy); } @@ -1686,7 +1693,7 @@ class SourceTableAttachedPolicy extends CoreConstruct implements iam.IGrantable * `SourceTableAttachedPolicy` class so it can act as an `IGrantable`. */ class SourceTableAttachedPrincipal extends iam.PrincipalBase { - public constructor(private readonly role: iam.IRole, private readonly policy: iam.Policy) { + public constructor(private readonly role: iam.IRole, private readonly policy: iam.ManagedPolicy) { super(); } diff --git a/packages/@aws-cdk/aws-dynamodb/test/integ.global-replicas-provisioned.expected.json b/packages/@aws-cdk/aws-dynamodb/test/integ.global-replicas-provisioned.expected.json index 89a9c3807fc21..b4ea44f2709c4 100644 --- a/packages/@aws-cdk/aws-dynamodb/test/integ.global-replicas-provisioned.expected.json +++ b/packages/@aws-cdk/aws-dynamodb/test/integ.global-replicas-provisioned.expected.json @@ -26,8 +26,8 @@ "UpdateReplacePolicy": "Delete", "DeletionPolicy": "Delete" }, - "TableSourceTableAttachedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderOnEventHandlerServiceRoleD9856B77945CD5DF": { - "Type": "AWS::IAM::Policy", + "TableSourceTableAttachedManagedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderOnEventHandlerServiceRoleD9856B771F8F2CCB": { + "Type": "AWS::IAM::ManagedPolicy", "Properties": { "PolicyDocument": { "Statement": [ @@ -93,7 +93,18 @@ ], "Version": "2012-10-17" }, - "PolicyName": "leAttachedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderOnEventHandlerServiceRoleD9856B77945CD5DF", + "Description": { + "Fn::Join": [ + "", + [ + "DynamoDB replication managed policy for table ", + { + "Ref": "TableCD117FA1" + } + ] + ] + }, + "Path": "/", "Roles": [ { "Fn::GetAtt": [ @@ -104,8 +115,8 @@ ] } }, - "TableSourceTableAttachedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRoleBE2B1C1AE3D3CF6D": { - "Type": "AWS::IAM::Policy", + "TableSourceTableAttachedManagedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRoleBE2B1C1A5DC546D2": { + "Type": "AWS::IAM::ManagedPolicy", "Properties": { "PolicyDocument": { "Statement": [ @@ -127,7 +138,18 @@ ], "Version": "2012-10-17" }, - "PolicyName": "ttachedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRoleBE2B1C1AE3D3CF6D", + "Description": { + "Fn::Join": [ + "", + [ + "DynamoDB replication managed policy for table ", + { + "Ref": "TableCD117FA1" + } + ] + ] + }, + "Path": "/", "Roles": [ { "Fn::GetAtt": [ @@ -153,8 +175,8 @@ "Region": "us-east-2" }, "DependsOn": [ - "TableSourceTableAttachedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRoleBE2B1C1AE3D3CF6D", - "TableSourceTableAttachedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderOnEventHandlerServiceRoleD9856B77945CD5DF", + "TableSourceTableAttachedManagedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRoleBE2B1C1A5DC546D2", + "TableSourceTableAttachedManagedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderOnEventHandlerServiceRoleD9856B771F8F2CCB", "TableWriteScalingTargetE5669214", "TableWriteScalingTargetTrackingD78DCCD8" ], @@ -178,8 +200,8 @@ }, "DependsOn": [ "TableReplicauseast28A15C236", - "TableSourceTableAttachedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRoleBE2B1C1AE3D3CF6D", - "TableSourceTableAttachedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderOnEventHandlerServiceRoleD9856B77945CD5DF", + "TableSourceTableAttachedManagedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRoleBE2B1C1A5DC546D2", + "TableSourceTableAttachedManagedPolicyawscdkdynamodbglobalreplicasprovisionedawscdkawsdynamodbReplicaProviderOnEventHandlerServiceRoleD9856B771F8F2CCB", "TableWriteScalingTargetE5669214", "TableWriteScalingTargetTrackingD78DCCD8" ], @@ -256,7 +278,7 @@ }, "/", { - "Ref": "AssetParameterse31d108faccc52dcd9a9d86276a05e6ad861311925fe6931eadc31d0fe17e1fdS3BucketEDAACFE7" + "Ref": "AssetParametersd56d097acd2563516c51a0e04dcf8d9bf3638678f723d5b80f95d5c240836aadS3Bucket806FEB2C" }, "/", { @@ -266,7 +288,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameterse31d108faccc52dcd9a9d86276a05e6ad861311925fe6931eadc31d0fe17e1fdS3VersionKey6FF3D50F" + "Ref": "AssetParametersd56d097acd2563516c51a0e04dcf8d9bf3638678f723d5b80f95d5c240836aadS3VersionKey81C7BC5B" } ] } @@ -279,7 +301,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameterse31d108faccc52dcd9a9d86276a05e6ad861311925fe6931eadc31d0fe17e1fdS3VersionKey6FF3D50F" + "Ref": "AssetParametersd56d097acd2563516c51a0e04dcf8d9bf3638678f723d5b80f95d5c240836aadS3VersionKey81C7BC5B" } ] } @@ -289,11 +311,11 @@ ] }, "Parameters": { - "referencetoawscdkdynamodbglobalreplicasprovisionedAssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3Bucket50997EC4Ref": { - "Ref": "AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3Bucket1C6779E0" + "referencetoawscdkdynamodbglobalreplicasprovisionedAssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3BucketD1258B42Ref": { + "Ref": "AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3BucketDEBF01E6" }, - "referencetoawscdkdynamodbglobalreplicasprovisionedAssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3VersionKey0F47C425Ref": { - "Ref": "AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3VersionKey5C1D9275" + "referencetoawscdkdynamodbglobalreplicasprovisionedAssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3VersionKey0F5C355ERef": { + "Ref": "AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3VersionKey42EBA2AE" }, "referencetoawscdkdynamodbglobalreplicasprovisionedAssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3Bucket6C51C355Ref": { "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3BucketDC4B98B1" @@ -334,17 +356,17 @@ } }, "Parameters": { - "AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3Bucket1C6779E0": { + "AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3BucketDEBF01E6": { "Type": "String", - "Description": "S3 bucket for asset \"f13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714\"" + "Description": "S3 bucket for asset \"dd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776\"" }, - "AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3VersionKey5C1D9275": { + "AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3VersionKey42EBA2AE": { "Type": "String", - "Description": "S3 key for asset version \"f13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714\"" + "Description": "S3 key for asset version \"dd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776\"" }, - "AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714ArtifactHash477AAEA7": { + "AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776ArtifactHash692B4CCE": { "Type": "String", - "Description": "Artifact hash for asset \"f13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714\"" + "Description": "Artifact hash for asset \"dd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776\"" }, "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3BucketDC4B98B1": { "Type": "String", @@ -358,17 +380,17 @@ "Type": "String", "Description": "Artifact hash for asset \"daeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1\"" }, - "AssetParameterse31d108faccc52dcd9a9d86276a05e6ad861311925fe6931eadc31d0fe17e1fdS3BucketEDAACFE7": { + "AssetParametersd56d097acd2563516c51a0e04dcf8d9bf3638678f723d5b80f95d5c240836aadS3Bucket806FEB2C": { "Type": "String", - "Description": "S3 bucket for asset \"e31d108faccc52dcd9a9d86276a05e6ad861311925fe6931eadc31d0fe17e1fd\"" + "Description": "S3 bucket for asset \"d56d097acd2563516c51a0e04dcf8d9bf3638678f723d5b80f95d5c240836aad\"" }, - "AssetParameterse31d108faccc52dcd9a9d86276a05e6ad861311925fe6931eadc31d0fe17e1fdS3VersionKey6FF3D50F": { + "AssetParametersd56d097acd2563516c51a0e04dcf8d9bf3638678f723d5b80f95d5c240836aadS3VersionKey81C7BC5B": { "Type": "String", - "Description": "S3 key for asset version \"e31d108faccc52dcd9a9d86276a05e6ad861311925fe6931eadc31d0fe17e1fd\"" + "Description": "S3 key for asset version \"d56d097acd2563516c51a0e04dcf8d9bf3638678f723d5b80f95d5c240836aad\"" }, - "AssetParameterse31d108faccc52dcd9a9d86276a05e6ad861311925fe6931eadc31d0fe17e1fdArtifactHash898696F1": { + "AssetParametersd56d097acd2563516c51a0e04dcf8d9bf3638678f723d5b80f95d5c240836aadArtifactHashD0230F6F": { "Type": "String", - "Description": "Artifact hash for asset \"e31d108faccc52dcd9a9d86276a05e6ad861311925fe6931eadc31d0fe17e1fd\"" + "Description": "Artifact hash for asset \"d56d097acd2563516c51a0e04dcf8d9bf3638678f723d5b80f95d5c240836aad\"" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-dynamodb/test/integ.global.expected.json b/packages/@aws-cdk/aws-dynamodb/test/integ.global.expected.json index a66dd3d965ed9..3896ac3a355b2 100644 --- a/packages/@aws-cdk/aws-dynamodb/test/integ.global.expected.json +++ b/packages/@aws-cdk/aws-dynamodb/test/integ.global.expected.json @@ -41,8 +41,8 @@ "UpdateReplacePolicy": "Delete", "DeletionPolicy": "Delete" }, - "TableSourceTableAttachedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderOnEventHandlerServiceRole6F43DF4AA4E210EA": { - "Type": "AWS::IAM::Policy", + "TableSourceTableAttachedManagedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderOnEventHandlerServiceRole6F43DF4A23250B4C": { + "Type": "AWS::IAM::ManagedPolicy", "Properties": { "PolicyDocument": { "Statement": [ @@ -119,7 +119,18 @@ ], "Version": "2012-10-17" }, - "PolicyName": "TableSourceTableAttachedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderOnEventHandlerServiceRole6F43DF4AA4E210EA", + "Description": { + "Fn::Join": [ + "", + [ + "DynamoDB replication managed policy for table ", + { + "Ref": "TableCD117FA1" + } + ] + ] + }, + "Path": "/", "Roles": [ { "Fn::GetAtt": [ @@ -130,8 +141,8 @@ ] } }, - "TableSourceTableAttachedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRole397161288F61AAFA": { - "Type": "AWS::IAM::Policy", + "TableSourceTableAttachedManagedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRole3971612857304880": { + "Type": "AWS::IAM::ManagedPolicy", "Properties": { "PolicyDocument": { "Statement": [ @@ -164,7 +175,18 @@ ], "Version": "2012-10-17" }, - "PolicyName": "leSourceTableAttachedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRole397161288F61AAFA", + "Description": { + "Fn::Join": [ + "", + [ + "DynamoDB replication managed policy for table ", + { + "Ref": "TableCD117FA1" + } + ] + ] + }, + "Path": "/", "Roles": [ { "Fn::GetAtt": [ @@ -190,8 +212,8 @@ "Region": "eu-west-2" }, "DependsOn": [ - "TableSourceTableAttachedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRole397161288F61AAFA", - "TableSourceTableAttachedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderOnEventHandlerServiceRole6F43DF4AA4E210EA" + "TableSourceTableAttachedManagedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRole3971612857304880", + "TableSourceTableAttachedManagedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderOnEventHandlerServiceRole6F43DF4A23250B4C" ], "UpdateReplacePolicy": "Delete", "DeletionPolicy": "Delete" @@ -212,8 +234,8 @@ }, "DependsOn": [ "TableReplicaeuwest290D3CD3A", - "TableSourceTableAttachedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRole397161288F61AAFA", - "TableSourceTableAttachedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderOnEventHandlerServiceRole6F43DF4AA4E210EA" + "TableSourceTableAttachedManagedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderIsCompleteHandlerServiceRole3971612857304880", + "TableSourceTableAttachedManagedPolicycdkdynamodbglobal20191121awscdkawsdynamodbReplicaProviderOnEventHandlerServiceRole6F43DF4A23250B4C" ], "UpdateReplacePolicy": "Delete", "DeletionPolicy": "Delete" @@ -231,7 +253,7 @@ }, "/", { - "Ref": "AssetParametersf8cfc24954f0c95960d9a93888c01bf5e95802f26bfa5dc6fde5c913a1429ceaS3Bucket434BDB62" + "Ref": "AssetParametersa789639d6caa7a94b8135bc6ff3a6935f95624a9ed88014b5e7b3d340f20c3b4S3Bucket8BB0CECD" }, "/", { @@ -241,7 +263,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParametersf8cfc24954f0c95960d9a93888c01bf5e95802f26bfa5dc6fde5c913a1429ceaS3VersionKey01638790" + "Ref": "AssetParametersa789639d6caa7a94b8135bc6ff3a6935f95624a9ed88014b5e7b3d340f20c3b4S3VersionKeyC531296D" } ] } @@ -254,7 +276,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParametersf8cfc24954f0c95960d9a93888c01bf5e95802f26bfa5dc6fde5c913a1429ceaS3VersionKey01638790" + "Ref": "AssetParametersa789639d6caa7a94b8135bc6ff3a6935f95624a9ed88014b5e7b3d340f20c3b4S3VersionKeyC531296D" } ] } @@ -264,11 +286,11 @@ ] }, "Parameters": { - "referencetocdkdynamodbglobal20191121AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3Bucket71E24D5BRef": { - "Ref": "AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3Bucket1C6779E0" + "referencetocdkdynamodbglobal20191121AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3Bucket06999F76Ref": { + "Ref": "AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3BucketDEBF01E6" }, - "referencetocdkdynamodbglobal20191121AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3VersionKeyD88E8BACRef": { - "Ref": "AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3VersionKey5C1D9275" + "referencetocdkdynamodbglobal20191121AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3VersionKey3D988AD7Ref": { + "Ref": "AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3VersionKey42EBA2AE" }, "referencetocdkdynamodbglobal20191121AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3BucketC7F3A147Ref": { "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3BucketDC4B98B1" @@ -283,17 +305,17 @@ } }, "Parameters": { - "AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3Bucket1C6779E0": { + "AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3BucketDEBF01E6": { "Type": "String", - "Description": "S3 bucket for asset \"f13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714\"" + "Description": "S3 bucket for asset \"dd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776\"" }, - "AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714S3VersionKey5C1D9275": { + "AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776S3VersionKey42EBA2AE": { "Type": "String", - "Description": "S3 key for asset version \"f13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714\"" + "Description": "S3 key for asset version \"dd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776\"" }, - "AssetParametersf13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714ArtifactHash477AAEA7": { + "AssetParametersdd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776ArtifactHash692B4CCE": { "Type": "String", - "Description": "Artifact hash for asset \"f13d472270faaa08099009152a8848a0e7434b14773f3c3f94acca6f6c3ae714\"" + "Description": "Artifact hash for asset \"dd0a4ac30ffa331e472caec08a7784ac440d122a6f924b1bea7d48dc85f8f776\"" }, "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3BucketDC4B98B1": { "Type": "String", @@ -307,17 +329,17 @@ "Type": "String", "Description": "Artifact hash for asset \"daeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1\"" }, - "AssetParametersf8cfc24954f0c95960d9a93888c01bf5e95802f26bfa5dc6fde5c913a1429ceaS3Bucket434BDB62": { + "AssetParametersa789639d6caa7a94b8135bc6ff3a6935f95624a9ed88014b5e7b3d340f20c3b4S3Bucket8BB0CECD": { "Type": "String", - "Description": "S3 bucket for asset \"f8cfc24954f0c95960d9a93888c01bf5e95802f26bfa5dc6fde5c913a1429cea\"" + "Description": "S3 bucket for asset \"a789639d6caa7a94b8135bc6ff3a6935f95624a9ed88014b5e7b3d340f20c3b4\"" }, - "AssetParametersf8cfc24954f0c95960d9a93888c01bf5e95802f26bfa5dc6fde5c913a1429ceaS3VersionKey01638790": { + "AssetParametersa789639d6caa7a94b8135bc6ff3a6935f95624a9ed88014b5e7b3d340f20c3b4S3VersionKeyC531296D": { "Type": "String", - "Description": "S3 key for asset version \"f8cfc24954f0c95960d9a93888c01bf5e95802f26bfa5dc6fde5c913a1429cea\"" + "Description": "S3 key for asset version \"a789639d6caa7a94b8135bc6ff3a6935f95624a9ed88014b5e7b3d340f20c3b4\"" }, - "AssetParametersf8cfc24954f0c95960d9a93888c01bf5e95802f26bfa5dc6fde5c913a1429ceaArtifactHashD0E61C22": { + "AssetParametersa789639d6caa7a94b8135bc6ff3a6935f95624a9ed88014b5e7b3d340f20c3b4ArtifactHash9D92B407": { "Type": "String", - "Description": "Artifact hash for asset \"f8cfc24954f0c95960d9a93888c01bf5e95802f26bfa5dc6fde5c913a1429cea\"" + "Description": "Artifact hash for asset \"a789639d6caa7a94b8135bc6ff3a6935f95624a9ed88014b5e7b3d340f20c3b4\"" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-dynamodb/test/replica-provider.test.ts b/packages/@aws-cdk/aws-dynamodb/test/replica-provider.test.ts index 3a1d97bd4b345..4b5acef3d15cb 100644 --- a/packages/@aws-cdk/aws-dynamodb/test/replica-provider.test.ts +++ b/packages/@aws-cdk/aws-dynamodb/test/replica-provider.test.ts @@ -54,25 +54,62 @@ test('on event', async () => { }); expect(data).toEqual({ - PhysicalResourceId: 'eu-west-2', + PhysicalResourceId: 'my-table-eu-west-2', }); }); -test('on event does not call updateTable for Update requests', async () => { +test('on event calls updateTable with Create for Update requests with table replacement', async () => { const updateTableMock = sinon.fake.resolves({}); AWS.mock('DynamoDB', 'updateTable', updateTableMock); const data = await onEventHandler({ ...createEvent, + OldResourceProperties: { + TableName: 'my-old-table', + }, RequestType: 'Update', }); - sinon.assert.notCalled(updateTableMock); + sinon.assert.calledWith(updateTableMock, { + TableName: 'my-table', + ReplicaUpdates: [ + { + Create: { + RegionName: 'eu-west-2', + }, + }, + ], + }); expect(data).toEqual({ - PhysicalResourceId: 'eu-west-2', + PhysicalResourceId: 'my-table-eu-west-2', + }); +}); + +test('on event calls updateTable with Delete', async () => { + const updateTableMock = sinon.fake.resolves({}); + + AWS.mock('DynamoDB', 'updateTable', updateTableMock); + + const data = await onEventHandler({ + ...createEvent, + RequestType: 'Delete', + }); + + sinon.assert.calledWith(updateTableMock, { + TableName: 'my-table', + ReplicaUpdates: [ + { + Delete: { + RegionName: 'eu-west-2', + }, + }, + ], }); + + // Physical resource id never changed on Delete + expect(data).toEqual({}); }); test('is complete for create returns false without replicas', async () => { diff --git a/packages/@aws-cdk/aws-ec2/lib/nat.ts b/packages/@aws-cdk/aws-ec2/lib/nat.ts index c164362703692..bf65671b92287 100644 --- a/packages/@aws-cdk/aws-ec2/lib/nat.ts +++ b/packages/@aws-cdk/aws-ec2/lib/nat.ts @@ -7,6 +7,26 @@ import { Port } from './port'; import { ISecurityGroup, SecurityGroup } from './security-group'; import { PrivateSubnet, PublicSubnet, RouterType, Vpc } from './vpc'; +/** + * Direction of traffic to allow all by default. + */ +export enum NatTrafficDirection { + /** + * Allow all outbound traffic and disallow all inbound traffic. + */ + OUTBOUND_ONLY = 'OUTBOUND_ONLY', + + /** + * Allow all outbound and inbound traffic. + */ + INBOUND_AND_OUTBOUND = 'INBOUND_AND_OUTBOUND', + + /** + * Disallow all outbound and inbound traffic. + */ + NONE = 'NONE', +} + /** * Pair represents a gateway created by NAT Provider */ @@ -148,7 +168,7 @@ export interface NatInstanceProps { readonly securityGroup?: ISecurityGroup; /** - * Allow all traffic through the NAT instance + * Allow all inbound traffic through the NAT instance * * If you set this to false, you must configure the NAT instance's security * groups in another way, either by passing in a fully configured Security @@ -157,8 +177,24 @@ export interface NatInstanceProps { * Provider to a Vpc. * * @default true + * @deprecated - Use `defaultAllowedTraffic`. */ readonly allowAllTraffic?: boolean; + + /** + * Direction to allow all traffic through the NAT instance by default. + * + * By default, inbound and outbound traffic is allowed. + * + * If you set this to another value than INBOUND_AND_OUTBOUND, you must + * configure the NAT instance's security groups in another way, either by + * passing in a fully configured Security Group using the `securityGroup` + * property, or by configuring it using the `.securityGroup` or + * `.connections` members after passing the NAT Instance Provider to a Vpc. + * + * @default NatTrafficDirection.INBOUND_AND_OUTBOUND + */ + readonly defaultAllowedTraffic?: NatTrafficDirection; } /** @@ -205,18 +241,26 @@ export class NatInstanceProvider extends NatProvider implements IConnectable { constructor(private readonly props: NatInstanceProps) { super(); + + if (props.defaultAllowedTraffic !== undefined && props.allowAllTraffic !== undefined) { + throw new Error('Can not specify both of \'defaultAllowedTraffic\' and \'defaultAllowedTraffic\'; prefer \'defaultAllowedTraffic\''); + } } public configureNat(options: ConfigureNatOptions) { + const defaultDirection = this.props.defaultAllowedTraffic ?? + (this.props.allowAllTraffic ?? true ? NatTrafficDirection.INBOUND_AND_OUTBOUND : NatTrafficDirection.OUTBOUND_ONLY); + // Create the NAT instances. They can share a security group and a Role. const machineImage = this.props.machineImage || new NatInstanceImage(); this._securityGroup = this.props.securityGroup ?? new SecurityGroup(options.vpc, 'NatSecurityGroup', { vpc: options.vpc, description: 'Security Group for NAT instances', + allowAllOutbound: isOutboundAllowed(defaultDirection), }); this._connections = new Connections({ securityGroups: [this._securityGroup] }); - if (this.props.allowAllTraffic ?? true) { + if (isInboundAllowed(defaultDirection)) { this.connections.allowFromAnyIpv4(Port.allTraffic()); } @@ -325,3 +369,12 @@ export class NatInstanceImage extends LookupMachineImage { }); } } + +function isOutboundAllowed(direction: NatTrafficDirection) { + return direction === NatTrafficDirection.INBOUND_AND_OUTBOUND || + direction === NatTrafficDirection.OUTBOUND_ONLY; +} + +function isInboundAllowed(direction: NatTrafficDirection) { + return direction === NatTrafficDirection.INBOUND_AND_OUTBOUND; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/test/vpc.test.ts b/packages/@aws-cdk/aws-ec2/test/vpc.test.ts index 9f4ceaf1357d6..77de63770cd6a 100644 --- a/packages/@aws-cdk/aws-ec2/test/vpc.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/vpc.test.ts @@ -2,9 +2,30 @@ import { countResources, expect as cdkExpect, haveResource, haveResourceLike, is import { CfnOutput, CfnResource, Fn, Lazy, Stack, Tags } from '@aws-cdk/core'; import { nodeunitShim, Test } from 'nodeunit-shim'; import { - AclCidr, AclTraffic, BastionHostLinux, CfnSubnet, CfnVPC, SubnetFilter, DefaultInstanceTenancy, GenericLinuxImage, - InstanceType, InterfaceVpcEndpoint, InterfaceVpcEndpointService, NatProvider, NetworkAcl, NetworkAclEntry, Peer, Port, PrivateSubnet, - PublicSubnet, RouterType, Subnet, SubnetType, TrafficDirection, Vpc, + AclCidr, + AclTraffic, + BastionHostLinux, + CfnSubnet, + CfnVPC, + SubnetFilter, + DefaultInstanceTenancy, + GenericLinuxImage, + InstanceType, + InterfaceVpcEndpoint, + InterfaceVpcEndpointService, + NatProvider, + NatTrafficDirection, + NetworkAcl, + NetworkAclEntry, + Peer, + Port, + PrivateSubnet, + PublicSubnet, + RouterType, + Subnet, + SubnetType, + TrafficDirection, + Vpc, } from '../lib'; nodeunitShim({ @@ -904,6 +925,22 @@ nodeunitShim({ DestinationCidrBlock: '0.0.0.0/0', InstanceId: { Ref: 'TheVPCPublicSubnet1NatInstanceCC514192' }, })); + cdkExpect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [ + { + CidrIp: '0.0.0.0/0', + Description: 'Allow all outbound traffic by default', + IpProtocol: '-1', + }, + ], + SecurityGroupIngress: [ + { + CidrIp: '0.0.0.0/0', + Description: 'from 0.0.0.0/0:ALL TRAFFIC', + IpProtocol: '-1', + }, + ], + })); test.done(); }, @@ -929,7 +966,7 @@ nodeunitShim({ test.done(); }, - 'can configure Security Groups of NAT instances'(test: Test) { + 'can configure Security Groups of NAT instances with allowAllTraffic false'(test: Test) { // GIVEN const stack = getTestStack(); @@ -948,6 +985,13 @@ nodeunitShim({ // THEN cdkExpect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [ + { + CidrIp: '0.0.0.0/0', + Description: 'Allow all outbound traffic by default', + IpProtocol: '-1', + }, + ], SecurityGroupIngress: [ { CidrIp: '1.2.3.4/32', @@ -962,6 +1006,105 @@ nodeunitShim({ test.done(); }, + 'can configure Security Groups of NAT instances with defaultAllowAll INBOUND_AND_OUTBOUND'(test: Test) { + // GIVEN + const stack = getTestStack(); + + // WHEN + const provider = NatProvider.instance({ + instanceType: new InstanceType('q86.mega'), + machineImage: new GenericLinuxImage({ + 'us-east-1': 'ami-1', + }), + defaultAllowedTraffic: NatTrafficDirection.INBOUND_AND_OUTBOUND, + }); + new Vpc(stack, 'TheVPC', { + natGatewayProvider: provider, + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [ + { + CidrIp: '0.0.0.0/0', + Description: 'Allow all outbound traffic by default', + IpProtocol: '-1', + }, + ], + SecurityGroupIngress: [ + { + CidrIp: '0.0.0.0/0', + Description: 'from 0.0.0.0/0:ALL TRAFFIC', + IpProtocol: '-1', + }, + ], + })); + + test.done(); + }, + + 'can configure Security Groups of NAT instances with defaultAllowAll OUTBOUND_ONLY'(test: Test) { + // GIVEN + const stack = getTestStack(); + + // WHEN + const provider = NatProvider.instance({ + instanceType: new InstanceType('q86.mega'), + machineImage: new GenericLinuxImage({ + 'us-east-1': 'ami-1', + }), + defaultAllowedTraffic: NatTrafficDirection.OUTBOUND_ONLY, + }); + new Vpc(stack, 'TheVPC', { + natGatewayProvider: provider, + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [ + { + CidrIp: '0.0.0.0/0', + Description: 'Allow all outbound traffic by default', + IpProtocol: '-1', + }, + ], + })); + + test.done(); + }, + + 'can configure Security Groups of NAT instances with defaultAllowAll NONE'(test: Test) { + // GIVEN + const stack = getTestStack(); + + // WHEN + const provider = NatProvider.instance({ + instanceType: new InstanceType('q86.mega'), + machineImage: new GenericLinuxImage({ + 'us-east-1': 'ami-1', + }), + defaultAllowedTraffic: NatTrafficDirection.NONE, + }); + new Vpc(stack, 'TheVPC', { + natGatewayProvider: provider, + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [ + { + CidrIp: '255.255.255.255/32', + Description: 'Disallow all traffic', + FromPort: 252, + IpProtocol: 'icmp', + ToPort: 86, + }, + ], + })); + + test.done(); + }, + }, 'Network ACL association': { diff --git a/packages/@aws-cdk/aws-ecr/lib/repository.ts b/packages/@aws-cdk/aws-ecr/lib/repository.ts index 3787903dedc3e..20f110c206428 100644 --- a/packages/@aws-cdk/aws-ecr/lib/repository.ts +++ b/packages/@aws-cdk/aws-ecr/lib/repository.ts @@ -450,7 +450,7 @@ export class Repository extends RepositoryBase { repositoryPolicyText: Lazy.any({ produce: () => this.policyDocument }), lifecyclePolicy: Lazy.any({ produce: () => this.renderLifecyclePolicy() }), imageScanningConfiguration: !props.imageScanOnPush ? undefined : { - scanOnPush: true, + ScanOnPush: true, }, }); diff --git a/packages/@aws-cdk/aws-ecr/test/integ.imagescan.expected.json b/packages/@aws-cdk/aws-ecr/test/integ.imagescan.expected.json index 5367d722f62c9..5fbca07aa35d4 100644 --- a/packages/@aws-cdk/aws-ecr/test/integ.imagescan.expected.json +++ b/packages/@aws-cdk/aws-ecr/test/integ.imagescan.expected.json @@ -4,7 +4,7 @@ "Type": "AWS::ECR::Repository", "Properties": { "ImageScanningConfiguration": { - "scanOnPush": true + "ScanOnPush": true } }, "UpdateReplacePolicy": "Delete", @@ -87,4 +87,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-ecr/test/test.repository.ts b/packages/@aws-cdk/aws-ecr/test/test.repository.ts index 20c53f1a3032a..8c8094be287e9 100644 --- a/packages/@aws-cdk/aws-ecr/test/test.repository.ts +++ b/packages/@aws-cdk/aws-ecr/test/test.repository.ts @@ -38,7 +38,7 @@ export = { // THEN expect(stack).to(haveResource('AWS::ECR::Repository', { ImageScanningConfiguration: { - scanOnPush: true, + ScanOnPush: true, }, })); test.done(); diff --git a/packages/@aws-cdk/aws-ecs/README.md b/packages/@aws-cdk/aws-ecs/README.md index 09a5ad13c14cd..a038cf65fbaf9 100644 --- a/packages/@aws-cdk/aws-ecs/README.md +++ b/packages/@aws-cdk/aws-ecs/README.md @@ -683,6 +683,49 @@ taskDefinition.addContainer('TheContainer', { }); ``` +## CloudMap Service Discovery + +To register your ECS service with a CloudMap Service Registry, you may add the +`cloudMapOptions` property to your service: + +```ts +const service = new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + // Create A records - useful for AWSVPC network mode. + dnsRecordType: cloudmap.DnsRecordType.A, + }, +}); +``` + +With `bridge` or `host` network modes, only `SRV` DNS record types are supported. +By default, `SRV` DNS record types will target the default container and default +port. However, you may target a different container and port on the same ECS task: + +```ts +// Add a container to the task definition +const specificContainer = taskDefinition.addContainer(...); + +// Add a port mapping +specificContainer.addPortMappings({ + containerPort: 7600, + protocol: ecs.Protocol.TCP, +}); + +new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + // Create SRV records - useful for bridge networking + dnsRecordType: cloudmap.DnsRecordType.SRV, + // Targets port TCP port 7600 `specificContainer` + container: specificContainer, + containerPort: 7600, + }, +}); +``` + ## Capacity Providers Currently, only `FARGATE` and `FARGATE_SPOT` capacity providers are supported. diff --git a/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts index 8e2483f98838f..1e1fdde585f1e 100644 --- a/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts @@ -8,8 +8,8 @@ import * as cloudmap from '@aws-cdk/aws-servicediscovery'; import { Annotations, Duration, IResolvable, IResource, Lazy, Resource, Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { LoadBalancerTargetOptions, NetworkMode, TaskDefinition } from '../base/task-definition'; +import { ContainerDefinition, Protocol } from '../container-definition'; import { ICluster, CapacityProviderStrategy } from '../cluster'; -import { Protocol } from '../container-definition'; import { CfnService } from '../ecs.generated'; import { ScalableTaskCount } from './scalable-task-count'; @@ -572,10 +572,12 @@ export abstract class BaseService extends Resource } } - // If the task definition that your service task specifies uses the AWSVPC network mode and a type SRV DNS record is - // used, you must specify a containerName and containerPort combination - const containerName = dnsRecordType === cloudmap.DnsRecordType.SRV ? this.taskDefinition.defaultContainer!.containerName : undefined; - const containerPort = dnsRecordType === cloudmap.DnsRecordType.SRV ? this.taskDefinition.defaultContainer!.containerPort : undefined; + const { containerName, containerPort } = determineContainerNameAndPort({ + taskDefinition: this.taskDefinition, + dnsRecordType: dnsRecordType!, + container: options.container, + containerPort: options.containerPort, + }); const cloudmapService = new cloudmap.Service(this, 'CloudmapService', { namespace: sdNamespace, @@ -799,7 +801,19 @@ export interface CloudMapOptions { * * NOTE: This is used for HealthCheckCustomConfig */ - readonly failureThreshold?: number, + readonly failureThreshold?: number; + + /** + * The container to point to for a SRV record. + * @default - the task definition's default container + */ + readonly container?: ContainerDefinition; + + /** + * The port to point to for a SRV record. + * @default - the default port of the task definition's default container + */ + readonly containerPort?: number; } /** @@ -885,3 +899,42 @@ export enum PropagatedTagSource { */ NONE = 'NONE' } + +/** + * Options for `determineContainerNameAndPort` + */ +interface DetermineContainerNameAndPortOptions { + dnsRecordType: cloudmap.DnsRecordType; + taskDefinition: TaskDefinition; + container?: ContainerDefinition; + containerPort?: number; +} + +/** + * Determine the name of the container and port to target for the service registry. + */ +function determineContainerNameAndPort(options: DetermineContainerNameAndPortOptions) { + // If the record type is SRV, then provide the containerName and containerPort to target. + // We use the name of the default container and the default port of the default container + // unless the user specifies otherwise. + if (options.dnsRecordType === cloudmap.DnsRecordType.SRV) { + // Ensure the user-provided container is from the right task definition. + if (options.container && options.container.taskDefinition != options.taskDefinition) { + throw new Error('Cannot add discovery for a container from another task definition'); + } + + const container = options.container ?? options.taskDefinition.defaultContainer!; + + // Ensure that any port given by the user is mapped. + if (options.containerPort && !container.portMappings.some(mapping => mapping.containerPort === options.containerPort)) { + throw new Error('Cannot add discovery for a container port that has not been mapped'); + } + + return { + containerName: container.containerName, + containerPort: options.containerPort ?? options.taskDefinition.defaultContainer!.containerPort, + }; + } + + return {}; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts b/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts index d88c5ecafd1d7..945832a09b869 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts @@ -2190,6 +2190,268 @@ nodeunitShim({ test.done(); }, + + 'user can select any container and port'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'FargateTaskDef', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + const mainContainer = taskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + mainContainer.addPortMappings({ containerPort: 8000 }); + + const otherContainer = taskDefinition.addContainer('OtherContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + otherContainer.addPortMappings({ containerPort: 8001 }); + + // WHEN + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + container: otherContainer, + containerPort: 8001, + }, + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ECS::Service', { + ServiceRegistries: [ + { + RegistryArn: { 'Fn::GetAtt': ['ServiceCloudmapService046058A4', 'Arn'] }, + ContainerName: 'OtherContainer', + ContainerPort: 8001, + }, + ], + })); + + test.done(); + }, + + 'By default, the container name is the default'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Task', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + taskDefinition.addContainer('main', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }).addPortMappings({ containerPort: 1234 }); + + taskDefinition.addContainer('second', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }).addPortMappings({ containerPort: 4321 }); + + // WHEN + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: {}, + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ECS::Service', { + ServiceRegistries: [{ + ContainerName: 'main', + ContainerPort: undefined, + }], + })); + + test.done(); + }, + + 'For SRV, by default, container name is default container and port is the default container port'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Task', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + taskDefinition.addContainer('main', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }).addPortMappings({ containerPort: 1234 }); + + taskDefinition.addContainer('second', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }).addPortMappings({ containerPort: 4321 }); + + // WHEN + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + }, + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ECS::Service', { + ServiceRegistries: [{ + ContainerName: 'main', + ContainerPort: 1234, + }], + })); + + test.done(); + }, + + 'allows SRV service discovery to select the container and port'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Task', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + taskDefinition.addContainer('main', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }).addPortMappings({ containerPort: 1234 }); + + const secondContainer = taskDefinition.addContainer('second', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }); + secondContainer.addPortMappings({ containerPort: 4321 }); + + // WHEN + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + container: secondContainer, + containerPort: 4321, + }, + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ECS::Service', { + ServiceRegistries: [{ + ContainerName: 'second', + ContainerPort: 4321, + }], + })); + + test.done(); + }, + + 'throws if SRV and container is not part of task definition'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Task', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + // The right container + taskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + + const wrongTaskDefinition = new ecs.Ec2TaskDefinition(stack, 'WrongTaskDef'); + // The wrong container + const wrongContainer = wrongTaskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + + // WHEN + test.throws(() => { + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + container: wrongContainer, + containerPort: 4321, + }, + }); + }, /another task definition/i); + + test.done(); + }, + + 'throws if SRV and the container port is not mapped'(test: Test) { + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Task', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + const container = taskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + + container.addPortMappings({ containerPort: 8000 }); + + test.throws(() => { + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + container: container, + containerPort: 4321, + }, + }); + }, /container port.*not.*mapped/i); + + test.done(); + }, }, 'Metric'(test: Test) { diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.expected.json b/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.expected.json new file mode 100644 index 0000000000000..e067e7d75a67d --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.expected.json @@ -0,0 +1,808 @@ +{ + "Resources": { + "Vpc8378EB38": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc" + } + ] + } + }, + "VpcpubSubnet1Subnet410C08CF": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/24", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "pub" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/pubSubnet1" + } + ] + } + }, + "VpcpubSubnet1RouteTableE0483FDA": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/pubSubnet1" + } + ] + } + }, + "VpcpubSubnet1RouteTableAssociation68036D8C": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcpubSubnet1RouteTableE0483FDA" + }, + "SubnetId": { + "Ref": "VpcpubSubnet1Subnet410C08CF" + } + } + }, + "VpcpubSubnet1DefaultRouteF020A9EF": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcpubSubnet1RouteTableE0483FDA" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcpubSubnet2Subnet44A37A0D": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.1.0/24", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "pub" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/pubSubnet2" + } + ] + } + }, + "VpcpubSubnet2RouteTable5A29DF40": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/pubSubnet2" + } + ] + } + }, + "VpcpubSubnet2RouteTableAssociationFB826925": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcpubSubnet2RouteTable5A29DF40" + }, + "SubnetId": { + "Ref": "VpcpubSubnet2Subnet44A37A0D" + } + } + }, + "VpcpubSubnet2DefaultRouteE6D48BA4": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcpubSubnet2RouteTable5A29DF40" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcIGWD7BA715C": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc" + } + ] + } + }, + "VpcVPCGWBF912B6E": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "InternetGatewayId": { + "Ref": "VpcIGWD7BA715C" + } + } + }, + "FargateCluster7CCD5F93": { + "Type": "AWS::ECS::Cluster" + }, + "FargateClustercapacityInstanceSecurityGroupCB3AEDA1": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ/FargateCluster/capacity/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "from 0.0.0.0/0:32768-61000", + "FromPort": 32768, + "IpProtocol": "tcp", + "ToPort": 61000 + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "FargateClustercapacityInstanceRoleBE253D2D": { + "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": "aws-ecs-integ/FargateCluster/capacity" + } + ] + } + }, + "FargateClustercapacityInstanceRoleDefaultPolicy90B38927": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecs:DeregisterContainerInstance", + "ecs:RegisterContainerInstance", + "ecs:Submit*" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "FargateCluster7CCD5F93", + "Arn" + ] + } + }, + { + "Action": [ + "ecs:Poll", + "ecs:StartTelemetrySession" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "FargateCluster7CCD5F93", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecs:DiscoverPollEndpoint", + "ecr:GetAuthorizationToken", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "FargateClustercapacityInstanceRoleDefaultPolicy90B38927", + "Roles": [ + { + "Ref": "FargateClustercapacityInstanceRoleBE253D2D" + } + ] + } + }, + "FargateClustercapacityInstanceProfile8294296C": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "FargateClustercapacityInstanceRoleBE253D2D" + } + ] + } + }, + "FargateClustercapacityLaunchConfig9B95CCB7": { + "Type": "AWS::AutoScaling::LaunchConfiguration", + "Properties": { + "ImageId": { + "Ref": "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter" + }, + "InstanceType": "t3.micro", + "IamInstanceProfile": { + "Ref": "FargateClustercapacityInstanceProfile8294296C" + }, + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "FargateClustercapacityInstanceSecurityGroupCB3AEDA1", + "GroupId" + ] + } + ], + "UserData": { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\necho ECS_CLUSTER=", + { + "Ref": "FargateCluster7CCD5F93" + }, + " >> /etc/ecs/ecs.config\nsudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP\nsudo service iptables save\necho ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config" + ] + ] + } + } + }, + "DependsOn": [ + "FargateClustercapacityInstanceRoleDefaultPolicy90B38927", + "FargateClustercapacityInstanceRoleBE253D2D" + ] + }, + "FargateClustercapacityASGE4034F96": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "MaxSize": "1", + "MinSize": "1", + "DesiredCapacity": "1", + "LaunchConfigurationName": { + "Ref": "FargateClustercapacityLaunchConfig9B95CCB7" + }, + "Tags": [ + { + "Key": "Name", + "PropagateAtLaunch": true, + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ], + "VPCZoneIdentifier": [ + { + "Ref": "VpcpubSubnet1Subnet410C08CF" + }, + { + "Ref": "VpcpubSubnet2Subnet44A37A0D" + } + ] + }, + "UpdatePolicy": { + "AutoScalingReplacingUpdate": { + "WillReplace": true + }, + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } + } + }, + "FargateClustercapacityDrainECSHookFunctionServiceRoleA28505D9": { + "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" + ] + ] + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ] + } + }, + "FargateClustercapacityDrainECSHookFunctionServiceRoleDefaultPolicy53CD1145": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeInstanceAttribute", + "ec2:DescribeInstanceStatus", + "ec2:DescribeHosts" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "autoscaling:CompleteLifecycleAction", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":autoscaling:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":autoScalingGroup:*:autoScalingGroupName/", + { + "Ref": "FargateClustercapacityASGE4034F96" + } + ] + ] + } + }, + { + "Action": [ + "ecs:DescribeContainerInstances", + "ecs:DescribeTasks" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "FargateCluster7CCD5F93", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecs:ListContainerInstances", + "ecs:SubmitContainerStateChange", + "ecs:SubmitTaskStateChange" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "FargateCluster7CCD5F93", + "Arn" + ] + } + }, + { + "Action": [ + "ecs:UpdateContainerInstancesState", + "ecs:ListTasks" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "FargateCluster7CCD5F93", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "FargateClustercapacityDrainECSHookFunctionServiceRoleDefaultPolicy53CD1145", + "Roles": [ + { + "Ref": "FargateClustercapacityDrainECSHookFunctionServiceRoleA28505D9" + } + ] + } + }, + "FargateClustercapacityDrainECSHookFunction3E60E6D0": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "import boto3, json, os, time\n\necs = boto3.client('ecs')\nautoscaling = boto3.client('autoscaling')\n\n\ndef lambda_handler(event, context):\n print(json.dumps(event))\n cluster = os.environ['CLUSTER']\n snsTopicArn = event['Records'][0]['Sns']['TopicArn']\n lifecycle_event = json.loads(event['Records'][0]['Sns']['Message'])\n instance_id = lifecycle_event.get('EC2InstanceId')\n if not instance_id:\n print('Got event without EC2InstanceId: %s', json.dumps(event))\n return\n\n instance_arn = container_instance_arn(cluster, instance_id)\n print('Instance %s has container instance ARN %s' % (lifecycle_event['EC2InstanceId'], instance_arn))\n\n if not instance_arn:\n return\n\n while has_tasks(cluster, instance_arn):\n time.sleep(10)\n\n try:\n print('Terminating instance %s' % instance_id)\n autoscaling.complete_lifecycle_action(\n LifecycleActionResult='CONTINUE',\n **pick(lifecycle_event, 'LifecycleHookName', 'LifecycleActionToken', 'AutoScalingGroupName'))\n except Exception as e:\n # Lifecycle action may have already completed.\n print(str(e))\n\n\ndef container_instance_arn(cluster, instance_id):\n \"\"\"Turn an instance ID into a container instance ARN.\"\"\"\n arns = ecs.list_container_instances(cluster=cluster, filter='ec2InstanceId==' + instance_id)['containerInstanceArns']\n if not arns:\n return None\n return arns[0]\n\n\ndef has_tasks(cluster, instance_arn):\n \"\"\"Return True if the instance is running tasks for the given cluster.\"\"\"\n instances = ecs.describe_container_instances(cluster=cluster, containerInstances=[instance_arn])['containerInstances']\n if not instances:\n return False\n instance = instances[0]\n\n if instance['status'] == 'ACTIVE':\n # Start draining, then try again later\n set_container_instance_to_draining(cluster, instance_arn)\n return True\n\n tasks = instance['runningTasksCount'] + instance['pendingTasksCount']\n print('Instance %s has %s tasks' % (instance_arn, tasks))\n\n return tasks > 0\n\n\ndef set_container_instance_to_draining(cluster, instance_arn):\n ecs.update_container_instances_state(\n cluster=cluster,\n containerInstances=[instance_arn], status='DRAINING')\n\n\ndef pick(dct, *keys):\n \"\"\"Pick a subset of a dict.\"\"\"\n return {k: v for k, v in dct.items() if k in keys}\n" + }, + "Role": { + "Fn::GetAtt": [ + "FargateClustercapacityDrainECSHookFunctionServiceRoleA28505D9", + "Arn" + ] + }, + "Environment": { + "Variables": { + "CLUSTER": { + "Ref": "FargateCluster7CCD5F93" + } + } + }, + "Handler": "index.lambda_handler", + "Runtime": "python3.6", + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ], + "Timeout": 310 + }, + "DependsOn": [ + "FargateClustercapacityDrainECSHookFunctionServiceRoleDefaultPolicy53CD1145", + "FargateClustercapacityDrainECSHookFunctionServiceRoleA28505D9" + ] + }, + "FargateClustercapacityDrainECSHookFunctionAllowInvokeawsecsintegFargateClustercapacityLifecycleHookDrainHookTopic07C1229F3B6FF246": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "FargateClustercapacityDrainECSHookFunction3E60E6D0", + "Arn" + ] + }, + "Principal": "sns.amazonaws.com", + "SourceArn": { + "Ref": "FargateClustercapacityLifecycleHookDrainHookTopic390A0E34" + } + } + }, + "FargateClustercapacityDrainECSHookFunctionTopic7D6C4884": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Protocol": "lambda", + "TopicArn": { + "Ref": "FargateClustercapacityLifecycleHookDrainHookTopic390A0E34" + }, + "Endpoint": { + "Fn::GetAtt": [ + "FargateClustercapacityDrainECSHookFunction3E60E6D0", + "Arn" + ] + } + } + }, + "FargateClustercapacityLifecycleHookDrainHookRoleDD26E39B": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "autoscaling.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ] + } + }, + "FargateClustercapacityLifecycleHookDrainHookRoleDefaultPolicyACCDDB70": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "FargateClustercapacityLifecycleHookDrainHookTopic390A0E34" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "FargateClustercapacityLifecycleHookDrainHookRoleDefaultPolicyACCDDB70", + "Roles": [ + { + "Ref": "FargateClustercapacityLifecycleHookDrainHookRoleDD26E39B" + } + ] + } + }, + "FargateClustercapacityLifecycleHookDrainHookTopic390A0E34": { + "Type": "AWS::SNS::Topic", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ] + } + }, + "FargateClustercapacityLifecycleHookDrainHook8AEDE53B": { + "Type": "AWS::AutoScaling::LifecycleHook", + "Properties": { + "AutoScalingGroupName": { + "Ref": "FargateClustercapacityASGE4034F96" + }, + "LifecycleTransition": "autoscaling:EC2_INSTANCE_TERMINATING", + "DefaultResult": "CONTINUE", + "HeartbeatTimeout": 300, + "NotificationTargetARN": { + "Ref": "FargateClustercapacityLifecycleHookDrainHookTopic390A0E34" + }, + "RoleARN": { + "Fn::GetAtt": [ + "FargateClustercapacityLifecycleHookDrainHookRoleDD26E39B", + "Arn" + ] + } + }, + "DependsOn": [ + "FargateClustercapacityLifecycleHookDrainHookRoleDefaultPolicyACCDDB70", + "FargateClustercapacityLifecycleHookDrainHookRoleDD26E39B" + ] + }, + "FargateClusterDefaultServiceDiscoveryNamespace04381E1E": { + "Type": "AWS::ServiceDiscovery::PrivateDnsNamespace", + "Properties": { + "Name": "aws-ecs-integ", + "Vpc": { + "Ref": "Vpc8378EB38" + } + } + }, + "TaskDefTaskRole1EDB4A67": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "TaskDef54694570": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "Essential": true, + "Image": "nginx", + "Memory": 512, + "MemoryReservation": 32, + "Name": "nginx", + "PortMappings": [ + { + "ContainerPort": 80, + "HostPort": 0, + "Protocol": "tcp" + } + ] + }, + { + "Environment": [ + { + "Name": "PORT", + "Value": "81" + } + ], + "Essential": true, + "Image": "nathanpeck/name", + "Memory": 512, + "MemoryReservation": 32, + "Name": "name", + "PortMappings": [ + { + "ContainerPort": 81, + "HostPort": 0, + "Protocol": "tcp" + } + ] + } + ], + "Family": "awsecsintegTaskDef6FDFB69A", + "NetworkMode": "bridge", + "RequiresCompatibilities": [ + "EC2" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "TaskDefTaskRole1EDB4A67", + "Arn" + ] + } + } + }, + "ServiceD69D759B": { + "Type": "AWS::ECS::Service", + "Properties": { + "Cluster": { + "Ref": "FargateCluster7CCD5F93" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 50 + }, + "DesiredCount": 3, + "EnableECSManagedTags": false, + "LaunchType": "EC2", + "SchedulingStrategy": "REPLICA", + "ServiceRegistries": [ + { + "ContainerName": "name", + "ContainerPort": 81, + "RegistryArn": { + "Fn::GetAtt": [ + "ServiceCloudmapService046058A4", + "Arn" + ] + } + } + ], + "TaskDefinition": { + "Ref": "TaskDef54694570" + } + } + }, + "ServiceCloudmapService046058A4": { + "Type": "AWS::ServiceDiscovery::Service", + "Properties": { + "DnsConfig": { + "DnsRecords": [ + { + "TTL": 60, + "Type": "SRV" + } + ], + "NamespaceId": { + "Fn::GetAtt": [ + "FargateClusterDefaultServiceDiscoveryNamespace04381E1E", + "Id" + ] + }, + "RoutingPolicy": "MULTIVALUE" + }, + "HealthCheckCustomConfig": { + "FailureThreshold": 1 + }, + "NamespaceId": { + "Fn::GetAtt": [ + "FargateClusterDefaultServiceDiscoveryNamespace04381E1E", + "Id" + ] + } + } + } + }, + "Parameters": { + "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/aws/service/ecs/optimized-ami/amazon-linux-2/recommended/image_id" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.ts b/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.ts new file mode 100644 index 0000000000000..6c1fadbb23666 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.ts @@ -0,0 +1,70 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as cloudmap from '@aws-cdk/aws-servicediscovery'; +import * as cdk from '@aws-cdk/core'; +import * as ecs from '../../lib'; + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-ecs-integ'); +const vpc = new ec2.Vpc(stack, 'Vpc', { + maxAzs: 2, + subnetConfiguration: [ + { + name: 'pub', + cidrMask: 24, + subnetType: ec2.SubnetType.PUBLIC, + }, + ], +}); +const cluster = new ecs.Cluster(stack, 'FargateCluster', { vpc }); + +const capacity = cluster.addCapacity('capacity', { + instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO), + desiredCapacity: 1, + minCapacity: 1, + maxCapacity: 1, +}); +capacity.connections.allowFromAnyIpv4(ec2.Port.tcpRange(32768, 61000)); + +cluster.addDefaultCloudMapNamespace({ name: 'aws-ecs-integ' }); + +const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', {}); + +// Main container +const mainContainer = taskDefinition.addContainer('nginx', { + image: ecs.ContainerImage.fromRegistry('nginx'), + memoryReservationMiB: 32, + memoryLimitMiB: 512, +}); + +mainContainer.addPortMappings({ + containerPort: 80, + protocol: ecs.Protocol.TCP, +}); + +// Name container with SRV +const nameContainer = taskDefinition.addContainer('name', { + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + environment: { + PORT: '81', + }, + memoryReservationMiB: 32, + memoryLimitMiB: 512, +}); + +nameContainer.addPortMappings({ + containerPort: 81, + protocol: ecs.Protocol.TCP, +}); + +new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + desiredCount: 3, + cloudMapOptions: { + container: nameContainer, + containerPort: 81, + dnsRecordType: cloudmap.DnsRecordType.SRV, + }, +}); + +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts b/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts index c7aa1fc633a1d..c982920225214 100644 --- a/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts +++ b/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts @@ -1834,6 +1834,52 @@ nodeunitShim({ test.done(); }, + + 'user can select any container and port'(test: Test) { + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + const mainContainer = taskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + mainContainer.addPortMappings({ containerPort: 8000 }); + + const otherContainer = taskDefinition.addContainer('OtherContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + otherContainer.addPortMappings({ containerPort: 8001 }); + + new ecs.FargateService(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + container: otherContainer, + containerPort: 8001, + }, + }); + + expect(stack).to(haveResourceLike('AWS::ECS::Service', { + ServiceRegistries: [ + { + RegistryArn: { 'Fn::GetAtt': ['ServiceCloudmapService046058A4', 'Arn'] }, + ContainerName: 'OtherContainer', + ContainerPort: 8001, + }, + ], + })); + + test.done(); + }, }, 'Metric'(test: Test) { diff --git a/packages/@aws-cdk/aws-route53-targets/README.md b/packages/@aws-cdk/aws-route53-targets/README.md index 5270482564ee8..ba9f1ea7e4831 100644 --- a/packages/@aws-cdk/aws-route53-targets/README.md +++ b/packages/@aws-cdk/aws-route53-targets/README.md @@ -63,6 +63,19 @@ This library contains Route53 Alias Record targets for: For example, if the Amazon-provided DNS for the load balancer is `ALB-xxxxxxx.us-west-2.elb.amazonaws.com`, CDK will create alias target in Route 53 will be `dualstack.ALB-xxxxxxx.us-west-2.elb.amazonaws.com`. +* GlobalAccelerator + + ```ts + new route53.ARecord(stack, 'AliasRecord', { + zone, + target: route53.RecordTarget.fromAlias(new targets.GlobalAcceleratorTarget(accelerator)), + // or - route53.RecordTarget.fromAlias(new targets.GlobalAcceleratorDomainTarget('xyz.awsglobalaccelerator.com')), + }); + ``` + +**Important:** If you use GlobalAcceleratorDomainTarget, passing a string rather than an instance of IAccelerator, ensure that the string is a valid domain name of an existing Global Accelerator instance. +See [the documentation on DNS addressing](https://docs.aws.amazon.com/global-accelerator/latest/dg/dns-addressing-custom-domains.dns-addressing.html) with Global Accelerator for more info. + * InterfaceVpcEndpoints **Important:** Based on the CFN docs for VPCEndpoints - [see here](attrDnsEntries) - the attributes returned for DnsEntries in CloudFormation is a combination of the hosted zone ID and the DNS name. The entries are ordered as follows: regional public DNS, zonal public DNS, private DNS, and wildcard DNS. This order is not enforced for AWS Marketplace services, and therefore this CDK construct is ONLY guaranteed to work with non-marketplace services. diff --git a/packages/@aws-cdk/aws-route53-targets/lib/global-accelerator-target.ts b/packages/@aws-cdk/aws-route53-targets/lib/global-accelerator-target.ts new file mode 100644 index 0000000000000..d80aaaa140b1b --- /dev/null +++ b/packages/@aws-cdk/aws-route53-targets/lib/global-accelerator-target.ts @@ -0,0 +1,41 @@ +import * as globalaccelerator from '@aws-cdk/aws-globalaccelerator'; +import * as route53 from '@aws-cdk/aws-route53'; + + +/** + * Use a Global Accelerator domain name as an alias record target. + */ +export class GlobalAcceleratorDomainTarget implements route53.IAliasRecordTarget { + /** + * The hosted zone Id if using an alias record in Route53. + * This value never changes. + * Ref: https://docs.aws.amazon.com/general/latest/gr/global_accelerator.html + */ + public static readonly GLOBAL_ACCELERATOR_ZONE_ID = 'Z2BJ6XQ5FK7U4H'; + + /** + * Create an Alias Target for a Global Accelerator domain name. + */ + constructor(private readonly acceleratorDomainName: string) { + } + + bind(_record: route53.IRecordSet): route53.AliasRecordTargetConfig { + return { + hostedZoneId: GlobalAcceleratorTarget.GLOBAL_ACCELERATOR_ZONE_ID, + dnsName: this.acceleratorDomainName, + }; + } +} + +/** + * Use a Global Accelerator instance domain name as an alias record target. + */ +export class GlobalAcceleratorTarget extends GlobalAcceleratorDomainTarget { + + /** + * Create an Alias Target for a Global Accelerator instance. + */ + constructor(accelerator: globalaccelerator.IAccelerator) { + super(accelerator.dnsName); + } +} diff --git a/packages/@aws-cdk/aws-route53-targets/lib/index.ts b/packages/@aws-cdk/aws-route53-targets/lib/index.ts index af574aa599519..5c8b86fb959c1 100644 --- a/packages/@aws-cdk/aws-route53-targets/lib/index.ts +++ b/packages/@aws-cdk/aws-route53-targets/lib/index.ts @@ -6,3 +6,4 @@ export * from './cloudfront-target'; export * from './load-balancer-target'; export * from './interface-vpc-endpoint-target'; export * from './userpool-domain'; +export * from './global-accelerator-target'; diff --git a/packages/@aws-cdk/aws-route53-targets/package.json b/packages/@aws-cdk/aws-route53-targets/package.json index 08ea2cb9e91f4..d98999baaab6a 100644 --- a/packages/@aws-cdk/aws-route53-targets/package.json +++ b/packages/@aws-cdk/aws-route53-targets/package.json @@ -80,6 +80,7 @@ "@aws-cdk/aws-elasticloadbalancing": "0.0.0", "@aws-cdk/aws-elasticloadbalancingv2": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-globalaccelerator": "0.0.0", "@aws-cdk/aws-route53": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/core": "0.0.0", @@ -96,6 +97,7 @@ "@aws-cdk/aws-elasticloadbalancing": "0.0.0", "@aws-cdk/aws-elasticloadbalancingv2": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-globalaccelerator": "0.0.0", "@aws-cdk/aws-route53": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/core": "0.0.0", diff --git a/packages/@aws-cdk/aws-route53-targets/test/global-accelerator-target.test.ts b/packages/@aws-cdk/aws-route53-targets/test/global-accelerator-target.test.ts new file mode 100644 index 0000000000000..07db27b92940e --- /dev/null +++ b/packages/@aws-cdk/aws-route53-targets/test/global-accelerator-target.test.ts @@ -0,0 +1,59 @@ +import '@aws-cdk/assert/jest'; +import * as globalaccelerator from '@aws-cdk/aws-globalaccelerator'; +import * as route53 from '@aws-cdk/aws-route53'; +import { Stack } from '@aws-cdk/core'; +import * as targets from '../lib'; + +test('GlobalAcceleratorTarget exposes a public constant of the zone id', () => { + expect(targets.GlobalAcceleratorTarget.GLOBAL_ACCELERATOR_ZONE_ID).toStrictEqual('Z2BJ6XQ5FK7U4H'); + expect(targets.GlobalAcceleratorDomainTarget.GLOBAL_ACCELERATOR_ZONE_ID).toStrictEqual('Z2BJ6XQ5FK7U4H'); +}); + +test('GlobalAcceleratorTarget creates an alias resource with a string domain name', () => { + // GIVEN + const stack = new Stack(); + const zone = new route53.PublicHostedZone(stack, 'HostedZone', { zoneName: 'test.public' }); + + // WHEN + new route53.ARecord(stack, 'GlobalAcceleratorAlias', { + target: route53.RecordTarget.fromAlias(new targets.GlobalAcceleratorDomainTarget('xyz.awsglobalaccelerator.com')), + recordName: 'test', + zone, + }); + + // THEN + expect(stack).toHaveResource('AWS::Route53::RecordSet', { + AliasTarget: { + DNSName: 'xyz.awsglobalaccelerator.com', + HostedZoneId: 'Z2BJ6XQ5FK7U4H', + }, + }); +}); + +test('GlobalAcceleratorTarget creates an alias resource with a Global Accelerator reference domain name', () => { + // GIVEN + const stack = new Stack(); + const accelerator = new globalaccelerator.Accelerator(stack, 'Accelerator'); + const logicalId = stack.getLogicalId(accelerator.node.defaultChild); + const zone = new route53.PublicHostedZone(stack, 'HostedZone', { zoneName: 'test.public' }); + + // WHEN + new route53.ARecord(stack, 'GlobalAcceleratorAlias', { + target: route53.RecordTarget.fromAlias(new targets.GlobalAcceleratorTarget(accelerator)), + recordName: 'test', + zone, + }); + + // THEN + expect(stack).toHaveResource('AWS::Route53::RecordSet', { + AliasTarget: { + DNSName: { + 'Fn::GetAtt': [ + logicalId, + 'DnsName', + ], + }, + HostedZoneId: 'Z2BJ6XQ5FK7U4H', + }, + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53-targets/test/integ.globalaccelerator-alias-target.expected.json b/packages/@aws-cdk/aws-route53-targets/test/integ.globalaccelerator-alias-target.expected.json new file mode 100644 index 0000000000000..e11d21275bbab --- /dev/null +++ b/packages/@aws-cdk/aws-route53-targets/test/integ.globalaccelerator-alias-target.expected.json @@ -0,0 +1,52 @@ +{ + "Resources": { + "Accelerator8EB0B6B1": { + "Type": "AWS::GlobalAccelerator::Accelerator", + "Properties": { + "Name": "aws-cdk-globalaccelerator-integ", + "Enabled": true + } + }, + "HostedZoneDB99F866": { + "Type": "AWS::Route53::HostedZone", + "Properties": { + "Name": "test.public." + } + }, + "LocalGlobalAcceleratorAlias18B4A87A": { + "Type": "AWS::Route53::RecordSet", + "Properties": { + "Name": "test-local.test.public.", + "Type": "A", + "AliasTarget": { + "DNSName": { + "Fn::GetAtt": [ + "Accelerator8EB0B6B1", + "DnsName" + ] + }, + "HostedZoneId": "Z2BJ6XQ5FK7U4H" + }, + "Comment": "Alias to the locally created Global Accelerator", + "HostedZoneId": { + "Ref": "HostedZoneDB99F866" + } + } + }, + "ExistingGlobalAcceleratorAlias7ACF888C": { + "Type": "AWS::Route53::RecordSet", + "Properties": { + "Name": "test-existing.test.public.", + "Type": "A", + "AliasTarget": { + "DNSName": "someexisting.awsglobalaccelerator.com", + "HostedZoneId": "Z2BJ6XQ5FK7U4H" + }, + "Comment": "Alias to the an existing Global Accelerator", + "HostedZoneId": { + "Ref": "HostedZoneDB99F866" + } + } + } + } + } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53-targets/test/integ.globalaccelerator-alias-target.ts b/packages/@aws-cdk/aws-route53-targets/test/integ.globalaccelerator-alias-target.ts new file mode 100644 index 0000000000000..560e828accaa5 --- /dev/null +++ b/packages/@aws-cdk/aws-route53-targets/test/integ.globalaccelerator-alias-target.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import * as globalaccelerator from '@aws-cdk/aws-globalaccelerator'; +import * as route53 from '@aws-cdk/aws-route53'; +import * as cdk from '@aws-cdk/core'; +import * as targets from '../lib'; + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-cdk-globalaccelerator-integ'); + +let accelerator = new globalaccelerator.Accelerator(stack, 'Accelerator', { + acceleratorName: `${stack.stackName}`, + enabled: true, +}); + +const zone = new route53.PublicHostedZone(stack, 'HostedZone', { zoneName: 'test.public' }); + +new route53.ARecord(stack, 'LocalGlobalAcceleratorAlias', { + comment: 'Alias to the locally created Global Accelerator', + target: route53.RecordTarget.fromAlias(new targets.GlobalAcceleratorTarget(accelerator)), + recordName: 'test-local', + zone, +}); + +new route53.ARecord(stack, 'ExistingGlobalAcceleratorAlias', { + comment: 'Alias to the an existing Global Accelerator', + target: route53.RecordTarget.fromAlias(new targets.GlobalAcceleratorDomainTarget('someexisting.awsglobalaccelerator.com')), + recordName: 'test-existing', + zone, +}); + +app.synth(); diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/find-in-map-with-dynamic-mapping.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/find-in-map-with-dynamic-mapping.json new file mode 100644 index 0000000000000..aedf3250272fc --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/find-in-map-with-dynamic-mapping.json @@ -0,0 +1,30 @@ +{ + "Parameters": { + "Stage": { + "Type": "String", + "AllowedValues": ["beta"], + "Default": "beta" + } + }, + "Mappings": { + "beta": { + "region": { + "key1": "name" + } + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::FindInMap": [ + { "Ref": "Stage" }, + "region", + "key1" + ] + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts index 873add618b9d0..34b403a2e7a99 100644 --- a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts @@ -746,6 +746,14 @@ describe('CDK Include', () => { }).toThrow(/Mapping with name 'NonExistentMapping' was not found in the template/); }); + test('can ingest a template that uses Fn::FindInMap with the first argument being a dynamic reference', () => { + includeTestTemplate(stack, 'find-in-map-with-dynamic-mapping.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('find-in-map-with-dynamic-mapping.json'), + ); + }); + test('handles renaming Mapping references', () => { const cfnTemplate = includeTestTemplate(stack, 'only-mapping-and-bucket.json'); const someMapping = cfnTemplate.getMapping('SomeMapping'); diff --git a/packages/@aws-cdk/core/lib/cfn-parse.ts b/packages/@aws-cdk/core/lib/cfn-parse.ts index 1a5c245b61c4e..93376ae19d365 100644 --- a/packages/@aws-cdk/core/lib/cfn-parse.ts +++ b/packages/@aws-cdk/core/lib/cfn-parse.ts @@ -566,11 +566,19 @@ export class CfnParser { case 'Fn::FindInMap': { const value = this.parseValue(object[key]); // the first argument to FindInMap is the mapping name - const mapping = this.finder.findMapping(value[0]); - if (!mapping) { - throw new Error(`Mapping used in FindInMap expression with name '${value[0]}' was not found in the template`); + let mappingName: string; + if (Token.isUnresolved(value[0])) { + // the first argument can be a dynamic expression like Ref: Param; + // if it is, we can't find the mapping in advance + mappingName = value[0]; + } else { + const mapping = this.finder.findMapping(value[0]); + if (!mapping) { + throw new Error(`Mapping used in FindInMap expression with name '${value[0]}' was not found in the template`); + } + mappingName = mapping.logicalId; } - return Fn._findInMap(mapping.logicalId, value[1], value[2]); + return Fn._findInMap(mappingName, value[1], value[2]); } case 'Fn::Select': { const value = this.parseValue(object[key]); diff --git a/version.v1.json b/version.v1.json index aa566c6d404ff..c2a1515792517 100644 --- a/version.v1.json +++ b/version.v1.json @@ -1,3 +1,3 @@ { - "version": "1.91.0" + "version": "1.92.0" }