diff --git a/.mergify.yml b/.mergify.yml index f626f6fcf428d..0869261bf3472 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -95,6 +95,7 @@ pull_request_rules: - base=main - -merged - -closed + - changes-requested-reviews-by!=aws-cdk-automation - name: if fails conventional commits actions: comment: diff --git a/packages/@aws-cdk/assertions/README.md b/packages/@aws-cdk/assertions/README.md index 83f86fe6e2f95..62ae9b8ae0aaa 100644 --- a/packages/@aws-cdk/assertions/README.md +++ b/packages/@aws-cdk/assertions/README.md @@ -34,6 +34,11 @@ const templateJson = '{ "Resources": ... }'; /* The CloudFormation template as J const template = Template.fromString(templateJson); ``` +**Cyclical Resources Note** + +If allowing cyclical references is desired, for example in the case of unprocessed Transform templates, supply TemplateParsingOptions and +set skipCyclicalDependenciesCheck to true. In all other cases, will fail on detecting cyclical dependencies. + ## Full Template Match The simplest assertion would be to assert that the template matches a given diff --git a/packages/@aws-cdk/assertions/lib/template.ts b/packages/@aws-cdk/assertions/lib/template.ts index 0dffd428da27a..ccc77ad555ec7 100644 --- a/packages/@aws-cdk/assertions/lib/template.ts +++ b/packages/@aws-cdk/assertions/lib/template.ts @@ -21,34 +21,42 @@ export class Template { /** * Base your assertions on the CloudFormation template synthesized by a CDK `Stack`. * @param stack the CDK Stack to run assertions on + * @param templateParsingOptions Optional param to configure template parsing behavior, such as disregarding circular + * dependencies. */ - public static fromStack(stack: Stack): Template { - return new Template(toTemplate(stack)); + public static fromStack(stack: Stack, templateParsingOptions?: TemplateParsingOptions): Template { + return new Template(toTemplate(stack), templateParsingOptions); } /** * Base your assertions from an existing CloudFormation template formatted as an in-memory * JSON object. * @param template the CloudFormation template formatted as a nested set of records + * @param templateParsingOptions Optional param to configure template parsing behavior, such as disregarding circular + * dependencies. */ - public static fromJSON(template: { [key: string] : any }): Template { - return new Template(template); + public static fromJSON(template: { [key: string] : any }, templateParsingOptions?: TemplateParsingOptions): Template { + return new Template(template, templateParsingOptions); } /** * Base your assertions from an existing CloudFormation template formatted as a * JSON string. * @param template the CloudFormation template in + * @param templateParsingOptions Optional param to configure template parsing behavior, such as disregarding circular + * dependencies. */ - public static fromString(template: string): Template { - return new Template(JSON.parse(template)); + public static fromString(template: string, templateParsingOptions?: TemplateParsingOptions): Template { + return new Template(JSON.parse(template), templateParsingOptions); } private readonly template: TemplateType; - private constructor(template: { [key: string]: any }) { + private constructor(template: { [key: string]: any }, templateParsingOptions: TemplateParsingOptions = {}) { this.template = template as TemplateType; - checkTemplateForCyclicDependencies(this.template); + if (!templateParsingOptions?.skipCyclicalDependenciesCheck ?? true) { + checkTemplateForCyclicDependencies(this.template); + } } /** @@ -243,6 +251,20 @@ export class Template { } } +/** + * Options to configure template parsing behavior, such as disregarding circular + * dependencies. + */ +export interface TemplateParsingOptions { + /** + * If set to true, will skip checking for cyclical / circular dependencies. Should be set to false other than for + * templates that are valid despite containing cycles, such as unprocessed transform stacks. + * + * @default false + */ + readonly skipCyclicalDependenciesCheck?: boolean; +} + function toTemplate(stack: Stack): any { const root = stack.node.root; if (!Stage.isStage(root)) { @@ -255,4 +277,4 @@ function toTemplate(stack: Stack): any { return JSON.parse(fs.readFileSync(path.join(assembly.directory, stack.templateFile)).toString('utf-8')); } return assembly.getStackArtifact(stack.artifactId).template; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/assertions/test/template.test.ts b/packages/@aws-cdk/assertions/test/template.test.ts index bae0a27ce2b0d..13354ca3614eb 100644 --- a/packages/@aws-cdk/assertions/test/template.test.ts +++ b/packages/@aws-cdk/assertions/test/template.test.ts @@ -1194,6 +1194,27 @@ describe('Template', () => { }); }).toThrow(/dependency cycle/); }); + + test('does not throw when given a template with cyclic dependencies if check is skipped', () => { + expect(() => { + Template.fromJSON({ + Resources: { + Res1: { + Type: 'Foo', + Properties: { + Thing: { Ref: 'Res2' }, + }, + }, + Res2: { + Type: 'Foo', + DependsOn: ['Res1'], + }, + }, + }, { + skipCyclicalDependenciesCheck: true, + }); + }).not.toThrow(/dependency cycle/); + }); }); function expectToThrow(fn: () => void, msgs: (RegExp | string)[], done: jest.DoneCallback): void { diff --git a/packages/@aws-cdk/aws-cloudwatch/README.md b/packages/@aws-cdk/aws-cloudwatch/README.md index 1f87ad8b169a1..c62002d5343c2 100644 --- a/packages/@aws-cdk/aws-cloudwatch/README.md +++ b/packages/@aws-cdk/aws-cloudwatch/README.md @@ -288,6 +288,30 @@ new cloudwatch.CompositeAlarm(this, 'MyAwesomeCompositeAlarm', { }); ``` +#### Actions Suppressor + +If you want to disable actions of a Composite Alarm based on a certain condition, you can use [Actions Suppression](https://www.amazonaws.cn/en/new/2022/amazon-cloudwatch-supports-composite-alarm-actions-suppression/). + +```ts +declare const childAlarm1: cloudwatch.Alarm; +declare const childAlarm2: cloudwatch.Alarm; +declare const onAlarmAction: cloudwatch.IAlarmAction; +declare const onOkAction: cloudwatch.IAlarmAction; +declare const actionsSuppressor: cloudwatch.Alarm; + +const alarmRule = cloudwatch.AlarmRule.anyOf(alarm1, alarm2); + +const myCompositeAlarm = new cloudwatch.CompositeAlarm(this, 'MyAwesomeCompositeAlarm', { + alarmRule, + actionsSuppressor, +}); +myCompositeAlarm.addAlarmActions(onAlarmAction); +myComposireAlarm.addOkAction(onOkAction); +``` + +In the provided example, if `actionsSuppressor` is in `ALARM` state, `onAlarmAction` won't be triggered even if `myCompositeAlarm` goes into `ALARM` state. +Similar, if `actionsSuppressor` is in `ALARM` state and `myCompositeAlarm` goes from `ALARM` into `OK` state, `onOkAction` won't be triggered. + ### A note on units In CloudWatch, Metrics datums are emitted with units, such as `seconds` or diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/composite-alarm.ts b/packages/@aws-cdk/aws-cloudwatch/lib/composite-alarm.ts index 08f0db1b0880c..e2a412209e5c4 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/composite-alarm.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/composite-alarm.ts @@ -1,4 +1,4 @@ -import { ArnFormat, Lazy, Names, Stack } from '@aws-cdk/core'; +import { ArnFormat, Lazy, Names, Stack, Duration } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { AlarmBase, IAlarm, IAlarmRule } from './alarm-base'; import { CfnCompositeAlarm } from './cloudwatch.generated'; @@ -18,14 +18,14 @@ export interface CompositeAlarmProps { /** * Description for the alarm * - * @default No description + * @default - No description. */ readonly alarmDescription?: string; /** * Name of the alarm * - * @default Automatically generated name + * @default - Automatically generated name. */ readonly compositeAlarmName?: string; @@ -34,6 +34,28 @@ export interface CompositeAlarmProps { */ readonly alarmRule: IAlarmRule; + /** + * Actions will be suppressed if the suppressor alarm is in the ALARM state. + * + * @default - alarm will not be suppressed. + */ + readonly actionsSuppressor?: IAlarm; + + /** + * The maximum duration that the composite alarm waits after suppressor alarm goes out of the ALARM state. + * After this time, the composite alarm performs its actions. + * + * @default - 1 minute extension period will be set. + */ + readonly actionsSuppressorExtensionPeriod?: Duration; + + /** + * The maximum duration that the composite alarm waits for the suppressor alarm to go into the ALARM state. + * After this time, the composite alarm performs its actions. + * + * @default - 1 minute wait period will be set. + */ + readonly actionsSuppressorWaitPeriod?: Duration; } /** @@ -98,6 +120,17 @@ export class CompositeAlarm extends AlarmBase { throw new Error('Alarm Rule expression cannot be greater than 10240 characters, please reduce the conditions in the Alarm Rule'); } + let extensionPeriod = props.actionsSuppressorExtensionPeriod; + let waitPeriod = props.actionsSuppressorWaitPeriod; + if (props.actionsSuppressor === undefined) { + if (extensionPeriod !== undefined || waitPeriod !== undefined) { + throw new Error('ActionsSuppressor Extension/Wait Periods require an ActionsSuppressor to be set.'); + } + } else { + extensionPeriod = extensionPeriod ?? Duration.minutes(1); + waitPeriod = waitPeriod ?? Duration.minutes(1); + } + this.alarmRule = props.alarmRule.renderAlarmRule(); const alarm = new CfnCompositeAlarm(this, 'Resource', { @@ -108,6 +141,9 @@ export class CompositeAlarm extends AlarmBase { alarmActions: Lazy.list({ produce: () => this.alarmActionArns }), insufficientDataActions: Lazy.list({ produce: (() => this.insufficientDataActionArns) }), okActions: Lazy.list({ produce: () => this.okActionArns }), + actionsSuppressor: props.actionsSuppressor?.alarmArn, + actionsSuppressorExtensionPeriod: extensionPeriod?.toSeconds(), + actionsSuppressorWaitPeriod: waitPeriod?.toSeconds(), }); this.alarmName = this.getResourceNameAttribute(alarm.ref); diff --git a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/CompositeAlarmIntegrationTest.assets.json b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/CompositeAlarmIntegrationTest.assets.json index 26cab6f7698f3..6f57ae7b8f1e6 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/CompositeAlarmIntegrationTest.assets.json +++ b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/CompositeAlarmIntegrationTest.assets.json @@ -1,7 +1,7 @@ { - "version": "20.0.0", + "version": "21.0.0", "files": { - "1f1d7f1c425488b9245a0ff851dae7650c25e5558781cc88a972edb6a36be237": { + "ad8a5012407e26a8fc0b1b169b0ab2373b8466d955070ee91a90193c5c70d1a4": { "source": { "path": "CompositeAlarmIntegrationTest.template.json", "packaging": "file" @@ -9,7 +9,7 @@ "destinations": { "current_account-current_region": { "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", - "objectKey": "1f1d7f1c425488b9245a0ff851dae7650c25e5558781cc88a972edb6a36be237.json", + "objectKey": "ad8a5012407e26a8fc0b1b169b0ab2373b8466d955070ee91a90193c5c70d1a4.json", "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" } } diff --git a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/CompositeAlarmIntegrationTest.template.json b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/CompositeAlarmIntegrationTest.template.json index ac9522e52679c..c03f0bcf32b15 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/CompositeAlarmIntegrationTest.template.json +++ b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/CompositeAlarmIntegrationTest.template.json @@ -107,7 +107,15 @@ "\")))) OR FALSE)" ] ] - } + }, + "ActionsSuppressor": { + "Fn::GetAtt": [ + "Alarm548383B2F", + "Arn" + ] + }, + "ActionsSuppressorExtensionPeriod": 60, + "ActionsSuppressorWaitPeriod": 60 } } }, diff --git a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/cdk.out b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/cdk.out index 588d7b269d34f..8ecc185e9dbee 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/cdk.out +++ b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/cdk.out @@ -1 +1 @@ -{"version":"20.0.0"} \ No newline at end of file +{"version":"21.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/integ.json b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/integ.json index 923f46cb8791c..62f015a9b23aa 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/integ.json +++ b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/integ.json @@ -1,5 +1,5 @@ { - "version": "20.0.0", + "version": "21.0.0", "testCases": { "integ.composite-alarm": { "stacks": [ diff --git a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/manifest.json b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/manifest.json index 0f228a394fe33..28faccf6ea43f 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/manifest.json +++ b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/manifest.json @@ -1,5 +1,5 @@ { - "version": "20.0.0", + "version": "21.0.0", "artifacts": { "Tree": { "type": "cdk:tree", @@ -23,7 +23,7 @@ "validateOnSynth": false, "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", - "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/1f1d7f1c425488b9245a0ff851dae7650c25e5558781cc88a972edb6a36be237.json", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/ad8a5012407e26a8fc0b1b169b0ab2373b8466d955070ee91a90193c5c70d1a4.json", "requiresBootstrapStackVersion": 6, "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", "additionalDependencies": [ diff --git a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/tree.json b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/tree.json index a9869726361c3..55ac27b6607a0 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/tree.json +++ b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.integ.snapshot/tree.json @@ -9,7 +9,7 @@ "path": "Tree", "constructInfo": { "fqn": "constructs.Construct", - "version": "10.1.85" + "version": "10.1.108" } }, "CompositeAlarmIntegrationTest": { @@ -220,7 +220,15 @@ "\")))) OR FALSE)" ] ] - } + }, + "actionsSuppressor": { + "Fn::GetAtt": [ + "Alarm548383B2F", + "Arn" + ] + }, + "actionsSuppressorExtensionPeriod": 60, + "actionsSuppressorWaitPeriod": 60 } }, "constructInfo": { @@ -236,14 +244,14 @@ } }, "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.1.85" + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" } } }, "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.1.85" + "fqn": "@aws-cdk/core.App", + "version": "0.0.0" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.test.ts b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.test.ts index 0633f455eadbf..ad229dbbc5605 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.test.ts +++ b/packages/@aws-cdk/aws-cloudwatch/test/composite-alarm.test.ts @@ -1,5 +1,5 @@ import { Template } from '@aws-cdk/assertions'; -import { Stack } from '@aws-cdk/core'; +import { Duration, Stack } from '@aws-cdk/core'; import { Alarm, AlarmRule, AlarmState, CompositeAlarm, Metric } from '../lib'; describe('CompositeAlarm', () => { @@ -109,4 +109,90 @@ describe('CompositeAlarm', () => { }); + test('test action suppressor translates to a correct CFN properties', () => { + const stack = new Stack(); + + const testMetric = new Metric({ + namespace: 'CDK/Test', + metricName: 'Metric', + }); + + const actionsSuppressor = new Alarm(stack, 'Alarm1', { + metric: testMetric, + threshold: 100, + evaluationPeriods: 3, + }); + + + const alarmRule = AlarmRule.fromBoolean(true); + + new CompositeAlarm(stack, 'CompositeAlarm', { + alarmRule, + actionsSuppressor, + actionsSuppressorExtensionPeriod: Duration.minutes(2), + actionsSuppressorWaitPeriod: Duration.minutes(5), + }); + + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::CompositeAlarm', { + AlarmName: 'CompositeAlarm', + ActionsSuppressor: { + 'Fn::GetAtt': [ + 'Alarm1F9009D71', + 'Arn', + ], + }, + ActionsSuppressorExtensionPeriod: 120, + ActionsSuppressorWaitPeriod: 300, + }); + }); + + test('test wait and extension periods set without action suppressor', () => { + const stack = new Stack(); + + const alarmRule = AlarmRule.fromBoolean(true); + + var createAlarm = () => new CompositeAlarm(stack, 'CompositeAlarm', { + alarmRule, + actionsSuppressorExtensionPeriod: Duration.minutes(2), + actionsSuppressorWaitPeriod: Duration.minutes(5), + }); + + expect(createAlarm).toThrow('ActionsSuppressor Extension/Wait Periods require an ActionsSuppressor to be set.'); + }); + + test('test action suppressor has correct defaults set', () => { + const stack = new Stack(); + + const testMetric = new Metric({ + namespace: 'CDK/Test', + metricName: 'Metric', + }); + + const actionsSuppressor = new Alarm(stack, 'Alarm1', { + metric: testMetric, + threshold: 100, + evaluationPeriods: 3, + }); + + + const alarmRule = AlarmRule.fromBoolean(true); + + new CompositeAlarm(stack, 'CompositeAlarm', { + alarmRule, + actionsSuppressor, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::CompositeAlarm', { + AlarmName: 'CompositeAlarm', + ActionsSuppressor: { + 'Fn::GetAtt': [ + 'Alarm1F9009D71', + 'Arn', + ], + }, + ActionsSuppressorExtensionPeriod: 60, + ActionsSuppressorWaitPeriod: 60, + }); + }); + }); diff --git a/packages/@aws-cdk/aws-cloudwatch/test/integ.composite-alarm.ts b/packages/@aws-cdk/aws-cloudwatch/test/integ.composite-alarm.ts index e4ed35c19c17f..a668b775ee8f5 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/integ.composite-alarm.ts +++ b/packages/@aws-cdk/aws-cloudwatch/test/integ.composite-alarm.ts @@ -57,6 +57,7 @@ class CompositeAlarmIntegrationTest extends Stack { new CompositeAlarm(this, 'CompositeAlarm', { alarmRule, + actionsSuppressor: alarm5, }); } diff --git a/packages/@aws-cdk/aws-gamelift/.gitignore b/packages/@aws-cdk/aws-gamelift/.gitignore index 6d05bba61dfa7..ff82c8959aad2 100644 --- a/packages/@aws-cdk/aws-gamelift/.gitignore +++ b/packages/@aws-cdk/aws-gamelift/.gitignore @@ -21,3 +21,6 @@ junit.xml !**/*.integ.snapshot/**/asset.*/*.d.ts !**/*.integ.snapshot/**/asset.*/** + +#include game build js file +!test/my-game-build/*.js diff --git a/packages/@aws-cdk/aws-gamelift/README.md b/packages/@aws-cdk/aws-gamelift/README.md index 5cc87f2e9eafc..3d84f4b418ebb 100644 --- a/packages/@aws-cdk/aws-gamelift/README.md +++ b/packages/@aws-cdk/aws-gamelift/README.md @@ -9,31 +9,83 @@ > > [CFN Resources]: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. +> They are subject to non-backward compatible changes or removal in any future version. These are +> not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be +> announced in the release notes. This means that while you may use them, you may need to update +> your source code when upgrading to a newer version of this package. + --- -This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. - -```ts nofixture -import * as gamelift from '@aws-cdk/aws-gamelift'; +[Amazon GameLift](https://docs.aws.amazon.com/gamelift/latest/developerguide/gamelift-intro.html) is a service used +to deploy, operate, and scale dedicated, low-cost servers in the cloud for session-based multiplayer games. Built +on AWS global computing infrastructure, GameLift helps deliver high-performance, high-reliability game servers +while dynamically scaling your resource usage to meet worldwide player demand. + +GameLift is composed of three main components: + +* GameLift FlexMatch which is a customizable matchmaking service for +multiplayer games. With FlexMatch, you can +build a custom set of rules that defines what a multiplayer match looks like +for your game, and determines how to +evaluate and select compatible players for each match. You can also customize +key aspects of the matchmaking +process to fit your game, including fine-tuning the matching algorithm. + +* GameLift hosting for custom or realtime servers which helps you deploy, +operate, and scale dedicated game servers. It regulates the resources needed to +host games, finds available game servers to host new game sessions, and puts +players into games. + +* GameLift FleetIQ to optimize the use of low-cost Amazon Elastic Compute Cloud +(Amazon EC2) Spot Instances for cloud-based game hosting. With GameLift +FleetIQ, you can work directly with your hosting resources in Amazon EC2 and +Amazon EC2 Auto Scaling while taking advantage of GameLift optimizations to +deliver inexpensive, resilient game hosting for your players + +This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. It allows you to define components for your matchmaking +configuration or game server fleet management system. + +## GameLift Hosting + +### Defining a GameLift Fleet + +GameLift helps you deploy, operate, and scale dedicated game servers for +session-based multiplayer games. It helps you regulate the resources needed to +host your games, finds available game servers to host new game sessions, and +puts players into games. + +### Uploading builds and scripts to GameLift + +Before deploying your GameLift-enabled multiplayer game servers for hosting with the GameLift service, you need to upload +your game server files. This section provides guidance on preparing and uploading custom game server build +files or Realtime Servers server script files. When you upload files, you create a GameLift build or script resource, which +you then deploy on fleets of hosting resources. + +### Upload a custom server build to GameLift + +Before uploading your configured game server to GameLift for hosting, package the game build files into a build directory. +This directory must include all components required to run your game servers and host game sessions, including the following: + +* Game server binaries – The binary files required to run the game server. A build can include binaries for multiple game +servers built to run on the same platform. For a list of supported platforms, see [Download Amazon GameLift SDKs](https://docs.aws.amazon.com/gamelift/latest/developerguide/gamelift-supported.html). + +* Dependencies – Any dependent files that your game server executables require to run. Examples include assets, configuration +files, and dependent libraries. + +* Install script – A script file to handle tasks that are required to fully install your game build on GameLift hosting +servers. Place this file at the root of the build directory. GameLift runs the install script as part of fleet creation. + +You can set up any application in your build, including your install script, to access your resources securely on other AWS +services. + +```ts +declare const bucket: s3.Bucket; +new gamelift.Build(this, 'Build', { + content: gamelift.Content.fromBucket(bucket, "sample-asset-key") +}); ``` - - - -There are no official hand-written ([L2](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib)) constructs for this service yet. Here are some suggestions on how to proceed: - -- Search [Construct Hub for GameLift construct libraries](https://constructs.dev/search?q=gamelift) -- Use the automatically generated [L1](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_l1_using) constructs, in the same way you would use [the CloudFormation AWS::GameLift resources](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/AWS_GameLift.html) directly. - - - - -There are no hand-written ([L2](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib)) constructs for this service yet. -However, you can still use the automatically generated [L1](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_l1_using) constructs, and use this service exactly as you would using CloudFormation directly. - -For more information on the resources and properties available for this service, see the [CloudFormation documentation for AWS::GameLift](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/AWS_GameLift.html). - -(Read the [CDK Contributing Guide](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and submit an RFC if you are interested in contributing to this construct library.) - - diff --git a/packages/@aws-cdk/aws-gamelift/lib/build.ts b/packages/@aws-cdk/aws-gamelift/lib/build.ts new file mode 100644 index 0000000000000..12c066880473c --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/lib/build.ts @@ -0,0 +1,207 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as s3_assets from '@aws-cdk/aws-s3-assets'; +import * as cdk from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { Content } from './content'; +import { CfnBuild } from './gamelift.generated'; + +/** + * Represents a GameLift server build. + */ +export interface IBuild extends cdk.IResource, iam.IGrantable { + + /** + * The Identifier of the build. + * + * @attribute + */ + readonly buildId: string; +} + +/** + * Base class for new and imported GameLift server build. + */ +export abstract class BuildBase extends cdk.Resource implements IBuild { + /** + * The Identifier of the build. + */ + public abstract readonly buildId: string; + + public abstract readonly grantPrincipal: iam.IPrincipal; +} + +/** + * The operating system that the game server binaries are built to run on. + */ +export enum OperatingSystem { + AMAZON_LINUX = 'AMAZON_LINUX', + AMAZON_LINUX_2 = 'AMAZON_LINUX_2', + WINDOWS_2012 = 'WINDOWS_2012' +} + + +/** + * Represents a Build content defined outside of this stack. + */ +export interface BuildAttributes { + /** + * The identifier of the build + */ + readonly buildId: string; + /** + * The IAM role assumed by GameLift to access server build in S3. + * @default - undefined + */ + readonly role?: iam.IRole; +} + +/** + * Properties for a new build + */ +export interface BuildProps { + /** + * Name of this build + * + * @default No name + */ + readonly buildName?: string; + + /** + * Version of this build + * + * @default No version + */ + readonly buildVersion?: string; + + /** + * The operating system that the game server binaries are built to run on. + * + * @default No version + */ + readonly operatingSystem?: OperatingSystem; + + /** + * The game build file storage + */ + readonly content: Content; + + /** + * The IAM role assumed by GameLift to access server build in S3. + * If providing a custom role, it needs to trust the GameLift service principal (gamelift.amazonaws.com) and be granted sufficient permissions + * to have Read access to a specific key content into a specific S3 bucket. + * Below an example of required permission: + * { + * "Version": "2012-10-17", + * "Statement": [{ + * "Effect": "Allow", + * "Action": [ + * "s3:GetObject", + * "s3:GetObjectVersion" + * ], + * "Resource": "arn:aws:s3:::bucket-name/object-name" + * }] + *} + * + * @see https://docs.aws.amazon.com/gamelift/latest/developerguide/security_iam_id-based-policy-examples.html#security_iam_id-based-policy-examples-access-storage-loc + * + * @default - a role will be created with default permissions. + */ + readonly role?: iam.IRole; +} + +/** + * A GameLift build, that is installed and runs on instances in an Amazon GameLift fleet. It consists of + * a zip file with all of the components of the game server build. + * + * @see https://docs.aws.amazon.com/gamelift/latest/developerguide/gamelift-build-cli-uploading.html + * + * @resource AWS::GameLift::Build + */ +export class Build extends BuildBase { + + /** + * Create a new Build from s3 content + */ + static fromBucket(scope: Construct, id: string, bucket: s3.IBucket, key: string, objectVersion?: string) { + return new Build(scope, id, { + content: Content.fromBucket(bucket, key, objectVersion), + }); + } + + /** + * Create a new Build from asset content + */ + static fromAsset(scope: Construct, id: string, path: string, options?: s3_assets.AssetOptions) { + return new Build(scope, id, { + content: Content.fromAsset(path, options), + }); + } + + /** + * Import a build into CDK using its identifier + */ + static fromBuildId(scope: Construct, id: string, buildId: string): IBuild { + return this.fromBuildAttributes(scope, id, { buildId }); + } + + /** + * Import an existing build from its attributes. + */ + static fromBuildAttributes(scope: Construct, id: string, attrs: BuildAttributes): IBuild { + class Import extends BuildBase { + public readonly buildId = attrs.buildId; + public readonly grantPrincipal = attrs.role ?? new iam.UnknownPrincipal({ resource: this }); + } + + return new Import(scope, id); + } + + /** + * The Identifier of the build. + */ + public readonly buildId: string; + + /** + * The IAM role GameLift assumes to acccess server build content. + */ + public readonly role: iam.IRole; + + /** + * The principal this GameLift Build is using. + */ + public readonly grantPrincipal: iam.IPrincipal; + + constructor(scope: Construct, id: string, props: BuildProps) { + super(scope, id, { + physicalName: props.buildName, + }); + + if (props.buildName && !cdk.Token.isUnresolved(props.buildName)) { + if (props.buildName.length > 1024) { + throw new Error(`Build name can not be longer than 1024 characters but has ${props.buildName.length} characters.`); + } + } + this.role = props.role ?? new iam.Role(this, 'ServiceRole', { + assumedBy: new iam.ServicePrincipal('gamelift.amazonaws.com'), + }); + this.grantPrincipal = this.role; + const content = props.content.bind(this, this.role); + + const resource = new CfnBuild(this, 'Resource', { + name: props.buildName, + version: props.buildVersion, + operatingSystem: props.operatingSystem, + storageLocation: { + bucket: content.s3Location && content.s3Location.bucketName, + key: content.s3Location && content.s3Location.objectKey, + objectVersion: content.s3Location && content.s3Location.objectVersion, + roleArn: this.role.roleArn, + }, + }); + + this.buildId = resource.ref; + } + + +} diff --git a/packages/@aws-cdk/aws-gamelift/lib/content.ts b/packages/@aws-cdk/aws-gamelift/lib/content.ts new file mode 100644 index 0000000000000..3c510757b3880 --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/lib/content.ts @@ -0,0 +1,110 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as s3_assets from '@aws-cdk/aws-s3-assets'; +import * as cdk from '@aws-cdk/core'; +import { Construct } from 'constructs'; + +/** + * Before deploying your GameLift-enabled multiplayer game servers for hosting with the GameLift service, you need to upload your game server files. + * The class helps you on preparing and uploading custom game server build files or Realtime Servers server script files. + */ +export abstract class Content { + /** + * Game content as an S3 object. + * @param bucket The S3 bucket + * @param key The object key + * @param objectVersion Optional S3 ob ject version + */ + public static fromBucket(bucket: s3.IBucket, key: string, objectVersion?: string): S3Content { + return new S3Content(bucket, key, objectVersion); + } + + + /** + * Loads the game content from a local disk path. + * + * @param path Either a directory with the game content bundle or a .zip file + */ + public static fromAsset(path: string, options?: s3_assets.AssetOptions): AssetContent { + return new AssetContent(path, options); + } + + /** + * Called when the Build is initialized to allow this object to bind + */ + public abstract bind(scope: Construct, grantable: iam.IGrantable): ContentConfig; + +} + +/** + * Result of binding `Content` into a `Build`. + */ +export interface ContentConfig { + /** + * The location of the content in S3. + */ + readonly s3Location: s3.Location; +} + +/** + * Game content from an S3 archive. + */ +export class S3Content extends Content { + + constructor(private readonly bucket: s3.IBucket, private key: string, private objectVersion?: string) { + super(); + if (!bucket.bucketName) { + throw new Error('bucketName is undefined for the provided bucket'); + } + } + + public bind(_scope: Construct, grantable: iam.IGrantable): ContentConfig { + this.bucket.grantRead(grantable, this.key); + return { + s3Location: { + bucketName: this.bucket.bucketName, + objectKey: this.key, + objectVersion: this.objectVersion, + }, + }; + } +} + +/** + * Game content from a local directory. + */ +export class AssetContent extends Content { + private asset?: s3_assets.Asset; + + /** + * @param path The path to the asset file or directory. + */ + constructor(public readonly path: string, private readonly options: s3_assets.AssetOptions = { }) { + super(); + } + + public bind(scope: Construct, grantable: iam.IGrantable): ContentConfig { + // If the same AssetContent is used multiple times, retain only the first instantiation. + if (!this.asset) { + this.asset = new s3_assets.Asset(scope, 'Content', { + path: this.path, + ...this.options, + }); + } else if (cdk.Stack.of(this.asset) !== cdk.Stack.of(scope)) { + throw new Error(`Asset is already associated with another stack '${cdk.Stack.of(this.asset).stackName}'. ` + + 'Create a new Content instance for every stack.'); + } + this.asset.grantRead(grantable); + + if (!this.asset.isZipArchive) { + throw new Error(`Asset must be a .zip file or a directory (${this.path})`); + } + + return { + s3Location: { + bucketName: this.asset.s3BucketName, + objectKey: this.asset.s3ObjectKey, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-gamelift/lib/index.ts b/packages/@aws-cdk/aws-gamelift/lib/index.ts index 843bf2236645e..cf989967b922d 100644 --- a/packages/@aws-cdk/aws-gamelift/lib/index.ts +++ b/packages/@aws-cdk/aws-gamelift/lib/index.ts @@ -1,2 +1,5 @@ +export * from './content'; +export * from './build'; + // AWS::GameLift CloudFormation Resources: export * from './gamelift.generated'; diff --git a/packages/@aws-cdk/aws-gamelift/package.json b/packages/@aws-cdk/aws-gamelift/package.json index 19f96730ef99e..6f133644d1d2b 100644 --- a/packages/@aws-cdk/aws-gamelift/package.json +++ b/packages/@aws-cdk/aws-gamelift/package.json @@ -82,24 +82,54 @@ "devDependencies": { "@aws-cdk/assertions": "0.0.0", "@aws-cdk/cdk-build-tools": "0.0.0", + "@aws-cdk/integ-runner": "0.0.0", "@aws-cdk/cfn2ts": "0.0.0", "@aws-cdk/pkglint": "0.0.0", - "@types/jest": "^27.5.2" + "@aws-cdk/cx-api": "0.0.0", + "@types/jest": "^27.5.2", + "jest": "^27.5.1" }, "dependencies": { "@aws-cdk/core": "0.0.0", + "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-events": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-logs": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", + "@aws-cdk/aws-sns": "0.0.0", + "@aws-cdk/region-info": "0.0.0", "constructs": "^10.0.0" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { "@aws-cdk/core": "0.0.0", + "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-events": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-logs": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", + "@aws-cdk/aws-sns": "0.0.0", + "@aws-cdk/region-info": "0.0.0", "constructs": "^10.0.0" }, "engines": { "node": ">= 14.15.0" }, + "awslint": { + "exclude": [ + "docs-public-apis:@aws-cdk/aws-gamelift.OperatingSystem.AMAZON_LINUX", + "docs-public-apis:@aws-cdk/aws-gamelift.OperatingSystem.AMAZON_LINUX_2", + "docs-public-apis:@aws-cdk/aws-gamelift.OperatingSystem.WINDOWS_2012" + ] + }, "stability": "experimental", - "maturity": "cfn-only", + "maturity": "experimental", "awscdkio": { "announce": false }, diff --git a/packages/@aws-cdk/aws-gamelift/rosetta/default.ts-fixture b/packages/@aws-cdk/aws-gamelift/rosetta/default.ts-fixture new file mode 100644 index 0000000000000..4a4c332becb50 --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/rosetta/default.ts-fixture @@ -0,0 +1,16 @@ +// Fixture with packages imported, but nothing else +import { Construct } from 'constructs'; +import { Duration, Size, Stack } from '@aws-cdk/core'; +import * as gamelift from '@aws-cdk/aws-gamelift'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as kms from '@aws-cdk/aws-kms'; +import * as iam from '@aws-cdk/aws-iam'; +import * as path from 'path'; + +class Fixture extends Stack { + constructor(scope: Construct, id: string) { + super(scope, id); + + /// here + } +} diff --git a/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/asset.6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7/index.js b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/asset.6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7/index.js new file mode 100644 index 0000000000000..73c02658c48d9 --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/asset.6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7/index.js @@ -0,0 +1 @@ +console.log('Hello World'); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/aws-gamelift-build.assets.json b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/aws-gamelift-build.assets.json new file mode 100644 index 0000000000000..d27cd073bc49a --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/aws-gamelift-build.assets.json @@ -0,0 +1,32 @@ +{ + "version": "21.0.0", + "files": { + "6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7": { + "source": { + "path": "asset.6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7", + "packaging": "zip" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7.zip", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + }, + "9c561e93c7a2947a15dba683670660e922cf493e17b2a6f8ca03cf221442c222": { + "source": { + "path": "aws-gamelift-build.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "9c561e93c7a2947a15dba683670660e922cf493e17b2a6f8ca03cf221442c222.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/aws-gamelift-build.template.json b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/aws-gamelift-build.template.json new file mode 100644 index 0000000000000..394b49a1b66ff --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/aws-gamelift-build.template.json @@ -0,0 +1,129 @@ +{ + "Resources": { + "BuildServiceRole1F57E904": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "gamelift.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "BuildServiceRoleDefaultPolicyCB7101C6": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetBucket*", + "s3:GetObject*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" + }, + "/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" + } + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "BuildServiceRoleDefaultPolicyCB7101C6", + "Roles": [ + { + "Ref": "BuildServiceRole1F57E904" + } + ] + } + }, + "Build45A36621": { + "Type": "AWS::GameLift::Build", + "Properties": { + "StorageLocation": { + "Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" + }, + "Key": "6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7.zip", + "RoleArn": { + "Fn::GetAtt": [ + "BuildServiceRole1F57E904", + "Arn" + ] + } + } + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } + } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/cdk.out b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/cdk.out new file mode 100644 index 0000000000000..8ecc185e9dbee --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/cdk.out @@ -0,0 +1 @@ +{"version":"21.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/integ.json b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/integ.json new file mode 100644 index 0000000000000..f646149706bea --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/integ.json @@ -0,0 +1,14 @@ +{ + "version": "21.0.0", + "testCases": { + "integ.build": { + "stacks": [ + "aws-gamelift-build" + ], + "diffAssets": false, + "stackUpdateWorkflow": true + } + }, + "synthContext": {}, + "enableLookups": false +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/manifest.json b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/manifest.json new file mode 100644 index 0000000000000..43fe5eb776640 --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/manifest.json @@ -0,0 +1,64 @@ +{ + "version": "21.0.0", + "artifacts": { + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + }, + "aws-gamelift-build.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "aws-gamelift-build.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "aws-gamelift-build": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "aws-gamelift-build.template.json", + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/9c561e93c7a2947a15dba683670660e922cf493e17b2a6f8ca03cf221442c222.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "aws-gamelift-build.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "aws-gamelift-build.assets" + ], + "metadata": { + "/aws-gamelift-build/Build/ServiceRole/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "BuildServiceRole1F57E904" + } + ], + "/aws-gamelift-build/Build/ServiceRole/DefaultPolicy/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "BuildServiceRoleDefaultPolicyCB7101C6" + } + ], + "/aws-gamelift-build/Build/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "Build45A36621" + } + ] + }, + "displayName": "aws-gamelift-build" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/tree.json b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/tree.json new file mode 100644 index 0000000000000..96d14a552e490 --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/build.integ.snapshot/tree.json @@ -0,0 +1,202 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.1.33" + } + }, + "build-test-assets": { + "id": "build-test-assets", + "path": "build-test-assets", + "children": { + "Build": { + "id": "Build", + "path": "build-test-assets/Build", + "children": { + "Service Role": { + "id": "Service Role", + "path": "build-test-assets/Build/Service Role", + "children": { + "Resource": { + "id": "Resource", + "path": "build-test-assets/Build/Service Role/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::Role", + "aws:cdk:cloudformation:props": { + "assumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "gamelift.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.CfnRole", + "version": "0.0.0" + } + }, + "DefaultPolicy": { + "id": "DefaultPolicy", + "path": "build-test-assets/Build/Service Role/DefaultPolicy", + "children": { + "Resource": { + "id": "Resource", + "path": "build-test-assets/Build/Service Role/DefaultPolicy/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::Policy", + "aws:cdk:cloudformation:props": { + "policyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" + } + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "policyName": "BuildServiceRoleDefaultPolicy90803718", + "roles": [ + { + "Ref": "BuildServiceRole4643E19E" + } + ] + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.CfnPolicy", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.Policy", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-iam.Role", + "version": "0.0.0" + } + }, + "Content": { + "id": "Content", + "path": "build-test-assets/Build/Content", + "children": { + "Stage": { + "id": "Stage", + "path": "build-test-assets/Build/Content/Stage", + "constructInfo": { + "fqn": "@aws-cdk/core.AssetStaging", + "version": "0.0.0" + } + }, + "AssetBucket": { + "id": "AssetBucket", + "path": "build-test-assets/Build/Content/AssetBucket", + "constructInfo": { + "fqn": "@aws-cdk/aws-s3.BucketBase", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-s3-assets.Asset", + "version": "0.0.0" + } + }, + "Resource": { + "id": "Resource", + "path": "build-test-assets/Build/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::GameLift::Build", + "aws:cdk:cloudformation:props": { + "storageLocation": { + "bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" + }, + "key": "6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7.zip", + "roleArn": { + "Fn::GetAtt": [ + "BuildServiceRole4643E19E", + "Arn" + ] + } + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-gamelift.CfnBuild", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-gamelift.Build", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.App", + "version": "0.0.0" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-gamelift/test/build.test.ts b/packages/@aws-cdk/aws-gamelift/test/build.test.ts new file mode 100644 index 0000000000000..7bf763ea2ebfc --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/build.test.ts @@ -0,0 +1,236 @@ +import * as path from 'path'; +import { Template } from '@aws-cdk/assertions'; +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; +import * as cxapi from '@aws-cdk/cx-api'; +import * as gamelift from '../lib'; +import { OperatingSystem } from '../lib'; + +describe('build', () => { + const buildId = 'test-identifier'; + const buildName = 'test-build'; + let stack: cdk.Stack; + + beforeEach(() => { + const app = new cdk.App({ context: { [cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false } }); + stack = new cdk.Stack(app); + }); + + describe('.fromBuildId()', () => { + test('with required fields', () => { + const build = gamelift.Build.fromBuildId(stack, 'ImportedBuild', buildId); + + expect(build.buildId).toEqual(buildId); + expect(build.grantPrincipal).toEqual(new iam.UnknownPrincipal({ resource: build })); + }); + }); + + describe('.fromBuildAttributes()', () => { + test('with required attrs only', () => { + const build = gamelift.Build.fromBuildAttributes(stack, 'ImportedBuild', { buildId }); + + expect(build.buildId).toEqual(buildId); + expect(build.grantPrincipal).toEqual(new iam.UnknownPrincipal({ resource: build })); + }); + + test('with all attrs', () => { + const role = iam.Role.fromRoleArn(stack, 'Role', 'arn:aws:iam::123456789012:role/TestRole'); + const build = gamelift.Build.fromBuildAttributes(stack, 'ImportedBuild', { buildId, role }); + + expect(buildId).toEqual(buildId); + expect(build.grantPrincipal).toEqual(role); + }); + }); + + describe('new', () => { + const localAsset = path.join(__dirname, 'my-game-build'); + const contentBucketName = 'bucketname'; + const contentBucketAccessStatement = { + Action: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + ], + Effect: 'Allow', + Resource: [ + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + `:s3:::${contentBucketName}`, + ], + ], + }, + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + `:s3:::${contentBucketName}/content`, + ], + ], + }, + ], + }; + let contentBucket: s3.IBucket; + let content: gamelift.Content; + let build: gamelift.Build; + let defaultProps: gamelift.BuildProps; + + beforeEach(() => { + contentBucket = s3.Bucket.fromBucketName(stack, 'ContentBucket', contentBucketName); + content = gamelift.Content.fromBucket(contentBucket, 'content'); + defaultProps = { + content, + }; + }); + + describe('.fromAsset()', () => { + test('should create a new build from asset', () => { + build = gamelift.Build.fromAsset(stack, 'ImportedBuild', localAsset); + + expect(stack.node.metadata.find(m => m.type === 'aws:cdk:asset')).toBeDefined(); + Template.fromStack(stack).hasResourceProperties('AWS::GameLift::Build', { + StorageLocation: { + Bucket: { + Ref: 'AssetParameters6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7S3Bucket72AA8348', + }, + }, + }); + + }); + }); + + describe('.fromBucket()', () => { + test('should create a new build from bucket', () => { + build = gamelift.Build.fromBucket(stack, 'ImportedBuild', contentBucket, 'content'); + + Template.fromStack(stack).hasResourceProperties('AWS::GameLift::Build', { + StorageLocation: { + Bucket: 'bucketname', + Key: 'content', + }, + }); + + }); + }); + + describe('with necessary props only', () => { + beforeEach(() => { + build = new gamelift.Build(stack, 'Build', defaultProps); + }); + + test('should create a role and use it with the build', () => { + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'gamelift.amazonaws.com', + }, + }, + ], + Version: '2012-10-17', + }, + }); + + // Role policy should grant reading from the assets bucket + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + contentBucketAccessStatement, + ], + }, + Roles: [ + { + Ref: 'BuildServiceRole1F57E904', + }, + ], + }); + + // check the build using the role + Template.fromStack(stack).hasResourceProperties('AWS::GameLift::Build', { + StorageLocation: { + Bucket: 'bucketname', + Key: 'content', + RoleArn: { + 'Fn::GetAtt': [ + 'BuildServiceRole1F57E904', + 'Arn', + ], + }, + }, + }); + }); + + test('should return correct buildId from CloudFormation', () => { + expect(stack.resolve(build.buildId)).toEqual({ Ref: 'Build45A36621' }); + }); + + test('with a custom role should use it and set it in CloudFormation', () => { + const role = iam.Role.fromRoleArn(stack, 'Role', 'arn:aws:iam::123456789012:role/TestRole'); + build = new gamelift.Build(stack, 'BuildWithRole', { + ...defaultProps, + role, + }); + + expect(build.grantPrincipal).toEqual(role); + Template.fromStack(stack).hasResourceProperties('AWS::GameLift::Build', { + StorageLocation: { + RoleArn: role.roleArn, + }, + }); + }); + + test('with a custom buildName should set it in CloudFormation', () => { + build = new gamelift.Build(stack, 'BuildWithName', { + ...defaultProps, + buildName: buildName, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::GameLift::Build', { + Name: buildName, + }); + }); + + test('with all optional attributes should set it in CloudFormation', () => { + build = new gamelift.Build(stack, 'BuildWithName', { + ...defaultProps, + buildName: buildName, + operatingSystem: OperatingSystem.AMAZON_LINUX_2, + buildVersion: '1.0', + }); + + Template.fromStack(stack).hasResourceProperties('AWS::GameLift::Build', { + Name: buildName, + OperatingSystem: OperatingSystem.AMAZON_LINUX_2, + Version: '1.0', + }); + }); + + test('with an incorrect buildName (>1024)', () => { + let incorrectBuildName = ''; + for (let i = 0; i < 1025; i++) { + incorrectBuildName += 'A'; + } + + expect(() => new gamelift.Build(stack, 'BuildWithWrongName', { + content, + buildName: incorrectBuildName, + })).toThrow(/Build name can not be longer than 1024 characters but has 1025 characters./); + }); + }); + }); +}); + + diff --git a/packages/@aws-cdk/aws-gamelift/test/content.test.ts b/packages/@aws-cdk/aws-gamelift/test/content.test.ts new file mode 100644 index 0000000000000..0c1f6e0617121 --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/content.test.ts @@ -0,0 +1,280 @@ +import * as path from 'path'; +import { Template } from '@aws-cdk/assertions'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; +import * as cxapi from '@aws-cdk/cx-api'; +import * as gamelift from '../lib'; + +describe('Code', () => { + let stack: cdk.Stack; + let content: gamelift.Content; + + beforeEach(() => { + const app = new cdk.App({ context: { [cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false } }); + stack = new cdk.Stack(app, 'Stack'); + }); + + describe('.fromBucket()', () => { + const key = 'content'; + let bucket: s3.IBucket; + + test('with valid bucket name and key and bound by build sets the right path and grants the build permissions to read from it', () => { + bucket = s3.Bucket.fromBucketName(stack, 'Bucket', 'bucketname'); + content = gamelift.Content.fromBucket(bucket, key); + new gamelift.Build(stack, 'Build1', { + content: content, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::GameLift::Build', { + StorageLocation: { + Bucket: 'bucketname', + Key: 'content', + }, + }); + + // Role policy should grant reading from the assets bucket + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + ], + Effect: 'Allow', + Resource: [ + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':s3:::bucketname', + ], + ], + }, + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':s3:::bucketname/content', + ], + ], + }, + ], + }, + ], + }, + Roles: [ + { + Ref: 'Build1ServiceRole24FABCB7', + }, + ], + }); + }); + }); + + describe('.fromAsset()', () => { + const directoryPath = path.join(__dirname, 'my-game-build'); + + beforeEach(() => { + content = gamelift.Content.fromAsset(directoryPath); + }); + + test("with valid and existing file path and bound to job sets job's script location and permissions stack metadata", () => { + new gamelift.Build(stack, 'Build1', { + content: content, + }); + + expect(stack.node.metadata.find(m => m.type === 'aws:cdk:asset')).toBeDefined(); + Template.fromStack(stack).hasResourceProperties('AWS::GameLift::Build', { + StorageLocation: { + Bucket: { + Ref: 'AssetParameters6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7S3Bucket72AA8348', + }, + Key: { + 'Fn::Join': [ + '', + [ + { + 'Fn::Select': [ + 0, + { + 'Fn::Split': [ + '||', + { + Ref: 'AssetParameters6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7S3VersionKey720D3160', + }, + ], + }, + ], + }, + { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '||', + { + Ref: 'AssetParameters6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7S3VersionKey720D3160', + }, + ], + }, + ], + }, + ], + ], + }, + RoleArn: { + 'Fn::GetAtt': [ + 'Build1ServiceRole24FABCB7', + 'Arn', + ], + }, + }, + }); + // Role policy should grant reading from the assets bucket + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + ], + Effect: 'Allow', + Resource: [ + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':s3:::', + { + Ref: 'AssetParameters6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7S3Bucket72AA8348', + }, + ], + ], + }, + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':s3:::', + { + Ref: 'AssetParameters6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7S3Bucket72AA8348', + }, + '/*', + ], + ], + }, + ], + }, + ], + }, + Roles: [ + { + Ref: 'Build1ServiceRole24FABCB7', + }, + ], + }); + }); + + test('with an unsupported file path throws', () => { + // GIVEN + const fileAsset = gamelift.Content.fromAsset(path.join(__dirname, 'my-game-build', 'index.js')); + + // THEN + expect(() => new gamelift.Build(stack, 'Build1', { content: fileAsset })) + .toThrow(/Asset must be a \.zip file or a directory/); + }); + + test('used in more than 1 build in the same stack should be reused', () => { + new gamelift.Build(stack, 'Build1', { + content: content, + }); + new gamelift.Build(stack, 'Build2', { + content: content, + }); + const StorageLocation = { + Bucket: { + Ref: 'AssetParameters6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7S3Bucket72AA8348', + }, + Key: { + 'Fn::Join': [ + '', + [ + { + 'Fn::Select': [ + 0, + { + 'Fn::Split': [ + '||', + { + Ref: 'AssetParameters6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7S3VersionKey720D3160', + }, + ], + }, + ], + }, + { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '||', + { + Ref: 'AssetParameters6019bfc8ab05a24b0ae9b5d8f4585cbfc7d1c30a23286d0b25ce7066a368a5d7S3VersionKey720D3160', + }, + ], + }, + ], + }, + ], + ], + }, + RoleArn: { + 'Fn::GetAtt': [ + 'Build1ServiceRole24FABCB7', + 'Arn', + ], + }, + }; + + expect(stack.node.metadata.find(m => m.type === 'aws:cdk:asset')).toBeDefined(); + // Job1 and Job2 use reuse the asset + Template.fromStack(stack).hasResourceProperties('AWS::GameLift::Build', { + StorageLocation, + }); + Template.fromStack(stack).hasResourceProperties('AWS::GameLift::Build', { + StorageLocation, + }); + }); + + test('throws if trying to rebind in another stack', () => { + new gamelift.Build(stack, 'Build1', { + content, + }); + const differentStack = new cdk.Stack(); + + expect(() => new gamelift.Build(differentStack, 'Build2', { + content, + })).toThrow(/Asset is already associated with another stack/); + }); + }); +}); diff --git a/packages/@aws-cdk/aws-gamelift/test/gamelift.test.ts b/packages/@aws-cdk/aws-gamelift/test/gamelift.test.ts deleted file mode 100644 index 465c7bdea0693..0000000000000 --- a/packages/@aws-cdk/aws-gamelift/test/gamelift.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@aws-cdk/assertions'; -import {} from '../lib'; - -test('No tests are specified for this package', () => { - expect(true).toBe(true); -}); diff --git a/packages/@aws-cdk/aws-gamelift/test/integ.build.ts b/packages/@aws-cdk/aws-gamelift/test/integ.build.ts new file mode 100644 index 0000000000000..d216cbccd9212 --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/integ.build.ts @@ -0,0 +1,13 @@ +import * as path from 'path'; +import * as cdk from '@aws-cdk/core'; +import * as gamelift from '../lib'; + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-gamelift-build'); + +new gamelift.Build(stack, 'Build', { + content: gamelift.Content.fromAsset(path.join(__dirname, 'my-game-build')), +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-gamelift/test/my-game-build.zip b/packages/@aws-cdk/aws-gamelift/test/my-game-build.zip new file mode 100644 index 0000000000000..4a13be08c2721 Binary files /dev/null and b/packages/@aws-cdk/aws-gamelift/test/my-game-build.zip differ diff --git a/packages/@aws-cdk/aws-gamelift/test/my-game-build/index.js b/packages/@aws-cdk/aws-gamelift/test/my-game-build/index.js new file mode 100644 index 0000000000000..73c02658c48d9 --- /dev/null +++ b/packages/@aws-cdk/aws-gamelift/test/my-game-build/index.js @@ -0,0 +1 @@ +console.log('Hello World'); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/principals.test.ts b/packages/@aws-cdk/aws-iam/test/principals.test.ts index e3b1078d2a933..37dc662121854 100644 --- a/packages/@aws-cdk/aws-iam/test/principals.test.ts +++ b/packages/@aws-cdk/aws-iam/test/principals.test.ts @@ -311,10 +311,10 @@ test('ServicePrincipalName returns just a string representing the principal', () // GIVEN const usEastStack = new Stack(undefined, undefined, { env: { region: 'us-east-1' } }); const afSouthStack = new Stack(undefined, undefined, { env: { region: 'af-south-1' } }); - const principalName = iam.ServicePrincipal.servicePrincipalName('ssm.amazonaws.com'); + const principalName = iam.ServicePrincipal.servicePrincipalName('states.amazonaws.com'); - expect(usEastStack.resolve(principalName)).toEqual('ssm.amazonaws.com'); - expect(afSouthStack.resolve(principalName)).toEqual('ssm.af-south-1.amazonaws.com'); + expect(usEastStack.resolve(principalName)).toEqual('states.us-east-1.amazonaws.com'); + expect(afSouthStack.resolve(principalName)).toEqual('states.af-south-1.amazonaws.com'); }); test('Passing non-string as accountId parameter in AccountPrincipal constructor should throw error', () => { @@ -327,14 +327,14 @@ test('ServicePrincipal in agnostic stack generates lookup table', () => { // WHEN new iam.Role(stack, 'Role', { - assumedBy: new iam.ServicePrincipal('ssm.amazonaws.com'), + assumedBy: new iam.ServicePrincipal('states.amazonaws.com'), }); // THEN const template = Template.fromStack(stack); const mappings = template.findMappings('ServiceprincipalMap'); - expect(mappings.ServiceprincipalMap['af-south-1']?.ssm).toEqual('ssm.af-south-1.amazonaws.com'); - expect(mappings.ServiceprincipalMap['us-east-1']?.ssm).toEqual('ssm.amazonaws.com'); + expect(mappings.ServiceprincipalMap['af-south-1']?.states).toEqual('states.af-south-1.amazonaws.com'); + expect(mappings.ServiceprincipalMap['us-east-1']?.states).toEqual('states.us-east-1.amazonaws.com'); }); test('Can enable session tags', () => { diff --git a/packages/@aws-cdk/aws-rds/lib/cluster-engine.ts b/packages/@aws-cdk/aws-rds/lib/cluster-engine.ts index db5b00c545b2a..37af190258d2a 100644 --- a/packages/@aws-cdk/aws-rds/lib/cluster-engine.ts +++ b/packages/@aws-cdk/aws-rds/lib/cluster-engine.ts @@ -245,6 +245,16 @@ export class AuroraEngineVersion { public static readonly VER_1_22_4 = AuroraEngineVersion.builtIn_5_6('1.22.4'); /** Version "5.6.mysql_aurora.1.22.5". */ public static readonly VER_1_22_5 = AuroraEngineVersion.builtIn_5_6('1.22.5'); + /** Version "5.6.mysql_aurora.1.23.0". */ + public static readonly VER_1_23_0 = AuroraEngineVersion.builtIn_5_6('1.23.0'); + /** Version "5.6.mysql_aurora.1.23.1". */ + public static readonly VER_1_23_1 = AuroraEngineVersion.builtIn_5_6('1.23.1'); + /** Version "5.6.mysql_aurora.1.23.2". */ + public static readonly VER_1_23_2 = AuroraEngineVersion.builtIn_5_6('1.23.2'); + /** Version "5.6.mysql_aurora.1.23.3". */ + public static readonly VER_1_23_3 = AuroraEngineVersion.builtIn_5_6('1.23.3'); + /** Version "5.6.mysql_aurora.1.23.4". */ + public static readonly VER_1_23_4 = AuroraEngineVersion.builtIn_5_6('1.23.4'); /** * Create a new AuroraEngineVersion with an arbitrary version. @@ -406,6 +416,8 @@ export class AuroraMysqlEngineVersion { public static readonly VER_3_01_1 = AuroraMysqlEngineVersion.builtIn_8_0('3.01.1'); /** Version "8.0.mysql_aurora.3.02.0". */ public static readonly VER_3_02_0 = AuroraMysqlEngineVersion.builtIn_8_0('3.02.0'); + /** Version "8.0.mysql_aurora.3.02.1". */ + public static readonly VER_3_02_1 = AuroraMysqlEngineVersion.builtIn_8_0('3.02.1'); /** * Create a new AuroraMysqlEngineVersion with an arbitrary version. diff --git a/packages/@aws-cdk/aws-s3/lib/bucket.ts b/packages/@aws-cdk/aws-s3/lib/bucket.ts index 4093110014dec..60c5498a33794 100644 --- a/packages/@aws-cdk/aws-s3/lib/bucket.ts +++ b/packages/@aws-cdk/aws-s3/lib/bucket.ts @@ -1354,10 +1354,15 @@ export interface BucketProps { readonly enforceSSL?: boolean; /** - * Specifies whether Amazon S3 should use an S3 Bucket Key with server-side - * encryption using KMS (SSE-KMS) for new objects in the bucket. + * Whether Amazon S3 should use its own intermediary key to generate data keys. * - * Only relevant, when Encryption is set to {@link BucketEncryption.KMS} + * Only relevant when using KMS for encryption. + * + * - If not enabled, every object GET and PUT will cause an API call to KMS (with the + * attendant cost implications of that). + * - If enabled, S3 will use its own time-limited key instead. + * + * Only relevant, when Encryption is set to `BucketEncryption.KMS` or `BucketEncryption.KMS_MANAGED`. * * @default - false */ @@ -1943,7 +1948,7 @@ export class Bucket extends BucketBase { } // if bucketKeyEnabled is set, encryption must be set to KMS. - if (props.bucketKeyEnabled && encryptionType !== BucketEncryption.KMS) { + if (props.bucketKeyEnabled && ![BucketEncryption.KMS, BucketEncryption.KMS_MANAGED].includes(encryptionType)) { throw new Error(`bucketKeyEnabled is specified, so 'encryption' must be set to KMS (value: ${encryptionType})`); } @@ -1983,7 +1988,10 @@ export class Bucket extends BucketBase { if (encryptionType === BucketEncryption.KMS_MANAGED) { const bucketEncryption = { serverSideEncryptionConfiguration: [ - { serverSideEncryptionByDefault: { sseAlgorithm: 'aws:kms' } }, + { + bucketKeyEnabled: props.bucketKeyEnabled, + serverSideEncryptionByDefault: { sseAlgorithm: 'aws:kms' }, + }, ], }; return { bucketEncryption }; @@ -2288,17 +2296,17 @@ export enum BucketEncryption { /** * Objects in the bucket are not encrypted. */ - UNENCRYPTED = 'NONE', + UNENCRYPTED = 'UNENCRYPTED', /** * Server-side KMS encryption with a master key managed by KMS. */ - KMS_MANAGED = 'MANAGED', + KMS_MANAGED = 'KMS_MANAGED', /** * Server-side encryption with a master key managed by S3. */ - S3_MANAGED = 'S3MANAGED', + S3_MANAGED = 'S3_MANAGED', /** * Server-side encryption with a KMS key managed by the user. diff --git a/packages/@aws-cdk/aws-s3/test/bucket.test.ts b/packages/@aws-cdk/aws-s3/test/bucket.test.ts index 5396f6504d585..ed727d615578a 100644 --- a/packages/@aws-cdk/aws-s3/test/bucket.test.ts +++ b/packages/@aws-cdk/aws-s3/test/bucket.test.ts @@ -306,25 +306,19 @@ describe('bucket', () => { }); }); - test('bucketKeyEnabled can be enabled', () => { + test.each([s3.BucketEncryption.KMS, s3.BucketEncryption.KMS_MANAGED])('bucketKeyEnabled can be enabled with %p encryption', (encryption) => { const stack = new cdk.Stack(); - new s3.Bucket(stack, 'MyBucket', { bucketKeyEnabled: true, encryption: s3.BucketEncryption.KMS }); + new s3.Bucket(stack, 'MyBucket', { bucketKeyEnabled: true, encryption }); Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', { 'BucketEncryption': { 'ServerSideEncryptionConfiguration': [ { 'BucketKeyEnabled': true, - 'ServerSideEncryptionByDefault': { - 'KMSMasterKeyID': { - 'Fn::GetAtt': [ - 'MyBucketKeyC17130CF', - 'Arn', - ], - }, + 'ServerSideEncryptionByDefault': Match.objectLike({ 'SSEAlgorithm': 'aws:kms', - }, + }), }, ], }, @@ -336,10 +330,10 @@ describe('bucket', () => { expect(() => { new s3.Bucket(stack, 'MyBucket', { bucketKeyEnabled: true, encryption: s3.BucketEncryption.S3_MANAGED }); - }).toThrow("bucketKeyEnabled is specified, so 'encryption' must be set to KMS (value: S3MANAGED)"); + }).toThrow("bucketKeyEnabled is specified, so 'encryption' must be set to KMS (value: S3_MANAGED)"); expect(() => { new s3.Bucket(stack, 'MyBucket3', { bucketKeyEnabled: true }); - }).toThrow("bucketKeyEnabled is specified, so 'encryption' must be set to KMS (value: NONE)"); + }).toThrow("bucketKeyEnabled is specified, so 'encryption' must be set to KMS (value: UNENCRYPTED)"); }); diff --git a/packages/@aws-cdk/aws-sqs/README.md b/packages/@aws-cdk/aws-sqs/README.md index 73bc568e11ea5..191eb4e6a7c6e 100644 --- a/packages/@aws-cdk/aws-sqs/README.md +++ b/packages/@aws-cdk/aws-sqs/README.md @@ -37,14 +37,14 @@ new sqs.Queue(this, 'Queue'); ## Encryption -If you want to encrypt the queue contents, set the `encryption` property. +By default queues are encrypted using SSE-SQS. If you want to change the encryption mode, set the `encryption` property. The following encryption modes are supported: * KMS key that SQS manages for you * KMS key that you can managed yourself * Server-side encryption managed by SQS (SSE-SQS) +* Unencrypted -Support for SSE-SQS is available in all AWS Commercial and GovCloud Regions except the China Regions. To learn more about SSE-SQS on Amazon SQS, please visit the [Amazon SQS documentation](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-server-side-encryption.html). @@ -66,6 +66,11 @@ new sqs.Queue(this, 'Queue', { new sqs.Queue(this, 'Queue', { encryption: sqs.QueueEncryption.SQS_MANAGED, }); + +// Unencrypted queue +new sqs.Queue(this, 'Queue', { + encryption: sqs.QueueEncryption.UNENCRYPTED, +}); ``` ## First-In-First-Out (FIFO) queues diff --git a/packages/@aws-cdk/aws-sqs/lib/queue.ts b/packages/@aws-cdk/aws-sqs/lib/queue.ts index 2d77ad297a427..4cb972782e03a 100644 --- a/packages/@aws-cdk/aws-sqs/lib/queue.ts +++ b/packages/@aws-cdk/aws-sqs/lib/queue.ts @@ -87,7 +87,7 @@ export interface QueueProps { * Be aware that encryption is not available in all regions, please see the docs * for current availability details. * - * @default Unencrypted + * @default SQS_MANAGED (SSE-SQS) for newly created queues */ readonly encryption?: QueueEncryption; @@ -210,7 +210,6 @@ export enum QueueEncryption { /** * Server-side encryption key managed by SQS (SSE-SQS). * - * Support for SSE-SQS is available in all AWS Commercial and GovCloud Regions except the China Regions. * To learn more about SSE-SQS on Amazon SQS, please visit the * [Amazon SQS documentation](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-server-side-encryption.html). */ @@ -378,7 +377,7 @@ export class Queue extends QueueBase { this.deadLetterQueue = props.deadLetterQueue; function _determineEncryptionProps(this: Queue): { encryptionProps: EncryptionProps, encryptionMasterKey?: kms.IKey } { - let encryption = props.encryption || QueueEncryption.UNENCRYPTED; + let encryption = props.encryption; if (encryption === QueueEncryption.SQS_MANAGED && props.encryptionMasterKey) { throw new Error("'encryptionMasterKey' is not supported if encryption type 'SQS_MANAGED' is used"); @@ -388,10 +387,18 @@ export class Queue extends QueueBase { encryption = QueueEncryption.KMS; // KMS is implied by specifying an encryption key } - if (encryption === QueueEncryption.UNENCRYPTED) { + if (!encryption) { return { encryptionProps: {} }; } + if (encryption === QueueEncryption.UNENCRYPTED) { + return { + encryptionProps: { + sqsManagedSseEnabled: false, + }, + }; + } + if (encryption === QueueEncryption.KMS_MANAGED) { return { encryptionProps: { diff --git a/packages/@aws-cdk/aws-sqs/test/integ.sqs.ts b/packages/@aws-cdk/aws-sqs/test/integ.sqs.ts index 72975390b0a75..b8e085da4f7cd 100644 --- a/packages/@aws-cdk/aws-sqs/test/integ.sqs.ts +++ b/packages/@aws-cdk/aws-sqs/test/integ.sqs.ts @@ -25,6 +25,9 @@ const highThroughputFifo = new Queue(stack, 'HighThroughputFifoQueue', { const sqsManagedEncryptedQueue = new Queue(stack, 'SqsManagedEncryptedQueue', { encryption: QueueEncryption.SQS_MANAGED, }); +const unencryptedQueue = new Queue(stack, 'UnencryptedQueue', { + encryption: QueueEncryption.UNENCRYPTED, +}); const role = new Role(stack, 'Role', { assumedBy: new AccountRootPrincipal(), @@ -35,6 +38,7 @@ queue.grantConsumeMessages(role); fifo.grantConsumeMessages(role); highThroughputFifo.grantConsumeMessages(role); sqsManagedEncryptedQueue.grantConsumeMessages(role); +unencryptedQueue.grantConsumeMessages(role); new CfnOutput(stack, 'QueueUrl', { value: queue.queueUrl }); diff --git a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/SqsTestDefaultTestDeployAssert659366A6.assets.json b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/SqsTestDefaultTestDeployAssert659366A6.assets.json new file mode 100644 index 0000000000000..92ee85c509105 --- /dev/null +++ b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/SqsTestDefaultTestDeployAssert659366A6.assets.json @@ -0,0 +1,19 @@ +{ + "version": "21.0.0", + "files": { + "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": { + "source": { + "path": "SqsTestDefaultTestDeployAssert659366A6.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/SqsTestDefaultTestDeployAssert659366A6.template.json b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/SqsTestDefaultTestDeployAssert659366A6.template.json new file mode 100644 index 0000000000000..ad9d0fb73d1dd --- /dev/null +++ b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/SqsTestDefaultTestDeployAssert659366A6.template.json @@ -0,0 +1,36 @@ +{ + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/aws-cdk-sqs.assets.json b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/aws-cdk-sqs.assets.json index 3c90c25fc43af..0dc8fd0e3462b 100644 --- a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/aws-cdk-sqs.assets.json +++ b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/aws-cdk-sqs.assets.json @@ -1,7 +1,7 @@ { - "version": "20.0.0", + "version": "21.0.0", "files": { - "11bd38722e879ee3efdf98cf177f0af6442195bb308e1bde662147a30bf350ed": { + "a660932c356f8eec80b4314b041c64311fd29803603b65a95351137afa8eba04": { "source": { "path": "aws-cdk-sqs.template.json", "packaging": "file" @@ -9,7 +9,7 @@ "destinations": { "current_account-current_region": { "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", - "objectKey": "11bd38722e879ee3efdf98cf177f0af6442195bb308e1bde662147a30bf350ed.json", + "objectKey": "a660932c356f8eec80b4314b041c64311fd29803603b65a95351137afa8eba04.json", "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" } } diff --git a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/aws-cdk-sqs.template.json b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/aws-cdk-sqs.template.json index 24a479f674dc2..a9fd362411f47 100644 --- a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/aws-cdk-sqs.template.json +++ b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/aws-cdk-sqs.template.json @@ -89,6 +89,14 @@ "UpdateReplacePolicy": "Delete", "DeletionPolicy": "Delete" }, + "UnencryptedQueue57F92F9C": { + "Type": "AWS::SQS::Queue", + "Properties": { + "SqsManagedSseEnabled": false + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, "Role1ABCC5F0": { "Type": "AWS::IAM::Role", "Properties": { @@ -165,6 +173,12 @@ "SqsManagedEncryptedQueue587679B3", "Arn" ] + }, + { + "Fn::GetAtt": [ + "UnencryptedQueue57F92F9C", + "Arn" + ] } ] }, diff --git a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/cdk.out b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/cdk.out index 588d7b269d34f..8ecc185e9dbee 100644 --- a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/cdk.out +++ b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/cdk.out @@ -1 +1 @@ -{"version":"20.0.0"} \ No newline at end of file +{"version":"21.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/integ.json b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/integ.json index 8fe8a05ed0a46..ef3f2c6411212 100644 --- a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/integ.json +++ b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/integ.json @@ -1,14 +1,12 @@ { - "version": "20.0.0", + "version": "21.0.0", "testCases": { - "integ.sqs": { + "SqsTest/DefaultTest": { "stacks": [ "aws-cdk-sqs" ], - "diffAssets": false, - "stackUpdateWorkflow": true + "assertionStack": "SqsTest/DefaultTest/DeployAssert", + "assertionStackName": "SqsTestDefaultTestDeployAssert659366A6" } - }, - "synthContext": {}, - "enableLookups": false + } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/manifest.json b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/manifest.json index 07902159b45d6..bcb8e707ddd5f 100644 --- a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/manifest.json +++ b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/manifest.json @@ -1,5 +1,5 @@ { - "version": "20.0.0", + "version": "21.0.0", "artifacts": { "Tree": { "type": "cdk:tree", @@ -23,7 +23,7 @@ "validateOnSynth": false, "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", - "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/11bd38722e879ee3efdf98cf177f0af6442195bb308e1bde662147a30bf350ed.json", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/a660932c356f8eec80b4314b041c64311fd29803603b65a95351137afa8eba04.json", "requiresBootstrapStackVersion": 6, "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", "additionalDependencies": [ @@ -75,6 +75,12 @@ "data": "SqsManagedEncryptedQueue587679B3" } ], + "/aws-cdk-sqs/UnencryptedQueue/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "UnencryptedQueue57F92F9C" + } + ], "/aws-cdk-sqs/Role/Resource": [ { "type": "aws:cdk:logicalId", @@ -107,6 +113,53 @@ ] }, "displayName": "aws-cdk-sqs" + }, + "SqsTestDefaultTestDeployAssert659366A6.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "SqsTestDefaultTestDeployAssert659366A6.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "SqsTestDefaultTestDeployAssert659366A6": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "SqsTestDefaultTestDeployAssert659366A6.template.json", + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "SqsTestDefaultTestDeployAssert659366A6.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "SqsTestDefaultTestDeployAssert659366A6.assets" + ], + "metadata": { + "/SqsTest/DefaultTest/DeployAssert/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/SqsTest/DefaultTest/DeployAssert/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "SqsTest/DefaultTest/DeployAssert" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/tree.json b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/tree.json index e220340ca1266..fe1731181527c 100644 --- a/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/tree.json +++ b/packages/@aws-cdk/aws-sqs/test/sqs.integ.snapshot/tree.json @@ -9,7 +9,7 @@ "path": "Tree", "constructInfo": { "fqn": "constructs.Construct", - "version": "10.1.85" + "version": "10.1.108" } }, "aws-cdk-sqs": { @@ -202,6 +202,30 @@ "version": "0.0.0" } }, + "UnencryptedQueue": { + "id": "UnencryptedQueue", + "path": "aws-cdk-sqs/UnencryptedQueue", + "children": { + "Resource": { + "id": "Resource", + "path": "aws-cdk-sqs/UnencryptedQueue/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::SQS::Queue", + "aws:cdk:cloudformation:props": { + "sqsManagedSseEnabled": false + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-sqs.CfnQueue", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-sqs.Queue", + "version": "0.0.0" + } + }, "Role": { "id": "Role", "path": "aws-cdk-sqs/Role", @@ -297,6 +321,12 @@ "SqsManagedEncryptedQueue587679B3", "Arn" ] + }, + { + "Fn::GetAtt": [ + "UnencryptedQueue57F92F9C", + "Arn" + ] } ] }, @@ -342,20 +372,56 @@ "id": "QueueUrl", "path": "aws-cdk-sqs/QueueUrl", "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.1.85" + "fqn": "@aws-cdk/core.CfnOutput", + "version": "0.0.0" } } }, "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.1.85" + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + }, + "SqsTest": { + "id": "SqsTest", + "path": "SqsTest", + "children": { + "DefaultTest": { + "id": "DefaultTest", + "path": "SqsTest/DefaultTest", + "children": { + "Default": { + "id": "Default", + "path": "SqsTest/DefaultTest/Default", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.1.108" + } + }, + "DeployAssert": { + "id": "DeployAssert", + "path": "SqsTest/DefaultTest/DeployAssert", + "constructInfo": { + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests.IntegTestCase", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests.IntegTest", + "version": "0.0.0" } } }, "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.1.85" + "fqn": "@aws-cdk/core.App", + "version": "0.0.0" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-sqs/test/sqs.test.ts b/packages/@aws-cdk/aws-sqs/test/sqs.test.ts index cf1cae53d3909..8e599ff356f42 100644 --- a/packages/@aws-cdk/aws-sqs/test/sqs.test.ts +++ b/packages/@aws-cdk/aws-sqs/test/sqs.test.ts @@ -448,6 +448,24 @@ describe('queue encryption', () => { }); }); + test('it is possible to disable encryption (unencrypted)', () => { + const stack = new Stack(); + + new sqs.Queue(stack, 'Queue', { encryption: sqs.QueueEncryption.UNENCRYPTED }); + Template.fromStack(stack).templateMatches({ + 'Resources': { + 'Queue4A7E3555': { + 'Type': 'AWS::SQS::Queue', + 'Properties': { + 'SqsManagedSseEnabled': false, + }, + 'UpdateReplacePolicy': 'Delete', + 'DeletionPolicy': 'Delete', + }, + }, + }); + }); + test('encryptionMasterKey is not supported if encryption type SQS_MANAGED is used', () => { // GIVEN const stack = new Stack(); diff --git a/packages/@aws-cdk/cloudformation-include/README.md b/packages/@aws-cdk/cloudformation-include/README.md index 6e10d290a5c67..66e8f1e47e296 100644 --- a/packages/@aws-cdk/cloudformation-include/README.md +++ b/packages/@aws-cdk/cloudformation-include/README.md @@ -132,7 +132,9 @@ make sure to download the unprocessed template However, certain unprocessed templates can fail when used with the `CfnInclude` class. The most common reason for the failure is that the unprocessed template can contain cycles between resources, which get removed after the Transform is processed, -but is not allowed when being included (as pure CloudFormation does not permit cycles). +but is not allowed when being included (as pure CloudFormation does not permit cycles). To enable cycle processing behavior similar +to cloudformation, set `allowCyclicalReferences` of CfnIncludeProps to true. + When that happens, you should instead download the processed template from the CloudFormation AWS Console (make sure the "View processed template" checkbox is **checked** in that case): diff --git a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts index 778bd9a124152..d4b6b9877c126 100644 --- a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts +++ b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts @@ -57,6 +57,15 @@ export interface CfnIncludeProps { * @default - parameters will retain their original definitions */ readonly parameters?: { [parameterName: string]: any }; + + /** + * Specifies whether to allow cyclical references, effectively disregarding safeguards meant to avoid undeployable + * templates. This should only be set to true in the case of templates utilizing cloud transforms (e.g. SAM) that + * after processing the transform will no longer contain any circular references. + * + * @default - will throw an error on detecting any cyclical references + */ + readonly allowCyclicalReferences?: boolean; } /** @@ -99,10 +108,16 @@ export class CfnInclude extends core.CfnElement { private readonly nestedStacksToInclude: { [name: string]: CfnIncludeProps }; private readonly template: any; private readonly preserveLogicalIds: boolean; + private readonly allowCyclicalReferences: boolean; + private logicalIdToPlaceholderMap: Map; constructor(scope: Construct, id: string, props: CfnIncludeProps) { super(scope, id); + this.allowCyclicalReferences = props.allowCyclicalReferences ?? false; + + this.logicalIdToPlaceholderMap = new Map(); + this.parametersToReplace = props.parameters || {}; // read the template into a JS object @@ -584,10 +599,31 @@ export class CfnInclude extends core.CfnElement { return cfnCondition; } + private getPlaceholderID(): string { + return `Placeholder${this.logicalIdToPlaceholderMap.size}`; + } + private getOrCreateResource(logicalId: string, cycleChain: string[] = []): core.CfnResource { cycleChain = cycleChain.concat([logicalId]); if (cycleChain.length !== new Set(cycleChain).size) { - throw new Error(`Found a cycle between resources in the template: ${cycleChain.join(' depends on ')}`); + if (!this.allowCyclicalReferences) { + throw new Error(`Found a cycle between resources in the template: ${cycleChain.join(' depends on ')}`); + } + //only allow one placeholder per logical id + if (this.logicalIdToPlaceholderMap.get(logicalId)) { + return this.resources[this.logicalIdToPlaceholderMap.get(logicalId)!]; + } + let placeholderResourceAttributes: any = this.template.Resources[logicalId]; + let placeholderId: string = this.getPlaceholderID(); + this.logicalIdToPlaceholderMap.set(logicalId, placeholderId); + let placeholderInstance = new core.CfnResource(this, placeholderId, { + type: placeholderResourceAttributes.Type, + properties: {}, + }); + placeholderInstance.overrideLogicalId(placeholderId); + this.resources[placeholderId] = placeholderInstance; + + return placeholderInstance; } const ret = this.resources[logicalId]; @@ -652,6 +688,17 @@ export class CfnInclude extends core.CfnElement { } } + /* + 1. remove placeholder version of object created for cycle breaking + 2. override logical id before deletion so references to the placeholder instead reference the original + */ + if (this.logicalIdToPlaceholderMap.get(logicalId)) { + let placeholderId: string = this.logicalIdToPlaceholderMap.get(logicalId)!; + this.resources[placeholderId].overrideLogicalId(logicalId); + this.node.tryRemoveChild(placeholderId); + delete this.resources[placeholderId]; + } + this.overrideLogicalIdIfNeeded(l1Instance, logicalId); this.resources[logicalId] = l1Instance; diff --git a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts index 2fa4a3f4d1f6a..dcdade326cb62 100644 --- a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import { Template } from '@aws-cdk/assertions'; import * as core from '@aws-cdk/core'; import * as cxapi from '@aws-cdk/cx-api'; import * as constructs from 'constructs'; @@ -141,16 +142,120 @@ describe('CDK Include', () => { }).toThrow(/Short-form Fn::GetAtt must contain a '.' in its string argument, got: 'Bucket1Arn'/); }); - test('detects a cycle between resources in the template', () => { + /** + * A->B + * B->A + * simplified version of cycle-in-resources.json, an example of cyclical references + */ + test('by default does not accept a cycle between resources in the template', () => { expect(() => { includeTestTemplate(stack, 'cycle-in-resources.json'); }).toThrow(/Found a cycle between resources in the template: Bucket1 depends on Bucket2 depends on Bucket1/); }); + + /** + * A->B + * B->C + * C->{D,A} + * D->B + * simplified version of multi-cycle-in-resources.json, an example of multiple cyclical references + */ + test('by default does not accept multiple cycles between resources in the template', () => { + expect(() => { + includeTestTemplate(stack, 'multi-cycle-in-resources.json'); + }).toThrow(/Found a cycle between resources in the template: Bucket1 depends on Bucket2 depends on Bucket3 depends on Bucket4 depends on Bucket2/); + }); + + /** + * A->B + * B->{C,A} + * C->A + * simplified version of multi-cycle-multi-dest-in-resources.json, an example of multiple cyclical references that + * include visiting the same destination more than once + */ + test('by default does not accept multiple cycles and multiple destinations between resources in the template', () => { + expect(() => { + includeTestTemplate(stack, 'multi-cycle-multi-dest-in-resources.json'); + }).toThrow(/Found a cycle between resources in the template: Bucket1 depends on Bucket2 depends on Bucket3 depends on Bucket1/); + }); + + /** + * A->B + * B->A + * simplified version of cycle-in-resources.json, an example of cyclical references + */ + test('accepts a cycle between resources in the template if allowed', () => { + includeTestTemplate(stack, 'cycle-in-resources.json', { allowCyclicalReferences: true }); + Template.fromStack(stack, { skipCyclicalDependenciesCheck: true }).templateMatches( + { + Resources: { + Bucket2: { Type: 'AWS::S3::Bucket', DependsOn: ['Bucket1'] }, + Bucket1: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: { Ref: 'Bucket2' } }, + }, + }, + }, + ); + }); + + /** + * A->B + * B->C + * C->{D,A} + * D->B + * simplified version of multi-cycle-in-resources.json, an example of multiple cyclical references + */ + test('accepts multiple cycles between resources in the template if allowed', () => { + includeTestTemplate(stack, 'multi-cycle-in-resources.json', { allowCyclicalReferences: true }); + Template.fromStack(stack, { skipCyclicalDependenciesCheck: true }).templateMatches( + { + Resources: { + Bucket2: { Type: 'AWS::S3::Bucket', DependsOn: ['Bucket3'] }, + Bucket3: { Type: 'AWS::S3::Bucket', DependsOn: ['Bucket4', 'Bucket1'] }, + Bucket4: { Type: 'AWS::S3::Bucket', DependsOn: ['Bucket2'] }, + Bucket1: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: { Ref: 'Bucket2' } }, + }, + }, + }, + ); + }); + + /** + * A->B + * B->{C,A} + * C->A + * simplified version of multi-cycle-multi-dest-in-resources.json, an example of multiple cyclical references that + * include visiting the same destination more than once + */ + test('accepts multiple cycles and multiple destinations between resources in the template if allowed', () => { + includeTestTemplate(stack, 'multi-cycle-multi-dest-in-resources.json', { allowCyclicalReferences: true }); + Template.fromStack(stack, { skipCyclicalDependenciesCheck: true }).templateMatches( + { + Resources: { + Bucket2: { Type: 'AWS::S3::Bucket', DependsOn: ['Bucket3', 'Bucket1'] }, + Bucket3: { Type: 'AWS::S3::Bucket', DependsOn: ['Bucket1'] }, + Bucket1: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: { Ref: 'Bucket2' } }, + }, + }, + }, + ); + }); }); -function includeTestTemplate(scope: constructs.Construct, testTemplate: string): inc.CfnInclude { +interface IncludeTestTemplateProps { + /** @default false */ + readonly allowCyclicalReferences?: boolean; +} + +function includeTestTemplate(scope: constructs.Construct, testTemplate: string, props: IncludeTestTemplateProps = {}): inc.CfnInclude { return new inc.CfnInclude(scope, 'MyScope', { templateFile: _testTemplateFilePath(testTemplate), + allowCyclicalReferences: props.allowCyclicalReferences, }); } diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/multi-cycle-in-resources.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/multi-cycle-in-resources.json new file mode 100644 index 0000000000000..06d96804f91c3 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/multi-cycle-in-resources.json @@ -0,0 +1,24 @@ +{ + "Resources": { + "Bucket1": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Ref": "Bucket2" + } + } + }, + "Bucket2": { + "Type": "AWS::S3::Bucket", + "DependsOn": "Bucket3" + }, + "Bucket3": { + "Type": "AWS::S3::Bucket", + "DependsOn": ["Bucket4", "Bucket1"] + }, + "Bucket4": { + "Type": "AWS::S3::Bucket", + "DependsOn": "Bucket2" + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/multi-cycle-multi-dest-in-resources.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/multi-cycle-multi-dest-in-resources.json new file mode 100644 index 0000000000000..d8ed03161b255 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/multi-cycle-multi-dest-in-resources.json @@ -0,0 +1,20 @@ +{ + "Resources": { + "Bucket1": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Ref": "Bucket2" + } + } + }, + "Bucket2": { + "Type": "AWS::S3::Bucket", + "DependsOn": ["Bucket3", "Bucket1"] + }, + "Bucket3": { + "Type": "AWS::S3::Bucket", + "DependsOn": ["Bucket1"] + } + } +} diff --git a/packages/@aws-cdk/region-info/lib/aws-entities.ts b/packages/@aws-cdk/region-info/lib/aws-entities.ts index 0c28399449434..d91e34848c73f 100644 --- a/packages/@aws-cdk/region-info/lib/aws-entities.ts +++ b/packages/@aws-cdk/region-info/lib/aws-entities.ts @@ -1,8 +1,3 @@ -/** - * After this point, SSM only creates regional principals - */ -export const RULE_SSM_PRINCIPALS_ARE_REGIONAL = Symbol('SSM_PRINCIPALS_ARE_REGIONAL'); - /** * After this point, S3 website domains look like `s3-website.REGION.s3.amazonaws.com` * @@ -49,7 +44,6 @@ export const AWS_REGIONS_AND_RULES: readonly (string | symbol)[] = [ 'ap-northeast-3', // Asia Pacific (Osaka) 'us-gov-east-1', // AWS GovCloud (US-East) 'eu-north-1', // Europe (Stockholm) - RULE_SSM_PRINCIPALS_ARE_REGIONAL, 'ap-east-1', // Asia Pacific (Hong Kong) 'me-south-1', // Middle East (Bahrain) 'eu-south-1', // Europe (Milan) diff --git a/packages/@aws-cdk/region-info/lib/default.ts b/packages/@aws-cdk/region-info/lib/default.ts index 39a8db5ddedc9..c240c55ec1068 100644 --- a/packages/@aws-cdk/region-info/lib/default.ts +++ b/packages/@aws-cdk/region-info/lib/default.ts @@ -1,5 +1,3 @@ -import { before, RULE_SSM_PRINCIPALS_ARE_REGIONAL } from './aws-entities'; - /** * Provides default values for certain regional information points. */ @@ -81,12 +79,6 @@ export class Default { } switch (service) { - // SSM turned from global to regional at some point - case 'ssm': - return before(region, RULE_SSM_PRINCIPALS_ARE_REGIONAL) - ? universal - : regional; - // CodeDeploy is regional+partitional in CN, only regional everywhere else case 'codedeploy': return region.startsWith('cn-') diff --git a/packages/@aws-cdk/region-info/test/__snapshots__/region-info.test.js.snap b/packages/@aws-cdk/region-info/test/__snapshots__/region-info.test.js.snap index dae9b4dda9d16..2ce71cae5df83 100644 --- a/packages/@aws-cdk/region-info/test/__snapshots__/region-info.test.js.snap +++ b/packages/@aws-cdk/region-info/test/__snapshots__/region-info.test.js.snap @@ -30,7 +30,7 @@ Object { "s3": "s3.amazonaws.com", "sns": "sns.amazonaws.com", "sqs": "sqs.amazonaws.com", - "ssm": "ssm.af-south-1.amazonaws.com", + "ssm": "ssm.amazonaws.com", "states": "states.af-south-1.amazonaws.com", }, "vpcEndPointServiceNamePrefix": "com.amazonaws.vpce", @@ -63,7 +63,7 @@ Object { "s3": "s3.amazonaws.com", "sns": "sns.amazonaws.com", "sqs": "sqs.amazonaws.com", - "ssm": "ssm.ap-east-1.amazonaws.com", + "ssm": "ssm.amazonaws.com", "states": "states.ap-east-1.amazonaws.com", }, "vpcEndPointServiceNamePrefix": "com.amazonaws.vpce", @@ -294,7 +294,7 @@ Object { "s3": "s3.amazonaws.com", "sns": "sns.amazonaws.com", "sqs": "sqs.amazonaws.com", - "ssm": "ssm.ap-southeast-3.amazonaws.com", + "ssm": "ssm.amazonaws.com", "states": "states.ap-southeast-3.amazonaws.com", }, "vpcEndPointServiceNamePrefix": "com.amazonaws.vpce", @@ -492,7 +492,7 @@ Object { "s3": "s3.amazonaws.com", "sns": "sns.amazonaws.com", "sqs": "sqs.amazonaws.com", - "ssm": "ssm.eu-south-1.amazonaws.com", + "ssm": "ssm.amazonaws.com", "states": "states.eu-south-1.amazonaws.com", }, "vpcEndPointServiceNamePrefix": "com.amazonaws.vpce", @@ -525,7 +525,7 @@ Object { "s3": "s3.amazonaws.com", "sns": "sns.amazonaws.com", "sqs": "sqs.amazonaws.com", - "ssm": "ssm.eu-south-2.amazonaws.com", + "ssm": "ssm.amazonaws.com", "states": "states.eu-south-2.amazonaws.com", }, "vpcEndPointServiceNamePrefix": "com.amazonaws.vpce", @@ -657,7 +657,7 @@ Object { "s3": "s3.amazonaws.com", "sns": "sns.amazonaws.com", "sqs": "sqs.amazonaws.com", - "ssm": "ssm.me-south-1.amazonaws.com", + "ssm": "ssm.amazonaws.com", "states": "states.me-south-1.amazonaws.com", }, "vpcEndPointServiceNamePrefix": "com.amazonaws.vpce", @@ -888,7 +888,7 @@ Object { "s3": "s3.amazonaws.com", "sns": "sns.amazonaws.com", "sqs": "sqs.amazonaws.com", - "ssm": "ssm.us-iso-west-1.amazonaws.com", + "ssm": "ssm.amazonaws.com", "states": "states.amazonaws.com", }, "vpcEndPointServiceNamePrefix": "gov.ic.c2s.vpce", diff --git a/packages/@aws-cdk/region-info/test/default.test.ts b/packages/@aws-cdk/region-info/test/default.test.ts index b9c7a4375f8fd..10651a4788070 100644 --- a/packages/@aws-cdk/region-info/test/default.test.ts +++ b/packages/@aws-cdk/region-info/test/default.test.ts @@ -5,7 +5,7 @@ const urlSuffix = '.nowhere.null'; describe('servicePrincipal', () => { for (const suffix of ['', '.amazonaws.com', '.amazonaws.com.cn']) { - for (const service of ['codedeploy', 'states', 'ssm']) { + for (const service of ['codedeploy', 'states']) { test(`${service}${suffix}`, () => { expect(Default.servicePrincipal(`${service}${suffix}`, region, urlSuffix)).toBe(`${service}.${region}.amazonaws.com`); }); @@ -58,11 +58,15 @@ describe('servicePrincipal', () => { describe('spot-check some service principals', () => { test('ssm', () => { + // SSM has advertised in its documentation that it is regional after a certain point, but that + // documentation only applies to SSM Inventory, not SSM Automation. Plus, there is no need for + // a different service principal, as all accounts are (at least currently) included in the global + // one. expectServicePrincipals('ssm.amazonaws.com', { 'us-east-1': 'ssm.amazonaws.com', 'eu-north-1': 'ssm.amazonaws.com', - 'ap-east-1': 'ssm.ap-east-1.amazonaws.com', - 'eu-south-1': 'ssm.eu-south-1.amazonaws.com', + 'ap-east-1': 'ssm.amazonaws.com', + 'eu-south-1': 'ssm.amazonaws.com', }); }); diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 4b4a03656fe9d..2be431787ca1a 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -410,6 +410,8 @@ For this reason, only use it for development purposes. **⚠ Note #2**: This command is considered experimental, and might have breaking changes in the future. +**⚠ Note #3**: Expected defaults for certain parameters may be different with the hotswap parameter. For example, an ECS service's minimum healthy percentage will currently be set to 0. Please review the source accordingly if this occurs. + ### `cdk watch` The `watch` command is similar to `deploy`, diff --git a/packages/aws-cdk/lib/api/hotswap/ecs-services.ts b/packages/aws-cdk/lib/api/hotswap/ecs-services.ts index baa7383fd3d62..47bba75413e8a 100644 --- a/packages/aws-cdk/lib/api/hotswap/ecs-services.ts +++ b/packages/aws-cdk/lib/api/hotswap/ecs-services.ts @@ -126,7 +126,8 @@ class EcsServiceHotswapOperation implements HotswapOperation { clusterPromises = []; servicePerClusterUpdates[clusterName] = clusterPromises; } - + // Forcing New Deployment and setting Minimum Healthy Percent to 0. + // As CDK HotSwap is development only, this seems the most efficient way to ensure all tasks are replaced immediately, regardless of original amount. clusterPromises.push({ promise: sdk.ecs().updateService({ service: ecsService.serviceArn, diff --git a/packages/aws-cdk/lib/init-templates/app/go/%name%.template.go b/packages/aws-cdk/lib/init-templates/app/go/%name%.template.go index 1cbe6d66b615c..8c966fa48359d 100644 --- a/packages/aws-cdk/lib/init-templates/app/go/%name%.template.go +++ b/packages/aws-cdk/lib/init-templates/app/go/%name%.template.go @@ -4,7 +4,7 @@ import ( "github.com/aws/aws-cdk-go/awscdk/v2" // "github.com/aws/aws-cdk-go/awscdk/v2/awssqs" "github.com/aws/constructs-go/constructs/v10" - // "github.com/aws/jsii-runtime-go" + "github.com/aws/jsii-runtime-go" ) type %name.PascalCased%StackProps struct { diff --git a/tools/@aws-cdk/prlint/lint.ts b/tools/@aws-cdk/prlint/lint.ts index 425be04d6f8e4..d01c3f2121126 100644 --- a/tools/@aws-cdk/prlint/lint.ts +++ b/tools/@aws-cdk/prlint/lint.ts @@ -29,6 +29,19 @@ export interface GitHubFile { readonly filename: string; } +export interface Review { + id: number; + user: { + login: string + }; + body: string; + state: string; +} + +export interface Comment { + id: number; +} + class LinterError extends Error { constructor(message: string) { super(message); @@ -158,37 +171,97 @@ export interface PullRequestLinterProps { export class PullRequestLinter { private readonly client: Octokit; private readonly prParams: { owner: string, repo: string, pull_number: number }; + private readonly issueParams: { owner: string, repo: string, issue_number: number }; constructor(private readonly props: PullRequestLinterProps) { this.client = props.client; this.prParams = { owner: props.owner, repo: props.repo, pull_number: props.number }; + this.issueParams = { owner: props.owner, repo: props.repo, issue_number: props.number }; } /** - * Dismisses previous reviews by aws-cdk-automation when changes have been made to the pull request. + * Deletes the previous linter comment if it exists. */ - private async dismissPreviousPRLinterReviews(): Promise { - const reviews = await this.client.pulls.listReviews(this.prParams); - reviews.data.forEach(async (review: any) => { - if (review.user?.login === 'aws-cdk-automation' && review.state !== 'DISMISSED') { - await this.client.pulls.dismissReview({ - ...this.prParams, - review_id: review.id, - message: 'Pull Request updated. Dissmissing previous PRLinter Review.', - }) - } + private async deletePRLinterComment(): Promise { + // Since previous versions of this pr linter didn't add comments, we need to do this check first. + const comment = await this.findExistingComment(); + if (comment) { + await this.client.issues.deleteComment({ + ...this.issueParams, + comment_id: comment.id, + }); + }; + }; + + /** + * Dismisses previous reviews by aws-cdk-automation when the pull request succeeds the linter. + * @param existingReview The review created by a previous run of the linter + */ + private async dismissPRLinterReview(existingReview?: Review): Promise { + if (existingReview) { + await this.client.pulls.dismissReview({ + ...this.prParams, + review_id: existingReview.id, + message: '✅ Updated pull request passes all PRLinter validations. Dissmissing previous PRLinter review.' + }) + } + } + + /** + * Creates a new review and comment for first run with failure or creates a new comment with new failures for existing reviews. + * @param failureMessages The failures received by the pr linter validation checks. + * @param existingReview The review created by a previous run of the linter. + */ + private async createOrUpdatePRLinterReview(failureMessages: string[], existingReview?: Review): Promise { + const body = `The pull request linter fails with the following errors:${this.formatErrors(failureMessages)}PRs must pass status checks before we can provide a meaningful review.`; + if (!existingReview) { + await this.client.pulls.createReview({ + ...this.prParams, + body: 'The pull request linter has failed. See the aws-cdk-automation comment below for failure reasons.' + + ' If you believe this pull request should receive an exemption, please comment and provide a justification.', + event: 'REQUEST_CHANGES', + }) + } + + await this.client.issues.createComment({ + ...this.issueParams, + body, }) + + throw new LinterError(body); + } + + /** + * Finds existing review, if present + * @returns Existing review, if present + */ + private async findExistingReview(): Promise { + const reviews = await this.client.pulls.listReviews(this.prParams); + return reviews.data.find((review) => review.user?.login === 'aws-cdk-automation' && review.state !== 'DISMISSED') as Review; + } + + /** + * Finds existing comment from previous review, if present + * @returns Existing comment, if present + */ + private async findExistingComment(): Promise { + const comments = await this.client.issues.listComments(this.issueParams); + return comments.data.find((comment) => comment.user?.login === 'aws-cdk-automation' && comment.body?.startsWith('The pull request linter fails with the following errors:')) as Comment; } /** * Creates a new review, requesting changes, with the reasons that the linter did not pass. - * @param failureReasons The list of reasons why the linter failed + * @param result The result of the PR Linter run. */ - private async communicateResult(failureReasons: string[]): Promise { - const body = `The Pull Request Linter fails with the following errors:${this.formatErrors(failureReasons)}PRs must pass status checks before we can provide a meaningful review.`; - await this.client.pulls.createReview({ ...this.prParams, body, event: 'REQUEST_CHANGES', }); - throw new LinterError(body); + private async communicateResult(result: ValidationCollector): Promise { + const existingReview = await this.findExistingReview(); + if (result.isValid()) { + console.log("✅ Success"); + await this.dismissPRLinterReview(existingReview); + } else { + await this.createOrUpdatePRLinterReview(result.errors, existingReview); + } } /** @@ -245,8 +318,8 @@ export class PullRequestLinter { testRuleSet: [ { test: noCliChanges } ], }); - await this.dismissPreviousPRLinterReviews(); - validationCollector.isValid() ? console.log("✅ Success") : await this.communicateResult(validationCollector.errors); + await this.deletePRLinterComment(); + await this.communicateResult(validationCollector); } private formatErrors(errors: string[]) { diff --git a/tools/@aws-cdk/prlint/test/lint.test.ts b/tools/@aws-cdk/prlint/test/lint.test.ts index 5cd298eb65a49..9a22289c7589d 100644 --- a/tools/@aws-cdk/prlint/test/lint.test.ts +++ b/tools/@aws-cdk/prlint/test/lint.test.ts @@ -168,7 +168,7 @@ describe('integration tests required on features', () => { ]; const prLinter = configureMock(issue, files); await expect(prLinter.validate()).rejects.toThrow( - 'The Pull Request Linter fails with the following errors:' + + 'The pull request linter fails with the following errors:' + '\n\n\t❌ Features must contain a change to an integration test file and the resulting snapshot.' + '\n\nPRs must pass status checks before we can provide a meaningful review.' ); @@ -198,7 +198,7 @@ describe('integration tests required on features', () => { ]; const prLinter = configureMock(issue, files); await expect(prLinter.validate()).rejects.toThrow( - 'The Pull Request Linter fails with the following errors:' + + 'The pull request linter fails with the following errors:' + '\n\n\t❌ Features must contain a change to an integration test file and the resulting snapshot.' + '\n\nPRs must pass status checks before we can provide a meaningful review.' ); @@ -228,7 +228,7 @@ describe('integration tests required on features', () => { ]; const prLinter = configureMock(issue, files); await expect(prLinter.validate()).rejects.toThrow( - 'The Pull Request Linter fails with the following errors:' + + 'The pull request linter fails with the following errors:' + '\n\n\t❌ Fixes must contain a change to an integration test file and the resulting snapshot.' + '\n\nPRs must pass status checks before we can provide a meaningful review.' ); @@ -258,7 +258,7 @@ describe('integration tests required on features', () => { ]; const prLinter = configureMock(issue, files); await expect(prLinter.validate()).rejects.toThrow( - 'The Pull Request Linter fails with the following errors:' + + 'The pull request linter fails with the following errors:' + '\n\n\t❌ Fixes must contain a change to an integration test file and the resulting snapshot.' + '\n\nPRs must pass status checks before we can provide a meaningful review.' ); @@ -355,12 +355,21 @@ function configureMock(pr: linter.GitHubPr, prFiles?: linter.GitHubFile[]): lint }, listReviews(_props: { _owner: string, _repo: string, _pull_number: number }) { - return { data: [{ id: 1111122222, user: { login: 'aws-cdk-automation' }, state: 'CHANGES_REQUESTED' }] }; + return { data: [{ id: 1111122222, user: { login: 'aws-cdk-automation' }, state: 'CHANGES_REQUESTED' }] }; }, dismissReview() {}, + }; - } + const issuesClient = { + createComment() {}, + + deleteComment() {}, + + listComments() { + return { data: [{ id: 1212121212, user: { login: 'aws-cdk-automation' }, body: 'The pull request linter fails with the following errors:' }] } + } + }; return new linter.PullRequestLinter({ owner: 'aws', repo: 'aws-cdk', @@ -369,6 +378,7 @@ function configureMock(pr: linter.GitHubPr, prFiles?: linter.GitHubFile[]): lint // hax hax client: { pulls: pullsClient as any, + issues: issuesClient as any, } as any, }) }