Skip to content

Commit da515f4

Browse files
rix0rrrgithub-actionskaizenccmrgrain
authored
fix: --force flag does not applies to assets (#197)
The `cdk deploy --force` flag is intended to disable all smartness around saving work. If set, it won't check whether assets already exist in the cloud, and remove the build and publishing steps from the work graph. However, this by itself is not enough to make sure the asset truly gets published again, because the `publish()` action has its own version of short-circuiting again. Rather than remove the short-circuiting behavior from `cdk-assets`, we add another `{ force }` flag there as well, which gets its value from the CLI's `--force` flag. This will make it possible to recover from corrupted assets which were accidentally published, as fixed in aws/aws-cdk#33692, by running `rm -rf cdk.out && cdk deploy --force`. Fixes aws/aws-cdk#14474. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Signed-off-by: github-actions <[email protected]> Co-authored-by: github-actions <[email protected]> Co-authored-by: Kaizen Conroy <[email protected]> Co-authored-by: Momo Kornher <[email protected]>
1 parent db6aa96 commit da515f4

File tree

9 files changed

+118
-54
lines changed

9 files changed

+118
-54
lines changed

packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// APIs
44
export { SdkProvider } from '../../../../aws-cdk/lib/api/aws-auth';
55
export { Context, PROJECT_CONTEXT } from '../../../../aws-cdk/lib/api/context';
6-
export { Deployments, type SuccessfulDeployStackResult } from '../../../../aws-cdk/lib/api/deployments';
6+
export { Deployments, type SuccessfulDeployStackResult, type DeployStackOptions, type DeployStackResult } from '../../../../aws-cdk/lib/api/deployments';
77
export { Settings } from '../../../../aws-cdk/lib/api/settings';
88
export { type Tag, tagsForStack } from '../../../../aws-cdk/lib/api/tags';
99
export { DEFAULT_TOOLKIT_STACK_NAME } from '../../../../aws-cdk/lib/api/toolkit-info';

packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts

+1
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
317317
stack: assetNode.parentStack,
318318
roleArn: options.roleArn,
319319
stackName: assetNode.parentStack.stackName,
320+
forcePublish: options.force,
320321
});
321322
};
322323

packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts

+53-42
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,40 @@
1-
let mockFindCloudWatchLogGroups = jest.fn();
2-
31
import { RequireApproval, StackParameters } from '../../lib';
2+
import * as awsCdkApi from '../../lib/api/aws-cdk';
3+
import type { DeployStackOptions, DeployStackResult } from '../../lib/api/aws-cdk';
44
import { Toolkit } from '../../lib/toolkit';
55
import { builderFixture, TestIoHost } from '../_helpers';
66
import { MockSdk } from '../util/aws-cdk';
77

8-
const sdk = new MockSdk();
9-
const ioHost = new TestIoHost();
10-
const toolkit = new Toolkit({ ioHost });
11-
const rollbackSpy = jest.spyOn(toolkit as any, '_rollback').mockResolvedValue({});
12-
13-
let mockDeployStack = jest.fn().mockResolvedValue({
14-
type: 'did-deploy-stack',
15-
stackArn: 'arn:aws:cloudformation:region:account:stack/test-stack',
16-
outputs: {},
17-
noOp: false,
18-
});
19-
20-
jest.mock('../../lib/api/aws-cdk', () => {
21-
return {
22-
...jest.requireActual('../../lib/api/aws-cdk'),
23-
Deployments: jest.fn().mockImplementation(() => ({
24-
deployStack: mockDeployStack,
25-
resolveEnvironment: jest.fn().mockResolvedValue({}),
26-
isSingleAssetPublished: jest.fn().mockResolvedValue(true),
27-
readCurrentTemplate: jest.fn().mockResolvedValue({ Resources: {} }),
28-
})),
29-
findCloudWatchLogGroups: mockFindCloudWatchLogGroups,
30-
};
31-
});
8+
let ioHost: TestIoHost;
9+
let toolkit: Toolkit;
10+
let mockDeployStack: jest.SpyInstance<Promise<DeployStackResult>, [DeployStackOptions]>;
3211

3312
beforeEach(() => {
34-
ioHost.notifySpy.mockClear();
35-
ioHost.requestSpy.mockClear();
13+
jest.restoreAllMocks();
14+
ioHost = new TestIoHost();
3615
ioHost.requireDeployApproval = RequireApproval.NEVER;
37-
jest.clearAllMocks();
38-
mockFindCloudWatchLogGroups.mockReturnValue({
16+
17+
toolkit = new Toolkit({ ioHost });
18+
const sdk = new MockSdk();
19+
20+
// Some default implementations
21+
mockDeployStack = jest.spyOn(awsCdkApi.Deployments.prototype, 'deployStack').mockResolvedValue({
22+
type: 'did-deploy-stack',
23+
stackArn: 'arn:aws:cloudformation:region:account:stack/test-stack',
24+
outputs: {},
25+
noOp: false,
26+
});
27+
jest.spyOn(awsCdkApi.Deployments.prototype, 'resolveEnvironment').mockResolvedValue({
28+
account: '11111111',
29+
region: 'aq-south-1',
30+
name: 'aws://11111111/aq-south-1',
31+
});
32+
jest.spyOn(awsCdkApi.Deployments.prototype, 'isSingleAssetPublished').mockResolvedValue(true);
33+
jest.spyOn(awsCdkApi.Deployments.prototype, 'readCurrentTemplate').mockResolvedValue({ Resources: {} });
34+
jest.spyOn(awsCdkApi.Deployments.prototype, 'buildSingleAsset').mockImplementation();
35+
jest.spyOn(awsCdkApi.Deployments.prototype, 'publishSingleAsset').mockImplementation();
36+
37+
jest.spyOn(awsCdkApi, 'findCloudWatchLogGroups').mockResolvedValue({
3938
env: { name: 'Z', account: 'X', region: 'Y' },
4039
sdk,
4140
logGroupNames: ['/aws/lambda/lambda-function-name'],
@@ -198,6 +197,20 @@ describe('deploy', () => {
198197

199198
successfulDeployment();
200199
});
200+
201+
test('force: true option is used for asset publishing', async () => {
202+
const publishSingleAsset = jest.spyOn(awsCdkApi.Deployments.prototype, 'publishSingleAsset').mockImplementation();
203+
204+
const cx = await builderFixture(toolkit, 'stack-with-asset');
205+
await toolkit.deploy(cx, {
206+
force: true,
207+
});
208+
209+
// THEN
210+
expect(publishSingleAsset).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({
211+
forcePublish: true,
212+
}));
213+
});
201214
});
202215

203216
describe('deployment results', () => {
@@ -211,22 +224,23 @@ describe('deploy', () => {
211224
});
212225

213226
test('failpaused-need-rollback-first', async () => {
227+
const rollbackSpy = jest.spyOn(toolkit as any, '_rollback').mockResolvedValue({});
228+
214229
// GIVEN
215-
mockDeployStack.mockImplementation((params) => {
230+
mockDeployStack.mockImplementation(async (params) => {
216231
if (params.rollback === true) {
217232
return {
218233
type: 'did-deploy-stack',
219234
stackArn: 'arn:aws:cloudformation:region:account:stack/test-stack',
220235
outputs: {},
221236
noOp: false,
222-
};
237+
} satisfies DeployStackResult;
223238
} else {
224239
return {
225240
type: 'failpaused-need-rollback-first',
226-
stackArn: 'arn:aws:cloudformation:region:account:stack/test-stack',
227-
outputs: {},
228-
noOp: false,
229-
};
241+
reason: 'replacement',
242+
status: 'asdf',
243+
} satisfies DeployStackResult;
230244
}
231245
});
232246

@@ -242,21 +256,18 @@ describe('deploy', () => {
242256

243257
test('replacement-requires-rollback', async () => {
244258
// GIVEN
245-
mockDeployStack.mockImplementation((params) => {
259+
mockDeployStack.mockImplementation(async (params) => {
246260
if (params.rollback === true) {
247261
return {
248262
type: 'did-deploy-stack',
249263
stackArn: 'arn:aws:cloudformation:region:account:stack/test-stack',
250264
outputs: {},
251265
noOp: false,
252-
};
266+
} satisfies DeployStackResult;
253267
} else {
254268
return {
255269
type: 'replacement-requires-rollback',
256-
stackArn: 'arn:aws:cloudformation:region:account:stack/test-stack',
257-
outputs: {},
258-
noOp: false,
259-
};
270+
} satisfies DeployStackResult;
260271
}
261272
});
262273

packages/aws-cdk/lib/api/deployments/deployments.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,13 @@ interface PublishStackAssetsOptions extends AssetOptions {
272272
* Stack name this asset is for
273273
*/
274274
readonly stackName?: string;
275+
276+
/**
277+
* Always publish, even if it already exists
278+
*
279+
* @default false
280+
*/
281+
readonly forcePublish?: boolean;
275282
}
276283

277284
export interface DestroyStackOptions {
@@ -645,7 +652,10 @@ export class Deployments {
645652

646653
// No need to validate anymore, we already did that during build
647654
const publisher = this.cachedPublisher(assetManifest, stackEnv, options.stackName);
648-
await publisher.publishEntry(asset, { allowCrossAccount: await this.allowCrossAccountAssetPublishingForEnv(options.stack) });
655+
await publisher.publishEntry(asset, {
656+
allowCrossAccount: await this.allowCrossAccountAssetPublishingForEnv(options.stack),
657+
force: options.forcePublish,
658+
});
649659
if (publisher.hasFailures) {
650660
throw new ToolkitError(`Failed to publish asset ${asset.displayName(true)}`);
651661
}

packages/aws-cdk/lib/cli/cdk-toolkit.ts

+1
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ export class CdkToolkit {
354354
stack: assetNode.parentStack,
355355
roleArn: options.roleArn,
356356
stackName: assetNode.parentStack.stackName,
357+
forcePublish: options.force,
357358
});
358359
};
359360

packages/aws-cdk/test/cli/cdk-toolkit.test.ts

+30
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ jest.setTimeout(30_000);
6060
import 'aws-sdk-client-mock';
6161
import * as os from 'os';
6262
import * as path from 'path';
63+
import * as cdkAssets from 'cdk-assets';
6364
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
6465
import { Manifest } from '@aws-cdk/cloud-assembly-schema';
6566
import * as cxapi from '@aws-cdk/cx-api';
@@ -682,6 +683,35 @@ describe('deploy', () => {
682683
expect(stderrMock).toHaveBeenCalledWith(expect.stringContaining('Publishing Asset Display Name (desto)'));
683684
});
684685

686+
test('force flag is passed to asset publishing', async () => {
687+
// GIVEN
688+
cloudExecutable = new MockCloudExecutable({
689+
stacks: [MockStack.MOCK_STACK_WITH_ASSET],
690+
});
691+
const toolkit = new CdkToolkit({
692+
cloudExecutable,
693+
configuration: cloudExecutable.configuration,
694+
sdkProvider: cloudExecutable.sdkProvider,
695+
deployments: new FakeCloudFormation({}),
696+
});
697+
698+
const publishEntry = jest.spyOn(cdkAssets.AssetPublishing.prototype, 'publishEntry');
699+
700+
// WHEN
701+
await toolkit.deploy({
702+
selector: { patterns: [MockStack.MOCK_STACK_WITH_ASSET.stackName] },
703+
hotswap: HotswapMode.FULL_DEPLOYMENT,
704+
force: true,
705+
});
706+
707+
// THEN
708+
expect(publishEntry).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
709+
force: true,
710+
}));
711+
712+
publishEntry.mockRestore();
713+
});
714+
685715
test('with stacks all stacks specified as wildcard', async () => {
686716
// GIVEN
687717
const toolkit = defaultToolkitSetup();

packages/cdk-assets/lib/private/asset-handler.ts

+7
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export interface PublishOptions {
1313
* @default true
1414
*/
1515
readonly allowCrossAccount?: boolean;
16+
17+
/**
18+
* Always upload, even if the target file already exists
19+
*
20+
* @default false
21+
*/
22+
readonly force?: boolean;
1623
}
1724

1825
/**

packages/cdk-assets/lib/private/handlers/files.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export class FileAssetHandler implements IAssetHandler {
112112
break;
113113
}
114114

115-
if (await objectExists(s3, destination.bucketName, destination.objectKey)) {
115+
if (!options.force && await objectExists(s3, destination.bucketName, destination.objectKey)) {
116116
this.host.emitMessage(EventType.FOUND, `Found ${s3Url}`);
117117
return;
118118
}

packages/cdk-assets/test/files.test.ts

+13-9
Original file line numberDiff line numberDiff line change
@@ -123,22 +123,26 @@ test('will only read bucketEncryption once even for multiple assets', async () =
123123
expect(s3).toHaveReceivedCommandTimes(GetBucketEncryptionCommand, 1);
124124
});
125125

126-
test('Do nothing if file already exists', async () => {
126+
test.each([false, true])('file already exists with { force: %p }', async (force) => {
127127
const s3 = mockClient(S3Client);
128128

129129
const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/simple/cdk.out')), { aws });
130130
s3.on(ListObjectsV2Command).resolves({ Contents: [{ Key: 'some_key' }] });
131131

132-
await pub.publish();
133-
134-
expect(s3).toHaveReceivedCommandWith(ListObjectsV2Command, {
135-
Bucket: 'some_bucket',
136-
Prefix: 'some_key',
137-
MaxKeys: 1,
138-
});
132+
await pub.publish({ force });
139133

140134
// Upload calls PutObjectCommand under the hood
141-
expect(s3).not.toHaveReceivedCommand(PutObjectCommand);
135+
if (force) {
136+
expect(s3).toHaveReceivedCommand(PutObjectCommand);
137+
} else {
138+
expect(s3).toHaveReceivedCommandWith(ListObjectsV2Command, {
139+
Bucket: 'some_bucket',
140+
Prefix: 'some_key',
141+
MaxKeys: 1,
142+
});
143+
144+
expect(s3).not.toHaveReceivedCommand(PutObjectCommand);
145+
}
142146
});
143147

144148
test('tiny file does not count as cache hit', async () => {

0 commit comments

Comments
 (0)