From 107130360ea84395451d762874ba70c5cddd78fd Mon Sep 17 00:00:00 2001 From: Steve Hetzel Date: Wed, 21 Feb 2024 14:27:45 -0700 Subject: [PATCH] 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);