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 +17 -1
- package/package.json +1 -1
- package/src/commands/node.js +32 -5
- package/src/nodeWorker/node_worker.py +57 -3
- package/src/nodeWorker/test_node_worker.py +55 -0
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
package/src/commands/node.js
CHANGED
|
@@ -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
|
-
|
|
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>]
|
|
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
|