From 107130360ea84395451d762874ba70c5cddd78fd Mon Sep 17 00:00:00 2001 From: Steve Hetzel Date: Wed, 21 Feb 2024 14:27:45 -0700 Subject: [PATCH 1/2] feat: add support for refreshing sandboxes --- messages/org.md | 4 ++ src/config/sandboxProcessCache.ts | 3 +- src/exported.ts | 1 + src/org/org.ts | 107 ++++++++++++++++++++++++++++-- test/unit/org/orgTest.ts | 4 +- 5 files changed, 110 insertions(+), 9 deletions(-) diff --git a/messages/org.md b/messages/org.md index d4dddcc81e..aa3ce88217 100644 --- a/messages/org.md +++ b/messages/org.md @@ -34,6 +34,10 @@ We can't find a SandboxProcess for the sandbox %s. The sandbox org creation failed with a result of %s. +# sandboxInfoRefreshFailed + +The sandbox org refresh failed with a result of %s. + # missingAuthUsername The sandbox %s does not have an authorized username. diff --git a/src/config/sandboxProcessCache.ts b/src/config/sandboxProcessCache.ts index 56dac4e790..075113ed2d 100644 --- a/src/config/sandboxProcessCache.ts +++ b/src/config/sandboxProcessCache.ts @@ -11,8 +11,9 @@ import { TTLConfig } from './ttlConfig'; export type SandboxRequestCacheEntry = { alias?: string; - setDefault: boolean; + setDefault?: boolean; prodOrgUsername: string; + action: 'Create' | 'Refresh'; // Sandbox create and refresh requests can be cached sandboxProcessObject: Partial; sandboxRequest: Partial; tracksSource?: boolean; diff --git a/src/exported.ts b/src/exported.ts index c067a98232..80fd77c980 100644 --- a/src/exported.ts +++ b/src/exported.ts @@ -62,6 +62,7 @@ export { Org, SandboxProcessObject, StatusEvent, + SandboxInfo, SandboxEvents, SandboxUserAuthResponse, SandboxUserAuthRequest, diff --git a/src/org/org.ts b/src/org/org.ts index 78fa75a00c..3f6f5443cb 100644 --- a/src/org/org.ts +++ b/src/org/org.ts @@ -111,6 +111,19 @@ export type SandboxProcessObject = { ApexClassId?: string; EndDate?: string; }; +const sandboxProcessFields = [ + 'Id', + 'Status', + 'SandboxName', + 'SandboxInfoId', + 'LicenseType', + 'CreatedDate', + 'CopyProgress', + 'SandboxOrganization', + 'SourceId', + 'Description', + 'EndDate', +]; export type SandboxRequest = { SandboxName: string; @@ -124,6 +137,27 @@ export type ResumeSandboxRequest = { SandboxProcessObjId?: string; }; +// https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_sandboxinfo.htm +export type SandboxInfo = { + Id: string; // 0GQB0000000TVobOAG + IsDeleted: boolean; + CreatedDate: string; // 2023-06-16T18:35:47.000+0000 + CreatedById: string; // 005B0000004TiUpIAK + LastModifiedDate: string; // 2023-09-27T20:50:26.000+0000 + LastModifiedById: string; // 005B0000004TiUpIAK + SandboxName: string; // must be 10 or less alphanumeric chars + LicenseType: 'DEVELOPER' | 'DEVELOPER PRO' | 'PARTIAL' | 'FULL'; + TemplateId?: string; // reference to PartitionLevelScheme + HistoryDays: -1 | 0 | 10 | 20 | 30 | 60 | 90 | 120 | 150 | 180; // full sandboxes only + CopyChatter: boolean; + AutoActivate: boolean; // only editable for an update/refresh + ApexClassId?: string; // apex class ID. Only editable on create. + Description?: string; + SourceId?: string; // SandboxInfoId as the source org used for a clone + // 'ActivationUserGroupId', // Support might be added back in API v61.0 (Summer '24) + CopyArchivedActivities?: boolean; // only for full sandboxes; depends if a license was purchased +}; + export type ScratchOrgRequest = Omit; export type SandboxFields = { @@ -227,6 +261,62 @@ export class Org extends AsyncOptionalCreatable { }); } + /** + * Refresh (update) a sandbox from a production org. + * 'this' needs to be a production org with sandbox licenses available + * + * @param sandboxInfo SandboxInfo to update the sandbox with + * @param options Wait: The amount of time to wait before timing out, Interval: The time interval between polling + */ + public async refreshSandbox( + sandboxInfo: SandboxInfo, + options: { wait?: Duration; interval?: Duration; async?: boolean } = { + wait: Duration.minutes(6), + async: false, + interval: Duration.seconds(30), + } + ): Promise { + this.logger.debug(sandboxInfo, 'RefreshSandbox called with SandboxInfo'); + const refreshResult = await this.connection.tooling.update('SandboxInfo', sandboxInfo); + this.logger.debug(refreshResult, 'Return from calling tooling.update'); + + if (!refreshResult.success) { + throw messages.createError('sandboxInfoRefreshFailed', [JSON.stringify(refreshResult)]); + } + + const soql = `SELECT ${sandboxProcessFields.join(',')} FROM SandboxProcess WHERE SandboxName='${ + sandboxInfo.SandboxName + }' ORDER BY CreatedDate DESC`; + const sbxProcessObjects = (await this.connection.tooling.query(soql)).records.filter( + (item) => !item.Status.startsWith('Del') + ); + this.logger.debug(sbxProcessObjects, `SandboxProcesses for ${sandboxInfo.SandboxName}`); + + // throw if none found + if (sbxProcessObjects?.length === 0) { + throw new Error(`No SandboxProcesses found for: ${sandboxInfo.SandboxName}`); + } + const sandboxRefreshProgress = sbxProcessObjects[0]; + + const isAsync = !!options.async; + + if (isAsync) { + // The user didn't want us to poll, so simply return the status + await Lifecycle.getInstance().emit(SandboxEvents.EVENT_ASYNC_RESULT, sandboxRefreshProgress); + return sandboxRefreshProgress; + } + const [wait, pollInterval] = this.validateWaitOptions(options); + this.logger.debug( + sandboxRefreshProgress, + `refresh - pollStatusAndAuth sandboxProcessObj, max wait time of ${wait.minutes} minutes` + ); + return this.pollStatusAndAuth({ + sandboxProcessObj: sandboxRefreshProgress, + wait, + pollInterval, + }); + } + /** * * @param sandboxReq SandboxRequest options to create the sandbox with @@ -245,10 +335,10 @@ export class Org extends AsyncOptionalCreatable { } /** - * Resume a sandbox creation from a production org. + * Resume a sandbox create or refresh from a production org. * `this` needs to be a production org with sandbox licenses available. * - * @param resumeSandboxRequest SandboxRequest options to create the sandbox with + * @param resumeSandboxRequest SandboxRequest options to create/refresh the sandbox with * @param options Wait: The amount of time to wait (default: 0 minutes) before timing out, * Interval: The time interval (default: 30 seconds) between polling */ @@ -1293,7 +1383,6 @@ export class Org extends AsyncOptionalCreatable { const authInfo = await AuthInfo.create({ username: sandboxRes.authUserName, - oauth2Options, parentUsername: productionAuthFields.username, }); @@ -1305,8 +1394,12 @@ export class Org extends AsyncOptionalCreatable { }, 'Creating AuthInfo for sandbox' ); - // save auth info for new sandbox - await authInfo.save(); + // save auth info for sandbox + await authInfo.save({ + ...oauth2Options, + isScratch: false, + isSandbox: true, + }); const sandboxOrgId = authInfo.getFields().orgId; @@ -1390,7 +1483,9 @@ export class Org extends AsyncOptionalCreatable { * @private */ private async querySandboxProcess(where: string): Promise { - const soql = `SELECT Id, Status, SandboxName, SandboxInfoId, LicenseType, CreatedDate, CopyProgress, SandboxOrganization, SourceId, Description, EndDate FROM SandboxProcess WHERE ${where} ORDER BY CreatedDate DESC`; + const soql = `SELECT ${sandboxProcessFields.join( + ',' + )} FROM SandboxProcess WHERE ${where} ORDER BY CreatedDate DESC`; const result = (await this.connection.tooling.query(soql)).records.filter( (item) => !item.Status.startsWith('Del') ); diff --git a/test/unit/org/orgTest.ts b/test/unit/org/orgTest.ts index 53879aff38..fa44839f31 100644 --- a/test/unit/org/orgTest.ts +++ b/test/unit/org/orgTest.ts @@ -1059,7 +1059,7 @@ describe('Org Tests', () => { describe('resumeSandbox', () => { const expectedSoql = - 'SELECT Id, Status, SandboxName, SandboxInfoId, LicenseType, CreatedDate, CopyProgress, SandboxOrganization, SourceId, Description, EndDate FROM SandboxProcess WHERE %s ORDER BY CreatedDate DESC'; + 'SELECT Id,Status,SandboxName,SandboxInfoId,LicenseType,CreatedDate,CopyProgress,SandboxOrganization,SourceId,Description,EndDate FROM SandboxProcess WHERE %s ORDER BY CreatedDate DESC'; let lifecycleSpy: SinonSpy; let queryStub: SinonStub; let pollStatusAndAuthSpy: SinonSpy; @@ -1250,7 +1250,7 @@ describe('Org Tests', () => { const deletedSbxProcess = Object.assign({}, statusResult.records[0], { Status: 'Deleted' }); queryStub.resolves({ records: [deletingSbxProcess, statusResult.records[0], deletedSbxProcess] }); const where = 'name="foo"'; - const expectedSoql = `SELECT Id, Status, SandboxName, SandboxInfoId, LicenseType, CreatedDate, CopyProgress, SandboxOrganization, SourceId, Description, EndDate FROM SandboxProcess WHERE ${where} ORDER BY CreatedDate DESC`; + const expectedSoql = `SELECT Id,Status,SandboxName,SandboxInfoId,LicenseType,CreatedDate,CopyProgress,SandboxOrganization,SourceId,Description,EndDate FROM SandboxProcess WHERE ${where} ORDER BY CreatedDate DESC`; // @ts-ignore Testing a private method const sbxProcess = await prod.querySandboxProcess(where); From e88d7807231c16e88e09361f2f0469fe7d977907 Mon Sep 17 00:00:00 2001 From: Steve Hetzel Date: Thu, 22 Feb 2024 12:23:56 -0700 Subject: [PATCH 2/2] fix: delete sandbox auth files before writing new one --- src/org/org.ts | 27 +++++--- test/unit/org/orgTest.ts | 132 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 143 insertions(+), 16 deletions(-) diff --git a/src/org/org.ts b/src/org/org.ts index 3f6f5443cb..f3c90c92cd 100644 --- a/src/org/org.ts +++ b/src/org/org.ts @@ -1136,7 +1136,9 @@ export class Org extends AsyncOptionalCreatable { private async queryLatestSandboxProcessBySandboxName(sandboxNameIn: string): Promise { const { tooling } = this.getConnection(); this.logger.debug(`QueryLatestSandboxProcessBySandboxName called with SandboxName: ${sandboxNameIn}`); - const queryStr = `SELECT Id, Status, SandboxName, SandboxInfoId, LicenseType, CreatedDate, CopyProgress, SandboxOrganization, SourceId, Description, EndDate FROM SandboxProcess WHERE SandboxName='${sandboxNameIn}' AND Status != 'D' ORDER BY CreatedDate DESC LIMIT 1`; + const queryStr = `SELECT ${sandboxProcessFields.join( + ',' + )} FROM SandboxProcess WHERE SandboxName='${sandboxNameIn}' AND Status != 'D' ORDER BY CreatedDate DESC LIMIT 1`; const queryResult = await tooling.query(queryStr); this.logger.debug(queryResult, 'Return from calling queryToolingApi'); @@ -1381,8 +1383,20 @@ export class Org extends AsyncOptionalCreatable { oauth2Options.clientId = productionAuthFields.clientId; } + // Before creating the AuthInfo, delete any existing auth files for the sandbox. + // This is common when refreshing sandboxes, and will cause AuthInfo to throw + // because it doesn't want to overwrite existing auth files. + const stateAggregator = await StateAggregator.getInstance(); + try { + await stateAggregator.orgs.read(sandboxRes.authUserName); + await stateAggregator.orgs.remove(sandboxRes.authUserName); + } catch (e) { + // ignore since this is only for deleting existing auth files. + } + const authInfo = await AuthInfo.create({ username: sandboxRes.authUserName, + oauth2Options, parentUsername: productionAuthFields.username, }); @@ -1396,7 +1410,6 @@ export class Org extends AsyncOptionalCreatable { ); // save auth info for sandbox await authInfo.save({ - ...oauth2Options, isScratch: false, isSandbox: true, }); @@ -1540,15 +1553,15 @@ export class Org extends AsyncOptionalCreatable { this.logger.debug(result, 'Result of calling sandboxAuth'); return result; - } catch (err) { - const error = err as Error; + } catch (err: unknown) { + const error = err instanceof Error ? err : SfError.wrap(isString(err) ? err : 'unknown'); // There are cases where the endDate is set before the sandbox has actually completed. // In that case, the sandboxAuth call will throw a specific exception. if (error?.name === 'INVALID_STATUS') { - this.logger.debug('Error while authenticating the user', error?.toString()); + this.logger.debug('Error while authenticating the user:', error.message); } else { - // If it fails for any unexpected reason, just pass that through - throw SfError.wrap(error); + // If it fails for any unexpected reason, rethrow + throw error; } } } diff --git a/test/unit/org/orgTest.ts b/test/unit/org/orgTest.ts index fa44839f31..3a690d3d59 100644 --- a/test/unit/org/orgTest.ts +++ b/test/unit/org/orgTest.ts @@ -16,7 +16,7 @@ import { assert, expect, config as chaiConfig } from 'chai'; import { OAuth2 } from 'jsforce'; import { Transport } from 'jsforce/lib/transport'; import { SinonSpy, SinonStub } from 'sinon'; -import { Org, SandboxEvents, SandboxProcessObject, SandboxUserAuthResponse } from '../../../src/org/org'; +import { Org, SandboxEvents, SandboxInfo, SandboxProcessObject, SandboxUserAuthResponse } from '../../../src/org/org'; import { AuthInfo } from '../../../src/org/authInfo'; import {} from '../../../src/org/connection'; import { Connection, SingleRecordQueryErrors } from '../../../src/org/connection'; @@ -894,6 +894,20 @@ describe('Org Tests', () => { ], }; + const sandboxProcessFields = [ + 'Id', + 'Status', + 'SandboxName', + 'SandboxInfoId', + 'LicenseType', + 'CreatedDate', + 'CopyProgress', + 'SandboxOrganization', + 'SourceId', + 'Description', + 'EndDate', + ]; + let prodTestData: MockTestOrgData; let prod: Org; @@ -973,16 +987,111 @@ describe('Org Tests', () => { SandboxName: 'test', EndDate: '2021-19-06T20:25:46.000+0000', } as SandboxProcessObject; + const err = new Error('could not auth'); + err.name = 'INVALID_STATUS'; // @ts-expect-error - type not assignable - stubMethod($$.SANDBOX, prod.getConnection().tooling, 'request').throws({ - name: 'INVALID_STATUS', - }); + stubMethod($$.SANDBOX, prod.getConnection().tooling, 'request').throws(err); // @ts-expect-error because private method await prod.sandboxSignupComplete(sandboxResponse); expect(logStub.callCount).to.equal(3); // error swallowed - expect(logStub.thirdCall.args[0]).to.equal('Error while authenticating the user'); + expect(logStub.thirdCall.args[0]).to.equal('Error while authenticating the user:'); + }); + }); + + describe('refreshSandbox', () => { + const sbxInfo: SandboxInfo = { + Id: '0GQ4p000000U6nFGAS', + SandboxName: 'testSbx1', + LicenseType: 'DEVELOPER', + HistoryDays: 0, + CopyChatter: false, + AutoActivate: true, + IsDeleted: false, + CreatedDate: '2024-02-16T17:06:47.000+0000', + CreatedById: '005B0000004TiUpIAK', + LastModifiedDate: '2024-02-16T17:06:47.000+0000', + LastModifiedById: '005B0000004TiUpIAK', + }; + const sbxProcess = { + attributes: { + type: 'SandboxProcess', + url: '/services/data/v60.0/tooling/sobjects/SandboxProcess/0GR1Q0000004kmaWAA', + }, + Id: '0GR1Q0000004kmaWAA', + Status: 'Activating', + SandboxName: 'sbxGS02', + SandboxInfoId: '0GQ1Q0000004iQDWAY', + LicenseType: 'DEVELOPER', + CreatedDate: '2024-02-21T23:06:58.000+0000', + CopyProgress: 95, + SandboxOrganization: '00DDX000000QT3W', + SourceId: null, + Description: null, + EndDate: null, + }; + let updateStub: SinonStub; + let querySandboxProcessStub: SinonStub; + let pollStatusAndAuthStub: SinonStub; + + beforeEach(async () => { + updateStub = stubMethod($$.SANDBOX, prod.getConnection().tooling, 'update').resolves({ + id: '0GQ4p000000U6nFGAS', + success: true, + }); + querySandboxProcessStub = stubMethod($$.SANDBOX, prod.getConnection().tooling, 'query'); + pollStatusAndAuthStub = stubMethod($$.SANDBOX, prod, 'pollStatusAndAuth').resolves(sbxProcess); + }); + + it('will refresh the SandboxInfo sObject correctly with polling', async () => { + querySandboxProcessStub.resolves({ records: [sbxProcess] }); + const expectedQuery = `SELECT ${sandboxProcessFields.join(',')} FROM SandboxProcess WHERE SandboxName='${ + sbxInfo.SandboxName + }' ORDER BY CreatedDate DESC`; + + const result = await prod.refreshSandbox(sbxInfo, { wait: Duration.seconds(30) }); + + expect(updateStub.calledOnce).to.be.true; + expect(updateStub.firstCall.args[0]).to.equal('SandboxInfo'); + expect(updateStub.firstCall.args[1]).to.equal(sbxInfo); + expect(querySandboxProcessStub.calledOnce).to.be.true; + expect(querySandboxProcessStub.firstCall.args[0]).to.equal(expectedQuery); + expect(pollStatusAndAuthStub.calledOnce).to.be.true; + expect(result).to.equal(sbxProcess); + }); + + it('will refresh the SandboxInfo sObject correctly async', async () => { + querySandboxProcessStub.resolves({ records: [sbxProcess] }); + const expectedQuery = `SELECT ${sandboxProcessFields.join(',')} FROM SandboxProcess WHERE SandboxName='${ + sbxInfo.SandboxName + }' ORDER BY CreatedDate DESC`; + + const result = await prod.refreshSandbox(sbxInfo, { async: true }); + + expect(updateStub.calledOnce).to.be.true; + expect(updateStub.firstCall.args[0]).to.equal('SandboxInfo'); + expect(updateStub.firstCall.args[1]).to.equal(sbxInfo); + expect(querySandboxProcessStub.calledOnce).to.be.true; + expect(querySandboxProcessStub.firstCall.args[0]).to.equal(expectedQuery); + expect(pollStatusAndAuthStub.called).to.be.false; + expect(result).to.equal(sbxProcess); + }); + + it('will throw an error if it fails to update the SandboxInfo', async () => { + updateStub.restore(); + updateStub = stubMethod($$.SANDBOX, prod.getConnection().tooling, 'update').resolves({ + error: 'duplicate value found', + success: false, + }); + try { + await shouldThrow(prod.refreshSandbox(sbxInfo, { async: true })); + } catch (e) { + expect(updateStub.calledOnce).to.be.true; + expect((e as Error).message).to.include('The sandbox org refresh failed with a result of'); + expect((e as Error).message).to.include('duplicate value found'); + expect((e as SfError).exitCode).to.equal(1); + } }); }); @@ -1058,8 +1167,9 @@ describe('Org Tests', () => { }); describe('resumeSandbox', () => { - const expectedSoql = - 'SELECT Id,Status,SandboxName,SandboxInfoId,LicenseType,CreatedDate,CopyProgress,SandboxOrganization,SourceId,Description,EndDate FROM SandboxProcess WHERE %s ORDER BY CreatedDate DESC'; + const expectedSoql = `SELECT ${sandboxProcessFields.join( + ',' + )} FROM SandboxProcess WHERE %s ORDER BY CreatedDate DESC`; let lifecycleSpy: SinonSpy; let queryStub: SinonStub; let pollStatusAndAuthSpy: SinonSpy; @@ -1193,7 +1303,9 @@ describe('Org Tests', () => { let queryStub: SinonStub; let pollStatusAndAuthStub: SinonStub; const sandboxNameIn = 'test-sandbox'; - const queryStr = `SELECT Id, Status, SandboxName, SandboxInfoId, LicenseType, CreatedDate, CopyProgress, SandboxOrganization, SourceId, Description, EndDate FROM SandboxProcess WHERE SandboxName='${sandboxNameIn}' AND Status != 'D' ORDER BY CreatedDate DESC LIMIT 1`; + const queryStr = `SELECT ${sandboxProcessFields.join( + ',' + )} FROM SandboxProcess WHERE SandboxName='${sandboxNameIn}' AND Status != 'D' ORDER BY CreatedDate DESC LIMIT 1`; beforeEach(async () => { queryStub = stubMethod($$.SANDBOX, prod.getConnection().tooling, 'query'); @@ -1250,7 +1362,9 @@ describe('Org Tests', () => { const deletedSbxProcess = Object.assign({}, statusResult.records[0], { Status: 'Deleted' }); queryStub.resolves({ records: [deletingSbxProcess, statusResult.records[0], deletedSbxProcess] }); const where = 'name="foo"'; - const expectedSoql = `SELECT Id,Status,SandboxName,SandboxInfoId,LicenseType,CreatedDate,CopyProgress,SandboxOrganization,SourceId,Description,EndDate FROM SandboxProcess WHERE ${where} ORDER BY CreatedDate DESC`; + const expectedSoql = `SELECT ${sandboxProcessFields.join( + ',' + )} FROM SandboxProcess WHERE ${where} ORDER BY CreatedDate DESC`; // @ts-ignore Testing a private method const sbxProcess = await prod.querySandboxProcess(where);