diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts index 23fddcb9d..3c041c1ec 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts @@ -483,7 +483,15 @@ class FullCloudFormationDeployment { }); } - if (this.options.forceDeployment) { + if (this.update && !this.cloudFormationStack.stackStatus.isDeploySuccess) { + await this.ioHelper.defaults.warn( + `CloudFormation reported no changes for ${this.stackName}, but the stack is in ${this.cloudFormationStack.stackStatus.name}. ` + + 'No update was performed and this does not indicate a successful deployment. ' + + (this.cloudFormationStack.stackStatus.isRollbackSuccess + ? 'The previous deployment rolled back; review the stack events before retrying.' + : 'Inspect the stack events and resolve the stack status before deploying again.'), + ); + } else if (this.options.forceDeployment) { await this.ioHelper.defaults.warn( [ 'You used the --force flag, but CloudFormation reported that the deployment would not make any changes.', diff --git a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts index 31632fb46..e6f52c412 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts @@ -921,6 +921,93 @@ test('deployStack reports no change if describeChangeSet returns an error that i expect(deployResult.type === 'did-deploy-stack' && deployResult.noOp).toEqual(true); }); +test.each<[string, StackStatus | undefined]>([ + ['new stack', undefined], + ['unexecuted CREATE change set', StackStatus.REVIEW_IN_PROGRESS], +])('empty change set does not warn about an unsuccessful deployment for %s', async (_description, stackStatus) => { + if (stackStatus) { + givenStackExists({ StackStatus: stackStatus }); + } + fakeCfn.overrideChangeSetChanges = []; + + const result = await testDeployStack(standardDeployStackArguments()); + + expect(result.type === 'did-deploy-stack' && result.noOp).toBe(true); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('this does not indicate a successful deployment'), + })); + expect(mockCloudFormationClient).toHaveReceivedCommand(DeleteChangeSetCommand); + expect(mockCloudFormationClient).not.toHaveReceivedCommand(ExecuteChangeSetCommand); +}); + +test.each([false, true])('empty change set warns about UPDATE_FAILED with force=%s', async (forceDeployment) => { + givenStackExists({ StackStatus: StackStatus.UPDATE_FAILED }); + givenTemplateIs(defaultTargetTemplate()); + fakeCfn.overrideChangeSetChanges = []; + + const result = await testDeployStack({ + ...standardDeployStackArguments(), + forceDeployment, + }); + + expect(result.type === 'did-deploy-stack' && result.noOp).toBe(true); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('withouterrors, but the stack is in UPDATE_FAILED'), + })); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('all resources are already up-to-date'), + })); + expect(mockCloudFormationClient).toHaveReceivedCommand(DeleteChangeSetCommand); + expect(mockCloudFormationClient).not.toHaveReceivedCommand(ExecuteChangeSetCommand); +}); + +test.each([ + StackStatus.CREATE_COMPLETE, + StackStatus.UPDATE_COMPLETE, + StackStatus.IMPORT_COMPLETE, +])('empty change set keeps the force warning for %s', async (stackStatus) => { + givenStackExists({ StackStatus: stackStatus }); + fakeCfn.overrideChangeSetChanges = []; + + await testDeployStack({ ...standardDeployStackArguments(), forceDeployment: true }); + + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('all resources are already up-to-date'), + })); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('this does not indicate a successful deployment'), + })); +}); + +test('empty change set warns about a rolled-back stack and preserves it with no-execute', async () => { + givenStackExists({ StackStatus: StackStatus.UPDATE_ROLLBACK_COMPLETE }); + fakeCfn.overrideChangeSetChanges = []; + + const result = await testDeployStack({ + ...standardDeployStackArguments(), + deploymentMethod: { method: 'change-set', execute: false }, + }); + + expect(result.type === 'did-deploy-stack' && result.noOp).toBe(true); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('stack is in UPDATE_ROLLBACK_COMPLETE'), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('The previous deployment rolled back; review the stack events before retrying.'), + })); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('resolve the stack status'), + })); + // Only the pre-creation cleanup runs; the newly created empty change set is retained. + expect(mockCloudFormationClient).toHaveReceivedCommandTimes(DeleteChangeSetCommand, 1); + expect(mockCloudFormationClient).not.toHaveReceivedCommand(ExecuteChangeSetCommand); +}); + test('deployStack throws error in case of early validation failures', async () => { mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({ ChangeSetName: 'cdk-deploy-change-set',