diff --git a/packages/aws-cdk/THIRD_PARTY_LICENSES b/packages/aws-cdk/THIRD_PARTY_LICENSES index 90663aa849512..e47c4bfb76c16 100644 --- a/packages/aws-cdk/THIRD_PARTY_LICENSES +++ b/packages/aws-cdk/THIRD_PARTY_LICENSES @@ -901,16 +901,28 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---------------- -** decamelize@5.0.1 - https://www.npmjs.com/package/decamelize/v/5.0.1 | MIT -MIT License +** decamelize@1.2.0 - https://www.npmjs.com/package/decamelize/v/1.2.0 | MIT +The MIT License (MIT) Copyright (c) Sindre Sorhus (sindresorhus.com) -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. ---------------- @@ -3029,6 +3041,26 @@ License, as follows: WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** semver@6.3.0 - https://www.npmjs.com/package/semver/v/6.3.0 | ISC +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ---------------- ** semver@7.3.7 - https://www.npmjs.com/package/semver/v/7.3.7 | ISC @@ -3604,11 +3636,11 @@ OTHER DEALINGS IN THE SOFTWARE. ---------------- -** uuid@8.0.0 - https://www.npmjs.com/package/uuid/v/8.0.0 | MIT +** uuid@3.4.0 - https://www.npmjs.com/package/uuid/v/3.4.0 | MIT ---------------- -** uuid@8.3.2 - https://www.npmjs.com/package/uuid/v/8.3.2 | MIT +** uuid@8.0.0 - https://www.npmjs.com/package/uuid/v/8.0.0 | MIT ---------------- diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 9a8112a78c95d..82dfd295ff5a2 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -4,7 +4,6 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; import * as fs from 'fs-extra'; -import PQueue from 'p-queue'; import * as promptly from 'promptly'; import { environmentsFromDescriptors, globEnvironmentsFromStacks, looksLikeGlob } from '../lib/api/cxapp/environments'; import { SdkProvider } from './api/aws-auth'; @@ -15,6 +14,7 @@ import { CloudExecutable } from './api/cxapp/cloud-executable'; import { findCloudWatchLogGroups } from './api/logs/find-cloudwatch-logs'; import { CloudWatchLogEventMonitor } from './api/logs/logs-monitor'; import { StackActivityProgress } from './api/util/cloudformation/stack-activity-monitor'; +import { deployStacks } from './deploy'; import { printSecurityDiff, printStackDiff, RequireApproval } from './diff'; import { ResourceImporter } from './import'; import { data, debug, error, highlight, print, success, warning } from './logging'; @@ -170,11 +170,6 @@ export class CdkToolkit { const outputsFile = options.outputsFile; const concurrency = options.concurrency || 1; - const queue = new PQueue({ concurrency }); - const stacksAwaitingDeploy = stacks.reduce((acc, stack) => ({ - ...acc, [stack.id]: true, - }), {} as Record); - const progress = concurrency > 1 ? StackActivityProgress.EVENTS : options.progress; if (concurrency > 1 && options.progress && options.progress != StackActivityProgress.EVENTS) { warning('⚠️ The --concurrency flag only supports --progress "events". Switching to "events".'); @@ -271,8 +266,6 @@ export class CdkToolkit { stackOutputs[stack.stackName] = result.outputs; } - stacksAwaitingDeploy[stack.id] = false; - for (const name of Object.keys(result.outputs).sort()) { const value = result.outputs[name]; print('%s.%s = %s', chalk.cyan(stack.id), chalk.cyan(name), chalk.underline(chalk.cyan(value))); @@ -303,33 +296,8 @@ export class CdkToolkit { print('\n✨ Total time: %ss\n', formatTime(elapsedSynthTime + elapsedDeployTime)); }; - const isStackUnblocked = (stack: cxapi.CloudFormationStackArtifact) => - stack.dependencies - .map(({ id }) => id) - .filter((id) => !id.endsWith('.assets')) - .every((id) => !stacksAwaitingDeploy[id]); - - const stackDeployPromises: Promise[] = []; - - const enqueueStackDeploys = async () => { - stacks.forEach(async (stack) => { - if (isStackUnblocked(stack)) { - // Find current index due to stacks list changing within loop - const index = stacks.indexOf(stack); - stacks.splice(index, 1); - - stackDeployPromises.push(queue.add(async () => { - await deployStack(stack); - await enqueueStackDeploys(); - })); - } - }); - }; - try { - await enqueueStackDeploys(); - await Promise.all(stackDeployPromises); - await queue.onIdle(); + await deployStacks(stacks, { concurrency, deployStack }); } catch (e) { error('\n ❌ Deployment failed: %s', e); throw e; diff --git a/packages/aws-cdk/lib/deploy.ts b/packages/aws-cdk/lib/deploy.ts new file mode 100644 index 0000000000000..208c0339ac732 --- /dev/null +++ b/packages/aws-cdk/lib/deploy.ts @@ -0,0 +1,64 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import PQueue from 'p-queue'; + +type Options = { + concurrency: number; + deployStack: (stack: cxapi.CloudFormationStackArtifact) => Promise; +}; + +type DeploymentState = 'pending' | 'queued' | 'deploying' | 'completed' | 'failed' | 'skipped'; + +export const deployStacks = async (stacks: cxapi.CloudFormationStackArtifact[], { concurrency, deployStack }: Options): Promise => { + const queue = new PQueue({ concurrency }); + const deploymentStates = stacks.reduce((acc, stack) => ({ ...acc, [stack.id]: 'pending' as const }), {} as Record); + const deployPromises: Promise[] = []; + + const isStackUnblocked = (stack: cxapi.CloudFormationStackArtifact) => + stack.dependencies + .map(({ id }) => id) + .filter((id) => !id.endsWith('.assets')) + .every((id) => deploymentStates[id] === 'completed'); + + const hasAnyStackFailed = (states: Record) => Object.values(states).includes('failed'); + + const enqueueStackDeploys = async () => { + stacks.forEach(async (stack) => { + if (deploymentStates[stack.id] === 'pending' && isStackUnblocked(stack)) { + deploymentStates[stack.id] = 'queued'; + + deployPromises.push( + queue.add(async () => { + // Do not start new deployments if any has already failed + if (hasAnyStackFailed(deploymentStates)) { + deploymentStates[stack.id] = 'skipped'; + return; + } + + deploymentStates[stack.id] = 'deploying'; + try { + await deployStack(stack); + } catch (e) { + deploymentStates[stack.id] = 'failed'; + throw e; + } + + deploymentStates[stack.id] = 'completed'; + await enqueueStackDeploys(); + }), + ); + } + }); + }; + + await enqueueStackDeploys(); + + const results = await Promise.allSettled(deployPromises); + const isRejectedResult = (result: PromiseSettledResult): result is PromiseRejectedResult => result.status === 'rejected'; + const errors = results.filter(isRejectedResult).map(({ reason }) => reason); + + if (errors.length) { + throw Error(`Stack Deployments Failed: ${errors}`); + } + + await queue.onIdle(); +}; \ No newline at end of file diff --git a/packages/aws-cdk/test/cdk-toolkit.test.ts b/packages/aws-cdk/test/cdk-toolkit.test.ts deleted file mode 100644 index 3195031b356d7..0000000000000 --- a/packages/aws-cdk/test/cdk-toolkit.test.ts +++ /dev/null @@ -1,942 +0,0 @@ -// We need to mock the chokidar library, used by 'cdk watch' -const mockChokidarWatcherOn = jest.fn(); -const fakeChokidarWatcher = { - on: mockChokidarWatcherOn, -}; -const fakeChokidarWatcherOn = { - get readyCallback(): () => void { - expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(1); - // The call to the first 'watcher.on()' in the production code is the one we actually want here. - // This is a pretty fragile, but at least with this helper class, - // we would have to change it only in one place if it ever breaks - const firstCall = mockChokidarWatcherOn.mock.calls[0]; - // let's make sure the first argument is the 'ready' event, - // just to be double safe - expect(firstCall[0]).toBe('ready'); - // the second argument is the callback - return firstCall[1]; - }, - - get fileEventCallback(): (event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', path: string) => Promise { - expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(2); - const secondCall = mockChokidarWatcherOn.mock.calls[1]; - // let's make sure the first argument is not the 'ready' event, - // just to be double safe - expect(secondCall[0]).not.toBe('ready'); - // the second argument is the callback - return secondCall[1]; - }, -}; - -const mockChokidarWatch = jest.fn(); -jest.mock('chokidar', () => ({ - watch: mockChokidarWatch, -})); -const fakeChokidarWatch = { - get includeArgs(): string[] { - expect(mockChokidarWatch.mock.calls.length).toBe(1); - // the include args are the first parameter to the 'watch()' call - return mockChokidarWatch.mock.calls[0][0]; - }, - - get excludeArgs(): string[] { - expect(mockChokidarWatch.mock.calls.length).toBe(1); - // the ignore args are a property of the second parameter to the 'watch()' call - const chokidarWatchOpts = mockChokidarWatch.mock.calls[0][1]; - return chokidarWatchOpts.ignored; - }, -}; - -const mockData = jest.fn(); -jest.mock('../lib/logging', () => ({ - ...jest.requireActual('../lib/logging'), - data: mockData, -})); - -import * as cxschema from '@aws-cdk/cloud-assembly-schema'; -import * as cxapi from '@aws-cdk/cx-api'; -import { Bootstrapper } from '../lib/api/bootstrap'; -import { CloudFormationDeployments, DeployStackOptions } from '../lib/api/cloudformation-deployments'; -import { DeployStackResult } from '../lib/api/deploy-stack'; -import { Template } from '../lib/api/util/cloudformation'; -import { CdkToolkit, Tag } from '../lib/cdk-toolkit'; -import { RequireApproval } from '../lib/diff'; -import { flatten } from '../lib/util'; -import { instanceMockFrom, MockCloudExecutable, TestStackArtifact } from './util'; - -let cloudExecutable: MockCloudExecutable; -let bootstrapper: jest.Mocked; -let stderrMock: jest.SpyInstance; -beforeEach(() => { - jest.resetAllMocks(); - - mockChokidarWatch.mockReturnValue(fakeChokidarWatcher); - // on() in chokidar's Watcher returns 'this' - mockChokidarWatcherOn.mockReturnValue(fakeChokidarWatcher); - - bootstrapper = instanceMockFrom(Bootstrapper); - bootstrapper.bootstrapEnvironment.mockResolvedValue({ noOp: false, outputs: {} } as any); - - cloudExecutable = new MockCloudExecutable({ - stacks: [ - MockStack.MOCK_STACK_A, - MockStack.MOCK_STACK_B, - ], - nestedAssemblies: [{ - stacks: [MockStack.MOCK_STACK_C], - }], - }); - - stderrMock = jest.spyOn(process.stderr, 'write').mockImplementation(() => { return true; }); -}); - -function defaultToolkitSetup() { - return new CdkToolkit({ - cloudExecutable, - configuration: cloudExecutable.configuration, - sdkProvider: cloudExecutable.sdkProvider, - cloudFormation: new FakeCloudFormation({ - 'Test-Stack-A': { Foo: 'Bar' }, - 'Test-Stack-B': { Baz: 'Zinga!' }, - 'Test-Stack-C': { Baz: 'Zinga!' }, - }), - }); -} - -describe('readCurrentTemplate', () => { - let template: any; - let mockForEnvironment = jest.fn(); - let mockCloudExecutable: MockCloudExecutable; - beforeEach(() => { - - template = { - Resources: { - Func: { - Type: 'AWS::Lambda::Function', - Properties: { - Key: 'Value', - }, - }, - }, - }; - mockCloudExecutable = new MockCloudExecutable({ - stacks: [ - { - stackName: 'Test-Stack-C', - template, - properties: { - assumeRoleArn: 'bloop:${AWS::Region}:${AWS::AccountId}', - lookupRole: { - arn: 'bloop-lookup:${AWS::Region}:${AWS::AccountId}', - requiresBootstrapStackVersion: 5, - bootstrapStackVersionSsmParameter: '/bootstrap/parameter', - }, - }, - }, - { - stackName: 'Test-Stack-A', - template, - properties: { - assumeRoleArn: 'bloop:${AWS::Region}:${AWS::AccountId}', - }, - }, - ], - }); - mockForEnvironment = jest.fn().mockImplementation(() => { return { sdk: mockCloudExecutable.sdkProvider.sdk, didAssumeRole: true }; }); - mockCloudExecutable.sdkProvider.forEnvironment = mockForEnvironment; - mockCloudExecutable.sdkProvider.stubCloudFormation({ - getTemplate() { - return { - TemplateBody: JSON.stringify(template), - }; - }, - describeStacks() { - return { - Stacks: [ - { - StackName: 'Test-Stack-C', - StackStatus: 'CREATE_COMPLETE', - CreationTime: new Date(), - }, - { - StackName: 'Test-Stack-A', - StackStatus: 'CREATE_COMPLETE', - CreationTime: new Date(), - }, - ], - }; - }, - }); - }); - - test('lookup role is used', async () => { - // GIVEN - let requestedParameterName: string; - mockCloudExecutable.sdkProvider.stubSSM({ - getParameter(request) { - requestedParameterName = request.Name; - return { - Parameter: { - Value: '6', - }, - }; - }, - }); - const cdkToolkit = new CdkToolkit({ - cloudExecutable: mockCloudExecutable, - configuration: mockCloudExecutable.configuration, - sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), - }); - - // WHEN - await cdkToolkit.deploy({ - selector: { patterns: ['Test-Stack-C'] }, - }); - - // THEN - expect(requestedParameterName!).toEqual('/bootstrap/parameter'); - expect(mockForEnvironment.mock.calls.length).toEqual(2); - expect(mockForEnvironment.mock.calls[0][2]).toEqual({ - assumeRoleArn: 'bloop-lookup:here:123456789012', - }); - }); - - test('fallback to deploy role if bootstrap stack version is not valid', async () => { - // GIVEN - let requestedParameterName: string; - mockCloudExecutable.sdkProvider.stubSSM({ - getParameter(request) { - requestedParameterName = request.Name; - return { - Parameter: { - Value: '1', - }, - }; - }, - }); - const cdkToolkit = new CdkToolkit({ - cloudExecutable: mockCloudExecutable, - configuration: mockCloudExecutable.configuration, - sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), - }); - - // WHEN - await cdkToolkit.deploy({ - selector: { patterns: ['Test-Stack-C'] }, - }); - - // THEN - expect(flatten(stderrMock.mock.calls)).toEqual(expect.arrayContaining([ - expect.stringMatching(/Could not assume bloop-lookup:here:123456789012/), - expect.stringMatching(/please upgrade to bootstrap version >= 5/), - ])); - expect(requestedParameterName!).toEqual('/bootstrap/parameter'); - expect(mockForEnvironment.mock.calls.length).toEqual(3); - expect(mockForEnvironment.mock.calls[0][2]).toEqual({ - assumeRoleArn: 'bloop-lookup:here:123456789012', - }); - expect(mockForEnvironment.mock.calls[1][2]).toEqual({ - assumeRoleArn: 'bloop:here:123456789012', - }); - }); - - test('fallback to deploy role if bootstrap version parameter not found', async () => { - // GIVEN - mockCloudExecutable.sdkProvider.stubSSM({ - getParameter() { - throw new Error('not found'); - }, - }); - const cdkToolkit = new CdkToolkit({ - cloudExecutable: mockCloudExecutable, - configuration: mockCloudExecutable.configuration, - sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), - }); - - // WHEN - await cdkToolkit.deploy({ - selector: { patterns: ['Test-Stack-C'] }, - }); - - // THEN - expect(flatten(stderrMock.mock.calls)).toEqual(expect.arrayContaining([ - expect.stringMatching(/Could not assume bloop-lookup:here:123456789012/), - expect.stringMatching(/please upgrade to bootstrap version >= 5/), - ])); - expect(mockForEnvironment.mock.calls.length).toEqual(3); - expect(mockForEnvironment.mock.calls[0][2]).toEqual({ - assumeRoleArn: 'bloop-lookup:here:123456789012', - }); - expect(mockForEnvironment.mock.calls[1][2]).toEqual({ - assumeRoleArn: 'bloop:here:123456789012', - }); - }); - - test('fallback to deploy role if forEnvironment throws', async () => { - // GIVEN - // throw error first for the 'prepareSdkWithLookupRoleFor' call and succeed for the rest - mockForEnvironment = jest.fn().mockImplementationOnce(() => { throw new Error('error'); }) - .mockImplementation(() => { return { sdk: mockCloudExecutable.sdkProvider.sdk, didAssumeRole: true };}); - mockCloudExecutable.sdkProvider.forEnvironment = mockForEnvironment; - mockCloudExecutable.sdkProvider.stubSSM({ - getParameter() { - return { }; - }, - }); - const cdkToolkit = new CdkToolkit({ - cloudExecutable: mockCloudExecutable, - configuration: mockCloudExecutable.configuration, - sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), - }); - - // WHEN - await cdkToolkit.deploy({ - selector: { patterns: ['Test-Stack-C'] }, - }); - - // THEN - expect(mockCloudExecutable.sdkProvider.sdk.ssm).not.toHaveBeenCalled(); - expect(flatten(stderrMock.mock.calls)).toEqual(expect.arrayContaining([ - expect.stringMatching(/Could not assume bloop-lookup:here:123456789012/), - expect.stringMatching(/please upgrade to bootstrap version >= 5/), - ])); - expect(mockForEnvironment.mock.calls.length).toEqual(3); - expect(mockForEnvironment.mock.calls[0][2]).toEqual({ - assumeRoleArn: 'bloop-lookup:here:123456789012', - }); - expect(mockForEnvironment.mock.calls[1][2]).toEqual({ - assumeRoleArn: 'bloop:here:123456789012', - }); - }); - - test('dont lookup bootstrap version parameter if default credentials are used', async () => { - // GIVEN - mockForEnvironment = jest.fn().mockImplementation(() => { return { sdk: mockCloudExecutable.sdkProvider.sdk, didAssumeRole: false }; }); - mockCloudExecutable.sdkProvider.forEnvironment = mockForEnvironment; - const cdkToolkit = new CdkToolkit({ - cloudExecutable: mockCloudExecutable, - configuration: mockCloudExecutable.configuration, - sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), - }); - mockCloudExecutable.sdkProvider.stubSSM({ - getParameter() { - return { }; - }, - }); - - // WHEN - await cdkToolkit.deploy({ - selector: { patterns: ['Test-Stack-C'] }, - }); - - // THEN - expect(flatten(stderrMock.mock.calls)).toEqual(expect.arrayContaining([ - expect.stringMatching(/please upgrade to bootstrap version >= 5/), - ])); - expect(mockCloudExecutable.sdkProvider.sdk.ssm).not.toHaveBeenCalled(); - expect(mockForEnvironment.mock.calls.length).toEqual(3); - expect(mockForEnvironment.mock.calls[0][2]).toEqual({ - assumeRoleArn: 'bloop-lookup:here:123456789012', - }); - expect(mockForEnvironment.mock.calls[1][2]).toEqual({ - assumeRoleArn: 'bloop:here:123456789012', - }); - }); - - test('do not print warnings if lookup role not provided in stack artifact', async () => { - // GIVEN - mockCloudExecutable.sdkProvider.stubSSM({ - getParameter() { - return {}; - }, - }); - const cdkToolkit = new CdkToolkit({ - cloudExecutable: mockCloudExecutable, - configuration: mockCloudExecutable.configuration, - sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), - }); - - // WHEN - await cdkToolkit.deploy({ - selector: { patterns: ['Test-Stack-A'] }, - }); - - // THEN - expect(flatten(stderrMock.mock.calls)).not.toEqual(expect.arrayContaining([ - expect.stringMatching(/Could not assume/), - expect.stringMatching(/please upgrade to bootstrap version/), - ])); - expect(mockCloudExecutable.sdkProvider.sdk.ssm).not.toHaveBeenCalled(); - expect(mockForEnvironment.mock.calls.length).toEqual(2); - expect(mockForEnvironment.mock.calls[0][2]).toEqual({ - assumeRoleArn: undefined, - assumeRoleExternalId: undefined, - }); - }); -}); - -describe('deploy', () => { - test('fails when no valid stack names are given', async () => { - // GIVEN - const toolkit = defaultToolkitSetup(); - - // WHEN - await expect(() => toolkit.deploy({ selector: { patterns: ['Test-Stack-D'] } })).rejects.toThrow('No stacks match the name(s) Test-Stack-D'); - }); - - describe('with hotswap deployment', () => { - test("passes through the 'hotswap' option to CloudFormationDeployments.deployStack()", async () => { - // GIVEN - const mockCfnDeployments = instanceMockFrom(CloudFormationDeployments); - mockCfnDeployments.deployStack.mockReturnValue(Promise.resolve({ - noOp: false, - outputs: {}, - stackArn: 'stackArn', - stackArtifact: instanceMockFrom(cxapi.CloudFormationStackArtifact), - })); - const cdkToolkit = new CdkToolkit({ - cloudExecutable, - configuration: cloudExecutable.configuration, - sdkProvider: cloudExecutable.sdkProvider, - cloudFormation: mockCfnDeployments, - }); - - // WHEN - await cdkToolkit.deploy({ - selector: { patterns: ['Test-Stack-A-Display-Name'] }, - requireApproval: RequireApproval.Never, - hotswap: true, - }); - - // THEN - expect(mockCfnDeployments.deployStack).toHaveBeenCalledWith(expect.objectContaining({ - hotswap: true, - })); - }); - }); - - describe('makes correct CloudFormation calls', () => { - test('without options', async () => { - // GIVEN - const toolkit = defaultToolkitSetup(); - - // WHEN - await toolkit.deploy({ selector: { patterns: ['Test-Stack-A', 'Test-Stack-B'] } }); - }); - - test('with stacks all stacks specified as double wildcard', async () => { - // GIVEN - const toolkit = defaultToolkitSetup(); - - // WHEN - await toolkit.deploy({ selector: { patterns: ['**'] } }); - }); - - - test('with one stack specified', async () => { - // GIVEN - const toolkit = defaultToolkitSetup(); - - // WHEN - await toolkit.deploy({ selector: { patterns: ['Test-Stack-A-Display-Name'] } }); - }); - - test('with stacks all stacks specified as wildcard', async () => { - // GIVEN - const toolkit = defaultToolkitSetup(); - - // WHEN - await toolkit.deploy({ selector: { patterns: ['*'] } }); - }); - - test('with concurrency', async () => { - // GIVEN - const toolkit = defaultToolkitSetup(); - - // WHEN - await toolkit.deploy({ concurrency: 2, selector: { patterns: ['Test-Stack-A', 'Test-Stack-B'] } }); - }); - - test('with sns notification arns', async () => { - // GIVEN - const notificationArns = [ - 'arn:aws:sns:us-east-2:444455556666:MyTopic', - 'arn:aws:sns:eu-west-1:111155556666:my-great-topic', - ]; - const toolkit = new CdkToolkit({ - cloudExecutable, - configuration: cloudExecutable.configuration, - sdkProvider: cloudExecutable.sdkProvider, - cloudFormation: new FakeCloudFormation({ - 'Test-Stack-A': { Foo: 'Bar' }, - 'Test-Stack-B': { Baz: 'Zinga!' }, - }, notificationArns), - }); - - // WHEN - await toolkit.deploy({ - selector: { patterns: ['Test-Stack-A', 'Test-Stack-B'] }, - notificationArns, - }); - }); - - test('fail with incorrect sns notification arns', async () => { - // GIVEN - const notificationArns = ['arn:::cfn-my-cool-topic']; - const toolkit = new CdkToolkit({ - cloudExecutable, - configuration: cloudExecutable.configuration, - sdkProvider: cloudExecutable.sdkProvider, - cloudFormation: new FakeCloudFormation({ - 'Test-Stack-A': { Foo: 'Bar' }, - }, notificationArns), - }); - - // WHEN - await expect(() => - toolkit.deploy({ - selector: { patterns: ['Test-Stack-A'] }, - notificationArns, - }), - ).rejects.toThrow('Notification arn arn:::cfn-my-cool-topic is not a valid arn for an SNS topic'); - - }); - - test('globless bootstrap uses environment without question', async () => { - // GIVEN - const toolkit = defaultToolkitSetup(); - - // WHEN - await toolkit.bootstrap(['aws://56789/south-pole'], bootstrapper, {}); - - // THEN - expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledWith({ - account: '56789', - region: 'south-pole', - name: 'aws://56789/south-pole', - }, expect.anything(), expect.anything()); - expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledTimes(1); - }); - - test('globby bootstrap uses whats in the stacks', async () => { - // GIVEN - const toolkit = defaultToolkitSetup(); - cloudExecutable.configuration.settings.set(['app'], 'something'); - - // WHEN - await toolkit.bootstrap(['aws://*/bermuda-triangle-1'], bootstrapper, {}); - - // THEN - expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledWith({ - account: '123456789012', - region: 'bermuda-triangle-1', - name: 'aws://123456789012/bermuda-triangle-1', - }, expect.anything(), expect.anything()); - expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledTimes(1); - }); - - test('bootstrap can be invoked without the --app argument', async () => { - // GIVEN - cloudExecutable.configuration.settings.clear(); - const mockSynthesize = jest.fn(); - cloudExecutable.synthesize = mockSynthesize; - - const toolkit = defaultToolkitSetup(); - - // WHEN - await toolkit.bootstrap(['aws://123456789012/west-pole'], bootstrapper, {}); - - // THEN - expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledWith({ - account: '123456789012', - region: 'west-pole', - name: 'aws://123456789012/west-pole', - }, expect.anything(), expect.anything()); - expect(bootstrapper.bootstrapEnvironment).toHaveBeenCalledTimes(1); - - expect(cloudExecutable.hasApp).toEqual(false); - expect(mockSynthesize).not.toHaveBeenCalled(); - }); - }); -}); - -describe('watch', () => { - test("fails when no 'watch' settings are found", async () => { - const toolkit = defaultToolkitSetup(); - - await expect(() => { - return toolkit.watch({ selector: { patterns: [] } }); - }).rejects.toThrow("Cannot use the 'watch' command without specifying at least one directory to monitor. " + - 'Make sure to add a "watch" key to your cdk.json'); - }); - - test('observes only the root directory by default', async () => { - cloudExecutable.configuration.settings.set(['watch'], {}); - const toolkit = defaultToolkitSetup(); - - await toolkit.watch({ selector: { patterns: [] } }); - - const includeArgs = fakeChokidarWatch.includeArgs; - expect(includeArgs.length).toBe(1); - }); - - test("allows providing a single string in 'watch.include'", async () => { - cloudExecutable.configuration.settings.set(['watch'], { - include: 'my-dir', - }); - const toolkit = defaultToolkitSetup(); - - await toolkit.watch({ selector: { patterns: [] } }); - - expect(fakeChokidarWatch.includeArgs).toStrictEqual(['my-dir']); - }); - - test("allows providing an array of strings in 'watch.include'", async () => { - cloudExecutable.configuration.settings.set(['watch'], { - include: ['my-dir1', '**/my-dir2/*'], - }); - const toolkit = defaultToolkitSetup(); - - await toolkit.watch({ selector: { patterns: [] } }); - - expect(fakeChokidarWatch.includeArgs).toStrictEqual(['my-dir1', '**/my-dir2/*']); - }); - - test('ignores the output dir, dot files, dot directories, and node_modules by default', async () => { - cloudExecutable.configuration.settings.set(['watch'], {}); - cloudExecutable.configuration.settings.set(['output'], 'cdk.out'); - const toolkit = defaultToolkitSetup(); - - await toolkit.watch({ selector: { patterns: [] } }); - - expect(fakeChokidarWatch.excludeArgs).toStrictEqual([ - 'cdk.out/**', - '**/.*', - '**/.*/**', - '**/node_modules/**', - ]); - }); - - test("allows providing a single string in 'watch.exclude'", async () => { - cloudExecutable.configuration.settings.set(['watch'], { - exclude: 'my-dir', - }); - const toolkit = defaultToolkitSetup(); - - await toolkit.watch({ selector: { patterns: [] } }); - - const excludeArgs = fakeChokidarWatch.excludeArgs; - expect(excludeArgs.length).toBe(5); - expect(excludeArgs[0]).toBe('my-dir'); - }); - - test("allows providing an array of strings in 'watch.exclude'", async () => { - cloudExecutable.configuration.settings.set(['watch'], { - exclude: ['my-dir1', '**/my-dir2'], - }); - const toolkit = defaultToolkitSetup(); - - await toolkit.watch({ selector: { patterns: [] } }); - - const excludeArgs = fakeChokidarWatch.excludeArgs; - expect(excludeArgs.length).toBe(6); - expect(excludeArgs[0]).toBe('my-dir1'); - expect(excludeArgs[1]).toBe('**/my-dir2'); - }); - - describe('with file change events', () => { - let toolkit: CdkToolkit; - let cdkDeployMock: jest.Mock; - - beforeEach(async () => { - cloudExecutable.configuration.settings.set(['watch'], {}); - toolkit = defaultToolkitSetup(); - cdkDeployMock = jest.fn(); - toolkit.deploy = cdkDeployMock; - await toolkit.watch({ selector: { patterns: [] } }); - }); - - test("does not trigger a 'deploy' before the 'ready' event has fired", async () => { - await fakeChokidarWatcherOn.fileEventCallback('add', 'my-file'); - - expect(cdkDeployMock).not.toHaveBeenCalled(); - }); - - describe("when the 'ready' event has already fired", () => { - beforeEach(() => { - // The ready callback triggers a deployment so each test - // that uses this function will see 'cdkDeployMock' called - // an additional time. - fakeChokidarWatcherOn.readyCallback(); - }); - - test("an initial 'deploy' is triggered, without any file changes", async () => { - expect(cdkDeployMock).toHaveBeenCalledTimes(1); - }); - - test("does trigger a 'deploy' for a file change", async () => { - await fakeChokidarWatcherOn.fileEventCallback('add', 'my-file'); - - expect(cdkDeployMock).toHaveBeenCalledTimes(2); - }); - - test("triggers a 'deploy' twice for two file changes", async () => { - await Promise.all([ - fakeChokidarWatcherOn.fileEventCallback('add', 'my-file1'), - fakeChokidarWatcherOn.fileEventCallback('change', 'my-file2'), - ]); - - expect(cdkDeployMock).toHaveBeenCalledTimes(3); - }); - - test("batches file changes that happen during 'deploy'", async () => { - await Promise.all([ - fakeChokidarWatcherOn.fileEventCallback('add', 'my-file1'), - fakeChokidarWatcherOn.fileEventCallback('change', 'my-file2'), - fakeChokidarWatcherOn.fileEventCallback('unlink', 'my-file3'), - fakeChokidarWatcherOn.fileEventCallback('add', 'my-file4'), - ]); - - expect(cdkDeployMock).toHaveBeenCalledTimes(3); - }); - }); - }); -}); - -describe('synth', () => { - test('successful synth outputs hierarchical stack ids', async () => { - const toolkit = defaultToolkitSetup(); - await toolkit.synth([], false, false); - - // Separate tests as colorizing hampers detection - expect(stderrMock.mock.calls[1][0]).toMatch('Test-Stack-A-Display-Name'); - expect(stderrMock.mock.calls[1][0]).toMatch('Test-Stack-B'); - }); - - test('with no stdout option', async () => { - // GIVE - const toolkit = defaultToolkitSetup(); - - // THEN - await toolkit.synth(['Test-Stack-A-Display-Name'], false, true); - expect(mockData.mock.calls.length).toEqual(0); - }); - - afterEach(() => { - process.env.STACKS_TO_VALIDATE = undefined; - }); - - describe('stack with error and flagged for validation', () => { - beforeEach(() => { - cloudExecutable = new MockCloudExecutable({ - stacks: [ - MockStack.MOCK_STACK_A, - MockStack.MOCK_STACK_B, - ], - nestedAssemblies: [{ - stacks: [ - { properties: { validateOnSynth: true }, ...MockStack.MOCK_STACK_WITH_ERROR }, - ], - }], - }); - }); - - test('causes synth to fail if autoValidate=true', async() => { - const toolkit = defaultToolkitSetup(); - const autoValidate = true; - await expect(toolkit.synth([], false, true, autoValidate)).rejects.toBeDefined(); - }); - - test('causes synth to succeed if autoValidate=false', async() => { - const toolkit = defaultToolkitSetup(); - const autoValidate = false; - await toolkit.synth([], false, true, autoValidate); - expect(mockData.mock.calls.length).toEqual(0); - }); - }); - - test('stack has error and was explicitly selected', async() => { - cloudExecutable = new MockCloudExecutable({ - stacks: [ - MockStack.MOCK_STACK_A, - MockStack.MOCK_STACK_B, - ], - nestedAssemblies: [{ - stacks: [ - { properties: { validateOnSynth: false }, ...MockStack.MOCK_STACK_WITH_ERROR }, - ], - }], - }); - - const toolkit = defaultToolkitSetup(); - - await expect(toolkit.synth(['Test-Stack-A/witherrors'], false, true)).rejects.toBeDefined(); - }); - - test('stack has error, is not flagged for validation and was not explicitly selected', async () => { - cloudExecutable = new MockCloudExecutable({ - stacks: [ - MockStack.MOCK_STACK_A, - MockStack.MOCK_STACK_B, - ], - nestedAssemblies: [{ - stacks: [ - { properties: { validateOnSynth: false }, ...MockStack.MOCK_STACK_WITH_ERROR }, - ], - }], - }); - - const toolkit = defaultToolkitSetup(); - - await toolkit.synth([], false, true); - }); - - test('stack has dependency and was explicitly selected', async () => { - cloudExecutable = new MockCloudExecutable({ - stacks: [ - MockStack.MOCK_STACK_C, - MockStack.MOCK_STACK_D, - ], - }); - - const toolkit = defaultToolkitSetup(); - - await toolkit.synth([MockStack.MOCK_STACK_D.stackName], true, false); - - expect(mockData.mock.calls.length).toEqual(1); - expect(mockData.mock.calls[0][0]).toBeDefined(); - }); -}); - -class MockStack { - public static readonly MOCK_STACK_A: TestStackArtifact = { - stackName: 'Test-Stack-A', - template: { Resources: { TemplateName: 'Test-Stack-A' } }, - env: 'aws://123456789012/bermuda-triangle-1', - metadata: { - '/Test-Stack-A': [ - { - type: cxschema.ArtifactMetadataEntryType.STACK_TAGS, - data: [ - { key: 'Foo', value: 'Bar' }, - ], - }, - ], - }, - displayName: 'Test-Stack-A-Display-Name', - }; - public static readonly MOCK_STACK_B: TestStackArtifact = { - stackName: 'Test-Stack-B', - template: { Resources: { TemplateName: 'Test-Stack-B' } }, - env: 'aws://123456789012/bermuda-triangle-1', - metadata: { - '/Test-Stack-B': [ - { - type: cxschema.ArtifactMetadataEntryType.STACK_TAGS, - data: [ - { key: 'Baz', value: 'Zinga!' }, - ], - }, - ], - }, - }; - public static readonly MOCK_STACK_C: TestStackArtifact = { - stackName: 'Test-Stack-C', - template: { Resources: { TemplateName: 'Test-Stack-C' } }, - env: 'aws://123456789012/bermuda-triangle-1', - metadata: { - '/Test-Stack-C': [ - { - type: cxschema.ArtifactMetadataEntryType.STACK_TAGS, - data: [ - { key: 'Baz', value: 'Zinga!' }, - ], - }, - ], - }, - displayName: 'Test-Stack-A/Test-Stack-C', - }; - public static readonly MOCK_STACK_D: TestStackArtifact = { - stackName: 'Test-Stack-D', - template: { Resources: { TemplateName: 'Test-Stack-D' } }, - env: 'aws://123456789012/bermuda-triangle-1', - metadata: { - '/Test-Stack-D': [ - { - type: cxschema.ArtifactMetadataEntryType.STACK_TAGS, - data: [ - { key: 'Baz', value: 'Zinga!' }, - ], - }, - ], - }, - depends: [MockStack.MOCK_STACK_C.stackName], - } - public static readonly MOCK_STACK_WITH_ERROR: TestStackArtifact = { - stackName: 'witherrors', - env: 'aws://123456789012/bermuda-triangle-1', - template: { resource: 'errorresource' }, - metadata: { - '/resource': [ - { - type: cxschema.ArtifactMetadataEntryType.ERROR, - data: 'this is an error', - }, - ], - }, - displayName: 'Test-Stack-A/witherrors', - } -} - -class FakeCloudFormation extends CloudFormationDeployments { - private readonly expectedTags: { [stackName: string]: Tag[] } = {}; - private readonly expectedNotificationArns?: string[]; - - constructor( - expectedTags: { [stackName: string]: { [key: string]: string } } = {}, - expectedNotificationArns?: string[], - ) { - super({ sdkProvider: undefined as any }); - - for (const [stackName, tags] of Object.entries(expectedTags)) { - this.expectedTags[stackName] = - Object.entries(tags).map(([Key, Value]) => ({ Key, Value })) - .sort((l, r) => l.Key.localeCompare(r.Key)); - } - if (expectedNotificationArns) { - this.expectedNotificationArns = expectedNotificationArns; - } - } - - public deployStack(options: DeployStackOptions): Promise { - expect([MockStack.MOCK_STACK_A.stackName, MockStack.MOCK_STACK_B.stackName, MockStack.MOCK_STACK_C.stackName]) - .toContain(options.stack.stackName); - expect(options.tags).toEqual(this.expectedTags[options.stack.stackName]); - expect(options.notificationArns).toEqual(this.expectedNotificationArns); - return Promise.resolve({ - stackArn: `arn:aws:cloudformation:::stack/${options.stack.stackName}/MockedOut`, - noOp: false, - outputs: { StackName: options.stack.stackName }, - stackArtifact: options.stack, - }); - } - - public readCurrentTemplate(stack: cxapi.CloudFormationStackArtifact): Promise