Skip to content

Commit b794cf5

Browse files
authored
fix(cli): reject invalid scale numeric options (#730)
* fix(cli): reject invalid scale numeric options * fix(cli): validate rollout percent and empty values --------- Co-authored-by: lazyGPT07 <288857205+lazyGPT07@users.noreply.github.com>
1 parent 1d90b85 commit b794cf5

2 files changed

Lines changed: 83 additions & 10 deletions

File tree

packages/cli/src/commands/scale.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
saveFleet,
1212
loadRollouts,
1313
saveRollouts,
14+
parsePositiveInteger,
15+
parseNonNegativeInteger,
16+
parsePositiveNumber,
17+
parsePercentage,
1418
} from './scale.js';
1519

1620
// Helper to create a temp dir and override CREDS_FILE path
@@ -62,6 +66,43 @@ describe('getNextId', () => {
6266
});
6367
});
6468

69+
describe('scale numeric option parsers', () => {
70+
it('accepts valid integer counts and finite prices', () => {
71+
expect(parsePositiveInteger('3')).toBe(3);
72+
expect(parseNonNegativeInteger('0')).toBe(0);
73+
expect(parsePositiveNumber('0.25')).toBe(0.25);
74+
expect(parsePercentage('100')).toBe(100);
75+
});
76+
77+
it.each(['nope', '1.5', '0', '-1', 'Infinity', 'NaN', ''])(
78+
'rejects invalid positive integers: %s',
79+
(value) => {
80+
expect(() => parsePositiveInteger(value)).toThrow();
81+
},
82+
);
83+
84+
it.each(['nope', '1.5', '-1', 'Infinity', 'NaN', ''])(
85+
'rejects invalid non-negative integers: %s',
86+
(value) => {
87+
expect(() => parseNonNegativeInteger(value)).toThrow();
88+
},
89+
);
90+
91+
it.each(['nope', '0', '-1', 'Infinity', 'NaN', ''])(
92+
'rejects invalid positive finite numbers: %s',
93+
(value) => {
94+
expect(() => parsePositiveNumber(value)).toThrow();
95+
},
96+
);
97+
98+
it.each(['0', '101', '1.5', 'Infinity', ''])(
99+
'rejects invalid rollout percentages: %s',
100+
(value) => {
101+
expect(() => parsePercentage(value)).toThrow();
102+
},
103+
);
104+
});
105+
65106
// ---------------------------------------------------------------------------
66107
// loadFleet / saveFleet
67108
// ---------------------------------------------------------------------------

packages/cli/src/commands/scale.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Command } from 'commander';
1+
import { Command, InvalidArgumentError } from 'commander';
22
import kleur from 'kleur';
33
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
44
import { homedir } from 'node:os';
@@ -121,6 +121,38 @@ export function getNextId(instances: FleetEntry[]): string {
121121
return `inst-${String(max + 1).padStart(4, '0')}`;
122122
}
123123

124+
export function parsePositiveInteger(value: string): number {
125+
const parsed = Number(value);
126+
if (value.trim() === '' || !Number.isSafeInteger(parsed) || parsed < 1) {
127+
throw new InvalidArgumentError('must be a positive integer');
128+
}
129+
return parsed;
130+
}
131+
132+
export function parseNonNegativeInteger(value: string): number {
133+
const parsed = Number(value);
134+
if (value.trim() === '' || !Number.isSafeInteger(parsed) || parsed < 0) {
135+
throw new InvalidArgumentError('must be zero or a positive integer');
136+
}
137+
return parsed;
138+
}
139+
140+
export function parsePositiveNumber(value: string): number {
141+
const parsed = Number(value);
142+
if (value.trim() === '' || !Number.isFinite(parsed) || parsed <= 0) {
143+
throw new InvalidArgumentError('must be a positive finite number');
144+
}
145+
return parsed;
146+
}
147+
148+
export function parsePercentage(value: string): number {
149+
const parsed = parsePositiveInteger(value);
150+
if (parsed > 100) {
151+
throw new InvalidArgumentError('must be between 1 and 100');
152+
}
153+
return parsed;
154+
}
155+
124156
function pickIps(count: number): string[] {
125157
// Simulated IP allocation on RFC 1918 / 100.64.0.0/10 space
126158
const base = 100 + Math.floor(Math.random() * 55);
@@ -167,9 +199,9 @@ scaleCmd.addCommand(deployCmd);
167199
scaleCmd
168200
.command('up')
169201
.description('Buy more instances of the current SKU (via sh1pt deploy under the hood)')
170-
.option('--instances <n>', 'how many to add', Number, 1)
202+
.option('--instances <n>', 'how many to add', parsePositiveInteger, 1)
171203
.option('--provider <id>', 'which cloud provider to add to (default: same as existing fleet, or first in pricing table)')
172-
.option('--max-hourly-price <usd>', 'abort if the new instances would push above this total/hr', Number)
204+
.option('--max-hourly-price <usd>', 'abort if the new instances would push above this total/hr', parsePositiveNumber)
173205
.option('--dry-run', 'show the plan without modifying state')
174206
.action((opts: {
175207
instances: number;
@@ -264,7 +296,7 @@ scaleCmd
264296
scaleCmd
265297
.command('down')
266298
.description('Tear down instances (cheapest / least-healthy first)')
267-
.option('--instances <n>', 'number of instances to destroy', Number, 1)
299+
.option('--instances <n>', 'number of instances to destroy', parsePositiveInteger, 1)
268300
.option('--provider <id>', 'only remove instances from this cloud provider')
269301
.option('--dry-run', 'show the plan without modifying state')
270302
.option('--json', 'machine-readable output')
@@ -373,10 +405,10 @@ scaleCmd
373405
scaleCmd
374406
.command('auto')
375407
.description('Set auto-scale rules (sh1pt cloud polls metrics and runs scale up/down on your behalf)')
376-
.option('--min <n>', 'minimum instances', Number, 1)
377-
.option('--max <n>', 'maximum instances', Number, 10)
378-
.option('--target-cpu <percent>', 'target CPU utilization to maintain', Number, 70)
379-
.option('--cooldown <seconds>', 'minimum time between scale events', Number, 300)
408+
.option('--min <n>', 'minimum instances', parseNonNegativeInteger, 1)
409+
.option('--max <n>', 'maximum instances', parsePositiveInteger, 10)
410+
.option('--target-cpu <percent>', 'target CPU utilization to maintain', parsePositiveInteger, 70)
411+
.option('--cooldown <seconds>', 'minimum time between scale events', parsePositiveInteger, 300)
380412
.option('--status', 'show current auto-scale rules')
381413
.option('--dry-run', 'show the rules without saving')
382414
.option('--json', 'machine-readable output')
@@ -499,7 +531,7 @@ scaleCmd
499531
.description('Wire round-robin DNS so traffic spreads across the fleet')
500532
.requiredOption('--provider <id>', 'dns-porkbun | dns-cloudflare')
501533
.requiredOption('--domain <fqdn>', 'e.g. api.example.com')
502-
.option('--ttl <seconds>', 'TTL for DNS records', Number, 60)
534+
.option('--ttl <seconds>', 'TTL for DNS records', parsePositiveInteger, 60)
503535
.option('--proxied', 'cloudflare only — route through the CF edge (orange cloud)')
504536
.option('--dry-run', 'show the DNS records that would be created/updated')
505537
.option('--json', 'machine-readable output')
@@ -611,7 +643,7 @@ scaleCmd
611643
.description('Stage a new version across the fleet (canary / blue-green / rolling)')
612644
.requiredOption('--version <id>', 'version identifier to deploy (e.g. v2.1.0)')
613645
.option('--strategy <kind>', 'canary | blue-green | rolling', 'canary')
614-
.option('--percent <n>', 'canary only — start at N% of traffic', Number, 5)
646+
.option('--percent <n>', 'canary only — start at N% of traffic', parsePercentage, 5)
615647
.option('--dry-run', 'show the plan without modifying state')
616648
.option('--status', 'show active rollouts and their state')
617649
.option('--rollback <id>', 'roll back a previously completed rollout by ID')

0 commit comments

Comments
 (0)