badgr-cli 1.1.7 → 1.1.8

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 CHANGED
@@ -547,9 +547,25 @@ badgr serve meta-llama/Llama-3.1-8B-Instruct --target gpu-box-1
547
547
 
548
548
  A connected node is **private by default** — it is never marketplace-listed or shared; only you can target it, and only with `--target`. Marketplace listing, payout, and reputation scoring are not built yet (explicit follow-up phases).
549
549
 
550
+ ### Host-bound dependencies (venvs, patched source, model/cache dirs)
551
+
552
+ Jobs run inside a Docker container, which starts with none of this machine's filesystem visible except what's explicitly mounted. If your launch command depends on paths that already exist on this host — a Python venv, a patched source checkout (e.g. a custom-built vLLM), a model directory, a pip/HuggingFace cache — those paths will **not** exist inside the container unless you mount them:
553
+
554
+ ```bash
555
+ badgr node connect \
556
+ --mount /home/you/venvs/vllm:/opt/venv \
557
+ --mount /home/you/vllm-src:/opt/vllm-src \
558
+ --mount /sync/Models:/models:ro \
559
+ --mount /home/you/.cache:/root/.cache
560
+ ```
561
+
562
+ Each `--mount` is `HOST:CONTAINER` or `HOST:CONTAINER:ro` (same shape as `docker run -v`), repeatable, and both sides must be absolute paths. Mounts are configured once per node (not per job) and applied to every job that node runs. If you connected without `--mount` and the worker is already running, stop it and restart with the printed `python3 .../node_worker.py ...` command plus your `--mount` flags — there is no separate command to add a mount to an already-running worker yet.
563
+
564
+ An exact command that already works when you run it directly on this machine can still fail through `--target` if it references host paths you haven't mounted — that's a mounting gap, not a vendor/architecture/capacity one (see the BYO Preflight vendor/architecture checks above, which catch a different class of mismatch and pass independently of whether paths are mounted).
565
+
550
566
  | Command | What it does |
551
567
  |---------|-------------|
552
- | `badgr node connect [--name <name>]` | Register this machine and start its worker |
568
+ | `badgr node connect [--name <name>] [--mount HOST:CONTAINER[:ro] ...]` | Register this machine and start its worker |
553
569
  | `badgr node list` | List your connected nodes |
554
570
  | `badgr node inspect <node-id>` | Show one node's full detail as JSON |
555
571
  | `badgr node disable <node-id>` | Stop a node from receiving new jobs |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "description": "Badgr — run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,13 +71,16 @@ function python3Available() {
71
71
  }
72
72
  }
73
73
 
74
- function startWorker(config, node) {
74
+ function startWorker(config, node, mounts = []) {
75
75
  const logDir = path.join(os.homedir(), '.badgr', 'logs');
76
76
  fs.mkdirSync(logDir, { recursive: true });
77
77
  const logPath = path.join(logDir, `node-${node.node_id}.log`);
78
78
  const workerArgs = [
79
79
  WORKER_SCRIPT, '--node-id', node.node_id, '--api-key', config.apiKey, '--base-url', config.baseUrl,
80
80
  ];
81
+ for (const mount of mounts) {
82
+ workerArgs.push('--mount', mount);
83
+ }
81
84
  // Header line makes it obvious in the log file which `connect` invocation
82
85
  // spawned this worker run (helpful when a node is connected more than
83
86
  // once, or the worker is restarted) without needing to inspect argv.
@@ -114,15 +117,30 @@ function detectHardware() {
114
117
  }
115
118
 
116
119
  function parseFlags(args) {
117
- const flags = {};
120
+ const flags = { mounts: [] };
118
121
  let i = 0;
119
122
  while (i < args.length) {
120
123
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
124
+ if (args[i] === '--mount') { flags.mounts.push(args[++i]); i++; continue; }
121
125
  i++;
122
126
  }
123
127
  return flags;
124
128
  }
125
129
 
130
+ const MOUNT_RE = /^(\/[^:]+):(\/[^:]+)(:ro)?$/;
131
+
132
+ function validateMounts(mounts, chalk) {
133
+ for (const spec of mounts) {
134
+ if (!MOUNT_RE.test(spec)) {
135
+ console.error(chalk.red(
136
+ `\n ✗ Invalid --mount "${spec}": expected HOST:CONTAINER or HOST:CONTAINER:ro with absolute paths on both sides.\n`,
137
+ ));
138
+ return false;
139
+ }
140
+ }
141
+ return true;
142
+ }
143
+
126
144
  function resolveNodeIdArg(config, args) {
127
145
  return args.find((a) => !a.startsWith('--'));
128
146
  }
@@ -134,6 +152,10 @@ export async function nodeCommand(config, args, chalk) {
134
152
 
135
153
  if (sub === 'connect') {
136
154
  const flags = parseFlags(rest);
155
+ if (!validateMounts(flags.mounts, chalk)) {
156
+ process.exitCode = 1;
157
+ return;
158
+ }
137
159
  const hardware = detectHardware();
138
160
  console.log(chalk.dim(
139
161
  ` Detected: vendor=${hardware.gpu_vendor || 'none'} gpu_count=${hardware.gpu_count} gpu_model=${hardware.gpu_model || 'none'} `
@@ -166,12 +188,16 @@ export async function nodeCommand(config, args, chalk) {
166
188
  ].filter(Boolean).join(', ');
167
189
  console.log(chalk.yellow(` Not starting the worker automatically — missing: ${missing}.`));
168
190
  console.log(` Once fixed, start it with:`);
169
- console.log(chalk.cyan(` python3 "${WORKER_SCRIPT}" --node-id ${node.node_id} --api-key <your-api-key>\n`));
191
+ const mountArgs = flags.mounts.map((m) => `--mount ${m}`).join(' ');
192
+ console.log(chalk.cyan(` python3 "${WORKER_SCRIPT}" --node-id ${node.node_id} --api-key <your-api-key>${mountArgs ? ` ${mountArgs}` : ''}\n`));
170
193
  return;
171
194
  }
172
195
 
173
- const { pid, logPath } = startWorker(config, node);
196
+ const { pid, logPath } = startWorker(config, node, flags.mounts);
174
197
  console.log(` Worker started (pid ${pid}) — this node is now receiving jobs.`);
198
+ if (flags.mounts.length) {
199
+ console.log(chalk.dim(` Host mounts: ${flags.mounts.join(', ')}`));
200
+ }
175
201
  console.log(chalk.dim(` Logs: ${logPath}\n`));
176
202
  return;
177
203
  }
@@ -242,7 +268,8 @@ export async function nodeCommand(config, args, chalk) {
242
268
  }
243
269
 
244
270
  console.log(chalk.bold('\nbadgr node — manage your own connected GPUs\n'));
245
- console.log(' badgr node connect [--name <name>] Connect this machine\'s GPU (private)');
271
+ console.log(' badgr node connect [--name <name>] [--mount HOST:CONTAINER[:ro] ...]');
272
+ console.log(' Connect this machine\'s GPU (private)');
246
273
  console.log(' badgr node list List your connected nodes');
247
274
  console.log(' badgr node inspect <node-id> Show one node\'s details');
248
275
  console.log(' badgr node disable <node-id> Stop a node from receiving new jobs');
@@ -12,6 +12,18 @@ standard NVIDIA Container Toolkit flag) on NVIDIA, or the ROCm container
12
12
  device flags (`--device=/dev/kfd --device=/dev/dri --group-add video
13
13
  --group-add render --security-opt seccomp=unconfined`) on AMD — no
14
14
  Slurm/Kubernetes, per this PR's locked scope.
15
+
16
+ The container starts with none of this machine's filesystem visible except
17
+ what's explicitly bind-mounted. A workload whose launch command depends on
18
+ host paths that already exist on this machine (a Python venv, a patched
19
+ source checkout, a model directory, a HuggingFace/pip cache) will not find
20
+ them inside the container unless this worker was started with `--mount
21
+ HOST:CONTAINER[:ro]` (repeatable; same shape as `docker run -v`) for each
22
+ one — see `--mount` in `main()`'s argparse setup and `badgr node connect
23
+ --mount`. These are static, host-bound paths configured once per node, not
24
+ part of a per-job plan: the backend has no visibility into what exists on
25
+ a customer's own machine, so it never sends mount paths — only this
26
+ worker, run by the machine's owner, can name them.
15
27
  A "job" workload runs to completion and reports its real exit code. An
16
28
  "endpoint" workload (badgr serve) runs detached and is reported "running"
17
29
  only after a real readiness check (GET health_path) and, when a model was
@@ -56,6 +68,37 @@ _RESULT_RE = re.compile(r"BADGR_RESULT=(PASS|FAIL)")
56
68
  # a long model load, so a tail -f doesn't look hung for minutes at a time.
57
69
  READINESS_LOG_EVERY_N_POLLS = 5
58
70
 
71
+ # HOST:CONTAINER or HOST:CONTAINER:ro -- same shape docker itself takes for
72
+ # `-v`. Requires absolute paths on both sides so a relative path (which
73
+ # would resolve against whatever directory the worker daemon happens to be
74
+ # started from, not the customer's intent) fails fast at startup instead of
75
+ # silently mounting the wrong thing.
76
+ _MOUNT_RE = re.compile(r"^(/[^:]+):(/[^:]+)(?::(ro))?$")
77
+
78
+
79
+ def _parse_mount(spec: str) -> tuple[str, str, bool]:
80
+ """Validates and splits one `--mount` value. Raises ValueError with a
81
+ user-facing reason on anything malformed, since a bad mount spec should
82
+ stop the worker from starting rather than fail confusingly on the first
83
+ job."""
84
+ m = _MOUNT_RE.match(spec)
85
+ if not m:
86
+ raise ValueError(
87
+ f"invalid --mount {spec!r}: expected HOST:CONTAINER or HOST:CONTAINER:ro "
88
+ "with absolute paths on both sides"
89
+ )
90
+ host, container, ro = m.group(1), m.group(2), m.group(3)
91
+ return host, container, bool(ro)
92
+
93
+
94
+ def _mount_docker_args(mounts: list[str] | None) -> list[str]:
95
+ """`-v` flags for every configured host mount. See module docstring."""
96
+ args: list[str] = []
97
+ for spec in mounts or []:
98
+ host, container, ro = _parse_mount(spec)
99
+ args += ["-v", f"{host}:{container}" + (":ro" if ro else "")]
100
+ return args
101
+
59
102
 
60
103
  def _detect_gpu_vendor() -> str | None:
61
104
  """One-time, read-only vendor probe (mirrors the CLI's own
@@ -175,7 +218,7 @@ def _parse_diagnostic_result(log_tail: str) -> str | None:
175
218
  return None
176
219
 
177
220
 
178
- def _run_job(base_url: str, api_key: str, node_id: str, job_id: str, plan: dict, gpu_vendor: str | None = None) -> None:
221
+ def _run_job(base_url: str, api_key: str, node_id: str, job_id: str, plan: dict, gpu_vendor: str | None = None, mounts: list[str] | None = None) -> None:
179
222
  image = plan.get("image")
180
223
  command = plan.get("command") or []
181
224
  env = plan.get("env") or {}
@@ -187,7 +230,7 @@ def _run_job(base_url: str, api_key: str, node_id: str, job_id: str, plan: dict,
187
230
  max_runtime_seconds = plan.get("max_runtime_seconds") or DEFAULT_JOB_TIMEOUT_SECONDS
188
231
 
189
232
  container_name = f"badgr-job-{job_id}"
190
- docker_cmd = ["docker", "run", *_gpu_docker_args(gpu_vendor), "--name", container_name]
233
+ docker_cmd = ["docker", "run", *_gpu_docker_args(gpu_vendor), *_mount_docker_args(mounts), "--name", container_name]
191
234
  docker_cmd += ["--rm"] if not is_endpoint else ["-d"]
192
235
  if privileged:
193
236
  # Bounded host diagnostics (NVLink/P2P/FabricManager/dmesg checks)
@@ -289,11 +332,22 @@ def main() -> None:
289
332
  parser.add_argument("--node-id", required=True, help="node_… id from `badgr node connect`")
290
333
  parser.add_argument("--api-key", required=True, help="badgr CLI API key (same one `badgr login` stored)")
291
334
  parser.add_argument("--base-url", default="https://aibadgr.com/v1")
335
+ parser.add_argument(
336
+ "--mount", action="append", default=[], metavar="HOST:CONTAINER[:ro]",
337
+ help="Bind-mount a host path into every job's container (repeatable). "
338
+ "Use for host-bound dependencies (venvs, patched source, model/cache "
339
+ "dirs) this workload's command expects to already exist on disk.",
340
+ )
292
341
  args = parser.parse_args()
293
342
 
343
+ for spec in args.mount:
344
+ _parse_mount(spec) # fail fast at startup on a malformed --mount, not mid-job
345
+
294
346
  gpu_vendor = _detect_gpu_vendor()
295
347
  _log("INFO", f"worker starting for node_id={args.node_id} against {args.base_url}")
296
348
  _log("INFO", f"gpu_vendor={gpu_vendor or 'unknown'} (docker run device flags: {' '.join(_gpu_docker_args(gpu_vendor))})")
349
+ if args.mount:
350
+ _log("INFO", f"host mounts: {', '.join(args.mount)}")
297
351
  _log("INFO", f"heartbeat_interval={HEARTBEAT_INTERVAL_SECONDS}s poll_interval={POLL_INTERVAL_SECONDS}s")
298
352
  last_heartbeat = 0.0
299
353
  while True:
@@ -315,7 +369,7 @@ def main() -> None:
315
369
  if job.get("job_id"):
316
370
  _log("INFO", f"received job {job['job_id']} from poll")
317
371
  try:
318
- _run_job(args.base_url, args.api_key, args.node_id, job["job_id"], job.get("plan") or {}, gpu_vendor)
372
+ _run_job(args.base_url, args.api_key, args.node_id, job["job_id"], job.get("plan") or {}, gpu_vendor, args.mount)
319
373
  except Exception: # noqa: BLE001 — a bug in job handling must not silently kill the worker loop
320
374
  _log("ERROR", f"unhandled exception while running job {job['job_id']}:\n{traceback.format_exc()}")
321
375
  else:
@@ -146,6 +146,61 @@ def test_gpu_docker_args_defaults_to_nvidia_flag_when_vendor_unknown():
146
146
  assert node_worker._gpu_docker_args(None) == ["--gpus", "all"]
147
147
 
148
148
 
149
+ def test_mount_docker_args_builds_v_flags():
150
+ args = node_worker._mount_docker_args([
151
+ "/home/faisal/venvs/vllm:/opt/venv",
152
+ "/home/faisal/.cache:/root/.cache:ro",
153
+ ])
154
+ assert args == [
155
+ "-v", "/home/faisal/venvs/vllm:/opt/venv",
156
+ "-v", "/home/faisal/.cache:/root/.cache:ro",
157
+ ]
158
+
159
+
160
+ def test_mount_docker_args_empty_when_no_mounts():
161
+ assert node_worker._mount_docker_args(None) == []
162
+ assert node_worker._mount_docker_args([]) == []
163
+
164
+
165
+ def test_parse_mount_rejects_relative_path():
166
+ import pytest
167
+ with pytest.raises(ValueError):
168
+ node_worker._parse_mount("relative/path:/container/path")
169
+
170
+
171
+ def test_parse_mount_rejects_malformed_spec():
172
+ import pytest
173
+ with pytest.raises(ValueError):
174
+ node_worker._parse_mount("/only/one/path")
175
+
176
+
177
+ def test_run_job_includes_configured_mounts_in_docker_command(monkeypatch):
178
+ captured_cmds = []
179
+
180
+ class _FakeProc:
181
+ pid = 4242
182
+ returncode = 0
183
+ stdout = None
184
+
185
+ def poll(self):
186
+ return 0
187
+
188
+ monkeypatch.setattr(node_worker.subprocess, "Popen", lambda cmd, **kw: captured_cmds.append(cmd) or _FakeProc())
189
+ monkeypatch.setattr(node_worker, "_api", lambda *a, **kw: {})
190
+
191
+ node_worker._run_job(
192
+ "https://x/v1", "key", "node_1", "job_6",
193
+ {"image": "vllm/vllm-openai:latest", "command": ["python", "-m", "vllm.entrypoints.openai.api_server"]},
194
+ "nvidia",
195
+ ["/home/faisal/vllm-src:/opt/vllm-src", "/sync/Models:/models:ro"],
196
+ )
197
+
198
+ docker_cmd = captured_cmds[0]
199
+ assert "-v" in docker_cmd
200
+ assert "/home/faisal/vllm-src:/opt/vllm-src" in docker_cmd
201
+ assert "/sync/Models:/models:ro" in docker_cmd
202
+
203
+
149
204
  def test_job_exceeding_max_runtime_is_killed_and_reported(monkeypatch):
150
205
  """A hung diagnostic must not wedge the worker forever -- the loop kills
151
206
  the named container once max_runtime_seconds elapses and reports a