packages/core/src/awsService/s3/commands/uploadFile.ts (479 lines of code) (raw):

/*! * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ import * as path from 'path' import * as mime from 'mime-types' import * as vscode from 'vscode' import { statSync } from 'fs' // eslint-disable-line no-restricted-imports import { getLogger } from '../../../shared/logger/logger' import { S3Node } from '../explorer/s3Nodes' import { readablePath } from '../util' import { localize } from '../../../shared/utilities/vsCodeUtils' import { showOutputMessage } from '../../../shared/utilities/messages' import { createQuickPick, promptUser, verifySinglePickerOutput } from '../../../shared/ui/picker' import { addCodiconToString } from '../../../shared/utilities/textUtilities' import { S3Bucket, Folder, S3Client } from '../../../shared/clients/s3' import { createBucketCommand } from './createBucket' import { S3BucketNode } from '../explorer/s3BucketNode' import { S3FolderNode } from '../explorer/s3FolderNode' import * as localizedText from '../../../shared/localizedText' import { CancellationError } from '../../../shared/utilities/timeoutUtils' import { progressReporter } from '../progressReporter' import globals from '../../../shared/extensionGlobals' import { telemetry } from '../../../shared/telemetry/telemetry' import { Upload } from '@aws-sdk/lib-storage' export interface FileSizeBytes { /** * Returns the file size in bytes. */ (file: vscode.Uri): number } interface UploadRequest { bucketName: string key: string fileLocation: vscode.Uri fileSizeBytes: number s3Client: S3Client ongoingUpload?: Upload } /** * Wizard to upload a file. * * @param s3Client account to upload the file to * @param nodeOrDocument node to upload to or file currently open, if undefined then there was no active editor * */ export async function uploadFileCommand( s3Client: S3Client, nodeOrDocument: S3BucketNode | S3FolderNode | vscode.Uri | undefined, fileSizeBytes: FileSizeBytes = statFile, getBucket = promptUserForBucket, getFile = getFilesToUpload, outputChannel = globals.outputChannel ): Promise<void> { let node: S3BucketNode | S3FolderNode | undefined let document: vscode.Uri | undefined const uploadRequests: UploadRequest[] = [] if (nodeOrDocument) { if (nodeOrDocument instanceof S3BucketNode || nodeOrDocument instanceof S3FolderNode) { node = nodeOrDocument as S3BucketNode | S3FolderNode document = undefined } else { node = undefined document = nodeOrDocument as vscode.Uri } } else { node = undefined document = undefined } const fileToUploadRequest = (bucketName: string, key: string, file: vscode.Uri) => ({ bucketName, key: key, fileLocation: file, fileSizeBytes: fileSizeBytes(file), s3Client, }) if (node) { const filesToUpload = await getFile(undefined) if (!filesToUpload) { showOutputMessage( localize('AWS.message.error.uploadFileCommand.noFileSelected', 'No file selected, cancelling upload'), outputChannel ) getLogger().info('UploadFile cancelled') telemetry.s3_uploadObject.emit({ result: 'Cancelled' }) return } uploadRequests.push( ...filesToUpload.map((file) => { const key = node!.path + path.basename(file.fsPath) return fileToUploadRequest(node!.bucket.Name, key, file) }) ) if (node instanceof S3FolderNode) { globals.globalState.tryUpdate('aws.lastUploadedToS3Folder', { bucket: node.bucket, folder: node.folder, }) } } else { while (true) { const filesToUpload = await getFile(document) if (!filesToUpload || filesToUpload.length === 0) { // if file is undefined, means the back button was pressed(there is no step before) or no file was selected // thus break the loop of the 'wizard' showOutputMessage( localize( 'AWS.message.error.uploadFileCommand.noFileSelected', 'No file selected, cancelling upload' ), outputChannel ) getLogger().info('UploadFile cancelled') telemetry.s3_uploadObject.emit({ result: 'Cancelled' }) return } const bucketResponse = await getBucket(s3Client).catch((e) => {}) if (!bucketResponse) { telemetry.s3_uploadObject.emit({ result: 'Failed' }) return } if (typeof bucketResponse === 'string') { if (bucketResponse === 'back') { continue } showOutputMessage( localize( 'AWS.message.error.uploadFileCommand.noBucketSelected', 'No bucket selected, cancelling upload' ), outputChannel ) getLogger().info('No bucket selected, cancelling upload') telemetry.s3_uploadObject.emit({ result: 'Cancelled' }) return } const bucketName = bucketResponse.bucket!.Name if (!bucketName) { throw Error(`bucketResponse is not a S3.Bucket`) } uploadRequests.push( ...filesToUpload.map((file) => { const key = bucketResponse.folder !== undefined ? bucketResponse.folder.path + path.basename(file.fsPath) : path.basename(file.fsPath) return fileToUploadRequest(bucketName, key, file) }) ) if (bucketResponse.folder) { globals.globalState.tryUpdate('aws.lastUploadedToS3Folder', { bucket: bucketResponse.bucket, folder: bucketResponse.folder, }) } break } } await runBatchUploads(uploadRequests, outputChannel) void vscode.commands.executeCommand('aws.refreshAwsExplorer', true) } async function promptForFileLocation(): Promise<vscode.Uri[] | undefined> { const fileLocations = await vscode.window.showOpenDialog({ canSelectMany: true, openLabel: localize('AWS.s3.uploadFile.openButton', 'Upload'), }) return fileLocations } function statFile(file: vscode.Uri) { return statSync(file.fsPath).size } /** * Continously attempts to upload the files until all succeed or the user cancels. */ async function runBatchUploads(uploadRequests: UploadRequest[], outputChannel = globals.outputChannel): Promise<void> { let failedRequests = await uploadBatchOfFiles(uploadRequests, outputChannel) showOutputMessage( localize( 'AWS.s3.uploadFile.complete', 'Uploaded {0}/{1} files', uploadRequests.length - failedRequests.length, uploadRequests.length ), outputChannel ) while (failedRequests.length > 0) { const failedKeys = failedRequests.map((request) => request.key) getLogger().error(`List of requests failed to upload:\n${failedRequests.toString().split(',').join('\n')}`) if (failedRequests.length > 5) { showOutputMessage( localize( 'AWS.s3.uploadFile.failedMany', 'Failed uploads:\n{0}\nSee logs for full list of failed items', failedKeys.toString().split(',').slice(0, 5).join('\n') ), outputChannel ) } else { showOutputMessage( localize( 'AWS.s3.uploadFile.failed', 'Failed uploads:\n{0}', failedKeys.toString().split(',').join('\n') ), outputChannel ) } // at least one request failed const response = await vscode.window.showErrorMessage( localize( 'AWS.s3.uploadFile.retryPrompt', 'S3 Upload: {0}/{1} failed.', failedRequests.length, uploadRequests.length ), localizedText.retry, localizedText.skip ) if (response === localizedText.retry) { // No tail call optimization in node :( failedRequests = await uploadBatchOfFiles(failedRequests, outputChannel) } else { break } } } /** * Uploads an array of requests to their specified s3 location. * * @returns array of unsuccessful requests */ async function uploadBatchOfFiles( uploadRequests: UploadRequest[], outputChannel = globals.outputChannel ): Promise<UploadRequest[]> { const totalBytes = uploadRequests.map((r) => r.fileSizeBytes).reduce((a, b) => a + b, 0) const response = await vscode.window.withProgress( { cancellable: true, location: vscode.ProgressLocation.Notification, title: localize( 'AWS.s3.uploadFile.progressTitle.batch', 'Uploading {0} files to {1}', uploadRequests.length, uploadRequests[0].bucketName ), }, async (progress, token) => { let requestIdx: number = 0 const failedRequests: UploadRequest[] = [] token.onCancellationRequested((e) => { if (uploadRequests[requestIdx].ongoingUpload) { void uploadRequests[requestIdx].ongoingUpload?.abort() } return failedRequests }) while (!token.isCancellationRequested && requestIdx < uploadRequests.length) { const request = uploadRequests[requestIdx] const fileName = path.basename(request.key) const destinationPath = readablePath({ bucket: { Name: request.bucketName }, path: request.key }) showOutputMessage( localize('AWS.s3.uploadFile.startUpload', 'Uploading file {0} to {1}', fileName, destinationPath), outputChannel ) let remainder = 0 let lastLoaded = 0 // TODO: don't use `withProgress`, it makes it hard to have control over the individual outputs // For now we will hide the noisy info to the channel. const progressWithCount: typeof progress = { report(value) { const loaded = ((value.increment ?? 0) / 100) * request.fileSizeBytes + remainder const rounded = Math.floor(loaded) const increment = ((rounded - lastLoaded) / totalBytes) * 100 remainder = loaded - rounded lastLoaded = rounded progress.report({ message: `${fileName} (${value.message})`, increment }) }, } const uploadResult = await uploadWithProgress(request, progressWithCount, token).catch((err) => { showOutputMessage( localize( 'AWS.s3.uploadFile.error.general', 'Failed to upload file {0}: {1}', fileName, err.message ), outputChannel ) return request }) if (uploadResult) { // this request failed to upload failedRequests.push(uploadResult) } requestIdx += 1 } return failedRequests.concat(uploadRequests.slice(requestIdx)) } ) telemetry.s3_uploadObject.emit({ result: response.length > 0 ? 'Failed' : 'Succeeded', value: uploadRequests.length, failedCount: response.length, successCount: uploadRequests.length - response.length, }) return response } /** * Uploads a single request to s3 with a progress window * * @param request File to be uploaded * @param progress Progress to report to * @param token Cancellation token * @returns The same request if failed, undefined otherwise */ async function uploadWithProgress( request: UploadRequest, progress: vscode.Progress<{ message?: string; increment?: number }>, token: vscode.CancellationToken ): Promise<UploadRequest | undefined> { const progressListener = progressReporter(progress, { reportMessage: true, totalBytes: request.fileSizeBytes, }) const currentStream = await request.s3Client.uploadFile({ bucketName: request.bucketName, key: request.key, content: request.fileLocation, progressListener, contentType: mime.contentType(path.extname(request.fileLocation.fsPath)) || undefined, }) progressListener(0) request.ongoingUpload = currentStream const cancelled = new Promise<void>((_, reject) => { token.onCancellationRequested((e) => { void currentStream.abort() reject(new CancellationError('user')) }) }) await Promise.race([currentStream.done(), cancelled]) return (request.ongoingUpload = undefined) } export interface BucketQuickPickItem extends vscode.QuickPickItem { bucket: (Partial<S3Bucket> & { Name: string }) | undefined folder?: Folder | undefined } interface SavedFolder { bucket: S3Bucket folder: Folder } // TODO:: extract and reuse logic from sam deploy wizard (bucket selection) /** * Will display a quick pick with the list of all buckets owned by the user. * @param s3client client to get the list of buckets * * @returns Bucket selected by the user, 'back' or 'cancel' * * @throws Error if there is an error calling s3 */ export async function promptUserForBucket( s3client: S3Client, promptUserFunction = promptUser, createBucket = createBucketCommand ): Promise<BucketQuickPickItem | 'cancel' | 'back'> { let allBuckets: S3Bucket[] try { allBuckets = (await s3client.listBuckets()).buckets } catch (e) { getLogger().error('Failed to list buckets from client %O', e) void vscode.window.showErrorMessage( localize('AWS.message.error.promptUserForBucket.listBuckets', 'Failed to list buckets from client') ) throw new Error('Failed to list buckets from client') } const s3Buckets = allBuckets.filter((bucket) => { return bucket && bucket.Name }) const createNewBucket: BucketQuickPickItem = { label: localize('AWS.command.s3.createBucket', 'Create new bucket'), bucket: undefined, } const bucketItems: BucketQuickPickItem[] = s3Buckets.map((bucket) => { return { label: bucket.Name!, bucket, } }) const lastTouchedFolder = globals.globalState.tryGet<SavedFolder>('aws.lastTouchedS3Folder', Object) let lastFolderItem: BucketQuickPickItem | undefined = undefined if (lastTouchedFolder) { lastFolderItem = { label: lastTouchedFolder.folder.name, description: '(last opened S3 folder)', bucket: { Name: lastTouchedFolder.bucket.Name }, folder: lastTouchedFolder.folder, } } const lastUploadedToFolder = globals.globalState.tryGet<SavedFolder>('aws.lastUploadedToS3Folder', Object) let lastUploadedFolderItem: BucketQuickPickItem | undefined = undefined if (lastUploadedToFolder) { lastUploadedFolderItem = { label: lastUploadedToFolder.folder.name, description: '(last uploaded-to S3 folder)', bucket: { Name: lastUploadedToFolder.bucket.Name }, folder: lastUploadedToFolder.folder, } } const folderItems = [] if (lastUploadedFolderItem !== undefined) { folderItems.push(lastUploadedFolderItem) } // de-dupe if folders are the same if ( lastFolderItem !== undefined && (lastUploadedFolderItem === undefined || lastFolderItem.folder?.path !== lastUploadedFolderItem.folder?.path) ) { folderItems.push(lastFolderItem) } const items: BucketQuickPickItem[] = [ ...(folderItems.length > 0 ? [ { label: localize('AWS.s3.uploadFile.folderSeparator', 'Folders'), kind: vscode.QuickPickItemKind.Separator, bucket: undefined, } as BucketQuickPickItem, ] : []), ...folderItems, { label: localize('AWS.s3.uploadFile.bucketSeparator', 'Buckets'), kind: vscode.QuickPickItemKind.Separator, bucket: undefined, } as BucketQuickPickItem, ...bucketItems, createNewBucket, ] const picker = createQuickPick({ options: { canPickMany: false, ignoreFocusOut: true, title: localize('AWS.message.selectBucket', 'Select an S3 bucket or folder to upload to'), step: 2, totalSteps: 2, }, items, buttons: [vscode.QuickInputButtons.Back], }) const response = verifySinglePickerOutput( await promptUserFunction({ picker: picker, onDidTriggerButton: (button, resolve, reject) => { if (button === vscode.QuickInputButtons.Back) { resolve([ { label: 'back', bucket: undefined, }, ]) } }, }) ) if (!response) { return 'cancel' } if (!response.bucket) { if (response.label === 'back') { return response.label } if (response.label === 'Create new bucket') { const s3Node = new S3Node(s3client) await createBucket(s3Node) return promptUserForBucket(s3client) } } else { return response } return 'cancel' } /** * Gets the open file in the current editor * Asks the user to browse for more files * If no file is open it prompts the user to select file * @param document document to use as currently open * * @returns file selected by the user */ export async function getFilesToUpload( document?: vscode.Uri, promptUserFunction = promptUser ): Promise<vscode.Uri[] | undefined> { let fileLocations: vscode.Uri[] | undefined if (!document) { fileLocations = await promptForFileLocation() } else { fileLocations = [document] const fileNameToDisplay = path.basename(fileLocations[0].fsPath) const fileOption: vscode.QuickPickItem = { label: addCodiconToString('file', fileNameToDisplay), } const selectMore: vscode.QuickPickItem = { label: localize('AWS.message.browseMoreFiles', 'Browse for more files...'), } const picker = createQuickPick({ options: { canPickMany: false, ignoreFocusOut: true, title: localize('AWS.message.selectFileUpload', 'Select a file to upload'), step: 1, totalSteps: 2, }, items: [fileOption, selectMore], buttons: [vscode.QuickInputButtons.Back], }) const response = verifySinglePickerOutput( await promptUserFunction({ picker: picker, onDidTriggerButton: (button, resolve, reject) => { if (button === vscode.QuickInputButtons.Back) { resolve(undefined) } }, }) ) if (!response) { return } if (response.label === selectMore.label) { fileLocations = await promptForFileLocation() } } return fileLocations }