Skip to content

Commit

Permalink
Merge pull request #1031 from forcedotcom/sh/refresh-sandbox
Browse files Browse the repository at this point in the history
feat: add support for refreshing sandboxes
  • Loading branch information
shetzel authored Feb 27, 2024
2 parents 1c31a2b + e88d780 commit ffe344a
Show file tree
Hide file tree
Showing 5 changed files with 249 additions and 21 deletions.
4 changes: 4 additions & 0 deletions messages/org.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/config/sandboxProcessCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SandboxProcessObject>;
sandboxRequest: Partial<SandboxRequest>;
tracksSource?: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/exported.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export {
Org,
SandboxProcessObject,
StatusEvent,
SandboxInfo,
SandboxEvents,
SandboxUserAuthResponse,
SandboxUserAuthRequest,
Expand Down
130 changes: 119 additions & 11 deletions src/org/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ScratchOrgCreateOptions, 'hubOrg'>;

export type SandboxFields = {
Expand Down Expand Up @@ -227,6 +261,62 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
});
}

/**
* 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<SandboxProcessObject> {
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<SandboxProcessObject>(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
Expand All @@ -245,10 +335,10 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
}

/**
* 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
*/
Expand Down Expand Up @@ -1046,7 +1136,9 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
private async queryLatestSandboxProcessBySandboxName(sandboxNameIn: string): Promise<SandboxProcessObject> {
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');
Expand Down Expand Up @@ -1291,6 +1383,17 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
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,
Expand All @@ -1305,8 +1408,11 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
},
'Creating AuthInfo for sandbox'
);
// save auth info for new sandbox
await authInfo.save();
// save auth info for sandbox
await authInfo.save({
isScratch: false,
isSandbox: true,
});

const sandboxOrgId = authInfo.getFields().orgId;

Expand Down Expand Up @@ -1390,7 +1496,9 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
* @private
*/
private async querySandboxProcess(where: string): Promise<SandboxProcessObject> {
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<SandboxProcessObject>(soql)).records.filter(
(item) => !item.Status.startsWith('Del')
);
Expand Down Expand Up @@ -1445,15 +1553,15 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {

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;
}
}
}
Expand Down
Loading

0 comments on commit ffe344a

Please sign in to comment.