Skip to content

Commit 1fc5007

Browse files
authored
@W-21083961 adding wait support for ODS start stop delete restart (#90)
* @W-21083961 adding wait support for ODS start stop delete restart * fixing the errors * adding wait flag * adding some tests * addressed review comments * aligned waitForSandbox with standard client-first pattern
1 parent 8592727 commit 1fc5007

11 files changed

Lines changed: 865 additions & 125 deletions

File tree

packages/b2c-cli/src/commands/ods/create.ts

Lines changed: 67 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@
66
import {Flags, ux} from '@oclif/core';
77
import cliui from 'cliui';
88
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
9-
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
9+
import {
10+
getApiErrorMessage,
11+
SandboxPollingError,
12+
SandboxPollingTimeoutError,
13+
SandboxTerminalStateError,
14+
waitForSandbox,
15+
type OdsComponents,
16+
} from '@salesforce/b2c-tooling-sdk';
1017
import {t, withDocs} from '../../i18n/index.js';
1118

1219
type SandboxModel = OdsComponents['schemas']['SandboxModel'];
1320
type SandboxResourceProfile = OdsComponents['schemas']['SandboxResourceProfile'];
14-
type SandboxState = OdsComponents['schemas']['SandboxState'];
1521
type OcapiSettings = OdsComponents['schemas']['OcapiSettings'];
1622
type WebDavSettings = OdsComponents['schemas']['WebDavSettings'];
1723
type SandboxSettings = OdsComponents['schemas']['SandboxSettings'];
1824

19-
/** States that indicate sandbox creation has completed (success or failure) */
20-
const TERMINAL_STATES = new Set<SandboxState>(['deleted', 'failed', 'started']);
21-
2225
/**
2326
* Default OCAPI resources to grant the client ID access to.
2427
* These enable common CI/CD operations like code deployment and job execution.
@@ -155,8 +158,66 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
155158
this.logger.info({sandboxId: sandbox.id}, t('commands.ods.create.success', 'Sandbox created successfully'));
156159

157160
if (wait && sandbox.id) {
161+
this.log(t('commands.ods.create.waiting', 'Waiting for sandbox to get started..'));
162+
163+
try {
164+
await waitForSandbox(this.odsClient, {
165+
sandboxId: sandbox.id,
166+
targetState: 'started',
167+
pollIntervalSeconds: pollInterval,
168+
timeoutSeconds: timeout,
169+
onPoll: ({elapsedSeconds, state}) => {
170+
this.logger.info(
171+
{sandboxId: sandbox.id, elapsed: elapsedSeconds, state},
172+
`[${elapsedSeconds}s] State: ${state}`,
173+
);
174+
},
175+
});
176+
} catch (error) {
177+
if (error instanceof SandboxPollingTimeoutError) {
178+
this.error(
179+
t('commands.ods.create.timeout', 'Timeout waiting for sandbox after {{seconds}} seconds', {
180+
seconds: String(error.timeoutSeconds),
181+
}),
182+
);
183+
}
184+
185+
if (error instanceof SandboxTerminalStateError) {
186+
if (error.state === 'deleted') {
187+
this.error(t('commands.ods.create.deleted', 'Sandbox was deleted'));
188+
}
189+
this.error(t('commands.ods.create.failed', 'Sandbox creation failed'));
190+
}
191+
192+
if (error instanceof SandboxPollingError) {
193+
this.error(
194+
t('commands.ods.create.pollError', 'Failed to fetch sandbox status: {{message}}', {
195+
message: error.message,
196+
}),
197+
);
198+
}
199+
200+
throw error;
201+
}
202+
203+
const finalResult = await this.odsClient.GET('/sandboxes/{sandboxId}', {
204+
params: {
205+
path: {sandboxId: sandbox.id},
206+
},
207+
});
208+
209+
if (!finalResult.data?.data) {
210+
this.error(
211+
t('commands.ods.create.pollError', 'Failed to fetch sandbox status: {{message}}', {
212+
message: finalResult.response?.statusText || 'Unknown error',
213+
}),
214+
);
215+
}
216+
217+
sandbox = finalResult.data.data;
218+
158219
this.log('');
159-
sandbox = await this.waitForSandbox(sandbox.id, pollInterval, timeout);
220+
this.logger.info({sandboxId: sandbox.id}, t('commands.ods.create.ready', 'Sandbox is now ready'));
160221
}
161222

162223
if (this.jsonEnabled()) {
@@ -226,93 +287,4 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
226287

227288
ux.stdout(ui.toString());
228289
}
229-
230-
/**
231-
* Sleep for a given number of milliseconds.
232-
*/
233-
private async sleep(ms: number): Promise<void> {
234-
await new Promise((resolve) => {
235-
setTimeout(resolve, ms);
236-
});
237-
}
238-
239-
/**
240-
* Polls for sandbox status until it reaches a terminal state.
241-
* @param sandboxId - The sandbox ID to poll
242-
* @param pollIntervalSeconds - Interval between polls in seconds
243-
* @param timeoutSeconds - Maximum time to wait (0 for no timeout)
244-
* @returns The final sandbox state
245-
*/
246-
private async waitForSandbox(
247-
sandboxId: string,
248-
pollIntervalSeconds: number,
249-
timeoutSeconds: number,
250-
): Promise<SandboxModel> {
251-
const startTime = Date.now();
252-
const pollIntervalMs = pollIntervalSeconds * 1000;
253-
const timeoutMs = timeoutSeconds * 1000;
254-
255-
this.log(t('commands.ods.create.waiting', 'Waiting for sandbox to be ready...'));
256-
257-
// Initial delay before first poll to allow the sandbox to be registered in the API
258-
await this.sleep(pollIntervalMs);
259-
260-
while (true) {
261-
// Check for timeout
262-
if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) {
263-
this.error(
264-
t('commands.ods.create.timeout', 'Timeout waiting for sandbox after {{seconds}} seconds', {
265-
seconds: String(timeoutSeconds),
266-
}),
267-
);
268-
}
269-
270-
// eslint-disable-next-line no-await-in-loop
271-
const result = await this.odsClient.GET('/sandboxes/{sandboxId}', {
272-
params: {
273-
path: {sandboxId},
274-
},
275-
});
276-
277-
if (!result.data?.data) {
278-
this.error(
279-
t('commands.ods.create.pollError', 'Failed to fetch sandbox status: {{message}}', {
280-
message: result.response?.statusText || 'Unknown error',
281-
}),
282-
);
283-
}
284-
285-
const sandbox = result.data.data;
286-
const currentState = sandbox.state as SandboxState;
287-
288-
// Log current state on each poll
289-
const elapsed = Math.round((Date.now() - startTime) / 1000);
290-
const state = currentState || 'unknown';
291-
this.logger.info({sandboxId, elapsed, state}, `[${elapsed}s] State: ${state}`);
292-
293-
// Check for terminal states
294-
if (currentState && TERMINAL_STATES.has(currentState)) {
295-
switch (currentState) {
296-
case 'deleted': {
297-
this.error(t('commands.ods.create.deleted', 'Sandbox was deleted'));
298-
break;
299-
}
300-
case 'failed': {
301-
this.error(t('commands.ods.create.failed', 'Sandbox creation failed'));
302-
break;
303-
}
304-
case 'started': {
305-
this.log('');
306-
this.logger.info({sandboxId}, t('commands.ods.create.ready', 'Sandbox is now ready'));
307-
break;
308-
}
309-
}
310-
return sandbox;
311-
}
312-
313-
// Wait before next poll
314-
// eslint-disable-next-line no-await-in-loop
315-
await this.sleep(pollIntervalMs);
316-
}
317-
}
318290
}

packages/b2c-cli/src/commands/ods/delete.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
import * as readline from 'node:readline';
77
import {Args, Flags} from '@oclif/core';
88
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
9-
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';
9+
import {
10+
getApiErrorMessage,
11+
SandboxPollingError,
12+
SandboxPollingTimeoutError,
13+
SandboxTerminalStateError,
14+
waitForSandbox,
15+
type SandboxState,
16+
} from '@salesforce/b2c-tooling-sdk';
1017
import {t, withDocs} from '../../i18n/index.js';
1118

1219
/**
@@ -54,10 +61,28 @@ export default class OdsDelete extends OdsCommand<typeof OdsDelete> {
5461
description: 'Skip confirmation prompt',
5562
default: false,
5663
}),
64+
wait: Flags.boolean({
65+
char: 'w',
66+
description: 'Wait for the sandbox to be fully deleted before returning',
67+
default: false,
68+
}),
69+
'poll-interval': Flags.integer({
70+
description: 'Polling interval in seconds when using --wait',
71+
default: 10,
72+
dependsOn: ['wait'],
73+
}),
74+
timeout: Flags.integer({
75+
description: 'Maximum time to wait in seconds when using --wait (0 for no timeout)',
76+
default: 600,
77+
dependsOn: ['wait'],
78+
}),
5779
};
5880

5981
async run(): Promise<void> {
6082
const sandboxId = await this.resolveSandboxId(this.args.sandboxId);
83+
const wait = this.flags.wait as boolean;
84+
const pollInterval = this.flags['poll-interval'] as number;
85+
const timeout = this.flags.timeout as number;
6186

6287
// Get sandbox details first to show in confirmation
6388
const getResult = await this.odsClient.GET('/sandboxes/{sandboxId}', {
@@ -104,5 +129,54 @@ export default class OdsDelete extends OdsCommand<typeof OdsDelete> {
104129
}
105130

106131
this.log(t('commands.ods.delete.success', 'Sandbox deletion initiated. The sandbox will be removed shortly.'));
132+
133+
if (wait) {
134+
this.log(
135+
t('commands.ods.delete.waiting', 'Waiting for sandbox to reach state {{state}}...', {
136+
state: 'deleted' satisfies SandboxState,
137+
}),
138+
);
139+
140+
try {
141+
await waitForSandbox(this.odsClient, {
142+
sandboxId,
143+
targetState: 'deleted',
144+
pollIntervalSeconds: pollInterval,
145+
timeoutSeconds: timeout,
146+
onPoll: ({elapsedSeconds, state}) => {
147+
this.logger.info({sandboxId, elapsed: elapsedSeconds, state}, `[${elapsedSeconds}s] State: ${state}`);
148+
},
149+
});
150+
} catch (error) {
151+
if (error instanceof SandboxPollingTimeoutError) {
152+
this.error(
153+
t('commands.ods.delete.timeout', 'Timeout waiting for sandbox after {{seconds}} seconds', {
154+
seconds: String(error.timeoutSeconds),
155+
}),
156+
);
157+
}
158+
159+
if (error instanceof SandboxTerminalStateError) {
160+
this.error(
161+
t('commands.ods.delete.failed', 'Sandbox did not reach the expected state. Current state: {{state}}', {
162+
state: error.state || 'unknown',
163+
}),
164+
);
165+
}
166+
167+
if (error instanceof SandboxPollingError) {
168+
this.error(
169+
t('commands.ods.delete.pollError', 'Failed to fetch sandbox status: {{message}}', {
170+
message: error.message,
171+
}),
172+
);
173+
}
174+
175+
throw error;
176+
}
177+
178+
this.log('');
179+
this.logger.info({sandboxId}, t('commands.ods.delete.ready', 'Sandbox is now deleted'));
180+
}
107181
}
108182
}

packages/b2c-cli/src/commands/ods/restart.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@
33
* SPDX-License-Identifier: Apache-2
44
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
55
*/
6-
import {Args} from '@oclif/core';
6+
import {Args, Flags} from '@oclif/core';
77
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
8-
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
8+
import {
9+
getApiErrorMessage,
10+
SandboxPollingError,
11+
SandboxPollingTimeoutError,
12+
SandboxTerminalStateError,
13+
waitForSandbox,
14+
type OdsComponents,
15+
} from '@salesforce/b2c-tooling-sdk';
916
import {t, withDocs} from '../../i18n/index.js';
1017

1118
type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel'];
@@ -31,11 +38,34 @@ export default class OdsRestart extends OdsCommand<typeof OdsRestart> {
3138
static examples = [
3239
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789',
3340
'<%= config.bin %> <%= command.id %> zzzv-123',
41+
'<%= config.bin %> <%= command.id %> zzzv-123 --wait',
42+
'<%= config.bin %> <%= command.id %> zzzv-123 --wait --poll-interval 15',
3443
'<%= config.bin %> <%= command.id %> zzzv_123 --json',
3544
];
3645

46+
static flags = {
47+
wait: Flags.boolean({
48+
char: 'w',
49+
description: 'Wait for the sandbox to reach started or failed state before returning',
50+
default: false,
51+
}),
52+
'poll-interval': Flags.integer({
53+
description: 'Polling interval in seconds when using --wait',
54+
default: 10,
55+
dependsOn: ['wait'],
56+
}),
57+
timeout: Flags.integer({
58+
description: 'Maximum time to wait in seconds when using --wait (0 for no timeout)',
59+
default: 600,
60+
dependsOn: ['wait'],
61+
}),
62+
};
63+
3764
async run(): Promise<SandboxOperationModel> {
3865
const sandboxId = await this.resolveSandboxId(this.args.sandboxId);
66+
const wait = this.flags.wait;
67+
const pollInterval = this.flags['poll-interval'];
68+
const timeout = this.flags.timeout;
3969

4070
this.log(t('commands.ods.restart.restarting', 'Restarting sandbox {{sandboxId}}...', {sandboxId}));
4171

@@ -64,6 +94,48 @@ export default class OdsRestart extends OdsCommand<typeof OdsRestart> {
6494
sandboxState: operation.sandboxState || 'unknown',
6595
}),
6696
);
97+
if (wait) {
98+
try {
99+
await waitForSandbox(this.odsClient, {
100+
sandboxId,
101+
targetState: 'started',
102+
pollIntervalSeconds: pollInterval,
103+
timeoutSeconds: timeout,
104+
onPoll: ({elapsedSeconds, state}) => {
105+
this.logger.info({sandboxId, elapsed: elapsedSeconds, state}, `[${elapsedSeconds}s] State: ${state}`);
106+
},
107+
});
108+
} catch (error) {
109+
if (error instanceof SandboxPollingTimeoutError) {
110+
this.error(
111+
t('commands.ods.restart.timeout', 'Timeout waiting for sandbox after {{seconds}} seconds', {
112+
seconds: String(error.timeoutSeconds),
113+
}),
114+
);
115+
}
116+
117+
if (error instanceof SandboxTerminalStateError) {
118+
this.error(
119+
t('commands.ods.restart.failed', 'Sandbox did not reach the expected state. Current state: {{state}}', {
120+
state: error.state || 'unknown',
121+
}),
122+
);
123+
}
124+
125+
if (error instanceof SandboxPollingError) {
126+
this.error(
127+
t('commands.ods.restart.pollError', 'Failed to fetch sandbox status: {{message}}', {
128+
message: error.message,
129+
}),
130+
);
131+
}
132+
133+
throw error;
134+
}
135+
136+
this.log('');
137+
this.logger.info({sandboxId}, t('commands.ods.restart.ready', 'Sandbox is now ready'));
138+
}
67139

68140
return operation;
69141
}

0 commit comments

Comments
 (0)