Compare commits

...
4 changed files with 176 additions and 4 deletions

No files matched your search

+76 -1
View File
@@ -3,8 +3,83 @@ import os from 'node:os';
import path from 'node:path';
import fs from 'fs-extra';
import nock from 'nock';
import { computeFileMd5Base64Async } from '../uploads';
import { computeFileMd5Base64Async, uploadWithPresignedPostWithRetryAsync } from '../uploads';
describe(uploadWithPresignedPostWithRetryAsync, () => {
const presignedPost = {
url: 'https://storage.example.com/upload',
fields: { key: 'object-key' },
};
let tmpDir: string;
let file: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-presigned-post-test-'));
file = path.join(tmpDir, 'bundle.js');
await fs.writeFile(file, 'abc');
});
afterAll(async () => {
await fs.remove(tmpDir);
});
afterEach(() => {
nock.cleanAll();
});
it('sends the policy fields and the file', async () => {
let body = '';
nock('https://storage.example.com')
.post('/upload', requestBody => {
body = requestBody;
return true;
})
.reply(204);
const response = await uploadWithPresignedPostWithRetryAsync(file, presignedPost, () => {});
expect(response.status).toBe(204);
expect(body).toContain('object-key');
expect(body).toContain('filename="bundle.js"');
expect(body).toContain('abc');
});
it('accepts the upload when Cloud Storage stored the same MD5', async () => {
nock('https://storage.example.com')
.post('/upload')
.reply(204, '', { 'x-goog-hash': 'crc32c=AAAAAA==,md5=kAFQmDzST7DWlj99KOF/cg==' });
const response = await uploadWithPresignedPostWithRetryAsync(file, presignedPost, () => {});
expect(response.status).toBe(204);
});
it('retries and then fails when Cloud Storage stored a different MD5', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
try {
nock('https://storage.example.com')
.post('/upload')
.times(4)
.reply(204, '', { 'x-goog-hash': 'crc32c=AAAAAA==,md5=AAAAAAAAAAAAAAAAAAAAAA==' });
const onAssetUploadBegin = jest.fn();
const upload = uploadWithPresignedPostWithRetryAsync(file, presignedPost, onAssetUploadBegin);
let settled = false;
upload.catch(() => {}).finally(() => (settled = true));
while (!settled) {
await new Promise(resolve => setImmediate(resolve));
jest.advanceTimersByTime(500);
}
await expect(upload).rejects.toThrow('does not match the local file');
expect(onAssetUploadBegin).toHaveBeenCalledTimes(4);
} finally {
jest.useRealTimers();
}
});
});
describe(computeFileMd5Base64Async, () => {
let tmpDir: string;
@@ -9,8 +9,9 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat
import { AssetMetadataStatus } from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { PublishQuery } from '../../graphql/queries/PublishQuery';
import Log from '../../log';
import { RequestedPlatform } from '../../platform';
import { uploadWithPresignedPostWithRetryAsync } from '../../uploads';
import { computeFileMd5Base64Async, uploadWithPresignedPostWithRetryAsync } from '../../uploads';
import { expoCommandAsync } from '../../utils/expoCli';
import {
MetadataJoi,
@@ -1093,6 +1094,60 @@ describe(uploadAssetsAsync, () => {
}
});
it('logs the uploaded object, stored hashes and local MD5 in debug mode', async () => {
const isDebugProperty = jest.replaceProperty(Log, 'isDebug', true);
const debugSpy = jest.spyOn(Log, 'debug').mockImplementation(() => {});
const uploadMock = jest.mocked(uploadWithPresignedPostWithRetryAsync);
uploadMock.mockResolvedValue(
new Response(undefined, { status: 204, headers: { etag: '"abc"', 'x-goog-hash': 'md5=def' } })
);
jest.mocked(computeFileMd5Base64Async).mockResolvedValue('ghi');
try {
const singleAssetGroup = {
android: {
launchAsset: {
type: 'bundle',
contentType: 'application/javascript',
path: androidBundlePath,
},
assets: [],
},
};
let callNum = 0;
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async (_, keys) => {
callNum++;
return keys.map(storageKey => ({
storageKey,
status: callNum >= 2 ? AssetMetadataStatus.Exists : AssetMetadataStatus.DoesNotExist,
__typename: 'AssetMetadataResult' as const,
}));
});
jest.spyOn(PublishMutation, 'getUploadURLsAsync').mockResolvedValue({
specifications: ['{"url":"https://storage.example.com","fields":{"key":"object-1"}}'],
});
await uploadAssetsAsync(
instance(mock<ExpoGraphqlClient>()),
singleAssetGroup,
testProjectId,
{ isCanceledOrFinished: false },
() => {},
() => {}
);
const messages = debugSpy.mock.calls.map(call => String(call[0]));
expect(messages).toContainEqual(
expect.stringContaining('HTTP 204, ETag "abc", x-goog-hash md5=def, local MD5 ghi')
);
expect(messages).toContainEqual(expect.stringContaining('has storage key'));
} finally {
uploadMock.mockReset();
debugSpy.mockRestore();
isDebugProperty.restore();
jest.mocked(computeFileMd5Base64Async).mockReset();
}
});
it('throws "Canceled upload" when canceled during the finalize poll', async () => {
jest.useFakeTimers({ doNotFake: ['Date'] });
try {
+33 -2
View File
@@ -44,7 +44,11 @@ import {
truncateString as truncateUpdateMessage,
updatePublishPlatformToAppPlatform,
} from '../update/utils';
import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../uploads';
import {
PresignedPost,
computeFileMd5Base64Async,
uploadWithPresignedPostWithRetryAsync,
} from '../uploads';
import {
expoCommandAsync,
shouldUseVersionedExpoCLI,
@@ -543,6 +547,13 @@ export async function uploadAssetsAsync(
notifyProgress(missingAssets);
Log.debug(
`Uploading ${missingAssets.length} of ${uniqueAssets.length} assets with Node ${process.version}`
);
for (const asset of missingAssets) {
Log.debug(`Asset ${asset.path} (${asset.contentType}) has storage key ${asset.storageKey}`);
}
// Just past the finalize cloud function's event max age plus a startup buffer. Beyond this,
// waiting longer can't help, so we re-upload to generate a fresh event with a new retry window.
const FINALIZE_POLL_MS = 55_000;
@@ -580,7 +591,19 @@ export async function uploadAssetsAsync(
throw Error('Canceled upload');
}
const presignedPost: PresignedPost = JSON.parse(specification);
await uploadWithPresignedPostWithRetryAsync(asset.path, presignedPost, onBegin);
const response = await uploadWithPresignedPostWithRetryAsync(
asset.path,
presignedPost,
onBegin
);
if (Log.isDebug) {
const md5 = await computeFileMd5Base64Async(asset.path);
Log.debug(
`Uploaded ${asset.path} to object ${presignedPost.fields.key} at ${new Date().toISOString()}: ` +
`HTTP ${response.status}, ETag ${response.headers.get('etag')}, ` +
`x-goog-hash ${response.headers.get('x-goog-hash')}, local MD5 ${md5}`
);
}
})
)
);
@@ -612,8 +635,16 @@ export async function uploadAssetsAsync(
// Re-upload assets that don't finalize in time to trigger a fresh finalize event and retry window.
let pendingAssets = missingAssets;
for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS && pendingAssets.length > 0; attempt++) {
Log.debug(
`Upload attempt ${attempt + 1} of ${MAX_UPLOAD_ATTEMPTS}: ${pendingAssets.length} asset(s)`
);
await uploadBatchAsync(pendingAssets, onAssetUploadBegin);
pendingAssets = await pollForFinalizationAsync(pendingAssets);
for (const asset of pendingAssets) {
Log.debug(
`Storage key ${asset.storageKey} for ${asset.path} not processed after ${FINALIZE_POLL_MS / 1000}s`
);
}
}
cancelationToken.isCanceledOrFinished = true;
+11
View File
@@ -90,6 +90,7 @@ export async function uploadWithPresignedPostWithRetryAsync(
presignedPost: PresignedPost,
onAssetUploadBegin: () => void
): Promise<Response> {
const localMd5 = await computeFileMd5Base64Async(file);
return await promiseRetry(
async retry => {
// retry fetch errors (usually connection or DNS errors)
@@ -115,6 +116,16 @@ export async function uploadWithPresignedPostWithRetryAsync(
throw new Error(`Presigned upload responded with a ${response.status} status`);
}
const storedMd5 = response.headers.get('x-goog-hash')?.match(/md5=([^,]+)/)?.[1];
if (storedMd5 && storedMd5 !== localMd5) {
return retry(
new Error(
`Uploaded file ${file} does not match the local file (Cloud Storage MD5 ${storedMd5}, local MD5 ${localMd5}). ` +
`The file changed between this machine and Cloud Storage. Try again from another network, and contact support if it keeps happening.`
)
);
}
return response;
},
// retry parameters match google suggested defaults: https://cloud.google.com/storage/docs/retry-strategy#node.js