Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 41 additions & 64 deletions packages/b2c-cli/src/commands/ods/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,14 @@ import cliui from 'cliui';
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk';
import {t, withDocs} from '../../i18n/index.js';
import {waitForSandboxStateCommon} from './polling.js';

type SandboxModel = OdsComponents['schemas']['SandboxModel'];
type SandboxResourceProfile = OdsComponents['schemas']['SandboxResourceProfile'];
type SandboxState = OdsComponents['schemas']['SandboxState'];
type OcapiSettings = OdsComponents['schemas']['OcapiSettings'];
type WebDavSettings = OdsComponents['schemas']['WebDavSettings'];
type SandboxSettings = OdsComponents['schemas']['SandboxSettings'];

/** States that indicate sandbox creation has completed (success or failure) */
const TERMINAL_STATES = new Set<SandboxState>(['deleted', 'failed', 'started']);

/**
* Default OCAPI resources to grant the client ID access to.
* These enable common CI/CD operations like code deployment and job execution.
Expand Down Expand Up @@ -155,7 +152,7 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
this.logger.info({sandboxId: sandbox.id}, t('commands.ods.create.success', 'Sandbox created successfully'));

if (wait && sandbox.id) {
this.log('');
this.log(t('commands.ods.create.waiting', 'Waiting for sandbox to get started..'));
sandbox = await this.waitForSandbox(sandbox.id, pollInterval, timeout);
}

Expand Down Expand Up @@ -248,71 +245,51 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
pollIntervalSeconds: number,
timeoutSeconds: number,
): Promise<SandboxModel> {
const startTime = Date.now();
const pollIntervalMs = pollIntervalSeconds * 1000;
const timeoutMs = timeoutSeconds * 1000;

this.log(t('commands.ods.create.waiting', 'Waiting for sandbox to be ready...'));

// Initial delay before first poll to allow the sandbox to be registered in the API
await this.sleep(pollIntervalMs);

while (true) {
// Check for timeout
if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) {
await waitForSandboxStateCommon({
sandboxId,
targetState: 'started',
pollIntervalSeconds,
timeoutSeconds,
odsClient: this.odsClient,
logger: this.logger,
sleep: (ms) => this.sleep(ms),
onPollError: (message) =>
this.error(
t('commands.ods.create.timeout', 'Timeout waiting for sandbox after {{seconds}} seconds', {
seconds: String(timeoutSeconds),
t('commands.ods.create.pollError', 'Failed to fetch sandbox status: {{message}}', {
message,
}),
);
}

// eslint-disable-next-line no-await-in-loop
const result = await this.odsClient.GET('/sandboxes/{sandboxId}', {
params: {
path: {sandboxId},
},
});

if (!result.data?.data) {
),
onTimeout: (seconds) =>
this.error(
t('commands.ods.create.pollError', 'Failed to fetch sandbox status: {{message}}', {
message: result.response?.statusText || 'Unknown error',
t('commands.ods.create.timeout', 'Timeout waiting for sandbox after {{seconds}} seconds', {
seconds: String(seconds),
}),
);
}

const sandbox = result.data.data;
const currentState = sandbox.state as SandboxState;

// Log current state on each poll
const elapsed = Math.round((Date.now() - startTime) / 1000);
const state = currentState || 'unknown';
this.logger.info({sandboxId, elapsed, state}, `[${elapsed}s] State: ${state}`);

// Check for terminal states
if (currentState && TERMINAL_STATES.has(currentState)) {
switch (currentState) {
case 'deleted': {
this.error(t('commands.ods.create.deleted', 'Sandbox was deleted'));
break;
}
case 'failed': {
this.error(t('commands.ods.create.failed', 'Sandbox creation failed'));
break;
}
case 'started': {
this.log('');
this.logger.info({sandboxId}, t('commands.ods.create.ready', 'Sandbox is now ready'));
break;
}
),
onFailure: (state) => {
if (state === 'deleted') {
this.error(t('commands.ods.create.deleted', 'Sandbox was deleted'));
}
return sandbox;
}
this.error(t('commands.ods.create.failed', 'Sandbox creation failed'));
},
});

const finalResult = await this.odsClient.GET('/sandboxes/{sandboxId}', {
params: {
path: {sandboxId},
},
});

// Wait before next poll
// eslint-disable-next-line no-await-in-loop
await this.sleep(pollIntervalMs);
if (!finalResult.data?.data) {
this.error(
t('commands.ods.create.pollError', 'Failed to fetch sandbox status: {{message}}', {
message: finalResult.response?.statusText || 'Unknown error',
}),
);
}

this.log('');
this.logger.info({sandboxId}, t('commands.ods.create.ready', 'Sandbox is now ready'));

return finalResult.data.data;
}
}
57 changes: 57 additions & 0 deletions packages/b2c-cli/src/commands/ods/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {Args, Flags} from '@oclif/core';
import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli';
import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';
import {t, withDocs} from '../../i18n/index.js';
import {waitForSandboxStateCommon, type SandboxState} from './polling.js';

/**
* Simple confirmation prompt.
Expand Down Expand Up @@ -54,10 +55,28 @@ export default class OdsDelete extends OdsCommand<typeof OdsDelete> {
description: 'Skip confirmation prompt',
default: false,
}),
wait: Flags.boolean({
char: 'w',
description: 'Wait for the sandbox to be fully deleted before returning',
default: false,
}),
'poll-interval': Flags.integer({
description: 'Polling interval in seconds when using --wait',
default: 10,
dependsOn: ['wait'],
}),
timeout: Flags.integer({
description: 'Maximum time to wait in seconds when using --wait (0 for no timeout)',
default: 600,
dependsOn: ['wait'],
}),
};

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

// Get sandbox details first to show in confirmation
const getResult = await this.odsClient.GET('/sandboxes/{sandboxId}', {
Expand Down Expand Up @@ -104,5 +123,43 @@ export default class OdsDelete extends OdsCommand<typeof OdsDelete> {
}

this.log(t('commands.ods.delete.success', 'Sandbox deletion initiated. The sandbox will be removed shortly.'));

if (wait) {
this.log(
t('commands.ods.delete.waiting', 'Waiting for sandbox to reach state {{state}}...', {
state: 'deleted' satisfies SandboxState,
}),
);

await waitForSandboxStateCommon({
sandboxId,
targetState: 'deleted',
pollIntervalSeconds: pollInterval,
timeoutSeconds: timeout,
odsClient: this.odsClient,
logger: this.logger,
onPollError: (message) =>
this.error(
t('commands.ods.delete.pollError', 'Failed to fetch sandbox status: {{message}}', {
message,
}),
),
onTimeout: (seconds) =>
this.error(
t('commands.ods.delete.timeout', 'Timeout waiting for sandbox after {{seconds}} seconds', {
seconds: String(seconds),
}),
),
onFailure: (state) =>
this.error(
t('commands.ods.delete.failed', 'Sandbox did not reach the expected state. Current state: {{state}}', {
state: state || 'unknown',
}),
),
});

this.log('');
this.logger.info({sandboxId}, t('commands.ods.delete.ready', 'Sandbox is now deleted'));
}
}
}
107 changes: 107 additions & 0 deletions packages/b2c-cli/src/commands/ods/polling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/

import {Command} from '@oclif/core';

/**
* Sandbox lifecycle states used for polling.
*
* Kept as a simple string union here to avoid depending on internal SDK types.
*/
export type SandboxState = 'deleted' | 'failed' | 'started' | 'stopped' | (string & {});

interface LoggerLike {
info(message: string, context?: unknown): void;
info(context: unknown, message: string): void;
}

interface OdsPollResult {
data?: {data?: {state?: string}};
response?: {statusText?: string};
}

interface OdsPollingClient {
GET: (path: '/sandboxes/{sandboxId}', options: {params: {path: {sandboxId: string}}}) => Promise<OdsPollResult>;
}

export interface WaitForSandboxStateOptions {
sandboxId: string;
targetState: SandboxState;
pollIntervalSeconds: number;
timeoutSeconds: number;
odsClient: OdsPollingClient;
logger: LoggerLike;
onPollError: (message: string) => never;
onTimeout: (seconds: number) => never;
onFailure: (state: SandboxState | undefined) => never;
sleep?: (ms: number) => Promise<void>;
}

async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

/**
* Shared polling helper for ODS sandbox state transitions.
*
* Commands are responsible for user-facing messages and error translations.
*/
export async function waitForSandboxStateCommon(options: WaitForSandboxStateOptions): Promise<void> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @charithaT07 I missed this PR.

Can we name this waitForSandbox?

We should probably pull out the entirety of waitForSandbox from the create and just have one.

This seems like it should be in the SDK and not the CLI in the operations/ods module as it seems general purpose. In that case it should only trace log (but the CLI can log with the polling callbacks).

We should use exceptions for failure modes in the SDK. i.e. I don't think a onFailure callback makes sense. But you can use typed exceptions for the different failure types. Same with a Timeout

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, agreed this should live in the SDK. I moved the sandbox polling logic into salesforce/b2c-tooling-sdk as waitForSandbox() (in operations/ods) and refactored the CLI commands to use it. The SDK now uses typed exceptions for timeout/polling/terminal-state failures and The CLI-local polling implementation is removed.

const {sandboxId, targetState, pollIntervalSeconds, timeoutSeconds, odsClient, logger} = options;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I think we should use exceptions over callbacks for failure modes I think we should add a "polling" callback when moving this to the SDK. That way the CLI can report.

const sleepFn = options.sleep ?? sleep;

const startTime = Date.now();
const pollIntervalMs = pollIntervalSeconds * 1000;
const timeoutMs = timeoutSeconds * 1000;

// Initial delay before first poll to give the operation time to start
await sleepFn(pollIntervalMs);

while (true) {
if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) {
options.onTimeout(timeoutSeconds);
}

// eslint-disable-next-line no-await-in-loop
const result = await odsClient.GET('/sandboxes/{sandboxId}', {
params: {
path: {sandboxId},
},
});

if (!result.data?.data) {
options.onPollError(result.response?.statusText || 'Unknown error');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we move to the SDK I'm not sure this callback is needed. Just use a typed exception and the caller can retry (but I'm not sure we should retry in this type of failure).

}

const sandbox = result.data.data;
const currentState = sandbox.state as SandboxState;

const elapsed = Math.round((Date.now() - startTime) / 1000);
const state = currentState || 'unknown';
logger.info({sandboxId, elapsed, state}, `[${elapsed}s] State: ${state}`);

if (currentState === targetState) {
return;
}

if (currentState === 'failed' || currentState === 'deleted') {
options.onFailure(currentState);
}

// eslint-disable-next-line no-await-in-loop
await sleepFn(pollIntervalMs);
}
}

export default class OdsPolling extends Command {
static hidden = true;

async run(): Promise<void> {
this.error('This is an internal module and not a public command.');
}
}
Loading
Loading