Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@aws-cdk/toolkit-lib/docs/message-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu
| `CDK_TOOLKIT_I5900` | Deployment results on success | `result` | {@link SuccessfulDeployStackResult} |
| `CDK_TOOLKIT_I5901` | Generic deployment success messages | `info` | n/a |
| `CDK_TOOLKIT_W5902` | Express Mode deployment completed with resources still stabilizing | `warn` | n/a |
| `CDK_TOOLKIT_W5903` | Deployment includes a replacement that CloudFormation does not support while rollback is disabled | `warn` | {@link ReplacementRequiresRollback} |
| `CDK_TOOLKIT_W5400` | Hotswap disclosure message | `warn` | n/a |
| `CDK_TOOLKIT_E5001` | No stacks found | `error` | n/a |
| `CDK_TOOLKIT_E5500` | Stack Monitoring error | `error` | {@link ErrorPayload} |
Expand Down
400 changes: 376 additions & 24 deletions packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ValidateResult } from '../../../actions/validate';
import type { StackDiff, DiffResult } from '../../../payloads';
import type { BootstrapEnvironmentProgress } from '../../../payloads/bootstrap-environment-progress';
import type { MissingContext, UpdatedContext } from '../../../payloads/context';
import type { BuildAsset, DeployConfirmationRequest, PublishAsset, PublishAssetEvent, StackDeployProgress, SuccessfulDeployStackResult } from '../../../payloads/deploy';
import type { BuildAsset, DeployConfirmationRequest, PublishAsset, PublishAssetEvent, ReplacementRequiresRollback, StackDeployProgress, SuccessfulDeployStackResult } from '../../../payloads/deploy';
import type { StackDestroy, StackDestroyProgress } from '../../../payloads/destroy';
import type { DriftResultPayload } from '../../../payloads/drift';
import type { FeatureFlagChangeRequest } from '../../../payloads/flags';
Expand Down Expand Up @@ -334,6 +334,11 @@ export const IO = {
code: 'CDK_TOOLKIT_W5902',
description: 'Express Mode deployment completed with resources still stabilizing',
}),
CDK_TOOLKIT_W5903: make.warn<ReplacementRequiresRollback>({
code: 'CDK_TOOLKIT_W5903',
description: 'Deployment includes a replacement that CloudFormation does not support while rollback is disabled',
interface: 'ReplacementRequiresRollback',
}),
CDK_TOOLKIT_W5400: make.warn({
code: 'CDK_TOOLKIT_W5400',
description: 'Hotswap disclosure message',
Expand Down
57 changes: 57 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/payloads/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,60 @@ export interface PublishAssetEvent {
*/
readonly asset?: IManifestEntry;
}

/**
* A resource that CloudFormation reported it would replace
*/
export interface ReplacedResource {
/**
* Logical ID of the resource being replaced
*
* Absent when CloudFormation reported the change or failure without naming a resource.
*/
readonly logicalId?: string;

/**
* CloudFormation resource type, when known
*/
readonly resourceType?: string;

/**
* The `Replacement` field CloudFormation reported for this change, when known
*/
readonly replacement?: string;

/**
* The `PolicyAction` CloudFormation reported for this change, when known
*/
readonly policyAction?: string;
}

/**
* A deployment includes a replacement that CloudFormation will not perform while rollback is disabled
*/
export interface ReplacementRequiresRollback {
/**
* The stack being deployed
*/
readonly stackName: string;

/**
* The change set the replacement was found in, if it was found in one
*/
readonly changeSetId?: string;

/**
* The resources that would be replaced
*
* Empty when CloudFormation reported the rejection without naming a resource.
*/
readonly replacements: ReplacedResource[];

/**
* How the replacement was detected
*
* `change-set` means the pre-flight check found it and nothing was submitted. `service-error` means we only found
* out from CloudFormation's failure, after the update had already been submitted.
*/
readonly detectedBy: 'change-set' | 'service-error';
}
4 changes: 3 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,9 @@ export class Toolkit extends CloudAssemblySourceBuilder {
}

case 'replacement-requires-rollback': {
const motivation = 'Change includes a replacement which cannot be deployed with "--no-rollback"';
const motivation = options.express
? 'Change includes a replacement, which CloudFormation does not support while rollback is disabled (the default for Express Mode)'
: 'Change includes a replacement which cannot be deployed with "--no-rollback"';
const question = `${motivation}. Perform a deployment with rollback enabled`;

const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I5050.req(question, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,19 @@ Tests that use the fake should use fake timers to advance time.
3. API returns `{ StackId }` immediately.
4. Stack status: `UPDATE_IN_PROGRESS`.
5. After delay:
- If any resource has `Fail: true` → `UPDATE_FAILED` (if
`DisableRollback`) or `UPDATE_ROLLBACK_IN_PROGRESS` →
- If any resource has `Fail: true` → `UPDATE_FAILED` (if rollback is
disabled) or `UPDATE_ROLLBACK_IN_PROGRESS` →
`UPDATE_ROLLBACK_COMPLETE`.
- Rollback counts as disabled when `DisableRollback: true` is passed, **or**
when `DeploymentConfig.Mode === 'EXPRESS'` and the call did not explicitly
re-enable it with `DeploymentConfig.DisableRollback: false`. This mirrors
Express Mode having rollback disabled server-side by default, and is what
makes a failed express update strand the stack in `UPDATE_FAILED`.
- Every failing resource that also has `FailReason: '...'` emits
resource-level `UPDATE_IN_PROGRESS` and `UPDATE_FAILED` events, with that
string as the `ResourceStatusReason` (this is where real CloudFormation
reports why an operation failed). Without `FailReason`, only stack-level
events are emitted. `ExecuteChangeSet` failures do the same.
- Otherwise → `UPDATE_COMPLETE`.

### DeleteStack
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type DeleteChangeSetCommandOutput,
type DeleteStackCommandInput,
type DeleteStackCommandOutput,
type DeploymentConfig,
type DescribeChangeSetCommandInput,
type DescribeChangeSetCommandOutput,
type DescribeEventsCommandInput,
Expand Down Expand Up @@ -119,6 +120,7 @@ interface InMemoryChangeSet {
capabilities: string[];
description?: string;
changes: Change[];
deploymentConfig?: DeploymentConfig;
creationTime: Date;
changeSetFailureEvents: OperationEvent[];
earlyValidationErrors: EarlyValidationErrorPrime[];
Expand Down Expand Up @@ -323,7 +325,7 @@ export class FakeCloudFormation {
const operationId = randomUUID();
const { id, stack, template } = this.initCreateStack(input, operationId);
this.scheduleAsync(() => {
this.finalizeCreateStack(stack, template, input.DisableRollback, operationId);
this.finalizeCreateStack(stack, template, this.rollbackIsDisabled(input), operationId);
});
return { StackId: id, $metadata: {} };
}
Expand All @@ -347,7 +349,8 @@ export class FakeCloudFormation {

this.scheduleAsync(() => {
if (this.shouldFail(template)) {
if (input.DisableRollback) {
this.addFailedUpdateResourceEvents(stack, template, operationId);
if (this.rollbackIsDisabled(input)) {
this.transitionStack(stack, 'UPDATE_FAILED', 'Resource update failed', operationId);
} else {
this.transitionStack(stack, 'UPDATE_ROLLBACK_IN_PROGRESS', 'Resource update failed', operationId);
Expand Down Expand Up @@ -435,6 +438,7 @@ export class FakeCloudFormation {
Tags?: Tag[];
Capabilities?: string[];
Description?: string;
DeploymentConfig?: DeploymentConfig;
}): CreateChangeSetCommandOutput {
const stackName = input.StackName;
const stack = this.requireStack(stackName);
Expand Down Expand Up @@ -470,6 +474,7 @@ export class FakeCloudFormation {
capabilities: input.Capabilities ?? [],
description: input.Description,
changes: changes ?? [],
deploymentConfig: input.DeploymentConfig,
creationTime: new Date(),
changeSetFailureEvents: [],
earlyValidationErrors: [],
Expand Down Expand Up @@ -511,6 +516,7 @@ export class FakeCloudFormation {
Capabilities: cs.capabilities as any,
Description: cs.description,
CreationTime: cs.creationTime,
...(cs.deploymentConfig ? { DeploymentConfig: cs.deploymentConfig } : undefined),
NextToken: nextToken,
$metadata: {},
};
Expand All @@ -523,6 +529,20 @@ export class FakeCloudFormation {
cfnError('InvalidChangeSetStatus', `ChangeSet [${cs.name}] is in ${cs.executionStatus} state and cannot be executed`);
}

const persistedRollbackDisabled = cs.deploymentConfig?.Mode === 'EXPRESS'
? cs.deploymentConfig.DisableRollback !== false
: undefined;
if (
input.DisableRollback !== undefined &&
persistedRollbackDisabled !== undefined &&
input.DisableRollback !== persistedRollbackDisabled
) {
cfnError(
'ValidationError',
'DisableRollback specified on ExecuteChangeSet conflicts with the value DisableRollback the ChangeSet was created with.',
);
}

// Remove the executed change set from the stack's list. Real CloudFormation
// also deletes all other change sets, but we skip that to avoid interfering
// with concurrent operations on the same stack in tests.
Expand Down Expand Up @@ -550,7 +570,8 @@ export class FakeCloudFormation {

if (this.shouldFail(cs.template)) {
const failedStatus = isCreate ? 'CREATE_FAILED' : 'UPDATE_FAILED';
if (input.DisableRollback) {
this.addFailedUpdateResourceEvents(stack, cs.template, operationId, isCreate ? 'CREATE' : 'UPDATE');
if (this.rollbackIsDisabled({ ...input, DeploymentConfig: cs.deploymentConfig })) {
this.transitionStack(stack, failedStatus, 'Resource operation failed', operationId);
} else {
const rollbackStatus = isCreate ? 'ROLLBACK_IN_PROGRESS' : 'UPDATE_ROLLBACK_IN_PROGRESS';
Expand Down Expand Up @@ -865,6 +886,7 @@ export class FakeCloudFormation {
capabilities: (input.Capabilities as string[]) ?? [],
description: input.Description,
changes: [],
deploymentConfig: input.DeploymentConfig,
creationTime: new Date(),
changeSetFailureEvents: [],
earlyValidationErrors: [],
Expand Down Expand Up @@ -1153,7 +1175,17 @@ export class FakeCloudFormation {
});
}

private addResourceEvent(stack: InMemoryStack, logicalId: string, resourceType: string, status: string, operationId?: string) {
private rollbackIsDisabled(input: { DisableRollback?: boolean; DeploymentConfig?: DeploymentConfig }): boolean {
if (input.DisableRollback) {
return true;
}
if (input.DeploymentConfig?.Mode === 'EXPRESS') {
return input.DeploymentConfig.DisableRollback !== false;
}
return false;
}

private addResourceEvent(stack: InMemoryStack, logicalId: string, resourceType: string, status: string, operationId?: string, reason?: string) {
stack.events.unshift({
StackId: stack.id,
StackName: stack.name,
Expand All @@ -1162,11 +1194,26 @@ export class FakeCloudFormation {
PhysicalResourceId: `fake-${logicalId}-${uid()}`,
ResourceType: resourceType,
ResourceStatus: status as any,
ResourceStatusReason: reason,
OperationId: operationId,
Timestamp: new Date(),
});
}

private addFailedUpdateResourceEvents(stack: InMemoryStack, template: Record<string, any>, operationId?: string, verb: 'UPDATE' | 'CREATE' = 'UPDATE') {
for (const [logicalId, res] of Object.entries(templateResources(template))) {
const r = res as any;
const reason = r.Properties?.FailReason;
if (reason === undefined) {
continue;
}
if (this.alwaysFailResources || r.Properties?.Fail === true) {
this.addResourceEvent(stack, logicalId, r.Type, `${verb}_IN_PROGRESS`, operationId);
this.addResourceEvent(stack, logicalId, r.Type, `${verb}_FAILED`, operationId, reason);
}
}
}

private toStackDescription(stack: InMemoryStack): Stack {
return {
StackName: stack.name,
Expand Down
7 changes: 6 additions & 1 deletion packages/@aws-cdk/toolkit-lib/test/_helpers/test-io-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,19 @@ export class TestIoHost implements IIoHost {
return spyResponse ?? msg.defaultResponse;
}

public expectMessage(m: { containing: string; level?: IoMessageLevel }) {
public expectMessage(m: { containing: string; level?: IoMessageLevel; code?: IoMessageCode }) {
expect(this.messages).toContainEqual(expect.objectContaining({
...m.level ? { level: m.level } : undefined,
...m.code ? { code: m.code } : undefined,
// Can be a partial string as well
message: expect.stringContaining(m.containing),
}));
}

public messagesWithCode(code: IoMessageCode): Array<IoMessage<unknown>> {
return this.messages.filter((m) => m.code === code);
}

/**
* Mocks the response for a given message code.
*
Expand Down
62 changes: 62 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,68 @@ IAM Statement Changes
// THEN
successfulDeployment();
});

test('replacement-requires-rollback under --express explains that rollback is disabled, and retries with it enabled', async () => {
// GIVEN
mockDeployStack.mockImplementation(async (params) => {
if (params.rollback === true) {
return {
type: 'did-deploy-stack',
stackArn: 'arn:aws:cloudformation:region:account:stack/test-stack',
outputs: {},
noOp: false,
deleteFailures: [],
stabilizingResources: [],
} satisfies DeployStackResult;
}
return { type: 'replacement-requires-rollback' } satisfies DeployStackResult;
});

// WHEN
const cx = await cdkOutFixture(toolkit, 'stack-with-role');
await toolkit.deploy(cx, { express: true });

// THEN
expect(ioHost.requestSpy).toHaveBeenCalledWith(expect.objectContaining({
code: 'CDK_TOOLKIT_I5050',
data: expect.objectContaining({
motivation: 'Change includes a replacement, which CloudFormation does not support while rollback is disabled (the default for Express Mode)',
}),
}));

expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ express: true, rollback: true }));
successfulDeployment();
});

test('replacement-requires-rollback without --express keeps the --no-rollback wording', async () => {
// GIVEN
mockDeployStack.mockImplementation(async (params) => {
if (params.rollback === true) {
return {
type: 'did-deploy-stack',
stackArn: 'arn:aws:cloudformation:region:account:stack/test-stack',
outputs: {},
noOp: false,
deleteFailures: [],
stabilizingResources: [],
} satisfies DeployStackResult;
}
return { type: 'replacement-requires-rollback' } satisfies DeployStackResult;
});

// WHEN
const cx = await cdkOutFixture(toolkit, 'stack-with-role');
await toolkit.deploy(cx, { rollback: false });

// THEN
expect(ioHost.requestSpy).toHaveBeenCalledWith(expect.objectContaining({
code: 'CDK_TOOLKIT_I5050',
data: expect.objectContaining({
motivation: 'Change includes a replacement which cannot be deployed with "--no-rollback"',
}),
}));
successfulDeployment();
});
});

test('deploy returns stack information', async () => {
Expand Down
Loading
Loading