mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc3c72fe16 | ||
|
|
3d34033803 | ||
|
|
c0785dab8c | ||
|
|
222e3c5204 |
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user