diff --git a/__tests__/commands/export-sql.ts b/__tests__/commands/export-sql.ts index d9beda614..79edc56db 100644 --- a/__tests__/commands/export-sql.ts +++ b/__tests__/commands/export-sql.ts @@ -254,7 +254,7 @@ describe( 'commands/ExportSQLCommand', () => { const downloadMock = jest.spyOn( downloadFileModule, 'downloadFile' ).mockResolvedValue(); await exportCommand.run(); - expect( stepSuccessSpy ).toHaveBeenCalledWith( 'prepare' ); + expect( stepSuccessSpy ).toHaveBeenCalledWith( 'prepare', expect.any( Array ) ); expect( stepSuccessSpy ).toHaveBeenCalledWith( 'create' ); expect( stepSuccessSpy ).toHaveBeenCalledWith( 'confirmEnoughStorage' ); expect( stepSuccessSpy ).toHaveBeenCalledWith( 'downloadLink' ); @@ -265,6 +265,35 @@ describe( 'commands/ExportSQLCommand', () => { expect.any( Function ) ); } ); + + it( 'should return download URL instead of downloading when skipDownload is true', async () => { + const exportCommandWithUrl = new ExportSQLCommand( app, env, { skipDownload: true } ); + const stepSuccessSpyUrl = jest.spyOn( exportCommandWithUrl.progressTracker, 'stepSuccess' ); + const stepSkippedSpy = jest.spyOn( exportCommandWithUrl.progressTracker, 'stepSkipped' ); + const confirmEnoughStorageSpyUrl = jest.spyOn( exportCommandWithUrl, 'confirmEnoughStorage' ); + const downloadMock = jest.spyOn( downloadFileModule, 'downloadFile' ).mockResolvedValue(); + const consoleLogSpy = jest.spyOn( console, 'log' ).mockImplementation(); + + confirmEnoughStorageSpyUrl.mockResolvedValue( { continue: true, isPromptShown: false } ); + + await exportCommandWithUrl.run(); + + // Should skip confirmEnoughStorage and download steps + expect( stepSkippedSpy ).toHaveBeenCalledWith( 'confirmEnoughStorage' ); + expect( stepSkippedSpy ).toHaveBeenCalledWith( 'download' ); + + // Should not call confirmEnoughStorage or download + expect( confirmEnoughStorageSpyUrl ).not.toHaveBeenCalled(); + expect( downloadMock ).not.toHaveBeenCalled(); + + // Should output the download URL + expect( consoleLogSpy ).toHaveBeenCalledWith( 'Download URL: https://test-backup.sql.gz' ); + + stepSuccessSpyUrl.mockRestore(); + stepSkippedSpy.mockRestore(); + confirmEnoughStorageSpyUrl.mockRestore(); + consoleLogSpy.mockRestore(); + } ); } ); describe( 'liveBackupCopy', () => { diff --git a/docs/export-sql-skip-download.md b/docs/export-sql-skip-download.md new file mode 100644 index 000000000..471a23a1f --- /dev/null +++ b/docs/export-sql-skip-download.md @@ -0,0 +1,112 @@ +# Export SQL: Skip Download Option + +The `vip export sql` command includes a `--skip-download` option that allows you to retrieve the download URL for a database backup without downloading the file. This is useful when you need the URL for automation workflows, want to download the file later, or need to use a different download method. + +## Usage + +```bash +vip @. export sql --skip-download +``` + +## Examples + +### Get download URL for the latest backup + +```bash +vip @example-app.production export sql --skip-download +``` + +This command will: +1. Prepare the most recent database backup +2. Generate a download URL +3. Display the URL without downloading the file + +### Combine with other options + +You can combine `--skip-download` with other export options: + +```bash +# Get URL for a fresh backup +vip @example-app.production export sql --generate-backup --skip-download + +# Get URL for a partial export +vip @example-app.production export sql --table=wp_posts --skip-download + +# Get URL for specific sites in a multisite network +vip @example-app.production export sql --site-id=2,3 --skip-download +``` + +## Output + +When using `--skip-download`, the command displays progress information and outputs the download URL: + +``` +✓ Preparing for backup download + • Attaching to an existing export for the backup with timestamp 2025-11-14T13:21:07.000Z +✓ Creating backup copy +✓ Requesting download link + • Download URL: https://example.org/sql.gz + +Download URL: +https://example.org/sql.gz +``` + +## Use Cases + +### Automation workflows + +Store the URL in a variable for use in scripts: + +```bash +#!/bin/bash +OUTPUT=$(vip @example-app.production export sql --skip-download 2>&1) +URL=$(echo "$OUTPUT" | grep -o 'https://[^[:space:]]*') +echo "Backup URL: $URL" +# Use $URL in your automation workflow +``` + +### Download with custom tools + +Get the URL and use your preferred download tool: + +```bash +# Get the URL +vip @example-app.production export sql --skip-download + +# Download with wget +wget -O backup.sql.gz "https://example.org/sql.gz" + +# Or with curl +curl -o backup.sql.gz "https://example.org/sql.gz" +``` + +### Integration with cloud storage + +Retrieve the URL and upload directly to cloud storage without saving locally: + +```bash +# Get the URL +URL=$(vip @example-app.production export sql --skip-download 2>&1 | grep -o 'https://[^[:space:]]*') + +# Upload directly to S3 +wget -O - "$URL" | aws s3 cp - s3://my-bucket/backups/backup.sql.gz +``` + +## Important Notes + +- The download URL is temporary and will expire after a period of time +- When using `--skip-download`, the command skips: + - Storage space confirmation prompt + - File download +- The output file path option (`--output`) has no effect when `--skip-download` is used +- The backup must still be prepared and a download URL generated, which may take some time depending on backup size + +## Related Commands + +- [`vip export sql`](https://docs.wpvip.com/vip-cli/export-sql/) - Download database backups +- [`vip backup db`](https://docs.wpvip.com/vip-cli/backup-db/) - Create database backups + +## See Also + +- [VIP CLI Export SQL Documentation](https://docs.wpvip.com/vip-cli/export-sql/) +- [Database Backups on WordPress VIP](https://docs.wpvip.com/technical-references/backups/) diff --git a/src/bin/vip-export-sql.js b/src/bin/vip-export-sql.js index 6f46d452e..0438791e1 100755 --- a/src/bin/vip-export-sql.js +++ b/src/bin/vip-export-sql.js @@ -21,6 +21,10 @@ const examples = [ description: 'Generate a fresh database backup for an environment and download an archived copy of that backup.', }, + { + usage: 'vip @example-app.develop export sql --skip-download', + description: 'Skip downloading the database backup file.', + }, { usage: 'vip @example-app.develop export sql --table=wp_posts --table=wp_comments', description: @@ -96,12 +100,13 @@ command( { 'generate-backup', 'Generate a fresh database backup and export an archived copy of that backup.' ) + .option( 'skip-download', 'Skip downloading the file.' ) .examples( examples ) .argv( process.argv, async ( arg, - { app, env, output, configFile, table, siteId, wpcliCommand, generateBackup } + { app, env, output, configFile, table, siteId, wpcliCommand, generateBackup, skipDownload } ) => { const liveBackupCopyCLIOptions = parseLiveBackupCopyCLIOptions( configFile, @@ -121,7 +126,7 @@ command( { const exportCommand = new ExportSQLCommand( app, env, - { outputFile: output, generateBackup, liveBackupCopyCLIOptions }, + { outputFile: output, generateBackup, liveBackupCopyCLIOptions, skipDownload }, trackerFn ); await exportCommand.run(); diff --git a/src/commands/export-sql.ts b/src/commands/export-sql.ts index bf91b92d5..c1e0762bc 100644 --- a/src/commands/export-sql.ts +++ b/src/commands/export-sql.ts @@ -19,7 +19,7 @@ import { PromptStatus, } from '../lib/backup-storage-availability/backup-storage-availability'; import * as exit from '../lib/cli/exit'; -import { formatBytes, getGlyphForStatus } from '../lib/cli/format'; +import { formatBytes } from '../lib/cli/format'; import { ProgressTracker } from '../lib/cli/progress'; import { downloadFile, OnProgressCallback } from '../lib/http/download-file'; import * as liveBackupCopy from '../lib/live-backup-copy'; @@ -221,6 +221,7 @@ export interface ExportSQLOptions { generateBackup?: boolean; liveBackupCopyCLIOptions?: LiveBackupCopyCLIOptions; showMyDumperWarning?: boolean; + skipDownload?: boolean; } /** @@ -244,6 +245,8 @@ export class ExportSQLCommand { private readonly showMyDumperWarning: boolean; + private readonly skipDownload: boolean; + /** * Creates an instance of SQLExportCommand * @@ -265,14 +268,15 @@ export class ExportSQLCommand { this.progressTracker = new ProgressTracker( [ { id: this.steps.PREPARE, name: 'Preparing for backup download' }, { id: this.steps.CREATE, name: 'Creating backup copy' }, - { id: this.steps.CONFIRM_ENOUGH_STORAGE, name: "Checking if there's enough storage" }, { id: this.steps.DOWNLOAD_LINK, name: 'Requesting download link' }, + { id: this.steps.CONFIRM_ENOUGH_STORAGE, name: "Checking if there's enough storage" }, { id: this.steps.DOWNLOAD, name: 'Downloading file' }, ] ); this.track = trackerFn; this.liveBackupCopyCLIOptions = options.liveBackupCopyCLIOptions; this.showMyDumperWarning = options.showMyDumperWarning === undefined ? true : options.showMyDumperWarning; + this.skipDownload = options.skipDownload || false; } /** @@ -412,6 +416,17 @@ export class ExportSQLCommand { size = Number( bytesWrittenMeta.value ); } + const downloadURLString = `\n${ chalk.green( 'Download URL' ) }:\n${ url }\n\n`; + + if ( this.skipDownload ) { + this.progressTracker.stepSkipped( this.steps.CONFIRM_ENOUGH_STORAGE ); + this.progressTracker.stepSkipped( this.steps.DOWNLOAD ); + this.progressTracker.print(); + this.progressTracker.stopPrinting(); + console.log( downloadURLString ); + return; + } + const storageConfirmed = await this.progressTracker.handleContinuePrompt( async setPromptShown => { const status = await this.confirmEnoughStorage( Number( size ) ); @@ -478,39 +493,26 @@ export class ExportSQLCommand { exit.withError( `No backup found for site ${ this.app.name }` ); } - if ( ! this.generateBackup ) { - console.log( - `${ getGlyphForStatus( 'success' ) } Latest backup found with timestamp ${ - latestBackup.createdAt - }` - ); - } else { - console.log( - `${ getGlyphForStatus( 'success' ) } Backup created with timestamp ${ - latestBackup.createdAt - }` - ); - } + const prepareAdditionalInfo = []; const showMyDumperWarning = this.showMyDumperWarning && ( latestBackup.sqlDumpTool ?? envSqlDumpTool ) === 'mydumper'; if ( showMyDumperWarning ) { - console.warn( - chalk.yellow.bold( 'WARNING:' ), - chalk.yellow( - 'This is a large or complex database. The backup file for this database is generated with MyDumper. ' + - 'The file can only be loaded with MyLoader. ' + - 'For more information: https://github.com/mydumper/mydumper' - ) + prepareAdditionalInfo.push( + `${ chalk.yellow.bold( + 'WARNING:' + ) } This is a large or complex database. The backup file for this database is generated with MyDumper. The file can only be loaded with MyLoader. For more information: https://github.com/mydumper/mydumper` ); } if ( await this.getExportJob() ) { - console.log( + prepareAdditionalInfo.push( `Attaching to an existing export for the backup with timestamp ${ latestBackup.createdAt }` ); } else { - console.log( `Exporting database backup with timestamp ${ latestBackup.createdAt }` ); + prepareAdditionalInfo.push( + `Exporting database backup with timestamp ${ latestBackup.createdAt }` + ); try { await createExportJob( this.app.id, this.env.id, latestBackup.id ); @@ -545,7 +547,7 @@ export class ExportSQLCommand { this.isPrepared.bind( this ) ); - this.progressTracker.stepSuccess( this.steps.PREPARE ); + this.progressTracker.stepSuccess( this.steps.PREPARE, prepareAdditionalInfo ); await pollUntil( this.getExportJob.bind( this ), @@ -555,7 +557,7 @@ export class ExportSQLCommand { this.progressTracker.stepSuccess( this.steps.CREATE ); const url = await generateDownloadLink( this.app.id, this.env.id, latestBackup.id ); - this.progressTracker.stepSuccess( this.steps.DOWNLOAD_LINK ); + this.progressTracker.stepSuccess( this.steps.DOWNLOAD_LINK, [ url ] ); return url; } @@ -588,7 +590,7 @@ export class ExportSQLCommand { } ); this.progressTracker.stepSuccess( this.steps.CREATE ); - this.progressTracker.stepSuccess( this.steps.DOWNLOAD_LINK ); + this.progressTracker.stepSuccess( this.steps.DOWNLOAD_LINK, [ result.url ] ); return result; } catch ( err ) { diff --git a/src/lib/cli/progress.ts b/src/lib/cli/progress.ts index f47239b65..ff68f5984 100644 --- a/src/lib/cli/progress.ts +++ b/src/lib/cli/progress.ts @@ -20,7 +20,9 @@ export interface Step { id: string; name: string; status: StepStatus; - [ key: string ]: string; + percentage?: string; + progress?: string; + additionalInfo?: string[]; } export type StepConstructorParam = Omit< Step, 'status' > & { status?: StepStatus }; @@ -133,20 +135,20 @@ export class ProgressTracker { return steps.find( ( { status } ) => status === StepStatus.RUNNING ); } - public stepRunning( stepId: string ): void { - this.setStatusForStepId( stepId, StepStatus.RUNNING ); + public stepRunning( stepId: string, additionalInfo: string | string[] = [] ): void { + this.setStatusForStepId( stepId, StepStatus.RUNNING, additionalInfo ); } - public stepFailed( stepId: string ): void { - this.setStatusForStepId( stepId, StepStatus.FAILED ); + public stepFailed( stepId: string, additionalInfo: string | string[] = [] ): void { + this.setStatusForStepId( stepId, StepStatus.FAILED, additionalInfo ); } - public stepSkipped( stepId: string ): void { - this.setStatusForStepId( stepId, StepStatus.SKIPPED ); + public stepSkipped( stepId: string, additionalInfo: string | string[] = [] ): void { + this.setStatusForStepId( stepId, StepStatus.SKIPPED, additionalInfo ); } - public stepSuccess( stepId: string ) { - this.setStatusForStepId( stepId, StepStatus.SUCCESS ); + public stepSuccess( stepId: string, additionalInfo: string | string[] = [] ) { + this.setStatusForStepId( stepId, StepStatus.SUCCESS, additionalInfo ); // The stepSuccess helper automatically sets the next step to "running" const nextStep = this.getNextStep(); if ( nextStep ) { @@ -158,7 +160,11 @@ export class ProgressTracker { return [ ...this.getSteps().values() ].every( ( { status } ) => status === StepStatus.SUCCESS ); } - public setStatusForStepId( stepId: string, status: StepStatus ) { + public setStatusForStepId( + stepId: string, + status: StepStatus, + additionalInfo: string | string[] = [] + ) { const step = this.stepsFromCaller.get( stepId ); if ( ! step ) { // Only allowed to update existing steps with this method @@ -176,6 +182,7 @@ export class ProgressTracker { this.stepsFromCaller.set( stepId, { ...step, status, + additionalInfo: Array.isArray( additionalInfo ) ? additionalInfo : [ additionalInfo ], } ); } @@ -249,7 +256,7 @@ export class ProgressTracker { } const stepValues = [ ...this.getSteps().values() ]; const logs = stepValues.reduce( - ( accumulator, { name, id, percentage, status, progress }, stepNumber ) => { + ( accumulator, { name, id, percentage, status, progress, additionalInfo }, stepNumber ) => { if ( stepNumber < this.displayFromStep ) { return accumulator; } @@ -263,6 +270,12 @@ export class ProgressTracker { } else if ( progress ) { suffix = progress; } + + if ( additionalInfo && additionalInfo.length > 0 ) { + suffix += EOL; + suffix += additionalInfo.map( stepInfo => ` - ${ stepInfo }` ).join( EOL ); + } + return `${ accumulator }${ statusIcon } ${ name } ${ suffix }\n`; }, ''