Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 6 additions & 2 deletions docs/cli/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ b2c sandbox reset zzzv-123 --json

## b2c sandbox update

Update a sandbox's TTL, scheduling, tags, or notification emails.
Update a sandbox's TTL, scheduling, resource profile, tags, or notification emails.

### Usage

Expand All @@ -503,6 +503,7 @@ b2c sandbox update <SANDBOXID> [FLAGS]
|------|-------------|
| `--ttl` | Number of hours to add to sandbox lifetime (0 or less for infinite). Must adhere to the maximum TTL configuration together with previous extensions. |
| `--auto-scheduled` / `--no-auto-scheduled` | Enable or disable automatic start/stop scheduling |
| `--resource-profile` | Resource profile (`medium`, `large`, `xlarge`, `xxlarge`) |
| `--tags` | Comma-separated list of tags |
| `--emails` | Comma-separated list of notification email addresses |

Expand All @@ -526,11 +527,14 @@ b2c sandbox update zzzv-123 --no-auto-scheduled
# Set tags
b2c sandbox update zzzv-123 --tags ci,nightly

# Update resource profile
b2c sandbox update zzzv-123 --resource-profile large

# Set notification emails
b2c sandbox update zzzv-123 --emails dev@example.com,qa@example.com

# Combine multiple updates
b2c sandbox update zzzv-123 --ttl 48 --tags ci,nightly
b2c sandbox update zzzv-123 --ttl 48 --resource-profile xlarge --tags ci,nightly

# Output as JSON
b2c sandbox update zzzv-123 --ttl 48 --json
Expand Down
32 changes: 27 additions & 5 deletions packages/b2c-cli/src/commands/sandbox/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {t, withDocs} from '../../i18n/index.js';

type SandboxModel = OdsComponents['schemas']['SandboxModel'];
type SandboxUpdateRequestModel = OdsComponents['schemas']['SandboxUpdateRequestModel'];
type SandboxResourceProfile = OdsComponents['schemas']['SandboxResourceProfile'];

/**
* Command to update an on-demand sandbox.
Expand All @@ -26,7 +27,10 @@ export default class SandboxUpdate extends OdsCommand<typeof SandboxUpdate> {
};

static description = withDocs(
t('commands.sandbox.update.description', 'Update a sandbox (extend TTL, change scheduling, update tags or emails)'),
t(
'commands.sandbox.update.description',
'Update a sandbox (extend TTL, change scheduling, update resource xprofile, tags, or emails)',
),
'/cli/sandbox.html#b2c-sandbox-update',
);

Expand All @@ -37,9 +41,10 @@ export default class SandboxUpdate extends OdsCommand<typeof SandboxUpdate> {
'<%= config.bin %> <%= command.id %> zzzv-123 --ttl 0',
'<%= config.bin %> <%= command.id %> zzzv-123 --auto-scheduled',
'<%= config.bin %> <%= command.id %> zzzv-123 --no-auto-scheduled',
'<%= config.bin %> <%= command.id %> zzzv-123 --resource-profile large',
'<%= config.bin %> <%= command.id %> zzzv-123 --tags tag1,tag2',
'<%= config.bin %> <%= command.id %> zzzv-123 --emails user@example.com,dev@example.com',
'<%= config.bin %> <%= command.id %> zzzv-123 --ttl 48 --tags ci,nightly --json',
'<%= config.bin %> <%= command.id %> zzzv-123 --ttl 48 --resource-profile xlarge --tags ci,nightly --json',
];

static flags = {
Expand All @@ -50,6 +55,10 @@ export default class SandboxUpdate extends OdsCommand<typeof SandboxUpdate> {
description: 'Enable or disable automatic start/stop scheduling',
allowNo: true,
}),
'resource-profile': Flags.string({
description: 'Resource profile (medium, large, xlarge, xxlarge)',
options: ['medium', 'large', 'xlarge', 'xxlarge'],
}),
tags: Flags.string({
description: 'Comma-separated list of tags',
}),
Expand All @@ -60,11 +69,19 @@ export default class SandboxUpdate extends OdsCommand<typeof SandboxUpdate> {

async run(): Promise<SandboxModel> {
const sandboxId = await this.resolveSandboxId(this.args.sandboxId);
const {ttl, 'auto-scheduled': autoScheduled, tags, emails} = this.flags;
const {ttl, 'auto-scheduled': autoScheduled, 'resource-profile': resourceProfile, tags, emails} = this.flags;

// Require at least one update flag
if (ttl === undefined && autoScheduled === undefined && tags === undefined && emails === undefined) {
this.error('At least one update flag is required. Use --ttl, --auto-scheduled, --tags, or --emails.');
if (
ttl === undefined &&
autoScheduled === undefined &&
resourceProfile === undefined &&
tags === undefined &&
emails === undefined
) {
this.error(
'At least one update flag is required. Use --ttl, --auto-scheduled, --resource-profile, --tags, or --emails.',
);
}

const body: SandboxUpdateRequestModel = {};
Expand All @@ -77,6 +94,10 @@ export default class SandboxUpdate extends OdsCommand<typeof SandboxUpdate> {
body.autoScheduled = autoScheduled;
}

if (resourceProfile !== undefined) {
body.resourceProfile = resourceProfile as SandboxResourceProfile;
}

if (tags !== undefined) {
body.tags = tags.split(',').map((tag) => tag.trim());
}
Expand Down Expand Up @@ -120,6 +141,7 @@ export default class SandboxUpdate extends OdsCommand<typeof SandboxUpdate> {
['Realm', sandbox.realm],
['Instance', sandbox.instance],
['State', sandbox.state],
['Profile', sandbox.resourceProfile],
['Auto Scheduled', sandbox.autoScheduled?.toString()],
['EOL', sandbox.eol ? new Date(sandbox.eol).toLocaleString() : undefined],
];
Expand Down
237 changes: 237 additions & 0 deletions packages/b2c-cli/test/commands/sandbox/update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/*
* 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 {expect} from 'chai';
import sinon from 'sinon';
import SandboxUpdate from '../../../src/commands/sandbox/update.js';
import {
createIsolatedConfigHooks,
createTestCommand,
makeCommandThrowOnError,
runSilent,
stubJsonEnabled,
} from '../../helpers/test-setup.js';

function stubOdsClient(command: any, client: Partial<{PATCH: any}>): void {
Object.defineProperty(command, 'odsClient', {
value: client,
configurable: true,
});
}

function stubOdsHost(command: any, host = 'admin.dx.test.com'): void {
Object.defineProperty(command, 'odsHost', {
value: host,
configurable: true,
});
}

describe('sandbox update', () => {
const hooks = createIsolatedConfigHooks();

beforeEach(async () => {
await hooks.beforeEach();
});

afterEach(() => {
sinon.restore();
hooks.afterEach();
});

async function setupCommand(flags: Record<string, unknown>, args: Record<string, unknown>): Promise<any> {
const config = hooks.getConfig();
const command = await createTestCommand(SandboxUpdate as any, config, flags, args);

stubOdsHost(command);
(command as any).log = () => {};
makeCommandThrowOnError(command);

return command;
}

it('sends resourceProfile in PATCH body when --resource-profile is set', async () => {
const command = await setupCommand({'resource-profile': 'large'}, {sandboxId: 'zzzz-001'});

sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
stubJsonEnabled(command, true);

let requestUrl: string | undefined;
let requestOptions: any;

stubOdsClient(command, {
async PATCH(url: string, options: any) {
requestUrl = url;
requestOptions = options;
return {
data: {
data: {
id: 'sb-uuid-123',
realm: 'zzzz',
state: 'started',
resourceProfile: 'large',
},
},
};
},
});

const result: any = await runSilent(() => command.run());

expect(requestUrl).to.equal('/sandboxes/{sandboxId}');
expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123');
expect(requestOptions).to.have.nested.property('body.resourceProfile', 'large');
expect(result.resourceProfile).to.equal('large');
});

it('allows combining --resource-profile with other update flags', async () => {
const command = await setupCommand(
{'resource-profile': 'xlarge', ttl: 48, tags: 'ci,nightly'},
{sandboxId: 'zzzz-001'},
);

sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
stubJsonEnabled(command, true);

let requestOptions: any;
stubOdsClient(command, {
async PATCH(_: string, options: any) {
requestOptions = options;
return {
data: {
data: {
id: 'sb-uuid-123',
realm: 'zzzz',
state: 'started',
resourceProfile: 'xlarge',
tags: ['ci', 'nightly'],
},
},
};
},
});

await runSilent(() => command.run());

expect(requestOptions.body).to.include({
ttl: 48,
resourceProfile: 'xlarge',
});
expect(requestOptions.body.tags).to.deep.equal(['ci', 'nightly']);
});

it('supports --no-auto-scheduled and sends autoScheduled=false', async () => {
const command = await setupCommand({'auto-scheduled': false}, {sandboxId: 'zzzz-001'});

sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
stubJsonEnabled(command, true);

let requestOptions: any;
stubOdsClient(command, {
async PATCH(_: string, options: any) {
requestOptions = options;
return {
data: {
data: {
id: 'sb-uuid-123',
realm: 'zzzz',
state: 'started',
autoScheduled: false,
},
},
};
},
});

await runSilent(() => command.run());

expect(requestOptions.body).to.include({
autoScheduled: false,
});
});

it('trims tags and emails when combined with --resource-profile', async () => {
const command = await setupCommand(
{
'resource-profile': 'xxlarge',
tags: ' ci , nightly ',
emails: ' dev@example.com , qa@example.com ',
},
{sandboxId: 'zzzz-001'},
);

sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
stubJsonEnabled(command, true);

let requestOptions: any;
stubOdsClient(command, {
async PATCH(_: string, options: any) {
requestOptions = options;
return {
data: {
data: {
id: 'sb-uuid-123',
realm: 'zzzz',
state: 'started',
resourceProfile: 'xxlarge',
tags: ['ci', 'nightly'],
emails: ['dev@example.com', 'qa@example.com'],
},
},
};
},
});

const result: any = await runSilent(() => command.run());

expect(requestOptions.body.resourceProfile).to.equal('xxlarge');
expect(requestOptions.body.tags).to.deep.equal(['ci', 'nightly']);
expect(requestOptions.body.emails).to.deep.equal(['dev@example.com', 'qa@example.com']);
expect(result.tags).to.deep.equal(['ci', 'nightly']);
expect(result.emails).to.deep.equal(['dev@example.com', 'qa@example.com']);
});

it('requires at least one update flag including --resource-profile', async () => {
const command = await setupCommand({}, {sandboxId: 'zzzz-001'});

sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
stubOdsClient(command, {
async PATCH() {
throw new Error('PATCH should not be called when no flags are provided');
},
});

try {
await runSilent(() => command.run());
expect.fail('Expected command to error when no update flags are provided');
} catch (error: any) {
expect(error.message).to.include('At least one update flag is required');
expect(error.message).to.include('--resource-profile');
}
});

it('throws a helpful error when API update fails', async () => {
const command = await setupCommand({'resource-profile': 'large'}, {sandboxId: 'zzzz-001'});

sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
stubOdsClient(command, {
async PATCH() {
return {
data: undefined,
error: {error: {message: 'Profile update not allowed in current state'}},
response: {statusText: 'Bad Request'},
};
},
});

try {
await runSilent(() => command.run());
expect.fail('Expected command to throw on API error');
} catch (error: any) {
expect(error.message).to.include('Failed to update sandbox');
expect(error.message).to.match(/Profile update not allowed|Bad Request/);
}
});
});
Loading