Skip to content

Commit ffe344a

Browse files
authored
Merge pull request #1031 from forcedotcom/sh/refresh-sandbox
feat: add support for refreshing sandboxes
2 parents 1c31a2b + e88d780 commit ffe344a

File tree

5 files changed

+249
-21
lines changed

5 files changed

+249
-21
lines changed

messages/org.md

+4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ We can't find a SandboxProcess for the sandbox %s.
3434

3535
The sandbox org creation failed with a result of %s.
3636

37+
# sandboxInfoRefreshFailed
38+
39+
The sandbox org refresh failed with a result of %s.
40+
3741
# missingAuthUsername
3842

3943
The sandbox %s does not have an authorized username.

src/config/sandboxProcessCache.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import { TTLConfig } from './ttlConfig';
1111

1212
export type SandboxRequestCacheEntry = {
1313
alias?: string;
14-
setDefault: boolean;
14+
setDefault?: boolean;
1515
prodOrgUsername: string;
16+
action: 'Create' | 'Refresh'; // Sandbox create and refresh requests can be cached
1617
sandboxProcessObject: Partial<SandboxProcessObject>;
1718
sandboxRequest: Partial<SandboxRequest>;
1819
tracksSource?: boolean;

src/exported.ts

+1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export {
6262
Org,
6363
SandboxProcessObject,
6464
StatusEvent,
65+
SandboxInfo,
6566
SandboxEvents,
6667
SandboxUserAuthResponse,
6768
SandboxUserAuthRequest,

src/org/org.ts

+119-11
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@ export type SandboxProcessObject = {
111111
ApexClassId?: string;
112112
EndDate?: string;
113113
};
114+
const sandboxProcessFields = [
115+
'Id',
116+
'Status',
117+
'SandboxName',
118+
'SandboxInfoId',
119+
'LicenseType',
120+
'CreatedDate',
121+
'CopyProgress',
122+
'SandboxOrganization',
123+
'SourceId',
124+
'Description',
125+
'EndDate',
126+
];
114127

115128
export type SandboxRequest = {
116129
SandboxName: string;
@@ -124,6 +137,27 @@ export type ResumeSandboxRequest = {
124137
SandboxProcessObjId?: string;
125138
};
126139

140+
// https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_sandboxinfo.htm
141+
export type SandboxInfo = {
142+
Id: string; // 0GQB0000000TVobOAG
143+
IsDeleted: boolean;
144+
CreatedDate: string; // 2023-06-16T18:35:47.000+0000
145+
CreatedById: string; // 005B0000004TiUpIAK
146+
LastModifiedDate: string; // 2023-09-27T20:50:26.000+0000
147+
LastModifiedById: string; // 005B0000004TiUpIAK
148+
SandboxName: string; // must be 10 or less alphanumeric chars
149+
LicenseType: 'DEVELOPER' | 'DEVELOPER PRO' | 'PARTIAL' | 'FULL';
150+
TemplateId?: string; // reference to PartitionLevelScheme
151+
HistoryDays: -1 | 0 | 10 | 20 | 30 | 60 | 90 | 120 | 150 | 180; // full sandboxes only
152+
CopyChatter: boolean;
153+
AutoActivate: boolean; // only editable for an update/refresh
154+
ApexClassId?: string; // apex class ID. Only editable on create.
155+
Description?: string;
156+
SourceId?: string; // SandboxInfoId as the source org used for a clone
157+
// 'ActivationUserGroupId', // Support might be added back in API v61.0 (Summer '24)
158+
CopyArchivedActivities?: boolean; // only for full sandboxes; depends if a license was purchased
159+
};
160+
127161
export type ScratchOrgRequest = Omit<ScratchOrgCreateOptions, 'hubOrg'>;
128162

129163
export type SandboxFields = {
@@ -227,6 +261,62 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
227261
});
228262
}
229263

264+
/**
265+
* Refresh (update) a sandbox from a production org.
266+
* 'this' needs to be a production org with sandbox licenses available
267+
*
268+
* @param sandboxInfo SandboxInfo to update the sandbox with
269+
* @param options Wait: The amount of time to wait before timing out, Interval: The time interval between polling
270+
*/
271+
public async refreshSandbox(
272+
sandboxInfo: SandboxInfo,
273+
options: { wait?: Duration; interval?: Duration; async?: boolean } = {
274+
wait: Duration.minutes(6),
275+
async: false,
276+
interval: Duration.seconds(30),
277+
}
278+
): Promise<SandboxProcessObject> {
279+
this.logger.debug(sandboxInfo, 'RefreshSandbox called with SandboxInfo');
280+
const refreshResult = await this.connection.tooling.update('SandboxInfo', sandboxInfo);
281+
this.logger.debug(refreshResult, 'Return from calling tooling.update');
282+
283+
if (!refreshResult.success) {
284+
throw messages.createError('sandboxInfoRefreshFailed', [JSON.stringify(refreshResult)]);
285+
}
286+
287+
const soql = `SELECT ${sandboxProcessFields.join(',')} FROM SandboxProcess WHERE SandboxName='${
288+
sandboxInfo.SandboxName
289+
}' ORDER BY CreatedDate DESC`;
290+
const sbxProcessObjects = (await this.connection.tooling.query<SandboxProcessObject>(soql)).records.filter(
291+
(item) => !item.Status.startsWith('Del')
292+
);
293+
this.logger.debug(sbxProcessObjects, `SandboxProcesses for ${sandboxInfo.SandboxName}`);
294+
295+
// throw if none found
296+
if (sbxProcessObjects?.length === 0) {
297+
throw new Error(`No SandboxProcesses found for: ${sandboxInfo.SandboxName}`);
298+
}
299+
const sandboxRefreshProgress = sbxProcessObjects[0];
300+
301+
const isAsync = !!options.async;
302+
303+
if (isAsync) {
304+
// The user didn't want us to poll, so simply return the status
305+
await Lifecycle.getInstance().emit(SandboxEvents.EVENT_ASYNC_RESULT, sandboxRefreshProgress);
306+
return sandboxRefreshProgress;
307+
}
308+
const [wait, pollInterval] = this.validateWaitOptions(options);
309+
this.logger.debug(
310+
sandboxRefreshProgress,
311+
`refresh - pollStatusAndAuth sandboxProcessObj, max wait time of ${wait.minutes} minutes`
312+
);
313+
return this.pollStatusAndAuth({
314+
sandboxProcessObj: sandboxRefreshProgress,
315+
wait,
316+
pollInterval,
317+
});
318+
}
319+
230320
/**
231321
*
232322
* @param sandboxReq SandboxRequest options to create the sandbox with
@@ -245,10 +335,10 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
245335
}
246336

247337
/**
248-
* Resume a sandbox creation from a production org.
338+
* Resume a sandbox create or refresh from a production org.
249339
* `this` needs to be a production org with sandbox licenses available.
250340
*
251-
* @param resumeSandboxRequest SandboxRequest options to create the sandbox with
341+
* @param resumeSandboxRequest SandboxRequest options to create/refresh the sandbox with
252342
* @param options Wait: The amount of time to wait (default: 0 minutes) before timing out,
253343
* Interval: The time interval (default: 30 seconds) between polling
254344
*/
@@ -1046,7 +1136,9 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
10461136
private async queryLatestSandboxProcessBySandboxName(sandboxNameIn: string): Promise<SandboxProcessObject> {
10471137
const { tooling } = this.getConnection();
10481138
this.logger.debug(`QueryLatestSandboxProcessBySandboxName called with SandboxName: ${sandboxNameIn}`);
1049-
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`;
1139+
const queryStr = `SELECT ${sandboxProcessFields.join(
1140+
','
1141+
)} FROM SandboxProcess WHERE SandboxName='${sandboxNameIn}' AND Status != 'D' ORDER BY CreatedDate DESC LIMIT 1`;
10501142

10511143
const queryResult = await tooling.query(queryStr);
10521144
this.logger.debug(queryResult, 'Return from calling queryToolingApi');
@@ -1291,6 +1383,17 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
12911383
oauth2Options.clientId = productionAuthFields.clientId;
12921384
}
12931385

1386+
// Before creating the AuthInfo, delete any existing auth files for the sandbox.
1387+
// This is common when refreshing sandboxes, and will cause AuthInfo to throw
1388+
// because it doesn't want to overwrite existing auth files.
1389+
const stateAggregator = await StateAggregator.getInstance();
1390+
try {
1391+
await stateAggregator.orgs.read(sandboxRes.authUserName);
1392+
await stateAggregator.orgs.remove(sandboxRes.authUserName);
1393+
} catch (e) {
1394+
// ignore since this is only for deleting existing auth files.
1395+
}
1396+
12941397
const authInfo = await AuthInfo.create({
12951398
username: sandboxRes.authUserName,
12961399
oauth2Options,
@@ -1305,8 +1408,11 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
13051408
},
13061409
'Creating AuthInfo for sandbox'
13071410
);
1308-
// save auth info for new sandbox
1309-
await authInfo.save();
1411+
// save auth info for sandbox
1412+
await authInfo.save({
1413+
isScratch: false,
1414+
isSandbox: true,
1415+
});
13101416

13111417
const sandboxOrgId = authInfo.getFields().orgId;
13121418

@@ -1390,7 +1496,9 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
13901496
* @private
13911497
*/
13921498
private async querySandboxProcess(where: string): Promise<SandboxProcessObject> {
1393-
const soql = `SELECT Id, Status, SandboxName, SandboxInfoId, LicenseType, CreatedDate, CopyProgress, SandboxOrganization, SourceId, Description, EndDate FROM SandboxProcess WHERE ${where} ORDER BY CreatedDate DESC`;
1499+
const soql = `SELECT ${sandboxProcessFields.join(
1500+
','
1501+
)} FROM SandboxProcess WHERE ${where} ORDER BY CreatedDate DESC`;
13941502
const result = (await this.connection.tooling.query<SandboxProcessObject>(soql)).records.filter(
13951503
(item) => !item.Status.startsWith('Del')
13961504
);
@@ -1445,15 +1553,15 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
14451553

14461554
this.logger.debug(result, 'Result of calling sandboxAuth');
14471555
return result;
1448-
} catch (err) {
1449-
const error = err as Error;
1556+
} catch (err: unknown) {
1557+
const error = err instanceof Error ? err : SfError.wrap(isString(err) ? err : 'unknown');
14501558
// There are cases where the endDate is set before the sandbox has actually completed.
14511559
// In that case, the sandboxAuth call will throw a specific exception.
14521560
if (error?.name === 'INVALID_STATUS') {
1453-
this.logger.debug('Error while authenticating the user', error?.toString());
1561+
this.logger.debug('Error while authenticating the user:', error.message);
14541562
} else {
1455-
// If it fails for any unexpected reason, just pass that through
1456-
throw SfError.wrap(error);
1563+
// If it fails for any unexpected reason, rethrow
1564+
throw error;
14571565
}
14581566
}
14591567
}

0 commit comments

Comments
 (0)