diff --git a/.changeset/fair-cobras-carry.md b/.changeset/fair-cobras-carry.md new file mode 100644 index 00000000000..c7e07c9a52f --- /dev/null +++ b/.changeset/fair-cobras-carry.md @@ -0,0 +1,6 @@ +--- +'@aws-amplify/backend-deployer': minor +'@aws-amplify/backend-cli': minor +--- + +adds check command diff --git a/packages/backend-deployer/API.md b/packages/backend-deployer/API.md index a8063e74b7d..8732b0307d1 100644 --- a/packages/backend-deployer/API.md +++ b/packages/backend-deployer/API.md @@ -35,6 +35,7 @@ export type DeployProps = { secretLastUpdated?: Date; validateAppSources?: boolean; profile?: string; + dryRun?: boolean; }; // @public (undocumented) diff --git a/packages/backend-deployer/src/cdk_deployer.test.ts b/packages/backend-deployer/src/cdk_deployer.test.ts index 254cb0ff3d5..92923e80dc1 100644 --- a/packages/backend-deployer/src/cdk_deployer.test.ts +++ b/packages/backend-deployer/src/cdk_deployer.test.ts @@ -707,6 +707,70 @@ void describe('invokeCDKCommand', () => { ]); }); + void it('stops after synthesis when dryRun is true', async () => { + executeCommandMock.mock.mockImplementation(() => Promise.resolve()); + + // Perform dry run deployment + const result = await invoker.deploy(sandboxBackendId, { + dryRun: true, + validateAppSources: true, + ...sandboxDeployProps, + }); + + assert.strictEqual(executeCommandMock.mock.callCount(), 3); + + // Call 0 -> synth + assert.deepStrictEqual(executeCommandMock.mock.calls[0].arguments[0], [ + 'cdk', + 'synth', + '--ci', + '--app', + "'npx tsx amplify/backend.ts'", + '--all', + '--output', + '.amplify/artifacts/cdk.out', + '--context', + 'amplify-backend-namespace=foo', + '--context', + 'amplify-backend-name=bar', + '--context', + 'amplify-backend-type=sandbox', + '--hotswap-fallback', + '--method=direct', + '--context', + `secretLastUpdated=${ + sandboxDeployProps.secretLastUpdated?.getTime() as number + }`, + '--quiet', + ]); + + // Call 1 -> tsc showConfig + assert.deepStrictEqual(executeCommandMock.mock.calls[1].arguments[0], [ + 'tsc', + '--showConfig', + '--project', + 'amplify', + ]); + + // Call 2 -> tsc + assert.deepStrictEqual(executeCommandMock.mock.calls[2].arguments[0], [ + 'tsc', + '--noEmit', + '--skipLibCheck', + '--project', + 'amplify', + ]); + + // return structure contains only synthesis metrics and should be same + assert.ok(result.deploymentTimes); + assert.ok(typeof result.deploymentTimes.synthesisTime === 'number'); + assert.strictEqual( + result.deploymentTimes.totalTime, + result.deploymentTimes.synthesisTime, + 'Total time should equal synthesis time for dry runs' + ); + }); + void it('returns human readable errors', async () => { mock.method(invoker, 'executeCommand', () => { throw new Error('Access Denied'); diff --git a/packages/backend-deployer/src/cdk_deployer.ts b/packages/backend-deployer/src/cdk_deployer.ts index 689bd0bcc7e..16964c955e4 100644 --- a/packages/backend-deployer/src/cdk_deployer.ts +++ b/packages/backend-deployer/src/cdk_deployer.ts @@ -104,6 +104,16 @@ export class CDKDeployer implements BackendDeployer { throw synthError; } + // If this is a dry run, don't proceed with deployment + if (deployProps?.dryRun) { + return { + deploymentTimes: { + synthesisTime: synthTimeSeconds, + totalTime: synthTimeSeconds, + }, + }; + } + // then deploy with the cloud assembly that was generated during synth const deployResult = await this.tryInvokeCdk( InvokableCommand.DEPLOY, diff --git a/packages/backend-deployer/src/cdk_deployer_singleton_factory.ts b/packages/backend-deployer/src/cdk_deployer_singleton_factory.ts index ef41107cc56..0948f2e81e1 100644 --- a/packages/backend-deployer/src/cdk_deployer_singleton_factory.ts +++ b/packages/backend-deployer/src/cdk_deployer_singleton_factory.ts @@ -11,6 +11,7 @@ export type DeployProps = { secretLastUpdated?: Date; validateAppSources?: boolean; profile?: string; + dryRun?: boolean; }; export type DestroyProps = { diff --git a/packages/cli/src/commands/check/check_command.test.ts b/packages/cli/src/commands/check/check_command.test.ts new file mode 100644 index 00000000000..3573d178998 --- /dev/null +++ b/packages/cli/src/commands/check/check_command.test.ts @@ -0,0 +1,72 @@ +import { BackendDeployer } from '@aws-amplify/backend-deployer'; +import { format, printer } from '@aws-amplify/cli-core'; +import assert from 'node:assert'; +import { beforeEach, describe, it, mock } from 'node:test'; +import { CheckCommand } from './check_command.js'; + +void describe('check command', () => { + const deployMock = mock.fn(); + const backendDeployerMock = { + deploy: deployMock, + } as unknown as BackendDeployer; + + const printerMock = { + print: mock.fn(), + indicateProgress: mock.fn( + async (message: string, action: () => Promise) => { + await action(); + } + ), + }; + + mock.method(printer, 'print', printerMock.print); + mock.method(printer, 'indicateProgress', printerMock.indicateProgress); + + beforeEach(() => { + deployMock.mock.resetCalls(); + printerMock.print.mock.resetCalls(); + printerMock.indicateProgress.mock.resetCalls(); + }); + + void it('runs type checking and CDK synthesis without deployment', async () => { + const command = new CheckCommand(backendDeployerMock); + const synthesisTime = 1.23; + + deployMock.mock.mockImplementation(async () => ({ + deploymentTimes: { + synthesisTime, + }, + })); + + await command.handler(); + + // Verify deploy was called with correct arguments + assert.strictEqual(deployMock.mock.callCount(), 1); + assert.deepStrictEqual(deployMock.mock.calls[0].arguments[0], { + namespace: 'sandbox', + name: 'dev', + type: 'sandbox', + }); + assert.deepStrictEqual(deployMock.mock.calls[0].arguments[1], { + validateAppSources: true, + dryRun: true, + }); + + // Verify progress indicator was shown + assert.strictEqual(printerMock.indicateProgress.mock.callCount(), 1); + assert.strictEqual( + printerMock.indicateProgress.mock.calls[0].arguments[0], + 'Running type checks and CDK synthesis...' + ); + + // Verify success message was printed + assert.strictEqual(printerMock.print.mock.callCount(), 1); + assert.strictEqual( + printerMock.print.mock.calls[0].arguments[0], + format.success( + `✔ Type checking and CDK synthesis completed successfully ` + + format.highlight(`(${synthesisTime}s)`) + ) + ); + }); +}); diff --git a/packages/cli/src/commands/check/check_command.ts b/packages/cli/src/commands/check/check_command.ts new file mode 100644 index 00000000000..663e54e789c --- /dev/null +++ b/packages/cli/src/commands/check/check_command.ts @@ -0,0 +1,48 @@ +import { BackendDeployer } from '@aws-amplify/backend-deployer'; +import { format, printer } from '@aws-amplify/cli-core'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { Argv, CommandModule } from 'yargs'; + +/** + * Command that runs type checking and CDK synthesis without deployment + */ +export class CheckCommand implements CommandModule { + command = 'check'; + describe = 'Run type checking and CDK synthesis without deployment'; + + /** + * Creates a new instance of CheckCommand + * @param backendDeployer - The deployer used to run CDK synthesis + */ + constructor(private readonly backendDeployer: BackendDeployer) {} + + handler = async () => { + const backendId: BackendIdentifier = { + namespace: 'sandbox', + name: 'dev', + type: 'sandbox', + }; + + let result: { deploymentTimes: { synthesisTime?: number } } = { + deploymentTimes: {}, + }; + await printer.indicateProgress( + 'Running type checks and CDK synthesis...', + async () => { + result = await this.backendDeployer.deploy(backendId, { + validateAppSources: true, + dryRun: true, + }); + } + ); + + printer.print( + format.success( + `✔ Type checking and CDK synthesis completed successfully ` + + format.highlight(`(${result.deploymentTimes.synthesisTime ?? 0}s)`) + ) + ); + }; + + builder = (yargs: Argv) => yargs; +} diff --git a/packages/cli/src/commands/check/check_command_factory.ts b/packages/cli/src/commands/check/check_command_factory.ts new file mode 100644 index 00000000000..50a1ad989e4 --- /dev/null +++ b/packages/cli/src/commands/check/check_command_factory.ts @@ -0,0 +1,16 @@ +import { BackendDeployerFactory } from '@aws-amplify/backend-deployer'; +import { PackageManagerControllerFactory, format } from '@aws-amplify/cli-core'; +import { CheckCommand } from './check_command.js'; + +/** + * Creates Check command. + */ +export const createCheckCommand = (): CheckCommand => { + const packageManagerControllerFactory = new PackageManagerControllerFactory(); + const backendDeployerFactory = new BackendDeployerFactory( + packageManagerControllerFactory.getPackageManagerController(), + format + ); + const backendDeployer = backendDeployerFactory.getInstance(); + return new CheckCommand(backendDeployer); +}; diff --git a/packages/cli/src/main_parser_factory.ts b/packages/cli/src/main_parser_factory.ts index 78fd56a5501..5d085a90f5e 100644 --- a/packages/cli/src/main_parser_factory.ts +++ b/packages/cli/src/main_parser_factory.ts @@ -4,6 +4,7 @@ import { createSandboxCommand } from './commands/sandbox/sandbox_command_factory import { createPipelineDeployCommand } from './commands/pipeline-deploy/pipeline_deploy_command_factory.js'; import { createConfigureCommand } from './commands/configure/configure_command_factory.js'; import { createInfoCommand } from './commands/info/info_command_factory.js'; +import { createCheckCommand } from './commands/check/check_command_factory.js'; import * as path from 'path'; /** @@ -28,6 +29,7 @@ export const createMainParser = (libraryVersion: string): Argv => { .command(createPipelineDeployCommand()) .command(createConfigureCommand()) .command(createInfoCommand()) + .command(createCheckCommand()) .help() .alias('h', 'help') .alias('v', 'version')