badgr-cli 1.1.5 → 1.1.7
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 +49 -13
- package/package.json +1 -1
- package/src/admin.js +29 -0
- package/src/api.js +35 -29
- package/src/badgr.js +18 -6
- package/src/catalog.js +12 -0
- package/src/commands/capacity.js +38 -1
- package/src/commands/check.js +127 -0
- package/src/commands/connect.js +3 -2
- package/src/commands/diagnose.js +89 -41
- package/src/commands/down.js +16 -7
- package/src/commands/job.js +5 -5
- package/src/commands/launch.js +37 -12
- package/src/commands/node.js +250 -0
- package/src/commands/run.js +14 -4
- package/src/commands/serve.js +103 -15
- package/src/commands/status.js +13 -5
- package/src/commands/train.js +1 -1
- package/src/config.js +12 -19
- package/src/credentials.js +6 -0
- package/src/fallback.js +9 -11
- package/src/gpuDoctor/gpuInfo.js +144 -5
- package/src/nodeWorker/node_worker.py +326 -0
- package/src/nodeWorker/test_node_worker.py +188 -0
- package/src/progress.js +2 -2
- package/src/spec.js +2 -2
package/src/credentials.js
CHANGED
|
@@ -8,6 +8,12 @@ export const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
|
|
|
8
8
|
export const PROVIDER_ENV_KEYS = {
|
|
9
9
|
anthropic: 'ANTHROPIC_API_KEY',
|
|
10
10
|
openai: 'OPENAI_API_KEY',
|
|
11
|
+
// Command Code is a single hosted account (bundled model catalog), not an
|
|
12
|
+
// OpenAI-compatible swap target — verified live: `COMMAND_CODE_API_KEY`
|
|
13
|
+
// read directly from env, no login step (see
|
|
14
|
+
// images/badgr-agent-commandcode/badgr-commandcode-run). Fixed credential
|
|
15
|
+
// like anthropic/openai, not part of the BYOK lane below.
|
|
16
|
+
commandcode: 'COMMAND_CODE_API_KEY',
|
|
11
17
|
// OpenAI-compatible BYOK lanes for `badgr launch cline --provider <name>`
|
|
12
18
|
// (see MODEL_PROVIDERS below) — the cline agent image only ever reads
|
|
13
19
|
// OPENAI_API_KEY/OPENAI_BASE_URL/MODEL (images/badgr-agent-cline/
|
package/src/fallback.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
|
|
|
49
49
|
const cmd = labels?.cmd ?? 'badgr run';
|
|
50
50
|
|
|
51
51
|
// 220s: comfortably above backend's BADGR_PROVISION_TIMEOUT_SECONDS (default
|
|
52
|
-
// 200s, itself set above
|
|
52
|
+
// 200s, itself set above the backend's own 180s routing-search
|
|
53
53
|
// deadline). Found live: the previous 130s value fired before the server's
|
|
54
54
|
// own (then-120s) deadline could, so the CLI reported "failed to submit"
|
|
55
55
|
// for jobs that had actually been created and were already billing —
|
|
@@ -144,16 +144,14 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
|
|
|
144
144
|
|
|
145
145
|
const d = firstErr.errorData;
|
|
146
146
|
|
|
147
|
-
// No client-side "provider retry" here on purpose. The backend
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
// smoke-check-failure path, both of which only report these codes once
|
|
156
|
-
// a resource actually existed). A second /run or /serve call here would
|
|
147
|
+
// No client-side "provider retry" here on purpose. The backend's own
|
|
148
|
+
// provisioning logic already tries every viable provider/offer for a
|
|
149
|
+
// request within the ONE deployment it creates before ever returning a
|
|
150
|
+
// failure -- a PROVISIONING_FAILED/PROVIDER_ADAPTER_ERROR response means
|
|
151
|
+
// that whole internal search already ran and a real, billable resource
|
|
152
|
+
// was very likely created and destroyed along the way (the backend only
|
|
153
|
+
// reports these codes once a resource actually existed). A second /run
|
|
154
|
+
// or /serve call here would
|
|
157
155
|
// be an entirely new deployment repeating that same internal search from
|
|
158
156
|
// scratch -- duplicate billable exposure for a workload that was never a
|
|
159
157
|
// capacity problem in the first place. This CLI previously did retry
|
package/src/gpuDoctor/gpuInfo.js
CHANGED
|
@@ -4,11 +4,19 @@ function round1(n) {
|
|
|
4
4
|
return Math.round(n * 10) / 10;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
function bytesToGb(n) {
|
|
8
|
+
return round1(Number(n) / (1024 ** 3));
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
/**
|
|
8
|
-
* Read-only nvidia-smi probe.
|
|
12
|
+
* Read-only nvidia-smi probe. Returns null when nvidia-smi isn't installed
|
|
13
|
+
* (ENOENT) so the caller can fall back to rocm-smi; any other failure
|
|
14
|
+
* (permission denied, driver broken, etc.) is a real NVIDIA-specific error
|
|
15
|
+
* and is returned as such, not treated as "try the other vendor".
|
|
9
16
|
*/
|
|
10
|
-
|
|
17
|
+
function _probeNvidia(execImpl) {
|
|
11
18
|
const result = {
|
|
19
|
+
vendor: 'nvidia',
|
|
12
20
|
available: false,
|
|
13
21
|
error: null,
|
|
14
22
|
driverVersion: null,
|
|
@@ -21,7 +29,8 @@ export function detectGpus(execImpl = execFileSync) {
|
|
|
21
29
|
try {
|
|
22
30
|
smiOut = execImpl('nvidia-smi', [], { encoding: 'utf8', timeout: 5000 });
|
|
23
31
|
} catch (err) {
|
|
24
|
-
|
|
32
|
+
if (err && err.code === 'ENOENT') return null;
|
|
33
|
+
result.error = (err && err.message) || 'nvidia-smi failed';
|
|
25
34
|
return result;
|
|
26
35
|
}
|
|
27
36
|
|
|
@@ -33,12 +42,20 @@ export function detectGpus(execImpl = execFileSync) {
|
|
|
33
42
|
if (cudaMatch) result.cudaVersion = cudaMatch[1];
|
|
34
43
|
|
|
35
44
|
try {
|
|
45
|
+
// compute_cap (e.g. "8.9" for Ada Lovelace, "9.0" for Hopper) is the
|
|
46
|
+
// real, stable NVIDIA architecture identifier -- the same value used in
|
|
47
|
+
// TORCH_CUDA_ARCH_LIST -- and is what byo_preflight.py's
|
|
48
|
+
// required_gpu_architecture check compares node_hardware.gpu_architecture
|
|
49
|
+
// against. Supported by nvidia-smi on any reasonably current driver;
|
|
50
|
+
// older drivers that reject the field fall into the catch below and
|
|
51
|
+
// simply leave architecture null per GPU, same as any other unsupported
|
|
52
|
+
// CSV field here.
|
|
36
53
|
const csv = execImpl('nvidia-smi', [
|
|
37
|
-
'--query-gpu=index,name,memory.total,memory.free,memory.used,temperature.gpu,power.draw',
|
|
54
|
+
'--query-gpu=index,name,memory.total,memory.free,memory.used,temperature.gpu,power.draw,compute_cap',
|
|
38
55
|
'--format=csv,noheader,nounits',
|
|
39
56
|
], { encoding: 'utf8', timeout: 5000 });
|
|
40
57
|
result.gpus = csv.trim().split(/\r?\n/).filter(Boolean).map((line) => {
|
|
41
|
-
const [index, name, total, free, used, temp, power] = line.split(',').map((s) => s.trim());
|
|
58
|
+
const [index, name, total, free, used, temp, power, computeCap] = line.split(',').map((s) => s.trim());
|
|
42
59
|
return {
|
|
43
60
|
index: Number(index),
|
|
44
61
|
name,
|
|
@@ -47,6 +64,7 @@ export function detectGpus(execImpl = execFileSync) {
|
|
|
47
64
|
vramUsedGb: round1(Number(used) / 1024),
|
|
48
65
|
temperatureC: temp && temp !== '[N/A]' ? Number(temp) : null,
|
|
49
66
|
powerDrawW: power && power !== '[N/A]' ? Number(power) : null,
|
|
67
|
+
architecture: computeCap && computeCap !== '[N/A]' ? computeCap : null,
|
|
50
68
|
};
|
|
51
69
|
});
|
|
52
70
|
} catch {
|
|
@@ -68,3 +86,124 @@ export function detectGpus(execImpl = execFileSync) {
|
|
|
68
86
|
|
|
69
87
|
return result;
|
|
70
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read-only rocm-smi probe (AMD/ROCm — e.g. Radeon Pro / Instinct cards on
|
|
92
|
+
* gfx9xx/gfx10xx). Same "return null on ENOENT" contract as _probeNvidia so
|
|
93
|
+
* detectGpus can tell "not installed" apart from "installed but failing".
|
|
94
|
+
*/
|
|
95
|
+
function _probeRocm(execImpl) {
|
|
96
|
+
const result = {
|
|
97
|
+
vendor: 'amd',
|
|
98
|
+
available: false,
|
|
99
|
+
error: null,
|
|
100
|
+
driverVersion: null,
|
|
101
|
+
cudaVersion: null,
|
|
102
|
+
gpus: [],
|
|
103
|
+
processes: [],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let raw;
|
|
107
|
+
try {
|
|
108
|
+
raw = execImpl('rocm-smi', [
|
|
109
|
+
'--showproductname', '--showmeminfo', 'vram', '--showtemp', '--showpower',
|
|
110
|
+
'--showdriverversion', '--showtarget', '--json',
|
|
111
|
+
], { encoding: 'utf8', timeout: 5000 });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err && err.code === 'ENOENT') return null;
|
|
114
|
+
result.error = (err && err.message) || 'rocm-smi failed';
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
result.available = true;
|
|
119
|
+
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(raw);
|
|
123
|
+
} catch {
|
|
124
|
+
// rocm-smi found but this build doesn't support --json (older ROCm) —
|
|
125
|
+
// still "available", just no per-GPU detail to report.
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cardKeys = Object.keys(parsed)
|
|
130
|
+
.filter((k) => /^card\d+$/.test(k))
|
|
131
|
+
.sort((a, b) => Number(a.slice(4)) - Number(b.slice(4)));
|
|
132
|
+
|
|
133
|
+
const findField = (card, ...needles) => {
|
|
134
|
+
const hit = Object.keys(card).find((k) => {
|
|
135
|
+
const lower = k.toLowerCase();
|
|
136
|
+
return needles.some((n) => lower.includes(n));
|
|
137
|
+
});
|
|
138
|
+
return hit ? card[hit] : undefined;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// The exact --showtarget key name has drifted across ROCm releases (e.g.
|
|
142
|
+
// "GPU Target Graphics Version" vs "Card Series" being reused, etc.), so
|
|
143
|
+
// key-name matching alone (as used for every other field above) isn't
|
|
144
|
+
// reliable here. A gfx architecture string (gfx900, gfx1030, gfx1100, ...)
|
|
145
|
+
// is distinctive and never appears as any other field's value, so scan
|
|
146
|
+
// every value on the card for that pattern regardless of which key it
|
|
147
|
+
// landed under -- robust to ROCm's key-naming drift by construction.
|
|
148
|
+
const _GFX_VALUE_RE = /\bgfx[0-9a-fA-F]{3,4}\b/;
|
|
149
|
+
const findArchitecture = (card) => {
|
|
150
|
+
for (const v of Object.values(card)) {
|
|
151
|
+
const m = String(v).match(_GFX_VALUE_RE);
|
|
152
|
+
if (m) return m[0].toLowerCase();
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
result.gpus = cardKeys.map((key, i) => {
|
|
158
|
+
const card = parsed[key] || {};
|
|
159
|
+
const totalBytes = findField(card, 'vram total memory');
|
|
160
|
+
const usedBytes = findField(card, 'vram total used memory');
|
|
161
|
+
const temp = findField(card, 'temperature');
|
|
162
|
+
const power = findField(card, 'power');
|
|
163
|
+
const totalGb = totalBytes != null ? bytesToGb(totalBytes) : null;
|
|
164
|
+
const usedGb = usedBytes != null ? bytesToGb(usedBytes) : null;
|
|
165
|
+
return {
|
|
166
|
+
index: i,
|
|
167
|
+
name: findField(card, 'card series', 'card model', 'product name') || 'AMD GPU',
|
|
168
|
+
vramTotalGb: totalGb,
|
|
169
|
+
vramFreeGb: totalGb != null && usedGb != null ? round1(totalGb - usedGb) : null,
|
|
170
|
+
vramUsedGb: usedGb,
|
|
171
|
+
temperatureC: temp != null && !Number.isNaN(parseFloat(temp)) ? Number(parseFloat(temp)) : null,
|
|
172
|
+
powerDrawW: power != null && !Number.isNaN(parseFloat(power)) ? Number(parseFloat(power)) : null,
|
|
173
|
+
architecture: findArchitecture(card),
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const systemFields = parsed.system || {};
|
|
178
|
+
const driverKey = Object.keys(systemFields).find((k) => k.toLowerCase().includes('driver version'));
|
|
179
|
+
if (driverKey) result.driverVersion = systemFields[driverKey];
|
|
180
|
+
// rocm-smi has no fleet-wide "ROCm version" equivalent to nvidia-smi's CUDA
|
|
181
|
+
// Version header — a workload's own required HSA_OVERRIDE_GFX_VERSION /
|
|
182
|
+
// PYTORCH_ROCM_ARCH stays a launch-command concern, not a hardware fact
|
|
183
|
+
// this probe can report. cudaVersion stays null here on purpose.
|
|
184
|
+
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read-only GPU probe. Tries nvidia-smi first, falls back to rocm-smi
|
|
190
|
+
* (AMD/ROCm) when NVIDIA tooling isn't installed. Never mutates the
|
|
191
|
+
* machine. `vendor` is 'nvidia' | 'amd' | null (neither tool present).
|
|
192
|
+
*/
|
|
193
|
+
export function detectGpus(execImpl = execFileSync) {
|
|
194
|
+
const nvidia = _probeNvidia(execImpl);
|
|
195
|
+
if (nvidia) return nvidia;
|
|
196
|
+
|
|
197
|
+
const rocm = _probeRocm(execImpl);
|
|
198
|
+
if (rocm) return rocm;
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
vendor: null,
|
|
202
|
+
available: false,
|
|
203
|
+
error: 'nvidia-smi and rocm-smi not found',
|
|
204
|
+
driverVersion: null,
|
|
205
|
+
cudaVersion: null,
|
|
206
|
+
gpus: [],
|
|
207
|
+
processes: [],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""
|
|
2
|
+
badgr-node — the BYO GPU (Phase 1) worker daemon.
|
|
3
|
+
|
|
4
|
+
Runs on a customer's own machine (registered via `badgr node connect`) and
|
|
5
|
+
implements the loop the locked spec requires: register -> heartbeat ->
|
|
6
|
+
receive job -> execute -> stream status -> complete. Talks only to
|
|
7
|
+
the backend's own /v1/nodes/{node_id}/* endpoints.
|
|
8
|
+
|
|
9
|
+
Execution is plain `docker run`, with GPU passthrough flags chosen from a
|
|
10
|
+
one-time local vendor probe (`_detect_gpu_vendor`): `--gpus all` (the
|
|
11
|
+
standard NVIDIA Container Toolkit flag) on NVIDIA, or the ROCm container
|
|
12
|
+
device flags (`--device=/dev/kfd --device=/dev/dri --group-add video
|
|
13
|
+
--group-add render --security-opt seccomp=unconfined`) on AMD — no
|
|
14
|
+
Slurm/Kubernetes, per this PR's locked scope.
|
|
15
|
+
A "job" workload runs to completion and reports its real exit code. An
|
|
16
|
+
"endpoint" workload (badgr serve) runs detached and is reported "running"
|
|
17
|
+
only after a real readiness check (GET health_path) and, when a model was
|
|
18
|
+
requested, a real minimal completion request (POST /v1/completions) both
|
|
19
|
+
succeed against the container locally — the same two checks
|
|
20
|
+
the backend's own readiness/inference verification logic makes, same URL
|
|
21
|
+
paths and payload shape, just run from here instead of from the backend. That's not a stylistic choice: Badgr's
|
|
22
|
+
backend cannot reach a customer's own machine directly (outbound-only,
|
|
23
|
+
often behind NAT — see this PR's locked "no P2P networking" scope), so
|
|
24
|
+
the backend trusts this worker's self-reported "running" for a
|
|
25
|
+
customer_node target instead of re-probing an address it could never
|
|
26
|
+
route to (see the backend's own deployment logic for the
|
|
27
|
+
customer-node target case).
|
|
28
|
+
"""
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
import traceback
|
|
37
|
+
import urllib.error
|
|
38
|
+
import urllib.request
|
|
39
|
+
|
|
40
|
+
POLL_INTERVAL_SECONDS = 3
|
|
41
|
+
HEARTBEAT_INTERVAL_SECONDS = 20
|
|
42
|
+
LOG_TAIL_LINES = 200
|
|
43
|
+
READINESS_TIMEOUT_SECONDS = 300
|
|
44
|
+
READINESS_POLL_SECONDS = 3
|
|
45
|
+
# A plan's own max_runtime_seconds always wins; this is only the backstop
|
|
46
|
+
# for a "job" workload that didn't set one, so a hung diagnostic can't wedge
|
|
47
|
+
# the worker's single-job-at-a-time loop forever (see module docstring).
|
|
48
|
+
DEFAULT_JOB_TIMEOUT_SECONDS = 900
|
|
49
|
+
|
|
50
|
+
# A diagnostic script prints this on its own last line to declare a
|
|
51
|
+
# structured pass/fail verdict distinct from the container's exit code
|
|
52
|
+
# (see report_job_status's `result` field). Anything else is just a normal
|
|
53
|
+
# job with no verdict to report.
|
|
54
|
+
_RESULT_RE = re.compile(r"BADGR_RESULT=(PASS|FAIL)")
|
|
55
|
+
# How often (in poll iterations) to log "still waiting on readiness" during
|
|
56
|
+
# a long model load, so a tail -f doesn't look hung for minutes at a time.
|
|
57
|
+
READINESS_LOG_EVERY_N_POLLS = 5
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _detect_gpu_vendor() -> str | None:
|
|
61
|
+
"""One-time, read-only vendor probe (mirrors the CLI's own
|
|
62
|
+
gpuDoctor/gpuInfo.js `detectGpus`, kept independent here since the
|
|
63
|
+
worker runs as a separate long-lived process and can't import JS).
|
|
64
|
+
`badgr node connect` already told the backend which vendor it saw at
|
|
65
|
+
connect time, but the worker itself needs a local answer too, to pick
|
|
66
|
+
the right `docker run` device flags per job — see module docstring.
|
|
67
|
+
NVIDIA is checked first only because that's the incumbent path; a
|
|
68
|
+
machine only ever has one or the other. Returns "nvidia", "amd", or
|
|
69
|
+
None (no GPU tooling found — jobs will still be attempted, docker run
|
|
70
|
+
just gets no device flags and will fail visibly instead of silently
|
|
71
|
+
picking a vendor that isn't there)."""
|
|
72
|
+
if shutil.which("nvidia-smi"):
|
|
73
|
+
return "nvidia"
|
|
74
|
+
if shutil.which("rocm-smi"):
|
|
75
|
+
return "amd"
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _gpu_docker_args(gpu_vendor: str | None) -> list[str]:
|
|
80
|
+
"""GPU passthrough flags for `docker run`, per vendor. See
|
|
81
|
+
module docstring."""
|
|
82
|
+
if gpu_vendor == "amd":
|
|
83
|
+
return [
|
|
84
|
+
"--device=/dev/kfd", "--device=/dev/dri",
|
|
85
|
+
"--group-add", "video", "--group-add", "render",
|
|
86
|
+
"--security-opt", "seccomp=unconfined",
|
|
87
|
+
]
|
|
88
|
+
# Default to the NVIDIA Container Toolkit flag -- matches this worker's
|
|
89
|
+
# pre-ROCm behavior when vendor detection itself fails to find either
|
|
90
|
+
# tool (rare: connect already required one to register the node).
|
|
91
|
+
return ["--gpus", "all"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _log(level: str, msg: str) -> None:
|
|
95
|
+
ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
|
|
96
|
+
stream = sys.stderr if level in ("WARN", "ERROR") else sys.stdout
|
|
97
|
+
print(f"{ts}Z [badgr-node] [{level}] {msg}", file=stream, flush=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _api(base_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict:
|
|
101
|
+
url = f"{base_url}{path}"
|
|
102
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
103
|
+
req = urllib.request.Request(url, data=data, method=method, headers={
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
"Authorization": f"Bearer {api_key}",
|
|
106
|
+
})
|
|
107
|
+
_log("DEBUG", f"-> {method} {path}" + (f" body={body}" if body else ""))
|
|
108
|
+
try:
|
|
109
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
110
|
+
raw = resp.read()
|
|
111
|
+
_log("DEBUG", f"<- {method} {path} HTTP {resp.status}")
|
|
112
|
+
return json.loads(raw) if raw else {}
|
|
113
|
+
except urllib.error.HTTPError as e:
|
|
114
|
+
detail = e.read().decode("utf-8", "replace")
|
|
115
|
+
_log("ERROR", f"<- {method} {path} HTTP {e.code}: {detail}")
|
|
116
|
+
raise RuntimeError(f"{method} {path} -> HTTP {e.code}: {detail}") from e
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _http_ok(method: str, url: str, body: dict | None = None, timeout: float = 5.0) -> tuple[bool, str]:
|
|
120
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
121
|
+
req = urllib.request.Request(url, data=data, method=method,
|
|
122
|
+
headers={"Content-Type": "application/json"} if data else {})
|
|
123
|
+
try:
|
|
124
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
125
|
+
return resp.status == 200, ""
|
|
126
|
+
except urllib.error.HTTPError as e:
|
|
127
|
+
return False, f"HTTP {e.code}"
|
|
128
|
+
except Exception as exc: # noqa: BLE001 — a probe failure is evidence ("not ready yet"), not a crash
|
|
129
|
+
return False, str(exc)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _verify_endpoint_locally(port: int, health_path: str, model: str | None) -> tuple[bool, str]:
|
|
133
|
+
"""Same two checks the backend's own readiness/inference verification
|
|
134
|
+
logic makes (same paths, same minimal completion payload) — run locally
|
|
135
|
+
since this is the only place that can actually reach the container.
|
|
136
|
+
See module docstring."""
|
|
137
|
+
base = f"http://127.0.0.1:{port}"
|
|
138
|
+
_log("INFO", f"readiness: polling GET {base}{health_path} (timeout {READINESS_TIMEOUT_SECONDS}s)")
|
|
139
|
+
deadline = time.monotonic() + READINESS_TIMEOUT_SECONDS
|
|
140
|
+
ready, detail = False, "readiness_timeout"
|
|
141
|
+
attempt = 0
|
|
142
|
+
while time.monotonic() < deadline:
|
|
143
|
+
attempt += 1
|
|
144
|
+
ready, detail = _http_ok("GET", base + health_path)
|
|
145
|
+
if ready:
|
|
146
|
+
_log("INFO", f"readiness: OK after {attempt} attempt(s)")
|
|
147
|
+
break
|
|
148
|
+
if attempt % READINESS_LOG_EVERY_N_POLLS == 0:
|
|
149
|
+
_log("INFO", f"readiness: still waiting (attempt {attempt}, last result: {detail})")
|
|
150
|
+
time.sleep(READINESS_POLL_SECONDS)
|
|
151
|
+
if not ready:
|
|
152
|
+
_log("ERROR", f"readiness: failed after {attempt} attempt(s): {detail}")
|
|
153
|
+
return False, f"readiness check failed: {detail}"
|
|
154
|
+
if not model:
|
|
155
|
+
_log("INFO", "inference verification: skipped (no model on plan)")
|
|
156
|
+
return True, ""
|
|
157
|
+
_log("INFO", f"inference verification: POST {base}/v1/completions model={model}")
|
|
158
|
+
ok, detail = _http_ok("POST", base + "/v1/completions", {"model": model, "prompt": "hi", "max_tokens": 1}, timeout=30.0)
|
|
159
|
+
if not ok:
|
|
160
|
+
_log("ERROR", f"inference verification: failed: {detail}")
|
|
161
|
+
return False, f"inference verification failed: {detail}"
|
|
162
|
+
_log("INFO", "inference verification: OK — real completion request succeeded")
|
|
163
|
+
return True, ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_diagnostic_result(log_tail: str) -> str | None:
|
|
167
|
+
"""Pulls the workload's own BADGR_RESULT= verdict out of its log, if it
|
|
168
|
+
printed one. Scans from the end since it's always the last thing a
|
|
169
|
+
diagnostic script emits (see the module's diagnostic scripts under
|
|
170
|
+
scripts/diagnostics/)."""
|
|
171
|
+
for line in reversed(log_tail.splitlines()):
|
|
172
|
+
m = _RESULT_RE.search(line)
|
|
173
|
+
if m:
|
|
174
|
+
return m.group(1)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run_job(base_url: str, api_key: str, node_id: str, job_id: str, plan: dict, gpu_vendor: str | None = None) -> None:
|
|
179
|
+
image = plan.get("image")
|
|
180
|
+
command = plan.get("command") or []
|
|
181
|
+
env = plan.get("env") or {}
|
|
182
|
+
port = plan.get("port")
|
|
183
|
+
is_endpoint = plan.get("workload_type") == "endpoint"
|
|
184
|
+
health_path = plan.get("health_path") or "/v1/models"
|
|
185
|
+
model = plan.get("model")
|
|
186
|
+
privileged = bool(plan.get("privileged"))
|
|
187
|
+
max_runtime_seconds = plan.get("max_runtime_seconds") or DEFAULT_JOB_TIMEOUT_SECONDS
|
|
188
|
+
|
|
189
|
+
container_name = f"badgr-job-{job_id}"
|
|
190
|
+
docker_cmd = ["docker", "run", *_gpu_docker_args(gpu_vendor), "--name", container_name]
|
|
191
|
+
docker_cmd += ["--rm"] if not is_endpoint else ["-d"]
|
|
192
|
+
if privileged:
|
|
193
|
+
# Bounded host diagnostics (NVLink/P2P/FabricManager/dmesg checks)
|
|
194
|
+
# need real host device + syslog access that a plain --gpus all
|
|
195
|
+
# container never gets -- see module docstring's "no P2P networking"
|
|
196
|
+
# note for why this still only ever talks to localhost/the host
|
|
197
|
+
# itself, never anything the backend can't already see logs from.
|
|
198
|
+
docker_cmd += ["--privileged", "--pid=host", "-v", "/dev:/dev", "-v", "/var/log:/var/log:ro"]
|
|
199
|
+
for k, v in env.items():
|
|
200
|
+
docker_cmd += ["-e", f"{k}={v}"]
|
|
201
|
+
if is_endpoint and port:
|
|
202
|
+
docker_cmd += ["-p", f"{port}:{port}"]
|
|
203
|
+
docker_cmd.append(image)
|
|
204
|
+
docker_cmd += [str(c) for c in command]
|
|
205
|
+
|
|
206
|
+
def _report(**kwargs) -> None:
|
|
207
|
+
_log("INFO", f"reporting status={kwargs.get('status')} for job {job_id}")
|
|
208
|
+
try:
|
|
209
|
+
_api(base_url, api_key, "POST", f"/nodes/{node_id}/jobs/{job_id}/status", kwargs)
|
|
210
|
+
except Exception as exc: # noqa: BLE001 — best-effort status reporting must never crash the worker loop
|
|
211
|
+
_log("ERROR", f"status report failed for job {job_id}: {exc}")
|
|
212
|
+
|
|
213
|
+
_log("INFO", f"starting job {job_id} (workload_type={'endpoint' if is_endpoint else 'job'}, image={image})")
|
|
214
|
+
_log("DEBUG", f"job {job_id} docker command: {' '.join(docker_cmd)}")
|
|
215
|
+
|
|
216
|
+
if is_endpoint:
|
|
217
|
+
_run_endpoint_job(job_id, docker_cmd, port, health_path, model, _report)
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
proc = subprocess.Popen(docker_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
221
|
+
_log("INFO", f"job {job_id} container started, pid={proc.pid}, streaming logs, "
|
|
222
|
+
f"max_runtime_seconds={max_runtime_seconds}")
|
|
223
|
+
deadline = time.monotonic() + max_runtime_seconds
|
|
224
|
+
log_lines: list[str] = []
|
|
225
|
+
timed_out = False
|
|
226
|
+
while proc.poll() is None:
|
|
227
|
+
if time.monotonic() > deadline:
|
|
228
|
+
timed_out = True
|
|
229
|
+
_log("ERROR", f"job {job_id} exceeded max_runtime_seconds={max_runtime_seconds}, killing container")
|
|
230
|
+
# `docker kill` on the named container, not proc.pid -- pid is
|
|
231
|
+
# the `docker run` CLI process, killing it alone would leave the
|
|
232
|
+
# container itself running.
|
|
233
|
+
subprocess.run(["docker", "kill", container_name], capture_output=True)
|
|
234
|
+
proc.wait()
|
|
235
|
+
break
|
|
236
|
+
line = proc.stdout.readline() if proc.stdout else ""
|
|
237
|
+
if line:
|
|
238
|
+
line = line.rstrip("\n")
|
|
239
|
+
log_lines.append(line)
|
|
240
|
+
log_lines = log_lines[-LOG_TAIL_LINES:]
|
|
241
|
+
_log("DEBUG", f"job {job_id} | {line}")
|
|
242
|
+
else:
|
|
243
|
+
time.sleep(0.5)
|
|
244
|
+
|
|
245
|
+
tail = "\n".join(log_lines)
|
|
246
|
+
result = _parse_diagnostic_result(tail)
|
|
247
|
+
if timed_out:
|
|
248
|
+
_log("ERROR", f"job {job_id} timed out after {max_runtime_seconds}s")
|
|
249
|
+
_report(status="error", log_tail=tail, error=f"timed out after {max_runtime_seconds}s", result=result or "FAIL")
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
exit_code = proc.returncode
|
|
253
|
+
if exit_code == 0:
|
|
254
|
+
_log("INFO", f"job {job_id} exited 0" + (f", result={result}" if result else ""))
|
|
255
|
+
_report(status="exited", exit_code=exit_code, log_tail=tail, result=result)
|
|
256
|
+
else:
|
|
257
|
+
_log("ERROR", f"job {job_id} failed (exit {exit_code}); last {len(log_lines)} log line(s) attached to report")
|
|
258
|
+
_report(status="error", exit_code=exit_code, log_tail=tail, error=f"container exited {exit_code}", result=result or "FAIL")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _run_endpoint_job(job_id: str, docker_cmd: list, port: int | None, health_path: str, model: str | None, report) -> None:
|
|
262
|
+
"""`badgr serve --target <node>`: launch detached, then block until the
|
|
263
|
+
workload is actually verified (readiness + real completion request) —
|
|
264
|
+
never report "running" on "container started" or "port open" alone."""
|
|
265
|
+
result = subprocess.run(docker_cmd, capture_output=True, text=True)
|
|
266
|
+
if result.returncode != 0:
|
|
267
|
+
_log("ERROR", f"job {job_id}: docker run failed (exit {result.returncode}): {result.stderr[-2000:]}")
|
|
268
|
+
report(status="error", error=f"docker run failed: {result.stderr[-2000:]}")
|
|
269
|
+
return
|
|
270
|
+
container_id = result.stdout.strip()
|
|
271
|
+
_log("INFO", f"job {job_id}: container started ({container_id[:12]}), port={port}, verifying before reporting running")
|
|
272
|
+
if not port:
|
|
273
|
+
_log("ERROR", f"job {job_id}: endpoint workload has no port to verify")
|
|
274
|
+
report(status="error", error="endpoint workload has no port to verify")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
ok, detail = _verify_endpoint_locally(port, health_path, model)
|
|
278
|
+
if not ok:
|
|
279
|
+
_log("ERROR", f"job {job_id}: endpoint verification failed: {detail} — run `docker logs {container_id[:12]}` on this machine to debug")
|
|
280
|
+
report(status="error", error=detail)
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
_log("INFO", f"job {job_id}: endpoint verified — model={model or '(no model check)'} health_path={health_path}")
|
|
284
|
+
report(status="running", endpoint_url=f"http://127.0.0.1:{port}", log_tail=f"verified: {detail or 'ok'}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main() -> None:
|
|
288
|
+
parser = argparse.ArgumentParser(description="badgr-node — BYO GPU worker daemon")
|
|
289
|
+
parser.add_argument("--node-id", required=True, help="node_… id from `badgr node connect`")
|
|
290
|
+
parser.add_argument("--api-key", required=True, help="badgr CLI API key (same one `badgr login` stored)")
|
|
291
|
+
parser.add_argument("--base-url", default="https://aibadgr.com/v1")
|
|
292
|
+
args = parser.parse_args()
|
|
293
|
+
|
|
294
|
+
gpu_vendor = _detect_gpu_vendor()
|
|
295
|
+
_log("INFO", f"worker starting for node_id={args.node_id} against {args.base_url}")
|
|
296
|
+
_log("INFO", f"gpu_vendor={gpu_vendor or 'unknown'} (docker run device flags: {' '.join(_gpu_docker_args(gpu_vendor))})")
|
|
297
|
+
_log("INFO", f"heartbeat_interval={HEARTBEAT_INTERVAL_SECONDS}s poll_interval={POLL_INTERVAL_SECONDS}s")
|
|
298
|
+
last_heartbeat = 0.0
|
|
299
|
+
while True:
|
|
300
|
+
now = time.time()
|
|
301
|
+
if now - last_heartbeat >= HEARTBEAT_INTERVAL_SECONDS:
|
|
302
|
+
try:
|
|
303
|
+
_api(args.base_url, args.api_key, "POST", f"/nodes/{args.node_id}/heartbeat")
|
|
304
|
+
last_heartbeat = now
|
|
305
|
+
_log("DEBUG", "heartbeat OK")
|
|
306
|
+
except Exception as exc: # noqa: BLE001 — a missed heartbeat must not crash the worker loop
|
|
307
|
+
_log("WARN", f"heartbeat failed (will retry next cycle): {exc}")
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
job = _api(args.base_url, args.api_key, "GET", f"/nodes/{args.node_id}/jobs/next")
|
|
311
|
+
except Exception as exc: # noqa: BLE001 — a missed poll must not crash the worker loop
|
|
312
|
+
_log("WARN", f"job poll failed (will retry next cycle): {exc}")
|
|
313
|
+
job = {}
|
|
314
|
+
|
|
315
|
+
if job.get("job_id"):
|
|
316
|
+
_log("INFO", f"received job {job['job_id']} from poll")
|
|
317
|
+
try:
|
|
318
|
+
_run_job(args.base_url, args.api_key, args.node_id, job["job_id"], job.get("plan") or {}, gpu_vendor)
|
|
319
|
+
except Exception: # noqa: BLE001 — a bug in job handling must not silently kill the worker loop
|
|
320
|
+
_log("ERROR", f"unhandled exception while running job {job['job_id']}:\n{traceback.format_exc()}")
|
|
321
|
+
else:
|
|
322
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == "__main__":
|
|
326
|
+
main()
|