Skip to content

Commit 2f9fb1e

Browse files
authored
feat(cli): automatically roll back stacks if necessary (#31920)
If a user is deploying with `--no-rollback`, and the stack contains replacements (or the `--no-rollback` flag is dropped), then a rollback needs to be performed before a regular deployment can happen again. In this PR, we add a prompt where we ask the user to confirm that they are okay with performing a rollback and then a normal deployment. The way this works is that `deployStack` detects a disallowed combination (replacement and no-rollback, or being in a stuck state and not being called with no-rollback), and returns a special status code. The driver of the calls, `CdkToolkit`, will see those special return codes, prompt the user, and retry. Also get rid of a stray `Stack undefined` that gets printed to the console. Closes #30546, Closes #31685 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent b3de7e6 commit 2f9fb1e

File tree

17 files changed

+500
-106
lines changed

17 files changed

+500
-106
lines changed

packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/rollback-test-app/app.js

+12-2
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
const cdk = require('aws-cdk-lib');
22
const lambda = require('aws-cdk-lib/aws-lambda');
3+
const sqs = require('aws-cdk-lib/aws-sqs');
34
const cr = require('aws-cdk-lib/custom-resources');
45

56
/**
67
* This stack will be deployed in multiple phases, to achieve a very specific effect
78
*
8-
* It contains resources r1 and r2, where r1 gets deployed first.
9+
* It contains resources r1 and r2, and a queue q, where r1 gets deployed first.
910
*
1011
* - PHASE = 1: both resources deploy regularly.
1112
* - PHASE = 2a: r1 gets updated, r2 will fail to update
1213
* - PHASE = 2b: r1 gets updated, r2 will fail to update, and r1 will fail its rollback.
14+
* - PHASE = 3: q gets replaced w.r.t. phases 1 and 2
1315
*
1416
* To exercise this app:
1517
*
@@ -22,7 +24,7 @@ const cr = require('aws-cdk-lib/custom-resources');
2224
* # This will start a rollback that will fail because r1 fails its rollabck
2325
*
2426
* env PHASE=2b npx cdk rollback --force
25-
* # This will retry the rollabck and skip r1
27+
* # This will retry the rollback and skip r1
2628
* ```
2729
*/
2830
class RollbacktestStack extends cdk.Stack {
@@ -31,6 +33,7 @@ class RollbacktestStack extends cdk.Stack {
3133

3234
let r1props = {};
3335
let r2props = {};
36+
let fifo = false;
3437

3538
const phase = process.env.PHASE;
3639
switch (phase) {
@@ -46,6 +49,9 @@ class RollbacktestStack extends cdk.Stack {
4649
r1props.FailRollback = true;
4750
r2props.FailUpdate = true;
4851
break;
52+
case '3':
53+
fifo = true;
54+
break;
4955
}
5056

5157
const fn = new lambda.Function(this, 'Fun', {
@@ -76,6 +82,10 @@ class RollbacktestStack extends cdk.Stack {
7682
properties: r2props,
7783
});
7884
r2.node.addDependency(r1);
85+
86+
new sqs.Queue(this, 'Queue', {
87+
fifo,
88+
});
7989
}
8090
}
8191

packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts

+97
Original file line numberDiff line numberDiff line change
@@ -2450,6 +2450,103 @@ integTest(
24502450
}),
24512451
);
24522452

2453+
integTest(
2454+
'automatic rollback if paused and change contains a replacement',
2455+
withSpecificFixture('rollback-test-app', async (fixture) => {
2456+
let phase = '1';
2457+
2458+
// Should succeed
2459+
await fixture.cdkDeploy('test-rollback', {
2460+
options: ['--no-rollback'],
2461+
modEnv: { PHASE: phase },
2462+
verbose: false,
2463+
});
2464+
try {
2465+
phase = '2a';
2466+
2467+
// Should fail
2468+
const deployOutput = await fixture.cdkDeploy('test-rollback', {
2469+
options: ['--no-rollback'],
2470+
modEnv: { PHASE: phase },
2471+
verbose: false,
2472+
allowErrExit: true,
2473+
});
2474+
expect(deployOutput).toContain('UPDATE_FAILED');
2475+
2476+
// Do a deployment with a replacement and --force: this will roll back first and then deploy normally
2477+
phase = '3';
2478+
await fixture.cdkDeploy('test-rollback', {
2479+
options: ['--no-rollback', '--force'],
2480+
modEnv: { PHASE: phase },
2481+
verbose: false,
2482+
});
2483+
} finally {
2484+
await fixture.cdkDestroy('test-rollback');
2485+
}
2486+
}),
2487+
);
2488+
2489+
integTest(
2490+
'automatic rollback if paused and --no-rollback is removed from flags',
2491+
withSpecificFixture('rollback-test-app', async (fixture) => {
2492+
let phase = '1';
2493+
2494+
// Should succeed
2495+
await fixture.cdkDeploy('test-rollback', {
2496+
options: ['--no-rollback'],
2497+
modEnv: { PHASE: phase },
2498+
verbose: false,
2499+
});
2500+
try {
2501+
phase = '2a';
2502+
2503+
// Should fail
2504+
const deployOutput = await fixture.cdkDeploy('test-rollback', {
2505+
options: ['--no-rollback'],
2506+
modEnv: { PHASE: phase },
2507+
verbose: false,
2508+
allowErrExit: true,
2509+
});
2510+
expect(deployOutput).toContain('UPDATE_FAILED');
2511+
2512+
// Do a deployment removing --no-rollback: this will roll back first and then deploy normally
2513+
phase = '1';
2514+
await fixture.cdkDeploy('test-rollback', {
2515+
options: ['--force'],
2516+
modEnv: { PHASE: phase },
2517+
verbose: false,
2518+
});
2519+
} finally {
2520+
await fixture.cdkDestroy('test-rollback');
2521+
}
2522+
}),
2523+
);
2524+
2525+
integTest(
2526+
'automatic rollback if replacement and --no-rollback is removed from flags',
2527+
withSpecificFixture('rollback-test-app', async (fixture) => {
2528+
let phase = '1';
2529+
2530+
// Should succeed
2531+
await fixture.cdkDeploy('test-rollback', {
2532+
options: ['--no-rollback'],
2533+
modEnv: { PHASE: phase },
2534+
verbose: false,
2535+
});
2536+
try {
2537+
// Do a deployment with a replacement and removing --no-rollback: this will do a regular rollback deploy
2538+
phase = '3';
2539+
await fixture.cdkDeploy('test-rollback', {
2540+
options: ['--force'],
2541+
modEnv: { PHASE: phase },
2542+
verbose: false,
2543+
});
2544+
} finally {
2545+
await fixture.cdkDestroy('test-rollback');
2546+
}
2547+
}),
2548+
);
2549+
24532550
integTest(
24542551
'test cdk rollback --force',
24552552
withSpecificFixture('rollback-test-app', async (fixture) => {

packages/@aws-cdk/cloudformation-diff/lib/format.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export class Formatter {
160160
const resourceType = diff.isRemoval ? diff.oldResourceType : diff.newResourceType;
161161

162162
// eslint-disable-next-line max-len
163-
this.print(`${this.formatResourcePrefix(diff)} ${this.formatValue(resourceType, chalk.cyan)} ${this.formatLogicalId(logicalId)} ${this.formatImpact(diff.changeImpact)}`);
163+
this.print(`${this.formatResourcePrefix(diff)} ${this.formatValue(resourceType, chalk.cyan)} ${this.formatLogicalId(logicalId)} ${this.formatImpact(diff.changeImpact)}`.trimEnd());
164164

165165
if (diff.isUpdate) {
166166
const differenceCount = diff.differenceCount;

packages/aws-cdk/README.md

+11-8
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,14 @@ $ cdk deploy -R
205205
```
206206

207207
If a deployment fails you can update your code and immediately retry the
208-
deployment from the point of failure. If you would like to explicitly roll back a failed, paused deployment,
209-
use `cdk rollback`.
208+
deployment from the point of failure. If you would like to explicitly roll back
209+
a failed, paused deployment, use `cdk rollback`.
210210

211-
NOTE: you cannot use `--no-rollback` for any updates that would cause a resource replacement, only for updates
212-
and creations of new resources.
211+
`--no-rollback` deployments cannot contain resource replacements. If the CLI
212+
detects that a resource is being replaced, it will prompt you to perform
213+
a regular replacement instead. If the stack rollback is currently paused
214+
and you are trying to perform an deployment that contains a replacement, you
215+
will be prompted to roll back first.
213216

214217
#### Deploying multiple stacks
215218

@@ -801,7 +804,7 @@ In practice this means for any resource in the provided template, for example,
801804
}
802805
```
803806

804-
There must not exist a resource of that type with the same identifier in the desired region. In this example that identfier
807+
There must not exist a resource of that type with the same identifier in the desired region. In this example that identfier
805808
would be "amzn-s3-demo-bucket"
806809

807810
##### **The provided template is not deployed to CloudFormation in the account/region, and there *is* overlap with existing resources in the account/region**
@@ -900,7 +903,7 @@ CDK Garbage Collection.
900903
> API of feature might still change. Otherwise the feature is generally production
901904
> ready and fully supported.
902905
903-
`cdk gc` garbage collects unused assets from your bootstrap bucket via the following mechanism:
906+
`cdk gc` garbage collects unused assets from your bootstrap bucket via the following mechanism:
904907

905908
- for each object in the bootstrap S3 Bucket, check to see if it is referenced in any existing CloudFormation templates
906909
- if not, it is treated as unused and gc will either tag it or delete it, depending on your configuration.
@@ -938,7 +941,7 @@ Found X objects to delete based off of the following criteria:
938941
Delete this batch (yes/no/delete-all)?
939942
```
940943

941-
Since it's quite possible that the bootstrap bucket has many objects, we work in batches of 1000 objects or 100 images.
944+
Since it's quite possible that the bootstrap bucket has many objects, we work in batches of 1000 objects or 100 images.
942945
To skip the prompt either reply with `delete-all`, or use the `--confirm=false` option.
943946

944947
```console
@@ -948,7 +951,7 @@ cdk gc --unstable=gc --confirm=false
948951
If you are concerned about deleting assets too aggressively, there are multiple levers you can configure:
949952

950953
- rollback-buffer-days: this is the amount of days an asset has to be marked as isolated before it is elligible for deletion.
951-
- created-buffer-days: this is the amount of days an asset must live before it is elligible for deletion.
954+
- created-buffer-days: this is the amount of days an asset must live before it is elligible for deletion.
952955

953956
When using `rollback-buffer-days`, instead of deleting unused objects, `cdk gc` will tag them with
954957
today's date instead. It will also check if any objects have been tagged by previous runs of `cdk gc`

packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { warning } from '../../logging';
88
import { loadStructuredFile, serializeStructure } from '../../serialize';
99
import { rootDir } from '../../util/directories';
1010
import { ISDK, Mode, SdkProvider } from '../aws-auth';
11-
import { DeployStackResult } from '../deploy-stack';
11+
import { SuccessfulDeployStackResult } from '../deploy-stack';
1212

1313
/* eslint-disable max-len */
1414

@@ -21,7 +21,7 @@ export class Bootstrapper {
2121
constructor(private readonly source: BootstrapSource) {
2222
}
2323

24-
public bootstrapEnvironment(environment: cxapi.Environment, sdkProvider: SdkProvider, options: BootstrapEnvironmentOptions = {}): Promise<DeployStackResult> {
24+
public bootstrapEnvironment(environment: cxapi.Environment, sdkProvider: SdkProvider, options: BootstrapEnvironmentOptions = {}): Promise<SuccessfulDeployStackResult> {
2525
switch (this.source.source) {
2626
case 'legacy':
2727
return this.legacyBootstrap(environment, sdkProvider, options);
@@ -41,7 +41,7 @@ export class Bootstrapper {
4141
* Deploy legacy bootstrap stack
4242
*
4343
*/
44-
private async legacyBootstrap(environment: cxapi.Environment, sdkProvider: SdkProvider, options: BootstrapEnvironmentOptions = {}): Promise<DeployStackResult> {
44+
private async legacyBootstrap(environment: cxapi.Environment, sdkProvider: SdkProvider, options: BootstrapEnvironmentOptions = {}): Promise<SuccessfulDeployStackResult> {
4545
const params = options.parameters ?? {};
4646

4747
if (params.trustedAccounts?.length) {
@@ -71,7 +71,7 @@ export class Bootstrapper {
7171
private async modernBootstrap(
7272
environment: cxapi.Environment,
7373
sdkProvider: SdkProvider,
74-
options: BootstrapEnvironmentOptions = {}): Promise<DeployStackResult> {
74+
options: BootstrapEnvironmentOptions = {}): Promise<SuccessfulDeployStackResult> {
7575

7676
const params = options.parameters ?? {};
7777

@@ -291,7 +291,7 @@ export class Bootstrapper {
291291
private async customBootstrap(
292292
environment: cxapi.Environment,
293293
sdkProvider: SdkProvider,
294-
options: BootstrapEnvironmentOptions = {}): Promise<DeployStackResult> {
294+
options: BootstrapEnvironmentOptions = {}): Promise<SuccessfulDeployStackResult> {
295295

296296
// Look at the template, decide whether it's most likely a legacy or modern bootstrap
297297
// template, and use the right bootstrapper for that.

packages/aws-cdk/lib/api/bootstrap/deploy-bootstrap.ts

+9-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as fs from 'fs-extra';
66
import { BOOTSTRAP_VERSION_OUTPUT, BootstrapEnvironmentOptions, BOOTSTRAP_VERSION_RESOURCE, BOOTSTRAP_VARIANT_PARAMETER, DEFAULT_BOOTSTRAP_VARIANT } from './bootstrap-props';
77
import * as logging from '../../logging';
88
import { Mode, SdkProvider, ISDK } from '../aws-auth';
9-
import { deployStack, DeployStackResult } from '../deploy-stack';
9+
import { assertIsSuccessfulDeployStackResult, deployStack, SuccessfulDeployStackResult } from '../deploy-stack';
1010
import { NoBootstrapStackEnvironmentResources } from '../environment-resources';
1111
import { DEFAULT_TOOLKIT_STACK_NAME, ToolkitInfo } from '../toolkit-info';
1212

@@ -63,14 +63,15 @@ export class BootstrapStack {
6363
template: any,
6464
parameters: Record<string, string | undefined>,
6565
options: Omit<BootstrapEnvironmentOptions, 'parameters'>,
66-
): Promise<DeployStackResult> {
66+
): Promise<SuccessfulDeployStackResult> {
6767
if (this.currentToolkitInfo.found && !options.force) {
6868
// Safety checks
6969
const abortResponse = {
70+
type: 'did-deploy-stack',
7071
noOp: true,
7172
outputs: {},
7273
stackArn: this.currentToolkitInfo.bootstrapStack.stackId,
73-
};
74+
} satisfies SuccessfulDeployStackResult;
7475

7576
// Validate that the bootstrap stack we're trying to replace is from the same variant as the one we're trying to deploy
7677
const currentVariant = this.currentToolkitInfo.variant;
@@ -110,7 +111,7 @@ export class BootstrapStack {
110111

111112
const assembly = builder.buildAssembly();
112113

113-
return deployStack({
114+
const ret = await deployStack({
114115
stack: assembly.getStackByName(this.toolkitStackName),
115116
resolvedEnvironment: this.resolvedEnvironment,
116117
sdk: this.sdk,
@@ -124,6 +125,10 @@ export class BootstrapStack {
124125
// Obviously we can't need a bootstrap stack to deploy a bootstrap stack
125126
envResources: new NoBootstrapStackEnvironmentResources(this.resolvedEnvironment, this.sdk),
126127
});
128+
129+
assertIsSuccessfulDeployStackResult(ret);
130+
131+
return ret;
127132
}
128133
}
129134

0 commit comments

Comments
 (0)