From 5b5c15c5d1239e3c906e3220dc0bad3651305bdb Mon Sep 17 00:00:00 2001 From: Kamil Sobol Date: Fri, 31 May 2024 11:35:15 -0700 Subject: [PATCH] use ssm parameter to detect CDK boostrap (#1576) * use ssm parameter to detect CDK boostrap * use ssm parameter to detect CDK boostrap * fix that * use default qualifier --- .changeset/proud-clouds-cross.md | 5 + .../sandbox/src/file_watching_sandbox.test.ts | 117 ++++++++---------- packages/sandbox/src/file_watching_sandbox.ts | 36 +++--- .../sandbox/src/sandbox_singleton_factory.ts | 4 +- 4 files changed, 78 insertions(+), 84 deletions(-) create mode 100644 .changeset/proud-clouds-cross.md diff --git a/.changeset/proud-clouds-cross.md b/.changeset/proud-clouds-cross.md new file mode 100644 index 0000000000..bdcf768690 --- /dev/null +++ b/.changeset/proud-clouds-cross.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/sandbox': patch +--- + +use ssm parameter to detect CDK boostrap diff --git a/packages/sandbox/src/file_watching_sandbox.test.ts b/packages/sandbox/src/file_watching_sandbox.test.ts index a30fe56f5d..2f267c3d2a 100644 --- a/packages/sandbox/src/file_watching_sandbox.test.ts +++ b/packages/sandbox/src/file_watching_sandbox.test.ts @@ -2,8 +2,7 @@ import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import path from 'path'; import watcher from '@parcel/watcher'; import { - CDK_BOOTSTRAP_STACK_NAME, - CDK_BOOTSTRAP_VERSION_KEY, + CDK_DEFAULT_BOOTSTRAP_VERSION_PARAMETER_NAME, FileWatchingSandbox, getBootstrapUrl, } from './file_watching_sandbox.js'; @@ -15,7 +14,6 @@ import { } from '@aws-amplify/backend-deployer'; import fs from 'fs'; import parseGitIgnore from 'parse-gitignore'; -import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; import _open from 'open'; import { SecretListItem, getSecretClient } from '@aws-amplify/backend-secret'; import { Sandbox, SandboxOptions } from './sandbox.js'; @@ -29,6 +27,7 @@ import { import { fileURLToPath } from 'url'; import { BackendIdentifier } from '@aws-amplify/plugin-types'; import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { SSMClient } from '@aws-sdk/client-ssm'; // Watcher mocks const unsubscribeMockFn = mock.fn(); @@ -85,24 +84,15 @@ const backendDeployerDestroyMock = mock.method(backendDeployer, 'destroy', () => Promise.resolve() ); const region = 'test-region'; -const cfnClientMock = new CloudFormationClient({ region }); -const cfnClientSendMock = mock.fn(); -mock.method(cfnClientMock, 'send', cfnClientSendMock); -cfnClientSendMock.mock.mockImplementation(() => +const ssmClientMock = new SSMClient({ region }); +const ssmClientSendMock = mock.fn(); +mock.method(ssmClientMock, 'send', ssmClientSendMock); +ssmClientSendMock.mock.mockImplementation(() => Promise.resolve({ - Stacks: [ - { - Name: CDK_BOOTSTRAP_STACK_NAME, - Outputs: [ - { - Description: - 'The version of the bootstrap resources that are currently mastered in this stack', - OutputKey: CDK_BOOTSTRAP_VERSION_KEY, - OutputValue: '18', - }, - ], - }, - ], + Parameter: { + Name: CDK_DEFAULT_BOOTSTRAP_VERSION_PARAMETER_NAME, + Value: '18', + }, }) ); const openMock = mock.fn(_open, (url: string) => Promise.resolve(url)); @@ -140,19 +130,19 @@ void describe('Sandbox to check if region is bootstrapped', () => { sandboxInstance = new FileWatchingSandbox( async () => testSandboxBackendId, sandboxExecutor, - cfnClientMock, + ssmClientMock, printer as unknown as Printer, openMock as never ); - cfnClientSendMock.mock.resetCalls(); + ssmClientSendMock.mock.resetCalls(); openMock.mock.resetCalls(); backendDeployerDestroyMock.mock.resetCalls(); backendDeployerDeployMock.mock.resetCalls(); }); afterEach(async () => { - cfnClientSendMock.mock.resetCalls(); + ssmClientSendMock.mock.resetCalls(); openMock.mock.resetCalls(); backendDeployerDestroyMock.mock.resetCalls(); backendDeployerDeployMock.mock.resetCalls(); @@ -164,7 +154,7 @@ void describe('Sandbox to check if region is bootstrapped', () => { }); void it('when region has not bootstrapped, then opens console to initiate bootstrap', async () => { - cfnClientSendMock.mock.mockImplementationOnce(() => { + ssmClientSendMock.mock.mockImplementationOnce(() => { throw new Error('Stack with id CDKToolkit does not exist'); }); @@ -173,7 +163,7 @@ void describe('Sandbox to check if region is bootstrapped', () => { exclude: ['exclude1', 'exclude2'], }); - assert.strictEqual(cfnClientSendMock.mock.callCount(), 1); + assert.strictEqual(ssmClientSendMock.mock.callCount(), 1); assert.strictEqual(openMock.mock.callCount(), 1); assert.strictEqual( openMock.mock.calls[0].arguments[0], @@ -182,21 +172,12 @@ void describe('Sandbox to check if region is bootstrapped', () => { }); void it('when region has bootstrapped, but with a version lower than the minimum (6), then opens console to initiate bootstrap', async () => { - cfnClientSendMock.mock.mockImplementationOnce(() => + ssmClientSendMock.mock.mockImplementationOnce(() => Promise.resolve({ - Stacks: [ - { - Name: CDK_BOOTSTRAP_STACK_NAME, - Outputs: [ - { - Description: - 'The version of the bootstrap resources that are currently mastered in this stack', - OutputKey: CDK_BOOTSTRAP_VERSION_KEY, - OutputValue: '5', - }, - ], - }, - ], + Parameter: { + Name: CDK_DEFAULT_BOOTSTRAP_VERSION_PARAMETER_NAME, + Value: '5', + }, }) ); @@ -205,7 +186,7 @@ void describe('Sandbox to check if region is bootstrapped', () => { exclude: ['exclude1', 'exclude2'], }); - assert.strictEqual(cfnClientSendMock.mock.callCount(), 1); + assert.strictEqual(ssmClientSendMock.mock.callCount(), 1); assert.strictEqual(openMock.mock.callCount(), 1); assert.strictEqual( openMock.mock.calls[0].arguments[0], @@ -219,7 +200,7 @@ void describe('Sandbox to check if region is bootstrapped', () => { exclude: ['exclude1', 'exclude2'], }); - assert.strictEqual(cfnClientSendMock.mock.callCount(), 1); + assert.strictEqual(ssmClientSendMock.mock.callCount(), 1); assert.strictEqual(openMock.mock.callCount(), 0); }); }); @@ -244,7 +225,7 @@ void describe('Sandbox using local project name resolver', () => { backendDeployerDestroyMock.mock.resetCalls(); backendDeployerDeployMock.mock.resetCalls(); subscribeMock.mock.resetCalls(); - cfnClientSendMock.mock.resetCalls(); + ssmClientSendMock.mock.resetCalls(); await sandboxInstance.stop(); // Printer mocks are reset after the sandbox stop to reset the "Shutting down" call as well. @@ -256,7 +237,7 @@ void describe('Sandbox using local project name resolver', () => { ({ sandboxInstance } = await setupAndStartSandbox( { executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, }, undefined, false @@ -279,7 +260,7 @@ void describe('Sandbox using local project name resolver', () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox( { executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, }, { // imaginary dir does not have any ts files @@ -309,7 +290,7 @@ void describe('Sandbox using local project name resolver', () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox( { executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, }, { dir: testDir, @@ -335,7 +316,7 @@ void describe('Sandbox using local project name resolver', () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox( { executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, }, { dir: 'testDir', @@ -364,13 +345,13 @@ void describe('Sandbox using local project name resolver', () => { validateAppSources: true, }, ]); - assert.strictEqual(cfnClientSendMock.mock.callCount(), 0); + assert.strictEqual(ssmClientSendMock.mock.callCount(), 0); }); void it('calls watcher subscribe with the default "./amplify" if no `dir` specified', async () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); await fileChangeEventCallback(null, [ { type: 'update', path: 'foo/test1.ts' }, @@ -383,7 +364,7 @@ void describe('Sandbox using local project name resolver', () => { void it('calls BackendDeployer only once when multiple file changes are present', async () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); await fileChangeEventCallback(null, [ { type: 'update', path: 'foo/test2.ts' }, @@ -403,7 +384,7 @@ void describe('Sandbox using local project name resolver', () => { void it('skips type checking if no typescript change is detected', async () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); await fileChangeEventCallback(null, [ { type: 'update', path: 'foo/test2.txt' }, @@ -423,7 +404,7 @@ void describe('Sandbox using local project name resolver', () => { void it('calls BackendDeployer once when multiple file changes are within few milliseconds (debounce)', async () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); // Not awaiting for this file event to be processed and submitting another one right away const firstFileChange = fileChangeEventCallback(null, [ @@ -449,7 +430,7 @@ void describe('Sandbox using local project name resolver', () => { void it('waits for file changes after completing a deployment and deploys again', async () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); await fileChangeEventCallback(null, [ { type: 'update', path: 'foo/test5.ts' }, @@ -479,7 +460,7 @@ void describe('Sandbox using local project name resolver', () => { void it('queues deployment if a file change is detected during an ongoing deployment', async () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); // Mimic BackendDeployer taking 200 ms. backendDeployerDeployMock.mock.mockImplementationOnce(async () => { @@ -524,7 +505,7 @@ void describe('Sandbox using local project name resolver', () => { void it('calls BackendDeployer destroy when delete is called', async () => { ({ sandboxInstance } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); await sandboxInstance.delete({}); @@ -541,7 +522,7 @@ void describe('Sandbox using local project name resolver', () => { const mockListener = mock.fn(); ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); sandboxInstance.on('successfulDeployment', mockListener); const contextualBackendDeployerMock = contextual.mock.method( @@ -578,7 +559,7 @@ void describe('Sandbox using local project name resolver', () => { void it('handles UpdateNotSupported error while deploying and offers to reset sandbox and customer says yes', async (contextual) => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); const contextualBackendDeployerMock = contextual.mock.method( backendDeployer, @@ -621,7 +602,7 @@ void describe('Sandbox using local project name resolver', () => { void it('handles UpdateNotSupported error while deploying and offers to reset sandbox and customer says no, continues running sandbox', async (contextual) => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); const contextualBackendDeployerMock = contextual.mock.method( backendDeployer, @@ -669,7 +650,7 @@ void describe('Sandbox using local project name resolver', () => { ({ sandboxInstance } = await setupAndStartSandbox( { executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, }, { dir: 'testDir', @@ -694,7 +675,7 @@ void describe('Sandbox using local project name resolver', () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox( { executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, }, { identifier: 'customSandboxName' } )); @@ -724,7 +705,7 @@ void describe('Sandbox using local project name resolver', () => { ({ sandboxInstance } = await setupAndStartSandbox( { executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, }, { identifier: 'customSandboxName' } )); @@ -759,7 +740,7 @@ void describe('Sandbox using local project name resolver', () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox( { executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, }, { exclude: ['customer_exclude1', 'customer_exclude2'], @@ -804,7 +785,7 @@ void describe('Sandbox using local project name resolver', () => { ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox( { executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, }, { exclude: ['customer_exclude1', 'customer_exclude2'], @@ -836,7 +817,7 @@ void describe('Sandbox using local project name resolver', () => { const mockListener = mock.fn(); ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); sandboxInstance.on('successfulDeployment', mockListener); @@ -852,7 +833,7 @@ void describe('Sandbox using local project name resolver', () => { const mockListener = mock.fn(); ({ sandboxInstance, fileChangeEventCallback } = await setupAndStartSandbox({ executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, })); sandboxInstance.on('successfulDeletion', mockListener); @@ -866,7 +847,7 @@ void describe('Sandbox using local project name resolver', () => { await setupAndStartSandbox( { executor: sandboxExecutor, - cfnClient: cfnClientMock, + ssmClient: ssmClientMock, }, { watchForChanges: false } ); @@ -894,7 +875,7 @@ const setupAndStartSandbox = async ( type: 'sandbox', }), testData.executor, - testData.cfnClient, + testData.ssmClient, printer as unknown as Printer, testData.open ?? _open ); @@ -909,7 +890,7 @@ const setupAndStartSandbox = async ( // Reset all the calls to avoid extra startup call backendDeployerDestroyMock.mock.resetCalls(); backendDeployerDeployMock.mock.resetCalls(); - cfnClientSendMock.mock.resetCalls(); + ssmClientSendMock.mock.resetCalls(); listSecretMock.mock.resetCalls(); } @@ -946,6 +927,6 @@ type SandboxTestData = { // To instantiate sandbox sandboxName?: string; executor: AmplifySandboxExecutor; - cfnClient: CloudFormationClient; + ssmClient: SSMClient; open?: typeof _open; }; diff --git a/packages/sandbox/src/file_watching_sandbox.ts b/packages/sandbox/src/file_watching_sandbox.ts index 2b2e63ec2c..cb4b9679f5 100644 --- a/packages/sandbox/src/file_watching_sandbox.ts +++ b/packages/sandbox/src/file_watching_sandbox.ts @@ -15,10 +15,7 @@ import _open from 'open'; // EventEmitter is a class name and expected to have PascalCase // eslint-disable-next-line @typescript-eslint/naming-convention import EventEmitter from 'events'; -import { - CloudFormationClient, - DescribeStacksCommand, -} from '@aws-sdk/client-cloudformation'; +import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; import { AmplifyPrompter, LogLevel, @@ -35,8 +32,20 @@ import { BackendIdentifierConversions, } from '@aws-amplify/platform-core'; -export const CDK_BOOTSTRAP_STACK_NAME = 'CDKToolkit'; -export const CDK_BOOTSTRAP_VERSION_KEY = 'BootstrapVersion'; +/** + * CDK stores bootstrap version in parameter store. Example parameter name looks like /cdk-bootstrap//version. + * The default value for qualifier is hnb659fds, i.e. default parameter path is /cdk-bootstrap/hnb659fds/version. + * The default qualifier is hardcoded value without any significance. + * Ability to provide custom qualifier is intended for name isolation between automated tests of the CDK itself. + * In order to use custom qualifier all stack synthesizers must be programmatically configured to use it. + * That makes bootstraps with custom qualifier incompatible with Amplify Backend and we treat that setup as + * not bootstrapped. + * See: https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html + */ +export const CDK_DEFAULT_BOOTSTRAP_VERSION_PARAMETER_NAME = + // suppress spell checker, it is triggered by qualifier value. + // eslint-disable-next-line spellcheck/spell-checker + '/cdk-bootstrap/hnb659fds/version'; export const CDK_MIN_BOOTSTRAP_VERSION = 6; /** @@ -61,7 +70,7 @@ export class FileWatchingSandbox extends EventEmitter implements Sandbox { constructor( private readonly backendIdSandboxResolver: BackendIdSandboxResolver, private readonly executor: AmplifySandboxExecutor, - private readonly cfnClient: CloudFormationClient, + private readonly ssmClient: SSMClient, private readonly printer: Printer, private readonly open = _open ) { @@ -109,7 +118,7 @@ export class FileWatchingSandbox extends EventEmitter implements Sandbox { 'The given region has not been bootstrapped. Sign in to console as a Root user or Admin to complete the bootstrap process, then restart the sandbox.' ); // get region from an available sdk client; - const region = await this.cfnClient.config.region(); + const region = await this.ssmClient.config.region(); await this.open(getBootstrapUrl(region)); return; } @@ -298,14 +307,13 @@ export class FileWatchingSandbox extends EventEmitter implements Sandbox { */ private isBootstrapped = async () => { try { - const { Stacks: stacks } = await this.cfnClient.send( - new DescribeStacksCommand({ - StackName: CDK_BOOTSTRAP_STACK_NAME, + const { Parameter: parameter } = await this.ssmClient.send( + new GetParameterCommand({ + Name: CDK_DEFAULT_BOOTSTRAP_VERSION_PARAMETER_NAME, }) ); - const bootstrapVersion = stacks?.[0]?.Outputs?.find( - (output) => output.OutputKey === CDK_BOOTSTRAP_VERSION_KEY - )?.OutputValue; + + const bootstrapVersion = parameter?.Value; if ( !bootstrapVersion || Number(bootstrapVersion) < CDK_MIN_BOOTSTRAP_VERSION diff --git a/packages/sandbox/src/sandbox_singleton_factory.ts b/packages/sandbox/src/sandbox_singleton_factory.ts index 56cc745db6..7279c4fd3d 100644 --- a/packages/sandbox/src/sandbox_singleton_factory.ts +++ b/packages/sandbox/src/sandbox_singleton_factory.ts @@ -7,7 +7,7 @@ import { FileWatchingSandbox } from './file_watching_sandbox.js'; import { BackendIdSandboxResolver, Sandbox } from './sandbox.js'; import { BackendDeployerFactory } from '@aws-amplify/backend-deployer'; import { AmplifySandboxExecutor } from './sandbox_executor.js'; -import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; +import { SSMClient } from '@aws-sdk/client-ssm'; import { getSecretClient } from '@aws-amplify/backend-secret'; /** @@ -42,7 +42,7 @@ export class SandboxSingletonFactory { getSecretClient(), this.printer ), - new CloudFormationClient(), + new SSMClient(), this.printer ); }