Skip to content

Commit cdfb838

Browse files
authored
Merge pull request #762 from salesforcecli/sh/resume-successful
fix: support resuming completed deployments
2 parents 90a8266 + f16acb8 commit cdfb838

File tree

6 files changed

+106
-104
lines changed

6 files changed

+106
-104
lines changed

messages/deploy.metadata.report.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
# summary
22

3-
Check the status of a deploy operation.
3+
Check or poll for the status of a deploy operation.
44

55
# description
66

77
Deploy operations include standard deploys, quick deploys, deploy validations, and deploy cancellations.
88

9-
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.
9+
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.
10+
11+
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.
12+
13+
This command doesn't update source tracking information.
1014

1115
# examples
1216

@@ -18,6 +22,10 @@ Run this command by either passing it a job ID or specifying the --use-most-rece
1822

1923
<%= config.bin %> <%= command.id %> --use-most-recent
2024

25+
- Poll for the status using a job ID and target org:
26+
27+
<%= config.bin %> <%= command.id %> --job-id 0Af0x000017yLUFCA2 --target-org [email protected] --wait 30
28+
2129
# flags.job-id.summary
2230

2331
Job ID of the deploy operation you want to check the status of.

messages/deploy.metadata.resume.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# summary
22

3-
Resume watching a deploy operation.
3+
Resume watching a deploy operation and update source tracking when the deploy completes.
44

55
# description
66

7-
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.
7+
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.
88

99
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.
1010

@@ -57,9 +57,9 @@ Show verbose output of the deploy operation result.
5757

5858
Show concise output of the deploy operation result.
5959

60-
# error.DeployNotResumable
60+
# warning.DeployNotResumable
6161

62-
Job ID %s is not resumable with status %s.
62+
Job ID %s is not resumable because it already completed with status: %s. Displaying results...
6363

6464
# flags.junit.summary
6565

src/commands/project/deploy/quick.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
import { bold } from 'chalk';
99
import { Messages, Org } from '@salesforce/core';
1010
import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core';
11-
import { RequestStatus } from '@salesforce/source-deploy-retrieve';
11+
import { MetadataApiDeploy, RequestStatus } from '@salesforce/source-deploy-retrieve';
1212
import { Duration } from '@salesforce/kit';
13-
import { DeployOptions, determineExitCode, poll, resolveApi } from '../../../utils/deploy';
13+
import { DeployOptions, determineExitCode, resolveApi } from '../../../utils/deploy';
1414
import { DeployCache } from '../../../utils/deployCache';
1515
import { DEPLOY_STATUS_CODES_DESCRIPTIONS } from '../../../utils/errorCodes';
1616
import { AsyncDeployResultFormatter } from '../../../formatters/asyncDeployResultFormatter';
@@ -90,10 +90,15 @@ export default class DeployMetadataQuick extends SfCommand<DeployResultJson> {
9090
const org = flags['target-org'] ?? (await Org.create({ aliasOrUsername: deployOpts['target-org'] }));
9191
const api = await resolveApi(this.configAggregator);
9292

93+
const mdapiDeploy = new MetadataApiDeploy({
94+
usernameOrConnection: org.getConnection(flags['api-version']),
95+
id: jobId,
96+
apiOptions: {
97+
rest: api === 'REST',
98+
},
99+
});
93100
// This is the ID of the deploy (of the validated metadata)
94-
const deployId = await org
95-
.getConnection(flags['api-version'])
96-
.metadata.deployRecentValidation({ id: jobId, rest: api === 'REST' });
101+
const deployId = await mdapiDeploy.deployRecentValidation(api === 'REST');
97102
this.log(`Deploy ID: ${bold(deployId)}`);
98103

99104
if (flags.async) {
@@ -102,7 +107,10 @@ export default class DeployMetadataQuick extends SfCommand<DeployResultJson> {
102107
return asyncFormatter.getJson();
103108
}
104109

105-
const result = await poll(org, deployId, flags.wait);
110+
const result = await mdapiDeploy.pollStatus({
111+
frequency: Duration.seconds(1),
112+
timeout: flags.wait,
113+
});
106114
const formatter = new DeployResultFormatter(result, flags);
107115

108116
if (!this.jsonEnabled()) formatter.display();

src/commands/project/deploy/resume.ts

Lines changed: 50 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
*/
77

88
import { bold } from 'chalk';
9-
import { EnvironmentVariable, Messages, SfError } from '@salesforce/core';
9+
import { EnvironmentVariable, Messages, Org, SfError } from '@salesforce/core';
1010
import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core';
11+
import { DeployResult, MetadataApiDeploy } from '@salesforce/source-deploy-retrieve';
1112
import { Duration } from '@salesforce/kit';
1213
import { DeployResultFormatter } from '../../../formatters/deployResultFormatter';
1314
import { DeployProgress } from '../../../utils/progressBar';
1415
import { DeployResultJson } from '../../../utils/types';
15-
import { determineExitCode, executeDeploy, isNotResumable } from '../../../utils/deploy';
16+
import { buildComponentSet, determineExitCode, executeDeploy, isNotResumable } from '../../../utils/deploy';
1617
import { DeployCache } from '../../../utils/deployCache';
1718
import { DEPLOY_STATUS_CODES_DESCRIPTIONS } from '../../../utils/errorCodes';
1819
import { coverageFormattersFlag } from '../../../utils/flags';
@@ -85,33 +86,56 @@ export default class DeployMetadataResume extends SfCommand<DeployResultJson> {
8586
// if it was async before, then it should not be async now.
8687
const deployOpts = { ...cache.get(jobId), async: false };
8788

89+
let result: DeployResult;
90+
91+
// If we already have a status from cache that is not resumable, display a warning and the deploy result.
8892
if (isNotResumable(deployOpts.status)) {
89-
throw messages.createError('error.DeployNotResumable', [jobId, deployOpts.status]);
93+
this.warn(messages.getMessage('warning.DeployNotResumable', [jobId, deployOpts.status]));
94+
const org = await Org.create({ aliasOrUsername: deployOpts['target-org'] });
95+
const componentSet = await buildComponentSet({ ...deployOpts, wait: Duration.seconds(0) });
96+
const mdapiDeploy = new MetadataApiDeploy({
97+
// setting an API version here won't matter since we're just checking deploy status
98+
// eslint-disable-next-line sf-plugin/get-connection-with-version
99+
usernameOrConnection: org.getConnection(),
100+
id: jobId,
101+
components: componentSet,
102+
apiOptions: {
103+
rest: deployOpts.api === 'REST',
104+
},
105+
});
106+
const deployStatus = await mdapiDeploy.checkStatus();
107+
result = new DeployResult(deployStatus, componentSet);
108+
} else {
109+
const wait = flags.wait ?? Duration.minutes(deployOpts.wait);
110+
const { deploy } = await executeDeploy(
111+
// there will always be conflicts on a resume if anything deployed--the changes on the server are not synced to local
112+
{
113+
...deployOpts,
114+
wait,
115+
'dry-run': false,
116+
'ignore-conflicts': true,
117+
// TODO: isMdapi is generated from 'metadata-dir' flag, but we don't have that flag here
118+
// change the cache value to actually cache the metadata-dir, and if there's a value, it isMdapi
119+
// deployCache~L38, so to tell the executeDeploy method it's ok to not have a project, we spoof a metadata-dir
120+
// in deploy~L140, it checks the if the id is present, so this metadata-dir value is never _really_ used
121+
'metadata-dir': deployOpts.isMdapi ? { type: 'file', path: 'testing' } : undefined,
122+
},
123+
this.config.bin,
124+
this.project,
125+
jobId
126+
);
127+
128+
this.log(`Deploy ID: ${bold(jobId)}`);
129+
new DeployProgress(deploy, this.jsonEnabled()).start();
130+
result = await deploy.pollStatus(500, wait.seconds);
131+
132+
if (!deploy.id) {
133+
throw new SfError('The deploy id is not available.');
134+
}
135+
cache.update(deploy.id, { status: result.response.status });
136+
await cache.write();
90137
}
91138

92-
const wait = flags.wait ?? Duration.minutes(deployOpts.wait);
93-
const { deploy } = await executeDeploy(
94-
// there will always be conflicts on a resume if anything deployed--the changes on the server are not synced to local
95-
{
96-
...deployOpts,
97-
wait,
98-
'dry-run': false,
99-
'ignore-conflicts': true,
100-
// TODO: isMdapi is generated from 'metadata-dir' flag, but we don't have that flag here
101-
// change the cache value to actually cache the metadata-dir, and if there's a value, it isMdapi
102-
// deployCache~L38, so to tell the executeDeploy method it's ok to not have a project, we spoof a metadata-dir
103-
// in deploy~L140, it checks the if the id is present, so this metadata-dir value is never _really_ used
104-
'metadata-dir': deployOpts.isMdapi ? { type: 'file', path: 'testing' } : undefined,
105-
},
106-
this.config.bin,
107-
this.project,
108-
jobId
109-
);
110-
111-
this.log(`Deploy ID: ${bold(jobId)}`);
112-
new DeployProgress(deploy, this.jsonEnabled()).start();
113-
114-
const result = await deploy.pollStatus(500, wait.seconds);
115139
process.exitCode = determineExitCode(result);
116140

117141
const formatter = new DeployResultFormatter(result, {
@@ -121,11 +145,6 @@ export default class DeployMetadataResume extends SfCommand<DeployResultJson> {
121145
});
122146

123147
if (!this.jsonEnabled()) formatter.display();
124-
if (!deploy.id) {
125-
throw new SfError('The deploy id is not available.');
126-
}
127-
cache.update(deploy.id, { status: result.response.status });
128-
await cache.write();
129148

130149
return formatter.getJson();
131150
}

src/utils/deploy.ts

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8-
import { ConfigAggregator, Messages, Org, PollingClient, SfError, SfProject, StatusResult } from '@salesforce/core';
8+
import { ConfigAggregator, Messages, Org, SfError, SfProject } from '@salesforce/core';
99
import { Duration } from '@salesforce/kit';
10-
import { AnyJson, Nullable } from '@salesforce/ts-types';
10+
import { Nullable } from '@salesforce/ts-types';
1111
import {
1212
ComponentSet,
1313
ComponentSetBuilder,
1414
DeployResult,
1515
MetadataApiDeploy,
16-
MetadataApiDeployStatus,
1716
RequestStatus,
1817
} from '@salesforce/source-deploy-retrieve';
1918
import { SourceTracking } from '@salesforce/source-tracking';
@@ -204,7 +203,10 @@ export async function cancelDeploy(opts: Partial<DeployOptions>, id: string): Pr
204203
await DeployCache.set(deploy.id, { ...opts });
205204

206205
await deploy.cancel();
207-
return poll(org, deploy.id, opts.wait ?? Duration.minutes(33));
206+
return deploy.pollStatus({
207+
frequency: Duration.milliseconds(500),
208+
timeout: opts.wait ?? Duration.minutes(33),
209+
});
208210
}
209211

210212
export async function cancelDeployAsync(opts: Partial<DeployOptions>, id: string): Promise<{ id: string }> {
@@ -218,28 +220,6 @@ export async function cancelDeployAsync(opts: Partial<DeployOptions>, id: string
218220
return { id: deploy.id };
219221
}
220222

221-
export async function poll(org: Org, id: string, wait: Duration, componentSet?: ComponentSet): Promise<DeployResult> {
222-
const report = async (): Promise<DeployResult> => {
223-
const res = await org.getConnection().metadata.checkDeployStatus(id, true);
224-
const deployStatus = res as MetadataApiDeployStatus;
225-
return new DeployResult(deployStatus, componentSet);
226-
};
227-
228-
const opts: PollingClient.Options = {
229-
frequency: Duration.milliseconds(1000),
230-
timeout: wait,
231-
poll: async (): Promise<StatusResult> => {
232-
const deployResult = await report();
233-
return {
234-
completed: deployResult.response.done,
235-
payload: deployResult as unknown as AnyJson,
236-
};
237-
},
238-
};
239-
const pollingClient = await PollingClient.create(opts);
240-
return pollingClient.subscribe();
241-
}
242-
243223
export function determineExitCode(result: DeployResult, async = false): number {
244224
if (async) {
245225
return result.response.status === RequestStatus.Succeeded ? 0 : 1;

test/commands/deploy/metadata/resume.nut.ts

Lines changed: 22 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function readDeployCache(projectDir: string): Record<string, CachedOptions> {
2020
return JSON.parse(contents) as Record<string, CachedOptions>;
2121
}
2222

23-
describe('deploy metadata resume NUTs', () => {
23+
describe('[project deploy resume] NUTs', () => {
2424
let testkit: SourceTestkit;
2525

2626
before(async () => {
@@ -36,7 +36,7 @@ describe('deploy metadata resume NUTs', () => {
3636

3737
describe('--use-most-recent', () => {
3838
it('should resume most recently started deployment', async () => {
39-
const first = await testkit.execute<DeployResultJson>('deploy:metadata', {
39+
const first = await testkit.execute<DeployResultJson>('project deploy start', {
4040
args: '--source-dir force-app --async',
4141
json: true,
4242
exitCode: 0,
@@ -47,7 +47,7 @@ describe('deploy metadata resume NUTs', () => {
4747
const cacheBefore = readDeployCache(testkit.projectDir);
4848
expect(cacheBefore).to.have.property(first.result.id);
4949

50-
const deploy = await testkit.execute<DeployResultJson>('deploy:metadata:resume', {
50+
const deploy = await testkit.execute<DeployResultJson>('project deploy resume', {
5151
args: '--use-most-recent',
5252
json: true,
5353
exitCode: 0,
@@ -57,62 +57,49 @@ describe('deploy metadata resume NUTs', () => {
5757

5858
const cacheAfter = readDeployCache(testkit.projectDir);
5959

60-
expect(cacheAfter).to.have.property(first.result.id);
61-
expect(cacheAfter[first.result.id]).have.property('status');
62-
expect(cacheAfter[first.result.id].status).to.equal(RequestStatus.Succeeded);
63-
});
64-
it.skip('should resume most recently started deployment without specifying the flag', async () => {
65-
const first = await testkit.execute<DeployResultJson>('deploy:metadata', {
66-
args: '--source-dir force-app --async',
67-
json: true,
68-
exitCode: 0,
69-
});
70-
assert(first);
71-
assert(first.result.id);
72-
73-
const cacheBefore = readDeployCache(testkit.projectDir);
74-
expect(cacheBefore).to.have.property(first.result.id);
75-
76-
const deploy = await testkit.execute<DeployResultJson>('deploy:metadata:resume', {
77-
json: true,
78-
exitCode: 0,
79-
});
80-
assert(deploy);
81-
await testkit.expect.filesToBeDeployedViaResult(['force-app/**/*'], ['force-app/test/**/*'], deploy.result.files);
82-
83-
const cacheAfter = readDeployCache(testkit.projectDir);
84-
8560
expect(cacheAfter).to.have.property(first.result.id);
8661
expect(cacheAfter[first.result.id]).have.property('status');
8762
expect(cacheAfter[first.result.id].status).to.equal(RequestStatus.Succeeded);
8863
});
8964
});
9065

9166
describe('--job-id', () => {
67+
let deployId: string;
68+
9269
it('should resume the provided job id (18 chars)', async () => {
93-
const first = await testkit.execute<DeployResultJson>('deploy:metadata', {
70+
const first = await testkit.execute<DeployResultJson>('project deploy start', {
9471
args: '--source-dir force-app --async --ignore-conflicts',
9572
json: true,
9673
exitCode: 0,
9774
});
9875
assert(first);
9976
assert(first.result.id);
77+
deployId = first.result.id;
10078

10179
const cacheBefore = readDeployCache(testkit.projectDir);
102-
expect(cacheBefore).to.have.property(first.result.id);
80+
expect(cacheBefore).to.have.property(deployId);
10381

104-
const deploy = await testkit.execute<DeployResultJson>('deploy:metadata:resume', {
105-
args: `--job-id ${first.result.id}`,
82+
const deploy = await testkit.execute<DeployResultJson>('project deploy resume', {
83+
args: `--job-id ${deployId}`,
10684
json: true,
10785
exitCode: 0,
10886
});
10987
assert(deploy);
11088

11189
await testkit.expect.filesToBeDeployedViaResult(['force-app/**/*'], ['force-app/test/**/*'], deploy.result.files);
11290
const cacheAfter = readDeployCache(testkit.projectDir);
113-
expect(cacheAfter).to.have.property(first.result.id);
114-
expect(cacheAfter[first.result.id]).have.property('status');
115-
expect(cacheAfter[first.result.id].status).to.equal(RequestStatus.Succeeded);
91+
expect(cacheAfter).to.have.property(deployId);
92+
expect(cacheAfter[deployId]).have.property('status');
93+
expect(cacheAfter[deployId].status).to.equal(RequestStatus.Succeeded);
94+
});
95+
96+
it('should resume a completed deploy by displaying results', async () => {
97+
const deploy = await testkit.execute<DeployResultJson>('project deploy resume', {
98+
args: `--job-id ${deployId}`,
99+
json: true,
100+
exitCode: 0,
101+
});
102+
assert(deploy);
116103
});
117104

118105
it('should resume the provided job id (15 chars)', async () => {

0 commit comments

Comments
 (0)