Skip to content

Commit 2415a80

Browse files
authored
chore: prevent empty zip uploads (#18487)
Due to something we haven't completely figured out yet, our asset packaging sometimes produces empty zip files, leading to an error like this uploading code Lambda: ``` Uploaded file must be a non-empty zip ``` Do the following to address this: * If any empty zip files already ended up in S3, do not regard this as a cache hit. Rebuild the asset and upload it again. We do this by checking the item's size: this may be overly pessimistic, but if it is we're probably not wasting a lot of time rebuilding and uploading a tiny file. * If a fresh asset build is producing an empty zip file, loudly complain and ask the user to come to our bug tracker, so we can figure out where these spurious issues are coming from. Relates to #18459. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent dcf6870 commit 2415a80

File tree

3 files changed

+104
-46
lines changed

3 files changed

+104
-46
lines changed

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

+48-3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import { pathExists } from '../fs-extra';
1010
import { replaceAwsPlaceholders } from '../placeholders';
1111
import { shell } from '../shell';
1212

13+
/**
14+
* The size of an empty zip file is 22 bytes
15+
*
16+
* Ref: https://en.wikipedia.org/wiki/ZIP_(file_format)
17+
*/
18+
const EMPTY_ZIP_FILE_SIZE = 22;
19+
1320
export class FileAssetHandler implements IAssetHandler {
1421
private readonly fileCacheRoot: string;
1522

@@ -74,6 +81,34 @@ export class FileAssetHandler implements IAssetHandler {
7481
const publishFile = this.asset.source.executable ?
7582
await this.externalPackageFile(this.asset.source.executable) : await this.packageFile(this.asset.source);
7683

84+
// Add a validation to catch the cases where we're accidentally producing an empty ZIP file (or worse,
85+
// an empty file)
86+
if (publishFile.contentType === 'application/zip') {
87+
const fileSize = (await fs.stat(publishFile.packagedPath)).size;
88+
if (fileSize <= EMPTY_ZIP_FILE_SIZE) {
89+
const message = [
90+
'🚨 WARNING: EMPTY ZIP FILE 🚨',
91+
'',
92+
'Zipping this asset produced an empty zip file. We do not know the root cause for this yet, and we need your help tracking it down.',
93+
'',
94+
'Please visit https://github.com/aws/aws-cdk/issues/18459 and tell us:',
95+
'Your OS version, Nodejs version, CLI version, package manager, what the asset is supposed to contain, whether',
96+
'or not this error is reproducible, what files are in your cdk.out directory, if you recently changed anything,',
97+
'and anything else you think might be relevant.',
98+
'',
99+
'The deployment will continue, but it may fail. You can try removing the cdk.out directory and running the command',
100+
'again; let us know if that resolves it.',
101+
'',
102+
'If you meant to produce an empty asset file on purpose, you can add an empty dotfile to the asset for now',
103+
'to disable this notice.',
104+
];
105+
106+
for (const line of message) {
107+
this.host.emitMessage(EventType.FAIL, line);
108+
}
109+
}
110+
}
111+
77112
this.host.emitMessage(EventType.UPLOAD, `Upload ${s3Url}`);
78113

79114
const params = Object.assign({}, {
@@ -101,11 +136,11 @@ export class FileAssetHandler implements IAssetHandler {
101136
const packagedPath = path.join(this.fileCacheRoot, `${this.asset.id.assetId}.zip`);
102137

103138
if (await pathExists(packagedPath)) {
104-
this.host.emitMessage(EventType.CACHED, `From cache ${path}`);
139+
this.host.emitMessage(EventType.CACHED, `From cache ${packagedPath}`);
105140
return { packagedPath, contentType };
106141
}
107142

108-
this.host.emitMessage(EventType.BUILD, `Zip ${fullPath} -> ${path}`);
143+
this.host.emitMessage(EventType.BUILD, `Zip ${fullPath} -> ${packagedPath}`);
109144
await zipDirectory(fullPath, packagedPath);
110145
return { packagedPath, contentType };
111146
} else {
@@ -149,9 +184,19 @@ async function objectExists(s3: AWS.S3, bucket: string, key: string) {
149184
* prefix, and limiting results to 1. Since the list operation returns keys ordered by binary
150185
* UTF-8 representation, the key we are looking for is guaranteed to always be the first match
151186
* returned if it exists.
187+
*
188+
* If the file is too small, we discount it as a cache hit. There is an issue
189+
* somewhere that sometimes produces empty zip files, and we would otherwise
190+
* never retry building those assets without users having to manually clear
191+
* their bucket, which is a bad experience.
152192
*/
153193
const response = await s3.listObjectsV2({ Bucket: bucket, Prefix: key, MaxKeys: 1 }).promise();
154-
return response.Contents != null && response.Contents.some(object => object.Key === key);
194+
return (
195+
response.Contents != null &&
196+
response.Contents.some(
197+
(object) => object.Key === key && (object.Size == null || object.Size > EMPTY_ZIP_FILE_SIZE),
198+
)
199+
);
155200
}
156201

157202

packages/cdk-assets/test/fake-listener.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { IPublishProgressListener, EventType, IPublishProgress } from '../lib/progress';
22

33
export class FakeListener implements IPublishProgressListener {
4+
public readonly types = new Array<EventType>();
45
public readonly messages = new Array<string>();
56

67
constructor(private readonly doAbort = false) {

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

+55-43
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@ jest.mock('child_process');
22

33
import { Manifest } from '@aws-cdk/cloud-assembly-schema';
44
import * as mockfs from 'mock-fs';
5-
import { AssetManifest, AssetPublishing } from '../lib';
5+
import { AssetPublishing, AssetManifest } from '../lib';
66
import { FakeListener } from './fake-listener';
77
import { mockAws, mockedApiFailure, mockedApiResult, mockUpload } from './mock-aws';
88
import { mockSpawn } from './mock-child_process';
99

1010
const ABS_PATH = '/simple/cdk.out/some_external_file';
1111

12+
const DEFAULT_DESTINATION = {
13+
region: 'us-north-50',
14+
assumeRoleArn: 'arn:aws:role',
15+
bucketName: 'some_bucket',
16+
objectKey: 'some_key',
17+
};
18+
1219
let aws: ReturnType<typeof mockAws>;
1320
beforeEach(() => {
1421
jest.resetAllMocks();
@@ -21,34 +28,20 @@ beforeEach(() => {
2128
source: {
2229
path: 'some_file',
2330
},
24-
destinations: {
25-
theDestination: {
26-
region: 'us-north-50',
27-
assumeRoleArn: 'arn:aws:role',
28-
bucketName: 'some_bucket',
29-
objectKey: 'some_key',
30-
},
31-
},
31+
destinations: { theDestination: DEFAULT_DESTINATION },
3232
},
3333
},
3434
}),
3535
'/simple/cdk.out/some_file': 'FILE_CONTENTS',
36-
[ABS_PATH]: 'FILE_CONTENTS',
36+
[ABS_PATH]: 'ZIP_FILE_THAT_IS_DEFINITELY_NOT_EMPTY',
3737
'/abs/cdk.out/assets.json': JSON.stringify({
3838
version: Manifest.version(),
3939
files: {
4040
theAsset: {
4141
source: {
4242
path: '/simple/cdk.out/some_file',
4343
},
44-
destinations: {
45-
theDestination: {
46-
region: 'us-north-50',
47-
assumeRoleArn: 'arn:aws:role',
48-
bucketName: 'some_other_bucket',
49-
objectKey: 'some_key',
50-
},
51-
},
44+
destinations: { theDestination: { ...DEFAULT_DESTINATION, bucketName: 'some_other_bucket' } },
5245
},
5346
},
5447
}),
@@ -59,14 +52,7 @@ beforeEach(() => {
5952
source: {
6053
executable: ['sometool'],
6154
},
62-
destinations: {
63-
theDestination: {
64-
region: 'us-north-50',
65-
assumeRoleArn: 'arn:aws:role',
66-
bucketName: 'some_external_bucket',
67-
objectKey: 'some_key',
68-
},
69-
},
55+
destinations: { theDestination: { ...DEFAULT_DESTINATION, bucketName: 'some_external_bucket' } },
7056
},
7157
},
7258
}),
@@ -77,32 +63,31 @@ beforeEach(() => {
7763
source: {
7864
path: 'plain_text.txt',
7965
},
80-
destinations: {
81-
theDestination: {
82-
region: 'us-north-50',
83-
assumeRoleArn: 'arn:aws:role',
84-
bucketName: 'some_bucket',
85-
objectKey: 'some_key.txt',
86-
},
87-
},
66+
destinations: { theDestination: { ...DEFAULT_DESTINATION, objectKey: 'some_key.txt' } },
8867
},
8968
theImageAsset: {
9069
source: {
9170
path: 'image.png',
9271
},
93-
destinations: {
94-
theDestination: {
95-
region: 'us-north-50',
96-
assumeRoleArn: 'arn:aws:role',
97-
bucketName: 'some_bucket',
98-
objectKey: 'some_key.png',
99-
},
100-
},
72+
destinations: { theDestination: { ...DEFAULT_DESTINATION, objectKey: 'some_key.png' } },
10173
},
10274
},
10375
}),
10476
'/types/cdk.out/plain_text.txt': 'FILE_CONTENTS',
10577
'/types/cdk.out/image.png': 'FILE_CONTENTS',
78+
'/emptyzip/cdk.out/assets.json': JSON.stringify({
79+
version: Manifest.version(),
80+
files: {
81+
theTextAsset: {
82+
source: {
83+
path: 'empty_dir',
84+
packaging: 'zip',
85+
},
86+
destinations: { theDestination: DEFAULT_DESTINATION },
87+
},
88+
},
89+
}),
90+
'/emptyzip/cdk.out/empty_dir': { }, // Empty directory
10691
});
10792

10893
aws = mockAws();
@@ -114,6 +99,7 @@ afterEach(() => {
11499

115100
test('pass destination properties to AWS client', async () => {
116101
const pub = new AssetPublishing(AssetManifest.fromPath('/simple/cdk.out'), { aws, throwOnError: false });
102+
aws.mockS3.listObjectsV2 = mockedApiResult({});
117103

118104
await pub.publish();
119105

@@ -127,15 +113,29 @@ test('Do nothing if file already exists', async () => {
127113
const pub = new AssetPublishing(AssetManifest.fromPath('/simple/cdk.out'), { aws });
128114

129115
aws.mockS3.listObjectsV2 = mockedApiResult({ Contents: [{ Key: 'some_key' }] });
116+
aws.mockS3.upload = mockUpload();
130117
await pub.publish();
131118

132119
expect(aws.mockS3.listObjectsV2).toHaveBeenCalledWith(expect.objectContaining({
133120
Bucket: 'some_bucket',
134121
Prefix: 'some_key',
135122
MaxKeys: 1,
136123
}));
124+
expect(aws.mockS3.upload).not.toHaveBeenCalled();
125+
});
126+
127+
test('tiny file does not count as cache hit', async () => {
128+
const pub = new AssetPublishing(AssetManifest.fromPath('/simple/cdk.out'), { aws });
129+
130+
aws.mockS3.listObjectsV2 = mockedApiResult({ Contents: [{ Key: 'some_key', Size: 5 }] });
131+
aws.mockS3.upload = mockUpload();
132+
133+
await pub.publish();
134+
135+
expect(aws.mockS3.upload).toHaveBeenCalled();
137136
});
138137

138+
139139
test('upload file if new (list returns other key)', async () => {
140140
const pub = new AssetPublishing(AssetManifest.fromPath('/simple/cdk.out'), { aws });
141141

@@ -310,6 +310,18 @@ test('correctly identify asset path if path is absolute', async () => {
310310
expect(true).toBeTruthy(); // No exception, satisfy linter
311311
});
312312

313+
test('empty directory prints failures', async () => {
314+
const progressListener = new FakeListener();
315+
const pub = new AssetPublishing(AssetManifest.fromPath('/emptyzip/cdk.out'), { aws, progressListener });
316+
317+
aws.mockS3.listObjectsV2 = mockedApiResult({ Contents: undefined });
318+
aws.mockS3.upload = mockUpload(); // Should not be hit
319+
320+
await pub.publish();
321+
322+
expect(progressListener.messages).toContainEqual(expect.stringContaining('EMPTY ZIP FILE'));
323+
});
324+
313325
describe('external assets', () => {
314326
let pub: AssetPublishing;
315327
beforeEach(() => {
@@ -330,7 +342,7 @@ describe('external assets', () => {
330342

331343
test('upload external asset correctly', async () => {
332344
aws.mockS3.listObjectsV2 = mockedApiResult({ Contents: undefined });
333-
aws.mockS3.upload = mockUpload('FILE_CONTENTS');
345+
aws.mockS3.upload = mockUpload('ZIP_FILE_THAT_IS_DEFINITELY_NOT_EMPTY');
334346
const expectAllSpawns = mockSpawn({ commandLine: ['sometool'], stdout: ABS_PATH });
335347

336348
await pub.publish();

0 commit comments

Comments
 (0)