Skip to content

Commit

Permalink
fix: support resuming completed deployments
Browse files Browse the repository at this point in the history
  • Loading branch information
shetzel committed Sep 25, 2023
1 parent 85eacd3 commit a9908fb
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 103 deletions.
8 changes: 7 additions & 1 deletion messages/deploy.metadata.report.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# 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.

Note that this command will not update source tracking information.

# examples

- Check the status using a job ID:
Expand All @@ -18,6 +20,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 [email protected] --wait 30

# flags.job-id.summary

Job ID of the deploy operation you want to check the status of.
Expand Down
8 changes: 4 additions & 4 deletions messages/deploy.metadata.resume.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# summary

Resume watching a deploy operation.
Resume watching a deploy operation and update source tracking upon completion as necessary.

# 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. Upon completion, source tracking information will be updated as necessary.

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.

Expand Down Expand Up @@ -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 since it already completed with status: %s. Displaying results...

# flags.junit.summary

Expand Down
20 changes: 14 additions & 6 deletions src/commands/project/deploy/quick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -90,10 +90,15 @@ export default class DeployMetadataQuick extends SfCommand<DeployResultJson> {
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) {
Expand All @@ -102,7 +107,10 @@ export default class DeployMetadataQuick extends SfCommand<DeployResultJson> {
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();
Expand Down
81 changes: 50 additions & 31 deletions src/commands/project/deploy/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,33 +86,56 @@ export default class DeployMetadataResume extends SfCommand<DeployResultJson> {
// 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, {
Expand All @@ -121,11 +145,6 @@ export default class DeployMetadataResume extends SfCommand<DeployResultJson> {
});

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();
}
Expand Down
32 changes: 6 additions & 26 deletions src/utils/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -204,7 +203,10 @@ export async function cancelDeploy(opts: Partial<DeployOptions>, 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<DeployOptions>, id: string): Promise<{ id: string }> {
Expand All @@ -218,28 +220,6 @@ export async function cancelDeployAsync(opts: Partial<DeployOptions>, id: string
return { id: deploy.id };
}

export async function poll(org: Org, id: string, wait: Duration, componentSet?: ComponentSet): Promise<DeployResult> {
const report = async (): Promise<DeployResult> => {
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<StatusResult> => {
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;
Expand Down
57 changes: 22 additions & 35 deletions test/commands/deploy/metadata/resume.nut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function readDeployCache(projectDir: string): Record<string, CachedOptions> {
return JSON.parse(contents) as Record<string, CachedOptions>;
}

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

before(async () => {
Expand All @@ -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<DeployResultJson>('deploy:metadata', {
const first = await testkit.execute<DeployResultJson>('project deploy start', {
args: '--source-dir force-app --async',
json: true,
exitCode: 0,
Expand All @@ -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<DeployResultJson>('deploy:metadata:resume', {
const deploy = await testkit.execute<DeployResultJson>('project deploy resume', {
args: '--use-most-recent',
json: true,
exitCode: 0,
Expand All @@ -57,62 +57,49 @@ 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<DeployResultJson>('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<DeployResultJson>('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);
});
});

describe('--job-id', () => {
let deployId: string;

it('should resume the provided job id (18 chars)', async () => {
const first = await testkit.execute<DeployResultJson>('deploy:metadata', {
const first = await testkit.execute<DeployResultJson>('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<DeployResultJson>('deploy:metadata:resume', {
args: `--job-id ${first.result.id}`,
const deploy = await testkit.execute<DeployResultJson>('project deploy resume', {
args: `--job-id ${deployId}`,
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);
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<DeployResultJson>('project deploy resume', {
args: `--job-id ${deployId}`,
json: true,
exitCode: 0,
});
assert(deploy);
});

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

0 comments on commit a9908fb

Please sign in to comment.