badgr-cli 1.0.48 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/package.json +1 -1
- package/src/api.js +16 -2
- package/src/artifactDownload.js +55 -0
- package/src/badgr.js +104 -0
- package/src/batch.js +22 -4
- package/src/browser.js +23 -0
- package/src/commands/artifacts.js +75 -0
- package/src/commands/batch.js +221 -28
- package/src/commands/billing.js +1 -12
- package/src/commands/capacity.js +9 -4
- package/src/commands/comfyui.js +3 -3
- package/src/commands/connect.js +83 -0
- package/src/commands/doctor.js +127 -0
- package/src/commands/down.js +29 -6
- package/src/commands/launch.js +431 -0
- package/src/commands/pull.js +137 -0
- package/src/commands/run.js +253 -37
- package/src/commands/sbatch.js +232 -0
- package/src/commands/serve.js +3 -3
- package/src/commands/status.js +12 -4
- package/src/commands/task.js +25 -0
- package/src/commands/test-run.js +4 -2
- package/src/credentials.js +65 -0
- package/src/fallback.js +7 -2
- package/src/fanout.js +70 -0
- package/src/gpuDoctor/diskInfo.js +42 -0
- package/src/gpuDoctor/doctor.js +451 -0
- package/src/gpuDoctor/gpuInfo.js +70 -0
- package/src/gpuDoctor/healthCheck.js +63 -0
- package/src/gpuDoctor/logClassifier.js +138 -0
- package/src/gpuDoctor/modelFit.js +107 -0
- package/src/gpuDoctor/probeCache.js +38 -0
- package/src/gpuDoctor/redact.js +29 -0
- package/src/gpuDoctor/torchInfo.js +61 -0
- package/src/gpuDoctor/workflowDoctor.js +96 -0
- package/src/onboarding.js +124 -0
- package/src/slurm.js +193 -0
- package/src/spec.js +59 -2
- package/src/store.js +16 -0
- package/tests/agent-images.test.js +17 -0
- package/tests/artifactDownload.test.js +113 -0
- package/tests/artifacts.test.js +168 -0
- package/tests/batch.test.js +312 -0
- package/tests/browser.test.js +51 -0
- package/tests/capacity.test.js +68 -0
- package/tests/commands.test.js +44 -0
- package/tests/connect.test.js +83 -0
- package/tests/down.test.js +23 -1
- package/tests/fallback-timeout.test.js +41 -0
- package/tests/fanout.test.js +124 -0
- package/tests/gpu-doctor-classifiers.test.js +402 -0
- package/tests/gpu-doctor-doctor.test.js +304 -0
- package/tests/gpu-doctor-probe-cache.test.js +110 -0
- package/tests/gpu-doctor-probes.test.js +257 -0
- package/tests/launch-command-argv.test.js +93 -0
- package/tests/launch-readiness.test.js +1 -0
- package/tests/launch.test.js +440 -0
- package/tests/onboarding.test.js +134 -0
- package/tests/pull.test.js +266 -0
- package/tests/run-lifecycle.test.js +405 -6
- package/tests/sbatch.test.js +190 -0
- package/tests/secrets.test.js +16 -0
- package/tests/slurm.test.js +77 -0
- package/tests/spec.test.js +59 -1
- package/tests/status.test.js +73 -0
- package/tests/task.test.js +109 -0
- package/tests/template.test.js +7 -0
package/tests/batch.test.js
CHANGED
|
@@ -9,6 +9,13 @@ import { tmpdir } from 'os';
|
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
|
|
11
11
|
vi.mock('../src/store.js', () => ({
|
|
12
|
+
selectedComputeFromDeployment: (dep) => ({
|
|
13
|
+
gpu: dep.gpu_type ?? null,
|
|
14
|
+
gpuCount: dep.gpu_count ?? null,
|
|
15
|
+
vcpus: dep.selected_vcpus ?? null,
|
|
16
|
+
ramGb: dep.selected_ram_gb ?? null,
|
|
17
|
+
vramGb: dep.selected_vram_gb ?? null,
|
|
18
|
+
}),
|
|
12
19
|
addReceipt: vi.fn(),
|
|
13
20
|
updateReceipt: vi.fn(),
|
|
14
21
|
generateReceiptId: vi.fn(() => 'rcpt-batch-001'),
|
|
@@ -48,6 +55,7 @@ import {
|
|
|
48
55
|
displayFailureReason,
|
|
49
56
|
missingOutputPaths,
|
|
50
57
|
readMetricFile,
|
|
58
|
+
nextArchiveTmpFile,
|
|
51
59
|
} from '../src/commands/batch.js';
|
|
52
60
|
import * as store from '../src/store.js';
|
|
53
61
|
import * as api from '../src/api.js';
|
|
@@ -99,6 +107,26 @@ describe('displayFailureReason', () => {
|
|
|
99
107
|
// missingOutputPaths / readMetricFile
|
|
100
108
|
// ---------------------------------------------------------------------------
|
|
101
109
|
|
|
110
|
+
describe('nextArchiveTmpFile', () => {
|
|
111
|
+
it('never returns the same path twice, even called back-to-back in the same millisecond', () => {
|
|
112
|
+
// Regression test: the original implementation used only Date.now() for
|
|
113
|
+
// the temp filename, which collides when --fan-out builds multiple
|
|
114
|
+
// archives in the same millisecond (routine once concurrency > 1),
|
|
115
|
+
// corrupting one task's archive out from under another and silently
|
|
116
|
+
// dropping tasks. A monotonic counter fixes it — assert directly rather
|
|
117
|
+
// than through the full CLI/filesystem, since the guarantee is about the
|
|
118
|
+
// string, not the I/O around it.
|
|
119
|
+
const paths = new Set();
|
|
120
|
+
for (let i = 0; i < 500; i++) paths.add(nextArchiveTmpFile());
|
|
121
|
+
expect(paths.size).toBe(500);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('always includes the badgr-batch-inputs prefix and .tar.gz suffix', () => {
|
|
125
|
+
const p = nextArchiveTmpFile();
|
|
126
|
+
expect(p).toMatch(/badgr-batch-inputs-\d+-\d+\.tar\.gz$/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
102
130
|
describe('missingOutputPaths', () => {
|
|
103
131
|
it('reports declared paths that do not exist under the extraction dir', () => {
|
|
104
132
|
mkdirSync(join(dir, 'outputs'), { recursive: true });
|
|
@@ -195,6 +223,7 @@ success_metric:
|
|
|
195
223
|
expect(JSON.parse(capturedBody.env.BADGR_OUTPUT_PATHS_JSON)).toEqual([
|
|
196
224
|
'/outputs/metrics.json', '/outputs/videos', '/outputs/logs',
|
|
197
225
|
]);
|
|
226
|
+
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({ workloadShape: 'container-batch' }));
|
|
198
227
|
});
|
|
199
228
|
|
|
200
229
|
it('rejects a workload.yml missing required fields before touching the network', async () => {
|
|
@@ -205,6 +234,23 @@ success_metric:
|
|
|
205
234
|
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
206
235
|
});
|
|
207
236
|
|
|
237
|
+
it('--dry-run never submits — regression: this flag was previously silently ignored, submitting real GPU jobs anyway', async () => {
|
|
238
|
+
// Found live: `badgr batch run workload.yml --dry-run` (and the
|
|
239
|
+
// --fan-out form below) provisioned real GPU deployments despite
|
|
240
|
+
// --dry-run, because the flag was never parsed or checked anywhere in
|
|
241
|
+
// this file — every real submission this test guards against actually
|
|
242
|
+
// happened once, on real infrastructure, before this fix.
|
|
243
|
+
const yamlPath = join(dir, 'workload.yml');
|
|
244
|
+
writeFileSync(yamlPath, 'name: t\nimage: busybox\ncommand: [echo, hi]\nmax_cost: 1\nmax_runtime_minutes: 5\n');
|
|
245
|
+
|
|
246
|
+
await batchCommand(config, ['run', yamlPath, '--dry-run'], chalk);
|
|
247
|
+
|
|
248
|
+
expect(process.exitCode).toBeUndefined();
|
|
249
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
250
|
+
expect(api.uploadBlob).not.toHaveBeenCalled();
|
|
251
|
+
expect(store.addReceipt).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
|
|
208
254
|
it('builds a body with no BADGR_* env vars when inputs/outputs are omitted', async () => {
|
|
209
255
|
const yamlPath = join(dir, 'workload.yml');
|
|
210
256
|
writeFileSync(yamlPath, `
|
|
@@ -237,6 +283,272 @@ max_runtime_minutes: 5
|
|
|
237
283
|
});
|
|
238
284
|
});
|
|
239
285
|
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// badgr batch run --fan-out <dir> — "run one program across many inputs"
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
describe('badgr batch run --fan-out', () => {
|
|
291
|
+
function writeFanOutYaml(dirPath, extra = '') {
|
|
292
|
+
const yamlPath = join(dirPath, 'workload.yml');
|
|
293
|
+
writeFileSync(yamlPath, `
|
|
294
|
+
name: screen
|
|
295
|
+
image: badgr/screen:latest
|
|
296
|
+
command: [python, screen.py, --input, /inputs/scenario.json]
|
|
297
|
+
inputs:
|
|
298
|
+
- ./scenarios/PLACEHOLDER.json:/inputs/scenario.json
|
|
299
|
+
outputs:
|
|
300
|
+
- /outputs/result.json
|
|
301
|
+
max_cost: 1
|
|
302
|
+
max_runtime_minutes: 10
|
|
303
|
+
${extra}
|
|
304
|
+
`);
|
|
305
|
+
return yamlPath;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
it('submits one deployment per file in the fan-out directory, in parallel', async () => {
|
|
309
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
310
|
+
writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
|
|
311
|
+
writeFileSync(join(dir, 'scenarios', 's1.json'), '{}');
|
|
312
|
+
writeFileSync(join(dir, 'scenarios', 'PLACEHOLDER.json'), '{}'); // satisfies inputs: parse; overridden per task
|
|
313
|
+
const yamlPath = writeFanOutYaml(dir);
|
|
314
|
+
|
|
315
|
+
let callCount = 0;
|
|
316
|
+
const capturedBodies = [];
|
|
317
|
+
fallback.callWithFallback.mockImplementation((endpoint, callOpts, buildBody) => {
|
|
318
|
+
callCount += 1;
|
|
319
|
+
capturedBodies.push(buildBody());
|
|
320
|
+
return Promise.resolve({
|
|
321
|
+
deployment_id: `dep-${callCount}`, receipt_id: `rcpt-${callCount}`, gpu_type: 'RTX_4090',
|
|
322
|
+
gpu_count: 1, cost_per_hour: 0.2, provider: 'runpod', tier: '1', status: 'running',
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
api.callApi.mockImplementation((path) => {
|
|
326
|
+
if (path.startsWith('/deployments/')) {
|
|
327
|
+
return Promise.resolve({ status: 'succeeded', teardown_ok: 'ok', runtime_seconds: 3, accrued_cost_usd: 0.01 });
|
|
328
|
+
}
|
|
329
|
+
return Promise.resolve({});
|
|
330
|
+
});
|
|
331
|
+
api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
|
|
332
|
+
|
|
333
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios')], chalk);
|
|
334
|
+
|
|
335
|
+
// 3 files in scenarios/ (PLACEHOLDER.json, s0.json, s1.json), one deployment each.
|
|
336
|
+
expect(callCount).toBe(3);
|
|
337
|
+
expect(store.addReceipt).toHaveBeenCalledTimes(3);
|
|
338
|
+
const names = capturedBodies.map(b => b.name);
|
|
339
|
+
expect(names.every(n => n.startsWith('screen-'))).toBe(true);
|
|
340
|
+
expect(new Set(names).size).toBe(3); // distinct per-file names
|
|
341
|
+
expect(capturedBodies[0].env.BADGR_FANOUT_INPUT).toBeDefined();
|
|
342
|
+
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({ workloadShape: 'fan-out' }));
|
|
343
|
+
expect(process.exitCode).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('rejects --fan-out when workload.yml declares more than one input', async () => {
|
|
347
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
348
|
+
writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
|
|
349
|
+
const yamlPath = join(dir, 'workload.yml');
|
|
350
|
+
writeFileSync(yamlPath, `
|
|
351
|
+
name: multi-input
|
|
352
|
+
image: busybox
|
|
353
|
+
command: [echo, hi]
|
|
354
|
+
inputs:
|
|
355
|
+
- ./a.txt:/inputs/a.txt
|
|
356
|
+
- ./b.txt:/inputs/b.txt
|
|
357
|
+
max_cost: 1
|
|
358
|
+
max_runtime_minutes: 5
|
|
359
|
+
`);
|
|
360
|
+
writeFileSync(join(dir, 'a.txt'), 'x');
|
|
361
|
+
writeFileSync(join(dir, 'b.txt'), 'x');
|
|
362
|
+
|
|
363
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios')], chalk);
|
|
364
|
+
expect(process.exitCode).toBe(1);
|
|
365
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('rejects a --fan-out path that does not exist', async () => {
|
|
369
|
+
const yamlPath = writeFanOutYaml(dir);
|
|
370
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'does-not-exist')], chalk);
|
|
371
|
+
expect(process.exitCode).toBe(1);
|
|
372
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('--dry-run with --fan-out never submits any task — same regression as the non-fan-out case', async () => {
|
|
376
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
377
|
+
writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
|
|
378
|
+
writeFileSync(join(dir, 'scenarios', 's1.json'), '{}');
|
|
379
|
+
writeFileSync(join(dir, 'scenarios', 'PLACEHOLDER.json'), '{}');
|
|
380
|
+
const yamlPath = writeFanOutYaml(dir);
|
|
381
|
+
|
|
382
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--dry-run'], chalk);
|
|
383
|
+
|
|
384
|
+
expect(process.exitCode).toBeUndefined();
|
|
385
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
386
|
+
expect(api.uploadBlob).not.toHaveBeenCalled();
|
|
387
|
+
expect(store.addReceipt).not.toHaveBeenCalled();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('rejects an empty --fan-out directory', async () => {
|
|
391
|
+
mkdirSync(join(dir, 'empty'));
|
|
392
|
+
const yamlPath = writeFanOutYaml(dir);
|
|
393
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'empty')], chalk);
|
|
394
|
+
expect(process.exitCode).toBe(1);
|
|
395
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('reports a non-zero exit code when at least one fan-out task fails', async () => {
|
|
399
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
400
|
+
writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
|
|
401
|
+
writeFileSync(join(dir, 'scenarios', 's1.json'), '{}');
|
|
402
|
+
const yamlPath = writeFanOutYaml(dir);
|
|
403
|
+
|
|
404
|
+
const { monitorBatchJob } = await import('../src/batch.js');
|
|
405
|
+
let monitorCall = 0;
|
|
406
|
+
monitorBatchJob.mockImplementation(() => {
|
|
407
|
+
monitorCall += 1;
|
|
408
|
+
return Promise.resolve(
|
|
409
|
+
monitorCall === 1
|
|
410
|
+
? { status: 'failed', exitCode: 1, runtimeMs: 100, reason: 'infrastructure' }
|
|
411
|
+
: { status: 'succeeded', exitCode: 0, runtimeMs: 100, reason: 'complete' },
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
let depCall = 0;
|
|
416
|
+
fallback.callWithFallback.mockImplementation((endpoint, callOpts, buildBody) => {
|
|
417
|
+
depCall += 1;
|
|
418
|
+
return Promise.resolve({
|
|
419
|
+
deployment_id: `dep-${depCall}`, receipt_id: `rcpt-${depCall}`, gpu_type: 'RTX_4090',
|
|
420
|
+
gpu_count: 1, cost_per_hour: 0.2, provider: 'runpod', tier: '1', status: 'running',
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
api.callApi.mockImplementation((path) => {
|
|
424
|
+
if (path.startsWith('/deployments/dep-1')) return Promise.resolve({ status: 'failed', teardown_ok: 'ok' });
|
|
425
|
+
if (path.startsWith('/deployments/')) return Promise.resolve({ status: 'succeeded', teardown_ok: 'ok' });
|
|
426
|
+
return Promise.resolve({});
|
|
427
|
+
});
|
|
428
|
+
api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
|
|
429
|
+
|
|
430
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios')], chalk);
|
|
431
|
+
expect(process.exitCode).toBe(1);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('prints a ready-to-copy --only rerun command listing the failed inputs', async () => {
|
|
435
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
436
|
+
writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
|
|
437
|
+
writeFileSync(join(dir, 'scenarios', 's1.json'), '{}');
|
|
438
|
+
const yamlPath = writeFanOutYaml(dir);
|
|
439
|
+
|
|
440
|
+
const { monitorBatchJob } = await import('../src/batch.js');
|
|
441
|
+
monitorBatchJob
|
|
442
|
+
.mockResolvedValueOnce({ status: 'failed', exitCode: 1, runtimeMs: 100, reason: 'infrastructure' })
|
|
443
|
+
.mockResolvedValueOnce({ status: 'succeeded', exitCode: 0, runtimeMs: 100, reason: 'complete' });
|
|
444
|
+
let depCall = 0;
|
|
445
|
+
fallback.callWithFallback.mockImplementation(() => {
|
|
446
|
+
depCall += 1;
|
|
447
|
+
return Promise.resolve({
|
|
448
|
+
deployment_id: `dep-${depCall}`, receipt_id: `rcpt-${depCall}`, gpu_type: 'RTX_4090',
|
|
449
|
+
gpu_count: 1, cost_per_hour: 0.2, provider: 'runpod', tier: '1', status: 'running',
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
api.callApi.mockImplementation((path) => {
|
|
453
|
+
if (path.startsWith('/deployments/dep-1')) return Promise.resolve({ status: 'failed', teardown_ok: 'ok' });
|
|
454
|
+
if (path.startsWith('/deployments/')) return Promise.resolve({ status: 'succeeded', teardown_ok: 'ok' });
|
|
455
|
+
return Promise.resolve({});
|
|
456
|
+
});
|
|
457
|
+
api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
|
|
458
|
+
|
|
459
|
+
const logs = [];
|
|
460
|
+
const origLog = console.log;
|
|
461
|
+
console.log = (...args) => { logs.push(args.join(' ')); };
|
|
462
|
+
// --max-concurrency 1 makes submit/monitor order deterministic (s0 fully
|
|
463
|
+
// completes before s1 starts), so the first mocked monitor result (failed)
|
|
464
|
+
// is guaranteed to land on s0 rather than depending on scheduling order.
|
|
465
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--max-concurrency', '1'], chalk);
|
|
466
|
+
console.log = origLog;
|
|
467
|
+
|
|
468
|
+
expect(process.exitCode).toBe(1);
|
|
469
|
+
expect(logs.some(l => l.includes('--only s0.json'))).toBe(true);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('--only restricts the fan-out to the named files', async () => {
|
|
473
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
474
|
+
writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
|
|
475
|
+
writeFileSync(join(dir, 'scenarios', 's1.json'), '{}');
|
|
476
|
+
writeFileSync(join(dir, 'scenarios', 's2.json'), '{}');
|
|
477
|
+
const yamlPath = writeFanOutYaml(dir);
|
|
478
|
+
|
|
479
|
+
let callCount = 0;
|
|
480
|
+
fallback.callWithFallback.mockImplementation(() => {
|
|
481
|
+
callCount += 1;
|
|
482
|
+
return Promise.resolve({
|
|
483
|
+
deployment_id: `dep-${callCount}`, receipt_id: `rcpt-${callCount}`, gpu_type: 'RTX_4090',
|
|
484
|
+
gpu_count: 1, cost_per_hour: 0.2, provider: 'runpod', tier: '1', status: 'running',
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
api.callApi.mockResolvedValue({ status: 'succeeded', teardown_ok: 'ok', runtime_seconds: 1, accrued_cost_usd: 0.01 });
|
|
488
|
+
api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
|
|
489
|
+
|
|
490
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--only', 's0.json,s2.json'], chalk);
|
|
491
|
+
|
|
492
|
+
expect(callCount).toBe(2); // not 3 — s1.json excluded
|
|
493
|
+
expect(process.exitCode).toBeUndefined();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('--only rejects a filename that does not exist in the fan-out directory', async () => {
|
|
497
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
498
|
+
writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
|
|
499
|
+
const yamlPath = writeFanOutYaml(dir);
|
|
500
|
+
|
|
501
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--only', 'does-not-exist.json'], chalk);
|
|
502
|
+
|
|
503
|
+
expect(process.exitCode).toBe(1);
|
|
504
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('rejects a non-positive --max-concurrency before touching the network', async () => {
|
|
508
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
509
|
+
writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
|
|
510
|
+
const yamlPath = writeFanOutYaml(dir);
|
|
511
|
+
|
|
512
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--max-concurrency', '0'], chalk);
|
|
513
|
+
|
|
514
|
+
expect(process.exitCode).toBe(1);
|
|
515
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('never exceeds --max-concurrency in-flight submissions', async () => {
|
|
519
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
520
|
+
for (let i = 0; i < 6; i++) writeFileSync(join(dir, 'scenarios', `s${i}.json`), '{}');
|
|
521
|
+
const yamlPath = writeFanOutYaml(dir);
|
|
522
|
+
|
|
523
|
+
let inFlight = 0;
|
|
524
|
+
let maxInFlight = 0;
|
|
525
|
+
let depCall = 0;
|
|
526
|
+
fallback.callWithFallback.mockImplementation(async () => {
|
|
527
|
+
depCall += 1;
|
|
528
|
+
inFlight++;
|
|
529
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
530
|
+
await new Promise(r => setTimeout(r, 5));
|
|
531
|
+
return {
|
|
532
|
+
deployment_id: `dep-${depCall}`, receipt_id: `rcpt-${depCall}`, gpu_type: 'RTX_4090',
|
|
533
|
+
gpu_count: 1, cost_per_hour: 0.2, provider: 'runpod', tier: '1', status: 'running',
|
|
534
|
+
};
|
|
535
|
+
});
|
|
536
|
+
const { monitorBatchJob } = await import('../src/batch.js');
|
|
537
|
+
monitorBatchJob.mockImplementation(async () => {
|
|
538
|
+
await new Promise(r => setTimeout(r, 5));
|
|
539
|
+
inFlight--;
|
|
540
|
+
return { status: 'succeeded', exitCode: 0, runtimeMs: 5, reason: 'complete' };
|
|
541
|
+
});
|
|
542
|
+
api.callApi.mockResolvedValue({ status: 'succeeded', teardown_ok: 'ok', runtime_seconds: 1, accrued_cost_usd: 0.01 });
|
|
543
|
+
api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
|
|
544
|
+
|
|
545
|
+
await batchCommand(config, ['run', yamlPath, '--fan-out', join(dir, 'scenarios'), '--max-concurrency', '2'], chalk);
|
|
546
|
+
|
|
547
|
+
expect(maxInFlight).toBeLessThanOrEqual(2);
|
|
548
|
+
expect(depCall).toBe(6);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
240
552
|
// ---------------------------------------------------------------------------
|
|
241
553
|
// badgr batch artifacts — clear, actionable failure messages
|
|
242
554
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for a real command-injection vector: openBrowser used to
|
|
3
|
+
* build a shell command via string interpolation (`execSync(\`open "${url}"\`)`).
|
|
4
|
+
* url is not always a local constant — onboarding.js passes
|
|
5
|
+
* session.login_url, a backend API response — so a response containing
|
|
6
|
+
* shell metacharacters (e.g. a double quote followed by a command) would
|
|
7
|
+
* have executed arbitrary shell commands on the user's machine. Every test
|
|
8
|
+
* that exercises onboarding.js mocks browser.js entirely, so this file
|
|
9
|
+
* itself was never actually run by any existing test.
|
|
10
|
+
*
|
|
11
|
+
* Fixed with execFileSync (argv array, no shell) — this test asserts the
|
|
12
|
+
* fix directly: no shell ever sees url as a string to parse.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
15
|
+
|
|
16
|
+
const mockExecFileSync = vi.fn();
|
|
17
|
+
vi.mock('child_process', () => ({ execFileSync: (...args) => mockExecFileSync(...args) }));
|
|
18
|
+
|
|
19
|
+
const { openBrowser } = await import('../src/browser.js');
|
|
20
|
+
|
|
21
|
+
describe('openBrowser', () => {
|
|
22
|
+
const maliciousUrl = 'https://example.com/"; touch /tmp/pwned; echo "';
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockExecFileSync.mockReset();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('darwin: passes the URL as a single argv element, never a shell string', () => {
|
|
29
|
+
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
|
30
|
+
openBrowser(maliciousUrl);
|
|
31
|
+
expect(mockExecFileSync).toHaveBeenCalledWith('open', [maliciousUrl]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('linux: passes the URL as a single argv element, never a shell string', () => {
|
|
35
|
+
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
|
|
36
|
+
openBrowser(maliciousUrl);
|
|
37
|
+
expect(mockExecFileSync).toHaveBeenCalledWith('xdg-open', [maliciousUrl]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('win32: passes the URL as a single argv element, never a shell string', () => {
|
|
41
|
+
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
|
42
|
+
openBrowser(maliciousUrl);
|
|
43
|
+
expect(mockExecFileSync).toHaveBeenCalledWith('cmd', ['/c', 'start', '', maliciousUrl]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('never throws even if execFileSync fails — caller always has the printed URL as fallback', () => {
|
|
47
|
+
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
|
48
|
+
mockExecFileSync.mockImplementation(() => { throw new Error('no such command'); });
|
|
49
|
+
expect(() => openBrowser(maliciousUrl)).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr capacity — price-cap behavior
|
|
3
|
+
*
|
|
4
|
+
* Regression coverage: `badgr capacity` used to silently default to
|
|
5
|
+
* --max-price 10 and print misleading "under $10.00/hr" messaging even
|
|
6
|
+
* though the user never asked for a cap. It should now omit max_price
|
|
7
|
+
* entirely unless the user passes --max-price, and only mention a price
|
|
8
|
+
* ceiling in output when one was actually requested.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/api.js', () => ({
|
|
13
|
+
callApi: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import * as api from '../src/api.js';
|
|
17
|
+
import { capacityCommand } from '../src/commands/capacity.js';
|
|
18
|
+
|
|
19
|
+
const config = { apiKey: 'test-key', baseUrl: 'https://api.test' };
|
|
20
|
+
const chalk = new Proxy({}, { get: () => (s) => s });
|
|
21
|
+
|
|
22
|
+
function urlParams(callIndex = 0) {
|
|
23
|
+
const url = api.callApi.mock.calls[callIndex][0];
|
|
24
|
+
return new URLSearchParams(url.split('?')[1] ?? '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('capacityCommand price cap', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
api.callApi.mockReset();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('omits max_price from /capacity/auto when --max-price is not passed', async () => {
|
|
33
|
+
api.callApi.mockResolvedValueOnce({ gpu: 'RTX_4090', region: 'US', price: 0.17 });
|
|
34
|
+
await capacityCommand(config, [], chalk);
|
|
35
|
+
expect(urlParams().has('max_price')).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('sends max_price on /capacity/auto when --max-price is passed', async () => {
|
|
39
|
+
api.callApi.mockResolvedValueOnce({ gpu: 'RTX_4090', region: 'US', price: 0.17 });
|
|
40
|
+
await capacityCommand(config, ['--max-price', '5'], chalk);
|
|
41
|
+
expect(urlParams().get('max_price')).toBe('5');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('omits max_price from /capacity/suggestions when --max-price is not passed', async () => {
|
|
45
|
+
api.callApi.mockResolvedValueOnce({ matches: [], alternatives: [] });
|
|
46
|
+
await capacityCommand(config, ['--gpu', '5090'], chalk);
|
|
47
|
+
expect(urlParams().has('max_price')).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not mention a price ceiling in the no-match message when none was requested', async () => {
|
|
51
|
+
api.callApi.mockResolvedValueOnce({ matches: [], alternatives: [] });
|
|
52
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
53
|
+
await capacityCommand(config, ['--gpu', '5090'], chalk);
|
|
54
|
+
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
55
|
+
logSpy.mockRestore();
|
|
56
|
+
expect(output).toContain('No 5090 available right now.');
|
|
57
|
+
expect(output).not.toContain('under $');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('mentions the price ceiling in the no-match message when --max-price was passed', async () => {
|
|
61
|
+
api.callApi.mockResolvedValueOnce({ matches: [], alternatives: [] });
|
|
62
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
63
|
+
await capacityCommand(config, ['--gpu', '5090', '--max-price', '10'], chalk);
|
|
64
|
+
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
65
|
+
logSpy.mockRestore();
|
|
66
|
+
expect(output).toContain('under $10.00/hr');
|
|
67
|
+
});
|
|
68
|
+
});
|
package/tests/commands.test.js
CHANGED
|
@@ -95,6 +95,42 @@ describe('parseRunArgs', () => {
|
|
|
95
95
|
expect(flags.minVram).toBe(40);
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
it('parses --gpu-memory as a GB-parsed alias for --min-vram', () => {
|
|
99
|
+
const { flags } = parseRunArgs(['python', 'job.py', '--gpu-memory', '24GB']);
|
|
100
|
+
expect(flags.minVram).toBe(24);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('--gpu-memory accepts a bare number (assumed GB)', () => {
|
|
104
|
+
const { flags } = parseRunArgs(['python', 'job.py', '--gpu-memory', '40']);
|
|
105
|
+
expect(flags.minVram).toBe(40);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('parses --cpu as an integer core count', () => {
|
|
109
|
+
const { flags } = parseRunArgs(['python', 'job.py', '--cpu', '16']);
|
|
110
|
+
expect(flags.cpu).toBe(16);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('parses --memory with a GB/MB suffix into a GB float', () => {
|
|
114
|
+
const { flags: gb } = parseRunArgs(['python', 'job.py', '--memory', '64GB']);
|
|
115
|
+
expect(gb.memory).toBe(64);
|
|
116
|
+
const { flags: mb } = parseRunArgs(['python', 'job.py', '--memory', '65536MB']);
|
|
117
|
+
expect(mb.memory).toBeCloseTo(64);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('--cpu, --memory, --gpu-memory can all be combined', () => {
|
|
121
|
+
const { flags } = parseRunArgs(['python', 'job.py', '--cpu', '16', '--memory', '64GB', '--gpu-memory', '24GB']);
|
|
122
|
+
expect(flags.cpu).toBe(16);
|
|
123
|
+
expect(flags.memory).toBe(64);
|
|
124
|
+
expect(flags.minVram).toBe(24);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('parses --no-gpu', () => {
|
|
128
|
+
const { flags } = parseRunArgs(['python', 'sim.py', '--no-gpu', '--cpu', '16', '--memory', '64GB']);
|
|
129
|
+
expect(flags.noGpu).toBe(true);
|
|
130
|
+
expect(flags.cpu).toBe(16);
|
|
131
|
+
expect(flags.memory).toBe(64);
|
|
132
|
+
});
|
|
133
|
+
|
|
98
134
|
it('-- separator: badgr flags before --, command argv after', () => {
|
|
99
135
|
const { flags, commandArgv, positional } = parseRunArgs([
|
|
100
136
|
'--gpu', 'RTX_4090', '--image', 'node:20', '--max-cost', '1', '--',
|
|
@@ -371,3 +407,11 @@ describe('promptFallback output', () => {
|
|
|
371
407
|
expect(joined).toContain('1.25');
|
|
372
408
|
});
|
|
373
409
|
});
|
|
410
|
+
|
|
411
|
+
describe('old agent-positional run path (removed)', () => {
|
|
412
|
+
it('no longer exports isAgentRun/buildAgentRun from run.js', async () => {
|
|
413
|
+
const mod = await import('../src/commands/run.js');
|
|
414
|
+
expect(mod.isAgentRun).toBeUndefined();
|
|
415
|
+
expect(mod.buildAgentRun).toBeUndefined();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const store = {};
|
|
4
|
+
vi.mock('../src/credentials.js', () => ({
|
|
5
|
+
KNOWN_PROVIDERS: ['anthropic', 'openai'],
|
|
6
|
+
getCredential: vi.fn(provider => store[provider] ?? null),
|
|
7
|
+
setCredential: vi.fn((provider, value) => { store[provider] = value; }),
|
|
8
|
+
removeCredential: vi.fn(provider => {
|
|
9
|
+
if (!(provider in store)) return false;
|
|
10
|
+
delete store[provider];
|
|
11
|
+
return true;
|
|
12
|
+
}),
|
|
13
|
+
listCredentials: vi.fn(() => Object.keys(store)),
|
|
14
|
+
CREDENTIALS_FILE: '/home/test/.badgr/credentials.json',
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const { connectCommand } = await import('../src/commands/connect.js');
|
|
18
|
+
|
|
19
|
+
const chalk = { red: s => s, dim: s => s, green: s => s, bold: s => s, yellow: s => s, cyan: s => s };
|
|
20
|
+
|
|
21
|
+
describe('connectCommand', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
process.exitCode = undefined;
|
|
24
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
25
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
26
|
+
for (const k of Object.keys(store)) delete store[k];
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
process.exitCode = undefined;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('lists nothing connected when the store is empty', async () => {
|
|
35
|
+
await connectCommand([], chalk);
|
|
36
|
+
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
37
|
+
expect(logged).toContain('No providers connected');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects an unknown provider', async () => {
|
|
41
|
+
await connectCommand(['aws'], chalk);
|
|
42
|
+
expect(process.exitCode).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('stores a credential with --key non-interactively', async () => {
|
|
46
|
+
await connectCommand(['anthropic', '--key', 'sk-ant-fake'], chalk);
|
|
47
|
+
expect(process.exitCode).toBeUndefined();
|
|
48
|
+
expect(store.anthropic).toBe('sk-ant-fake');
|
|
49
|
+
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
50
|
+
expect(logged).toContain('Connected anthropic');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('lists a connected provider', async () => {
|
|
54
|
+
await connectCommand(['anthropic', '--key', 'sk-ant-fake'], chalk);
|
|
55
|
+
await connectCommand([], chalk);
|
|
56
|
+
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
57
|
+
expect(logged).toContain('anthropic');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('removes a stored credential with --remove', async () => {
|
|
61
|
+
await connectCommand(['anthropic', '--key', 'sk-ant-fake'], chalk);
|
|
62
|
+
await connectCommand(['anthropic', '--remove'], chalk);
|
|
63
|
+
expect(store.anthropic).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('`badgr connect list` lists connected providers instead of erroring as an unknown provider', async () => {
|
|
67
|
+
await connectCommand(['anthropic', '--key', 'sk-ant-fake'], chalk);
|
|
68
|
+
await connectCommand(['list'], chalk);
|
|
69
|
+
expect(process.exitCode).toBeUndefined();
|
|
70
|
+
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
71
|
+
expect(logged).toContain('anthropic');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('requires --key when not running in an interactive TTY', async () => {
|
|
75
|
+
await connectCommand(['anthropic'], chalk);
|
|
76
|
+
expect(process.exitCode).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects an empty --key', async () => {
|
|
80
|
+
await connectCommand(['anthropic', '--key', ' '], chalk);
|
|
81
|
+
expect(process.exitCode).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/tests/down.test.js
CHANGED
|
@@ -34,7 +34,7 @@ const chalk = {
|
|
|
34
34
|
cyan: s => s,
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
function makeStoppedDep(runtimeSeconds, costPerHour = 1.00) {
|
|
37
|
+
function makeStoppedDep(runtimeSeconds, costPerHour = 1.00, overrides = {}) {
|
|
38
38
|
const now = Date.now() / 1000;
|
|
39
39
|
return {
|
|
40
40
|
deployment_id: 'dep-test-001',
|
|
@@ -42,6 +42,8 @@ function makeStoppedDep(runtimeSeconds, costPerHour = 1.00) {
|
|
|
42
42
|
cost_per_hour: costPerHour,
|
|
43
43
|
stopped_at: now,
|
|
44
44
|
started_at: now - runtimeSeconds,
|
|
45
|
+
teardown_ok: 'ok',
|
|
46
|
+
...overrides,
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -114,6 +116,26 @@ describe('receipt is recorded', () => {
|
|
|
114
116
|
});
|
|
115
117
|
});
|
|
116
118
|
|
|
119
|
+
describe('teardown not confirmed', () => {
|
|
120
|
+
it('reports unconfirmed status and never claims "Stopped" when the backend did not confirm teardown', async () => {
|
|
121
|
+
// A 200 response with teardown_ok: 'failed' — the DELETE was accepted
|
|
122
|
+
// but the backend could not confirm the provider resource is gone.
|
|
123
|
+
api.terminateDeployment.mockResolvedValue(makeStoppedDep(27 * 60, 0.34, { teardown_ok: 'failed' }));
|
|
124
|
+
const logLines = [];
|
|
125
|
+
vi.spyOn(console, 'log').mockImplementation(msg => logLines.push(msg ?? ''));
|
|
126
|
+
|
|
127
|
+
await downCommand(config, ['dep-test-001'], chalk);
|
|
128
|
+
|
|
129
|
+
const out = logLines.join('\n');
|
|
130
|
+
expect(out).not.toContain('✓ Stopped');
|
|
131
|
+
expect(out).toContain('not confirmed');
|
|
132
|
+
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
|
|
133
|
+
status: 'teardown_unconfirmed',
|
|
134
|
+
}));
|
|
135
|
+
console.log.mockRestore();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
117
139
|
describe('error handling', () => {
|
|
118
140
|
it('prints error and returns when terminateDeployment throws', async () => {
|
|
119
141
|
api.terminateDeployment.mockRejectedValue(new Error('network error'));
|