diff --git a/messages/deploy.metadata.report.md b/messages/deploy.metadata.report.md index 15aa3ddb..427385ab 100644 --- a/messages/deploy.metadata.report.md +++ b/messages/deploy.metadata.report.md @@ -1,12 +1,16 @@ # summary -Check the status of a deploy operation. +Check or poll for the status of a deploy operation. # description Deploy operations include standard deploys, quick deploys, deploy validations, and deploy cancellations. -Run this command by either passing it a job ID or specifying the --use-most-recent flag to use the job ID of the most recent deploy operation. +Run this command by either passing it a job ID or specifying the --use-most-recent flag to use the job ID of the most recent deploy operation. If you specify the --wait flag, the command polls for the status every second until the timeout of --wait minutes. If you don't specify the --wait flag, the command simply checks and displays the status of the deploy; the command doesn't poll for the status. + +You typically don't specify the --target-org flag because the cached job already references the org to which you deployed. But if you run this command on a computer different than the one from which you deployed, then you must specify the --target-org and it must point to the same org. + +This command doesn't update source tracking information. # examples @@ -18,6 +22,10 @@ Run this command by either passing it a job ID or specifying the --use-most-rece <%= config.bin %> <%= command.id %> --use-most-recent +- Poll for the status using a job ID and target org: + + <%= config.bin %> <%= command.id %> --job-id 0Af0x000017yLUFCA2 --target-org me@my.org --wait 30 + # flags.job-id.summary Job ID of the deploy operation you want to check the status of. diff --git a/messages/deploy.metadata.resume.md b/messages/deploy.metadata.resume.md index ceafe7a1..4b417fab 100644 --- a/messages/deploy.metadata.resume.md +++ b/messages/deploy.metadata.resume.md @@ -1,10 +1,10 @@ # summary -Resume watching a deploy operation. +Resume watching a deploy operation and update source tracking when the deploy completes. # description -Use this command to resume watching a deploy operation if the original command times out or you specified the --async flag. Deploy operations include standard deploys, quick deploys, deploy validations, and deploy cancellations. This command doesn't resume the original operation itself, because the operation always continues after you've started it, regardless of whether you're watching it or not. +Use this command to resume watching a deploy operation if the original command times out or you specified the --async flag. Deploy operations include standard deploys, quick deploys, deploy validations, and deploy cancellations. This command doesn't resume the original operation itself, because the operation always continues after you've started it, regardless of whether you're watching it or not. When the deploy completes, source tracking information is updated as needed. Run this command by either passing it a job ID or specifying the --use-most-recent flag to use the job ID of the most recent deploy operation. @@ -57,9 +57,9 @@ Show verbose output of the deploy operation result. Show concise output of the deploy operation result. -# error.DeployNotResumable +# warning.DeployNotResumable -Job ID %s is not resumable with status %s. +Job ID %s is not resumable because it already completed with status: %s. Displaying results... # flags.junit.summary diff --git a/src/commands/project/deploy/quick.ts b/src/commands/project/deploy/quick.ts index 31c33da7..2bb8faee 100644 --- a/src/commands/project/deploy/quick.ts +++ b/src/commands/project/deploy/quick.ts @@ -8,9 +8,9 @@ import { bold } from 'chalk'; import { Messages, Org } from '@salesforce/core'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; -import { RequestStatus } from '@salesforce/source-deploy-retrieve'; +import { MetadataApiDeploy, RequestStatus } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; -import { DeployOptions, determineExitCode, poll, resolveApi } from '../../../utils/deploy'; +import { DeployOptions, determineExitCode, resolveApi } from '../../../utils/deploy'; import { DeployCache } from '../../../utils/deployCache'; import { DEPLOY_STATUS_CODES_DESCRIPTIONS } from '../../../utils/errorCodes'; import { AsyncDeployResultFormatter } from '../../../formatters/asyncDeployResultFormatter'; @@ -90,10 +90,15 @@ export default class DeployMetadataQuick extends SfCommand { const org = flags['target-org'] ?? (await Org.create({ aliasOrUsername: deployOpts['target-org'] })); const api = await resolveApi(this.configAggregator); + const mdapiDeploy = new MetadataApiDeploy({ + usernameOrConnection: org.getConnection(flags['api-version']), + id: jobId, + apiOptions: { + rest: api === 'REST', + }, + }); // This is the ID of the deploy (of the validated metadata) - const deployId = await org - .getConnection(flags['api-version']) - .metadata.deployRecentValidation({ id: jobId, rest: api === 'REST' }); + const deployId = await mdapiDeploy.deployRecentValidation(api === 'REST'); this.log(`Deploy ID: ${bold(deployId)}`); if (flags.async) { @@ -102,7 +107,10 @@ export default class DeployMetadataQuick extends SfCommand { return asyncFormatter.getJson(); } - const result = await poll(org, deployId, flags.wait); + const result = await mdapiDeploy.pollStatus({ + frequency: Duration.seconds(1), + timeout: flags.wait, + }); const formatter = new DeployResultFormatter(result, flags); if (!this.jsonEnabled()) formatter.display(); diff --git a/src/commands/project/deploy/resume.ts b/src/commands/project/deploy/resume.ts index 6078a275..97bc61a2 100644 --- a/src/commands/project/deploy/resume.ts +++ b/src/commands/project/deploy/resume.ts @@ -6,13 +6,14 @@ */ import { bold } from 'chalk'; -import { EnvironmentVariable, Messages, SfError } from '@salesforce/core'; +import { EnvironmentVariable, Messages, Org, SfError } from '@salesforce/core'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; +import { DeployResult, MetadataApiDeploy } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter'; import { DeployProgress } from '../../../utils/progressBar'; import { DeployResultJson } from '../../../utils/types'; -import { determineExitCode, executeDeploy, isNotResumable } from '../../../utils/deploy'; +import { buildComponentSet, determineExitCode, executeDeploy, isNotResumable } from '../../../utils/deploy'; import { DeployCache } from '../../../utils/deployCache'; import { DEPLOY_STATUS_CODES_DESCRIPTIONS } from '../../../utils/errorCodes'; import { coverageFormattersFlag } from '../../../utils/flags'; @@ -85,33 +86,56 @@ export default class DeployMetadataResume extends SfCommand { // if it was async before, then it should not be async now. const deployOpts = { ...cache.get(jobId), async: false }; + let result: DeployResult; + + // If we already have a status from cache that is not resumable, display a warning and the deploy result. if (isNotResumable(deployOpts.status)) { - throw messages.createError('error.DeployNotResumable', [jobId, deployOpts.status]); + this.warn(messages.getMessage('warning.DeployNotResumable', [jobId, deployOpts.status])); + const org = await Org.create({ aliasOrUsername: deployOpts['target-org'] }); + const componentSet = await buildComponentSet({ ...deployOpts, wait: Duration.seconds(0) }); + const mdapiDeploy = new MetadataApiDeploy({ + // setting an API version here won't matter since we're just checking deploy status + // eslint-disable-next-line sf-plugin/get-connection-with-version + usernameOrConnection: org.getConnection(), + id: jobId, + components: componentSet, + apiOptions: { + rest: deployOpts.api === 'REST', + }, + }); + const deployStatus = await mdapiDeploy.checkStatus(); + result = new DeployResult(deployStatus, componentSet); + } else { + const wait = flags.wait ?? Duration.minutes(deployOpts.wait); + const { deploy } = await executeDeploy( + // there will always be conflicts on a resume if anything deployed--the changes on the server are not synced to local + { + ...deployOpts, + wait, + 'dry-run': false, + 'ignore-conflicts': true, + // TODO: isMdapi is generated from 'metadata-dir' flag, but we don't have that flag here + // change the cache value to actually cache the metadata-dir, and if there's a value, it isMdapi + // deployCache~L38, so to tell the executeDeploy method it's ok to not have a project, we spoof a metadata-dir + // in deploy~L140, it checks the if the id is present, so this metadata-dir value is never _really_ used + 'metadata-dir': deployOpts.isMdapi ? { type: 'file', path: 'testing' } : undefined, + }, + this.config.bin, + this.project, + jobId + ); + + this.log(`Deploy ID: ${bold(jobId)}`); + new DeployProgress(deploy, this.jsonEnabled()).start(); + result = await deploy.pollStatus(500, wait.seconds); + + if (!deploy.id) { + throw new SfError('The deploy id is not available.'); + } + cache.update(deploy.id, { status: result.response.status }); + await cache.write(); } - const wait = flags.wait ?? Duration.minutes(deployOpts.wait); - const { deploy } = await executeDeploy( - // there will always be conflicts on a resume if anything deployed--the changes on the server are not synced to local - { - ...deployOpts, - wait, - 'dry-run': false, - 'ignore-conflicts': true, - // TODO: isMdapi is generated from 'metadata-dir' flag, but we don't have that flag here - // change the cache value to actually cache the metadata-dir, and if there's a value, it isMdapi - // deployCache~L38, so to tell the executeDeploy method it's ok to not have a project, we spoof a metadata-dir - // in deploy~L140, it checks the if the id is present, so this metadata-dir value is never _really_ used - 'metadata-dir': deployOpts.isMdapi ? { type: 'file', path: 'testing' } : undefined, - }, - this.config.bin, - this.project, - jobId - ); - - this.log(`Deploy ID: ${bold(jobId)}`); - new DeployProgress(deploy, this.jsonEnabled()).start(); - - const result = await deploy.pollStatus(500, wait.seconds); process.exitCode = determineExitCode(result); const formatter = new DeployResultFormatter(result, { @@ -121,11 +145,6 @@ export default class DeployMetadataResume extends SfCommand { }); if (!this.jsonEnabled()) formatter.display(); - if (!deploy.id) { - throw new SfError('The deploy id is not available.'); - } - cache.update(deploy.id, { status: result.response.status }); - await cache.write(); return formatter.getJson(); } diff --git a/src/utils/deploy.ts b/src/utils/deploy.ts index f600e5af..84b23a0f 100644 --- a/src/utils/deploy.ts +++ b/src/utils/deploy.ts @@ -5,15 +5,14 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { ConfigAggregator, Messages, Org, PollingClient, SfError, SfProject, StatusResult } from '@salesforce/core'; +import { ConfigAggregator, Messages, Org, SfError, SfProject } from '@salesforce/core'; import { Duration } from '@salesforce/kit'; -import { AnyJson, Nullable } from '@salesforce/ts-types'; +import { Nullable } from '@salesforce/ts-types'; import { ComponentSet, ComponentSetBuilder, DeployResult, MetadataApiDeploy, - MetadataApiDeployStatus, RequestStatus, } from '@salesforce/source-deploy-retrieve'; import { SourceTracking } from '@salesforce/source-tracking'; @@ -204,7 +203,10 @@ export async function cancelDeploy(opts: Partial, id: string): Pr await DeployCache.set(deploy.id, { ...opts }); await deploy.cancel(); - return poll(org, deploy.id, opts.wait ?? Duration.minutes(33)); + return deploy.pollStatus({ + frequency: Duration.milliseconds(500), + timeout: opts.wait ?? Duration.minutes(33), + }); } export async function cancelDeployAsync(opts: Partial, id: string): Promise<{ id: string }> { @@ -218,28 +220,6 @@ export async function cancelDeployAsync(opts: Partial, id: string return { id: deploy.id }; } -export async function poll(org: Org, id: string, wait: Duration, componentSet?: ComponentSet): Promise { - const report = async (): Promise => { - const res = await org.getConnection().metadata.checkDeployStatus(id, true); - const deployStatus = res as MetadataApiDeployStatus; - return new DeployResult(deployStatus, componentSet); - }; - - const opts: PollingClient.Options = { - frequency: Duration.milliseconds(1000), - timeout: wait, - poll: async (): Promise => { - const deployResult = await report(); - return { - completed: deployResult.response.done, - payload: deployResult as unknown as AnyJson, - }; - }, - }; - const pollingClient = await PollingClient.create(opts); - return pollingClient.subscribe(); -} - export function determineExitCode(result: DeployResult, async = false): number { if (async) { return result.response.status === RequestStatus.Succeeded ? 0 : 1; diff --git a/test/commands/deploy/metadata/resume.nut.ts b/test/commands/deploy/metadata/resume.nut.ts index 738c02f0..6408a4da 100644 --- a/test/commands/deploy/metadata/resume.nut.ts +++ b/test/commands/deploy/metadata/resume.nut.ts @@ -20,7 +20,7 @@ function readDeployCache(projectDir: string): Record { return JSON.parse(contents) as Record; } -describe('deploy metadata resume NUTs', () => { +describe('[project deploy resume] NUTs', () => { let testkit: SourceTestkit; before(async () => { @@ -36,7 +36,7 @@ describe('deploy metadata resume NUTs', () => { describe('--use-most-recent', () => { it('should resume most recently started deployment', async () => { - const first = await testkit.execute('deploy:metadata', { + const first = await testkit.execute('project deploy start', { args: '--source-dir force-app --async', json: true, exitCode: 0, @@ -47,7 +47,7 @@ describe('deploy metadata resume NUTs', () => { const cacheBefore = readDeployCache(testkit.projectDir); expect(cacheBefore).to.have.property(first.result.id); - const deploy = await testkit.execute('deploy:metadata:resume', { + const deploy = await testkit.execute('project deploy resume', { args: '--use-most-recent', json: true, exitCode: 0, @@ -57,31 +57,6 @@ describe('deploy metadata resume NUTs', () => { const cacheAfter = readDeployCache(testkit.projectDir); - expect(cacheAfter).to.have.property(first.result.id); - expect(cacheAfter[first.result.id]).have.property('status'); - expect(cacheAfter[first.result.id].status).to.equal(RequestStatus.Succeeded); - }); - it.skip('should resume most recently started deployment without specifying the flag', async () => { - const first = await testkit.execute('deploy:metadata', { - args: '--source-dir force-app --async', - json: true, - exitCode: 0, - }); - assert(first); - assert(first.result.id); - - const cacheBefore = readDeployCache(testkit.projectDir); - expect(cacheBefore).to.have.property(first.result.id); - - const deploy = await testkit.execute('deploy:metadata:resume', { - json: true, - exitCode: 0, - }); - assert(deploy); - await testkit.expect.filesToBeDeployedViaResult(['force-app/**/*'], ['force-app/test/**/*'], deploy.result.files); - - const cacheAfter = readDeployCache(testkit.projectDir); - expect(cacheAfter).to.have.property(first.result.id); expect(cacheAfter[first.result.id]).have.property('status'); expect(cacheAfter[first.result.id].status).to.equal(RequestStatus.Succeeded); @@ -89,20 +64,23 @@ describe('deploy metadata resume NUTs', () => { }); describe('--job-id', () => { + let deployId: string; + it('should resume the provided job id (18 chars)', async () => { - const first = await testkit.execute('deploy:metadata', { + const first = await testkit.execute('project deploy start', { args: '--source-dir force-app --async --ignore-conflicts', json: true, exitCode: 0, }); assert(first); assert(first.result.id); + deployId = first.result.id; const cacheBefore = readDeployCache(testkit.projectDir); - expect(cacheBefore).to.have.property(first.result.id); + expect(cacheBefore).to.have.property(deployId); - const deploy = await testkit.execute('deploy:metadata:resume', { - args: `--job-id ${first.result.id}`, + const deploy = await testkit.execute('project deploy resume', { + args: `--job-id ${deployId}`, json: true, exitCode: 0, }); @@ -110,9 +88,18 @@ describe('deploy metadata resume NUTs', () => { await testkit.expect.filesToBeDeployedViaResult(['force-app/**/*'], ['force-app/test/**/*'], deploy.result.files); const cacheAfter = readDeployCache(testkit.projectDir); - expect(cacheAfter).to.have.property(first.result.id); - expect(cacheAfter[first.result.id]).have.property('status'); - expect(cacheAfter[first.result.id].status).to.equal(RequestStatus.Succeeded); + expect(cacheAfter).to.have.property(deployId); + expect(cacheAfter[deployId]).have.property('status'); + expect(cacheAfter[deployId].status).to.equal(RequestStatus.Succeeded); + }); + + it('should resume a completed deploy by displaying results', async () => { + const deploy = await testkit.execute('project deploy resume', { + args: `--job-id ${deployId}`, + json: true, + exitCode: 0, + }); + assert(deploy); }); it('should resume the provided job id (15 chars)', async () => {