Skip to content

Commit f56be93

Browse files
authored
@W-21720001 update resource profile support for sandboxes (#290)
1 parent 49a5af4 commit f56be93

3 files changed

Lines changed: 270 additions & 7 deletions

File tree

docs/cli/sandbox.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ b2c sandbox reset zzzv-123 --json
483483

484484
## b2c sandbox update
485485

486-
Update a sandbox's TTL, scheduling, tags, or notification emails.
486+
Update a sandbox's TTL, scheduling, resource profile, tags, or notification emails.
487487

488488
### Usage
489489

@@ -503,6 +503,7 @@ b2c sandbox update <SANDBOXID> [FLAGS]
503503
|------|-------------|
504504
| `--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. |
505505
| `--auto-scheduled` / `--no-auto-scheduled` | Enable or disable automatic start/stop scheduling |
506+
| `--resource-profile` | Resource profile (`medium`, `large`, `xlarge`, `xxlarge`) |
506507
| `--tags` | Comma-separated list of tags |
507508
| `--emails` | Comma-separated list of notification email addresses |
508509

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

530+
# Update resource profile
531+
b2c sandbox update zzzv-123 --resource-profile large
532+
529533
# Set notification emails
530534
b2c sandbox update zzzv-123 --emails dev@example.com,qa@example.com
531535

532536
# Combine multiple updates
533-
b2c sandbox update zzzv-123 --ttl 48 --tags ci,nightly
537+
b2c sandbox update zzzv-123 --ttl 48 --resource-profile xlarge --tags ci,nightly
534538

535539
# Output as JSON
536540
b2c sandbox update zzzv-123 --ttl 48 --json

packages/b2c-cli/src/commands/sandbox/update.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {t, withDocs} from '../../i18n/index.js';
1111

1212
type SandboxModel = OdsComponents['schemas']['SandboxModel'];
1313
type SandboxUpdateRequestModel = OdsComponents['schemas']['SandboxUpdateRequestModel'];
14+
type SandboxResourceProfile = OdsComponents['schemas']['SandboxResourceProfile'];
1415

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

2829
static description = withDocs(
29-
t('commands.sandbox.update.description', 'Update a sandbox (extend TTL, change scheduling, update tags or emails)'),
30+
t(
31+
'commands.sandbox.update.description',
32+
'Update a sandbox (extend TTL, change scheduling, update resource xprofile, tags, or emails)',
33+
),
3034
'/cli/sandbox.html#b2c-sandbox-update',
3135
);
3236

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

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

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

6574
// Require at least one update flag
66-
if (ttl === undefined && autoScheduled === undefined && tags === undefined && emails === undefined) {
67-
this.error('At least one update flag is required. Use --ttl, --auto-scheduled, --tags, or --emails.');
75+
if (
76+
ttl === undefined &&
77+
autoScheduled === undefined &&
78+
resourceProfile === undefined &&
79+
tags === undefined &&
80+
emails === undefined
81+
) {
82+
this.error(
83+
'At least one update flag is required. Use --ttl, --auto-scheduled, --resource-profile, --tags, or --emails.',
84+
);
6885
}
6986

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

97+
if (resourceProfile !== undefined) {
98+
body.resourceProfile = resourceProfile as SandboxResourceProfile;
99+
}
100+
80101
if (tags !== undefined) {
81102
body.tags = tags.split(',').map((tag) => tag.trim());
82103
}
@@ -120,6 +141,7 @@ export default class SandboxUpdate extends OdsCommand<typeof SandboxUpdate> {
120141
['Realm', sandbox.realm],
121142
['Instance', sandbox.instance],
122143
['State', sandbox.state],
144+
['Profile', sandbox.resourceProfile],
123145
['Auto Scheduled', sandbox.autoScheduled?.toString()],
124146
['EOL', sandbox.eol ? new Date(sandbox.eol).toLocaleString() : undefined],
125147
];
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
import {expect} from 'chai';
8+
import sinon from 'sinon';
9+
import SandboxUpdate from '../../../src/commands/sandbox/update.js';
10+
import {
11+
createIsolatedConfigHooks,
12+
createTestCommand,
13+
makeCommandThrowOnError,
14+
runSilent,
15+
stubJsonEnabled,
16+
} from '../../helpers/test-setup.js';
17+
18+
function stubOdsClient(command: any, client: Partial<{PATCH: any}>): void {
19+
Object.defineProperty(command, 'odsClient', {
20+
value: client,
21+
configurable: true,
22+
});
23+
}
24+
25+
function stubOdsHost(command: any, host = 'admin.dx.test.com'): void {
26+
Object.defineProperty(command, 'odsHost', {
27+
value: host,
28+
configurable: true,
29+
});
30+
}
31+
32+
describe('sandbox update', () => {
33+
const hooks = createIsolatedConfigHooks();
34+
35+
beforeEach(async () => {
36+
await hooks.beforeEach();
37+
});
38+
39+
afterEach(() => {
40+
sinon.restore();
41+
hooks.afterEach();
42+
});
43+
44+
async function setupCommand(flags: Record<string, unknown>, args: Record<string, unknown>): Promise<any> {
45+
const config = hooks.getConfig();
46+
const command = await createTestCommand(SandboxUpdate as any, config, flags, args);
47+
48+
stubOdsHost(command);
49+
(command as any).log = () => {};
50+
makeCommandThrowOnError(command);
51+
52+
return command;
53+
}
54+
55+
it('sends resourceProfile in PATCH body when --resource-profile is set', async () => {
56+
const command = await setupCommand({'resource-profile': 'large'}, {sandboxId: 'zzzz-001'});
57+
58+
sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
59+
stubJsonEnabled(command, true);
60+
61+
let requestUrl: string | undefined;
62+
let requestOptions: any;
63+
64+
stubOdsClient(command, {
65+
async PATCH(url: string, options: any) {
66+
requestUrl = url;
67+
requestOptions = options;
68+
return {
69+
data: {
70+
data: {
71+
id: 'sb-uuid-123',
72+
realm: 'zzzz',
73+
state: 'started',
74+
resourceProfile: 'large',
75+
},
76+
},
77+
};
78+
},
79+
});
80+
81+
const result: any = await runSilent(() => command.run());
82+
83+
expect(requestUrl).to.equal('/sandboxes/{sandboxId}');
84+
expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123');
85+
expect(requestOptions).to.have.nested.property('body.resourceProfile', 'large');
86+
expect(result.resourceProfile).to.equal('large');
87+
});
88+
89+
it('allows combining --resource-profile with other update flags', async () => {
90+
const command = await setupCommand(
91+
{'resource-profile': 'xlarge', ttl: 48, tags: 'ci,nightly'},
92+
{sandboxId: 'zzzz-001'},
93+
);
94+
95+
sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
96+
stubJsonEnabled(command, true);
97+
98+
let requestOptions: any;
99+
stubOdsClient(command, {
100+
async PATCH(_: string, options: any) {
101+
requestOptions = options;
102+
return {
103+
data: {
104+
data: {
105+
id: 'sb-uuid-123',
106+
realm: 'zzzz',
107+
state: 'started',
108+
resourceProfile: 'xlarge',
109+
tags: ['ci', 'nightly'],
110+
},
111+
},
112+
};
113+
},
114+
});
115+
116+
await runSilent(() => command.run());
117+
118+
expect(requestOptions.body).to.include({
119+
ttl: 48,
120+
resourceProfile: 'xlarge',
121+
});
122+
expect(requestOptions.body.tags).to.deep.equal(['ci', 'nightly']);
123+
});
124+
125+
it('supports --no-auto-scheduled and sends autoScheduled=false', async () => {
126+
const command = await setupCommand({'auto-scheduled': false}, {sandboxId: 'zzzz-001'});
127+
128+
sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
129+
stubJsonEnabled(command, true);
130+
131+
let requestOptions: any;
132+
stubOdsClient(command, {
133+
async PATCH(_: string, options: any) {
134+
requestOptions = options;
135+
return {
136+
data: {
137+
data: {
138+
id: 'sb-uuid-123',
139+
realm: 'zzzz',
140+
state: 'started',
141+
autoScheduled: false,
142+
},
143+
},
144+
};
145+
},
146+
});
147+
148+
await runSilent(() => command.run());
149+
150+
expect(requestOptions.body).to.include({
151+
autoScheduled: false,
152+
});
153+
});
154+
155+
it('trims tags and emails when combined with --resource-profile', async () => {
156+
const command = await setupCommand(
157+
{
158+
'resource-profile': 'xxlarge',
159+
tags: ' ci , nightly ',
160+
emails: ' dev@example.com , qa@example.com ',
161+
},
162+
{sandboxId: 'zzzz-001'},
163+
);
164+
165+
sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
166+
stubJsonEnabled(command, true);
167+
168+
let requestOptions: any;
169+
stubOdsClient(command, {
170+
async PATCH(_: string, options: any) {
171+
requestOptions = options;
172+
return {
173+
data: {
174+
data: {
175+
id: 'sb-uuid-123',
176+
realm: 'zzzz',
177+
state: 'started',
178+
resourceProfile: 'xxlarge',
179+
tags: ['ci', 'nightly'],
180+
emails: ['dev@example.com', 'qa@example.com'],
181+
},
182+
},
183+
};
184+
},
185+
});
186+
187+
const result: any = await runSilent(() => command.run());
188+
189+
expect(requestOptions.body.resourceProfile).to.equal('xxlarge');
190+
expect(requestOptions.body.tags).to.deep.equal(['ci', 'nightly']);
191+
expect(requestOptions.body.emails).to.deep.equal(['dev@example.com', 'qa@example.com']);
192+
expect(result.tags).to.deep.equal(['ci', 'nightly']);
193+
expect(result.emails).to.deep.equal(['dev@example.com', 'qa@example.com']);
194+
});
195+
196+
it('requires at least one update flag including --resource-profile', async () => {
197+
const command = await setupCommand({}, {sandboxId: 'zzzz-001'});
198+
199+
sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
200+
stubOdsClient(command, {
201+
async PATCH() {
202+
throw new Error('PATCH should not be called when no flags are provided');
203+
},
204+
});
205+
206+
try {
207+
await runSilent(() => command.run());
208+
expect.fail('Expected command to error when no update flags are provided');
209+
} catch (error: any) {
210+
expect(error.message).to.include('At least one update flag is required');
211+
expect(error.message).to.include('--resource-profile');
212+
}
213+
});
214+
215+
it('throws a helpful error when API update fails', async () => {
216+
const command = await setupCommand({'resource-profile': 'large'}, {sandboxId: 'zzzz-001'});
217+
218+
sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123');
219+
stubOdsClient(command, {
220+
async PATCH() {
221+
return {
222+
data: undefined,
223+
error: {error: {message: 'Profile update not allowed in current state'}},
224+
response: {statusText: 'Bad Request'},
225+
};
226+
},
227+
});
228+
229+
try {
230+
await runSilent(() => command.run());
231+
expect.fail('Expected command to throw on API error');
232+
} catch (error: any) {
233+
expect(error.message).to.include('Failed to update sandbox');
234+
expect(error.message).to.match(/Profile update not allowed|Bad Request/);
235+
}
236+
});
237+
});

0 commit comments

Comments
 (0)