mjolnir-cli 0.2.0__py3-none-any.whl
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.
- mjolnir_cli/__init__.py +8 -0
- mjolnir_cli/__main__.py +4 -0
- mjolnir_cli/accuracy.py +333 -0
- mjolnir_cli/autoresearch.py +539 -0
- mjolnir_cli/benchy.py +475 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
- mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
- mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
- mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
- mjolnir_cli/bundled/docker/perses/README.md +89 -0
- mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
- mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
- mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
- mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
- mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
- mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
- mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
- mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
- mjolnir_cli/cachectl.py +146 -0
- mjolnir_cli/charts.py +1687 -0
- mjolnir_cli/cli.py +1480 -0
- mjolnir_cli/clidoc.py +170 -0
- mjolnir_cli/config.py +346 -0
- mjolnir_cli/dockerctl.py +533 -0
- mjolnir_cli/drivers/fa2_driver.py +89 -0
- mjolnir_cli/drivers/fi_driver.py +134 -0
- mjolnir_cli/e2e.py +302 -0
- mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
- mjolnir_cli/fonts/OFL.txt +92 -0
- mjolnir_cli/gate.py +233 -0
- mjolnir_cli/history.py +159 -0
- mjolnir_cli/interactive.py +383 -0
- mjolnir_cli/legs.py +109 -0
- mjolnir_cli/litellm.py +254 -0
- mjolnir_cli/perses.py +214 -0
- mjolnir_cli/picker.py +70 -0
- mjolnir_cli/readme.py +1020 -0
- mjolnir_cli/stack.py +340 -0
- mjolnir_cli/tasks.py +122 -0
- mjolnir_cli/theme.py +281 -0
- mjolnir_cli/thor.py +108 -0
- mjolnir_cli/verify/__init__.py +2 -0
- mjolnir_cli/verify/cli.py +816 -0
- mjolnir_cli/verify/docker.py +334 -0
- mjolnir_cli/verify/kvcache.py +180 -0
- mjolnir_cli/verify/legs.py +134 -0
- mjolnir_cli/verify/ncu.py +141 -0
- mjolnir_cli/verify/profile.py +312 -0
- mjolnir_cli/verify/report.py +177 -0
- mjolnir_cli/verify/results.py +157 -0
- mjolnir_cli/verify/spec.py +279 -0
- mjolnir_cli/verify/stages.py +817 -0
- mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
- mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
- mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
- mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
- mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
mjolnir_cli/stack.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Generic docker-compose stack driver — the shared machinery for every
|
|
2
|
+
side-stack the CLI manages (litellm, perses, ...).
|
|
3
|
+
|
|
4
|
+
A *stack* is a docker-compose project (``docker/<name>/docker-compose.yaml``)
|
|
5
|
+
driven by the CLI under the ``mjolnir-<name>`` compose project. Stacks are
|
|
6
|
+
*independent* of the vLLM server: ``<name> down`` never touches vLLM, and
|
|
7
|
+
``serve down`` never touches a stack. They reach the host (e.g. the vLLM
|
|
8
|
+
port) through ``host.docker.internal`` (host-gateway), never through a
|
|
9
|
+
shared network.
|
|
10
|
+
|
|
11
|
+
What lives here (the part every stack would otherwise re-implement):
|
|
12
|
+
|
|
13
|
+
* the path conventions — data dir (``$MJOLNIR_DATA/<name>``, overridable
|
|
14
|
+
per stack), env file (``<name>.env`` in the data dir), compose file
|
|
15
|
+
(repo-relative)
|
|
16
|
+
* env-file parsing + the merge order (env file < injected < defaults)
|
|
17
|
+
* the compose plumbing — v2 check, argv building, ``up -d`` (with the
|
|
18
|
+
best-effort rollback on failure), ``down`` (the "nothing to stop" check
|
|
19
|
+
+ purge), ``ps`` parsing (the NDJSON quirk), ``logs``
|
|
20
|
+
* envsubst-style template rendering
|
|
21
|
+
* the up-wait (health-URL poll)
|
|
22
|
+
|
|
23
|
+
Adding a new stack = ``docker/<name>/docker-compose.yaml`` (+ templates) + a
|
|
24
|
+
thin ``src/mjolnir/<name>.py`` (a ``StackSpec`` + glue: what to render, the
|
|
25
|
+
health URL, the banner) + a typer group in ``cli.py``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import shutil
|
|
34
|
+
import subprocess
|
|
35
|
+
import time
|
|
36
|
+
import urllib.request
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from mjolnir_cli.config import resource_path
|
|
41
|
+
|
|
42
|
+
# envsubst-style variables: ${NAME} or bare $NAME
|
|
43
|
+
VAR_RE = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StackError(RuntimeError):
|
|
47
|
+
"""The compose driver failed (up failed, health poll timed out, ...)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class StackSpec:
|
|
52
|
+
"""Declarative description of one compose stack.
|
|
53
|
+
|
|
54
|
+
name the CLI command group + the ``mjolnir-<name>`` project
|
|
55
|
+
compose_rel the compose file, relative to the repo root
|
|
56
|
+
env_file_var env var overriding the env-file location
|
|
57
|
+
data_dir_var env var overriding the data-dir location
|
|
58
|
+
data_dir_env the env var the compose file interpolates for the data
|
|
59
|
+
dir (``${...}``); set so the value always reaches compose
|
|
60
|
+
(defaults to ``data_dir_var``)
|
|
61
|
+
data_subdirs dirs under the data dir created on up / wiped on
|
|
62
|
+
``down --purge``
|
|
63
|
+
port_env env var holding the host port of the stack's main service
|
|
64
|
+
default_port its default
|
|
65
|
+
wait_timeout_s default timeout for the up health-wait
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
name: str
|
|
69
|
+
compose_rel: Path
|
|
70
|
+
env_file_var: str = ""
|
|
71
|
+
data_dir_var: str = ""
|
|
72
|
+
data_dir_env: str = ""
|
|
73
|
+
data_subdirs: tuple[str, ...] = ()
|
|
74
|
+
port_env: str = ""
|
|
75
|
+
default_port: int = 0
|
|
76
|
+
wait_timeout_s: float = 600.0
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def project(self) -> str:
|
|
80
|
+
return f"mjolnir-{self.name}"
|
|
81
|
+
|
|
82
|
+
def data_dir(self) -> Path:
|
|
83
|
+
"""Stack data root: ``$<data_dir_var>`` or
|
|
84
|
+
``$MJOLNIR_DATA/<name>`` (default ``~/.local/share/mjolnir/<name>``)."""
|
|
85
|
+
env = self.data_dir_var and os.environ.get(self.data_dir_var)
|
|
86
|
+
if not env:
|
|
87
|
+
mjolnir_data = os.environ.get("MJOLNIR_DATA", str(Path.home() / ".local" / "share" / "mjolnir"))
|
|
88
|
+
env = str(Path(mjolnir_data).expanduser() / self.name)
|
|
89
|
+
return Path(env).expanduser()
|
|
90
|
+
|
|
91
|
+
def env_file_path(self) -> Path:
|
|
92
|
+
"""The user's env file: ``$<env_file_var>`` or
|
|
93
|
+
``<data_dir>/<name>.env``."""
|
|
94
|
+
env = self.env_file_var and os.environ.get(self.env_file_var)
|
|
95
|
+
if env:
|
|
96
|
+
return Path(env).expanduser()
|
|
97
|
+
return self.data_dir() / f"{self.name}.env"
|
|
98
|
+
|
|
99
|
+
def compose_file(self) -> Path:
|
|
100
|
+
return resource_path(self.compose_rel)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── env file + rendering ────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_env_file(path: Path) -> dict[str, str]:
|
|
107
|
+
"""Parse a ``KEY=VALUE`` env file (comments/blank lines ignored, optional
|
|
108
|
+
single/double quotes around the value)."""
|
|
109
|
+
out: dict[str, str] = {}
|
|
110
|
+
if not path.exists():
|
|
111
|
+
return out
|
|
112
|
+
for line in path.read_text().splitlines():
|
|
113
|
+
line = line.strip()
|
|
114
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
115
|
+
continue
|
|
116
|
+
k, _, v = line.partition("=")
|
|
117
|
+
k, v = k.strip(), v.strip()
|
|
118
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in "'\"":
|
|
119
|
+
v = v[1:-1]
|
|
120
|
+
if k:
|
|
121
|
+
out[k] = v
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_env(spec: StackSpec, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
126
|
+
"""Merged env for rendering + compose interpolation:
|
|
127
|
+
user env file < extra (injected by the stack's glue) < stack defaults
|
|
128
|
+
(data dir, main port)."""
|
|
129
|
+
env = load_env_file(spec.env_file_path())
|
|
130
|
+
if extra:
|
|
131
|
+
env.update(extra)
|
|
132
|
+
if spec.data_dir_var:
|
|
133
|
+
# the value the compose file interpolates ($LITELLM_DATA_DIR, ...)
|
|
134
|
+
env.setdefault(spec.data_dir_env or spec.data_dir_var, str(spec.data_dir()))
|
|
135
|
+
if spec.port_env:
|
|
136
|
+
env.setdefault(spec.port_env, str(spec.default_port))
|
|
137
|
+
return env
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render(template_text: str, env: dict[str, str]) -> tuple[str, list[str]]:
|
|
141
|
+
"""envsubst-style substitution; returns (rendered, missing-vars).
|
|
142
|
+
Comment lines keep their placeholders (they may document ${...})."""
|
|
143
|
+
|
|
144
|
+
def sub(m: re.Match) -> str:
|
|
145
|
+
return env.get(m.group(1), m.group(0))
|
|
146
|
+
|
|
147
|
+
rendered = VAR_RE.sub(sub, template_text)
|
|
148
|
+
missing: list[str] = []
|
|
149
|
+
for line in rendered.splitlines():
|
|
150
|
+
if line.lstrip().startswith("#"):
|
|
151
|
+
continue
|
|
152
|
+
for m in VAR_RE.finditer(line):
|
|
153
|
+
if m.group(1) not in missing:
|
|
154
|
+
missing.append(m.group(1))
|
|
155
|
+
return rendered, missing
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── compose plumbing ────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _docker() -> str:
|
|
162
|
+
if shutil.which("docker") is None:
|
|
163
|
+
raise StackError("docker not found on PATH")
|
|
164
|
+
return "docker"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def compose_ok() -> None:
|
|
168
|
+
p = subprocess.run(["docker", "compose", "version", "--short"], capture_output=True, text=True)
|
|
169
|
+
if p.returncode != 0:
|
|
170
|
+
raise StackError("docker compose v2 plugin not found (needed: sudo apt install docker-compose-plugin)")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def compose_argv(spec: StackSpec, extra: list[str], env: dict[str] | None = None) -> list[str]:
|
|
174
|
+
"""The full ``docker compose ...`` argv for one subcommand."""
|
|
175
|
+
return [_docker(), "compose", "-f", str(spec.compose_file()), "-p", spec.project, *extra]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run_compose(spec: StackSpec, extra: list[str], env: dict[str], **kw) -> subprocess.CompletedProcess:
|
|
179
|
+
compose_ok()
|
|
180
|
+
return subprocess.run(compose_argv(spec, extra, env), env={**os.environ, **env}, **kw)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def project_services(spec: StackSpec) -> dict[str, dict]:
|
|
184
|
+
"""``{service: {name, state, health, status}}`` from ``compose ps -a``
|
|
185
|
+
(empty dict when the project has no containers)."""
|
|
186
|
+
p = _run_compose(spec, ["ps", "-a", "--format", "json"], resolve_env(spec), capture_output=True, text=True)
|
|
187
|
+
if p.returncode != 0 or not p.stdout.strip():
|
|
188
|
+
return {}
|
|
189
|
+
# compose v5 emits NDJSON (one object per line), older v2 an array
|
|
190
|
+
rows: list[dict] = []
|
|
191
|
+
for line in p.stdout.splitlines():
|
|
192
|
+
line = line.strip()
|
|
193
|
+
if not line:
|
|
194
|
+
continue
|
|
195
|
+
try:
|
|
196
|
+
obj = json.loads(line)
|
|
197
|
+
except json.JSONDecodeError:
|
|
198
|
+
continue
|
|
199
|
+
rows.extend(obj if isinstance(obj, list) else [obj])
|
|
200
|
+
out: dict[str, dict] = {}
|
|
201
|
+
for r in rows:
|
|
202
|
+
name = r.get("Name") or ""
|
|
203
|
+
svc = r.get("Service")
|
|
204
|
+
if not svc:
|
|
205
|
+
base = name[len(spec.project) + 1 :] if name.startswith(spec.project + "-") else name
|
|
206
|
+
svc = base.rsplit("-", 1)[0] if base.endswith("-1") else base
|
|
207
|
+
out[svc] = {"name": name, "state": r.get("State"), "health": r.get("Health"), "status": r.get("Status")}
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def probe(url: str, timeout_s: float = 5.0) -> str:
|
|
212
|
+
"""GET ``url`` once → ``"ok"`` (200) | ``"HTTP <code>"`` | ``"unreachable"``."""
|
|
213
|
+
try:
|
|
214
|
+
with urllib.request.urlopen(url, timeout=timeout_s) as r:
|
|
215
|
+
return "ok" if r.status == 200 else f"HTTP {r.status}"
|
|
216
|
+
except Exception: # noqa: BLE001
|
|
217
|
+
return "unreachable"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── up / down / status / logs ────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def up(
|
|
224
|
+
spec: StackSpec,
|
|
225
|
+
s: object,
|
|
226
|
+
prepare: object = None,
|
|
227
|
+
*,
|
|
228
|
+
wait: bool = True,
|
|
229
|
+
wait_timeout_s: float | None = None,
|
|
230
|
+
dry_run: bool = False,
|
|
231
|
+
health: object = None,
|
|
232
|
+
banner: object = None,
|
|
233
|
+
compose_wait: bool = False,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Start the stack.
|
|
236
|
+
|
|
237
|
+
``prepare(s) -> extra env vars`` — the stack's glue renders its files
|
|
238
|
+
here (and may print notes). ``health(env) -> URL`` must answer 200 before
|
|
239
|
+
up returns (with ``wait``). ``banner(env) -> [lines]`` is the success
|
|
240
|
+
output. ``compose_wait`` adds ``--wait`` to ``up -d`` (only useful when
|
|
241
|
+
the services define healthchecks; the URL poll always does the real
|
|
242
|
+
waiting). A failed up rolls the stack back down (no partial state)."""
|
|
243
|
+
wait_timeout_s = spec.wait_timeout_s if wait_timeout_s is None else wait_timeout_s
|
|
244
|
+
extra = prepare(s) if prepare is not None else None
|
|
245
|
+
env = resolve_env(spec, extra)
|
|
246
|
+
up_args = ["up", "-d"] + (["--wait"] if (wait and compose_wait) else [])
|
|
247
|
+
if dry_run:
|
|
248
|
+
print(" ".join(compose_argv(spec, up_args, env)))
|
|
249
|
+
return
|
|
250
|
+
for sub in spec.data_subdirs:
|
|
251
|
+
(spec.data_dir() / sub).mkdir(parents=True, exist_ok=True)
|
|
252
|
+
p = _run_compose(spec, up_args, env, text=True, stderr=subprocess.PIPE)
|
|
253
|
+
if p.returncode != 0:
|
|
254
|
+
err = (p.stderr or "").strip()
|
|
255
|
+
# best-effort rollback: a failed up never leaves partial state
|
|
256
|
+
_run_compose(spec, ["down"], env, capture_output=True, text=True)
|
|
257
|
+
raise StackError(f"docker compose up failed ({p.returncode}); rolled the stack back down:\n{err}")
|
|
258
|
+
if wait and health is not None:
|
|
259
|
+
url = health(env)
|
|
260
|
+
t0 = time.time()
|
|
261
|
+
while time.time() - t0 < wait_timeout_s:
|
|
262
|
+
if probe(url) == "ok":
|
|
263
|
+
break
|
|
264
|
+
time.sleep(3)
|
|
265
|
+
else:
|
|
266
|
+
raise StackError(
|
|
267
|
+
f"{spec.name} not responsive at {url} after {wait_timeout_s:.0f}s — check: mjolnir {spec.name} logs"
|
|
268
|
+
)
|
|
269
|
+
if banner is not None:
|
|
270
|
+
for line in banner(env):
|
|
271
|
+
print(line)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def down(spec: StackSpec, purge: bool = False, dry_run: bool = False, keep_note: str = "stack state survives") -> int:
|
|
275
|
+
"""Stop the stack. Data kept under the data dir by default; ``purge``
|
|
276
|
+
wipes the ``data_subdirs``."""
|
|
277
|
+
env = resolve_env(spec)
|
|
278
|
+
if not dry_run and not project_services(spec):
|
|
279
|
+
print(f"nothing to stop (no {spec.name} stack running)")
|
|
280
|
+
return 0
|
|
281
|
+
if dry_run:
|
|
282
|
+
print(" ".join(compose_argv(spec, ["down"], env)))
|
|
283
|
+
if purge:
|
|
284
|
+
print(
|
|
285
|
+
f"(purge: rm -rf {' '.join(str(spec.data_dir() / s) for s in spec.data_subdirs)}"
|
|
286
|
+
" — via a throwaway root container)"
|
|
287
|
+
)
|
|
288
|
+
return 0
|
|
289
|
+
_run_compose(spec, ["down"], env, text=True)
|
|
290
|
+
if purge:
|
|
291
|
+
_purge_data(spec)
|
|
292
|
+
print(f"stopped + purged the stack data ({', '.join(str(spec.data_dir() / s) for s in spec.data_subdirs)})")
|
|
293
|
+
else:
|
|
294
|
+
print(f"stopped (data kept under {spec.data_dir()} — {keep_note})")
|
|
295
|
+
return 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _purge_data(spec: StackSpec) -> None:
|
|
299
|
+
"""Wipe the stack's ``data_subdirs``.
|
|
300
|
+
|
|
301
|
+
The services run as their images' baked-in nonroot uid (perses 65532,
|
|
302
|
+
prometheus 65534) and create ``0750`` subdirs under the bind-mounted
|
|
303
|
+
data dirs that the host user can't traverse or delete — a host-side
|
|
304
|
+
``shutil.rmtree`` would silently fail to empty them. Instead, wipe them
|
|
305
|
+
in a throwaway **root** container (root bypasses the permissions).
|
|
306
|
+
Falls back to a best-effort host rmtree if docker can't run."""
|
|
307
|
+
data_dir = spec.data_dir()
|
|
308
|
+
rm = " ".join(f"/d/{sub}" for sub in spec.data_subdirs)
|
|
309
|
+
try:
|
|
310
|
+
subprocess.run(
|
|
311
|
+
["docker", "run", "--rm", "-v", f"{data_dir}:/d", "busybox", "sh", "-c", f"rm -rf {rm}"],
|
|
312
|
+
check=True,
|
|
313
|
+
capture_output=True,
|
|
314
|
+
)
|
|
315
|
+
return
|
|
316
|
+
except Exception: # noqa: BLE001 — fall back to host rmtree
|
|
317
|
+
pass
|
|
318
|
+
for sub in spec.data_subdirs:
|
|
319
|
+
shutil.rmtree(data_dir / sub, ignore_errors=True)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def status(spec: StackSpec, extra: dict | None = None) -> dict:
|
|
323
|
+
"""``{project, services}`` + the stack's glue-specific fields."""
|
|
324
|
+
st: dict = {"project": spec.project, "services": project_services(spec)}
|
|
325
|
+
if extra:
|
|
326
|
+
st.update(extra)
|
|
327
|
+
return st
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def logs(spec: StackSpec, follow: bool = False, tail: int = 100, service: str | None = None) -> int:
|
|
331
|
+
"""Tail one service's logs (default: the service named ``spec.name``)."""
|
|
332
|
+
env = resolve_env(spec)
|
|
333
|
+
args = compose_argv(spec, ["logs", "--tail", str(tail)], env)
|
|
334
|
+
if follow:
|
|
335
|
+
args.append("-f")
|
|
336
|
+
args.append(service or spec.name)
|
|
337
|
+
try:
|
|
338
|
+
return subprocess.call(args, env={**os.environ, **env})
|
|
339
|
+
except KeyboardInterrupt:
|
|
340
|
+
return 0
|
mjolnir_cli/tasks.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Image canary tasks + the clean-window gate.
|
|
2
|
+
|
|
3
|
+
The canaries are the sm_110 gate probes and the Thor-authored kernel tests
|
|
4
|
+
that ship with the image build (``docker/vllm-thor/``); ``mjolnir image
|
|
5
|
+
gates`` runs them in a fresh container against an image (no server needed;
|
|
6
|
+
GPU tests self-skip on a box without CUDA). Kernel correctness and
|
|
7
|
+
benchmarks live in ``mjolnir verify``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from mjolnir_cli.config import Settings, find_repo_root
|
|
19
|
+
from mjolnir_cli.dockerctl import _docker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Task:
|
|
24
|
+
name: str
|
|
25
|
+
script: str # repo-relative path
|
|
26
|
+
desc: str
|
|
27
|
+
gpu: bool = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TASKS: list[Task] = [
|
|
31
|
+
Task(
|
|
32
|
+
"gates",
|
|
33
|
+
"docker/vllm-thor/kernel_test_sm110_gates.py",
|
|
34
|
+
"sm_110 gate-probe canary (T6 GDN, T9 FA4, T10 draft-CG, T11 hd256)",
|
|
35
|
+
),
|
|
36
|
+
Task(
|
|
37
|
+
"fi-update",
|
|
38
|
+
"docker/vllm-thor/kernel_test_fi_update.py",
|
|
39
|
+
"fused draft-decode FI kernel (advance plan vs numpy reference)",
|
|
40
|
+
),
|
|
41
|
+
Task(
|
|
42
|
+
"dspark-nc",
|
|
43
|
+
"docker/vllm-thor/kernel_test_dspark_draft_nonncausal.py",
|
|
44
|
+
"DSpark non-causal draft cudagraph (gate/route/capture-replay/determinism)",
|
|
45
|
+
),
|
|
46
|
+
Task(
|
|
47
|
+
"functional",
|
|
48
|
+
"docker/vllm-thor/functional_check_55519.py",
|
|
49
|
+
"55519 draft-group warning gate check (no GPU)",
|
|
50
|
+
gpu=False,
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
BY_NAME = {t.name: t for t in TASKS}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def task_container(task: Task) -> str:
|
|
58
|
+
return f"mjolnir-task-{task.name}-{os.getpid()}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_docker(task: Task, s: Settings) -> list[str]:
|
|
62
|
+
script = find_repo_root() / task.script
|
|
63
|
+
cmd = [_docker(), "run", "--rm", "--name", task_container(task)]
|
|
64
|
+
if task.gpu:
|
|
65
|
+
cmd += ["--gpus", "all"]
|
|
66
|
+
cmd += ["--entrypoint", "python3", "-v", f"{script}:/tmp/{script.name}:ro"]
|
|
67
|
+
return cmd + [s.image, f"/tmp/{script.name}"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_task(s: Settings, name: str, script_args: list[str], dry_run: bool = False) -> int:
|
|
71
|
+
"""Run one canary task (or ``gates`` = the gate probe) in a fresh container."""
|
|
72
|
+
task = BY_NAME.get(name)
|
|
73
|
+
if task is None:
|
|
74
|
+
print(f"[mjolnir] unknown task '{name}'. Known: {', '.join(BY_NAME)}", file=sys.stderr)
|
|
75
|
+
return 4
|
|
76
|
+
try:
|
|
77
|
+
script = find_repo_root() / task.script
|
|
78
|
+
except FileNotFoundError as e:
|
|
79
|
+
print(f"[mjolnir] {e}", file=sys.stderr)
|
|
80
|
+
return 4
|
|
81
|
+
if not script.exists():
|
|
82
|
+
print(f"[mjolnir] ERROR: script not found: {script}", file=sys.stderr)
|
|
83
|
+
return 4
|
|
84
|
+
cmd = build_docker(task, s) + list(script_args)
|
|
85
|
+
if dry_run:
|
|
86
|
+
print(" ".join(shlex.quote(c) for c in cmd))
|
|
87
|
+
return 0
|
|
88
|
+
print(f"[mjolnir] running: {' '.join(cmd)}", file=sys.stderr)
|
|
89
|
+
from mjolnir_cli.dockerctl import run_once
|
|
90
|
+
|
|
91
|
+
return run_once(cmd, task_container(task))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run_gate(s: Settings, script_args: list[str], dry_run: bool) -> int:
|
|
95
|
+
"""Run the clean-window gate on the host (no container)."""
|
|
96
|
+
from mjolnir_cli.gate import wait_for_idle, server_load
|
|
97
|
+
import time as _time
|
|
98
|
+
|
|
99
|
+
ap = argparse.ArgumentParser()
|
|
100
|
+
ap.add_argument("--metrics-url", default=s.metrics_url)
|
|
101
|
+
ap.add_argument("--confirm", type=int, default=6)
|
|
102
|
+
ap.add_argument("--timeout", type=float, default=3600)
|
|
103
|
+
ap.add_argument("--once", action="store_true")
|
|
104
|
+
args = ap.parse_args(script_args)
|
|
105
|
+
|
|
106
|
+
if args.once:
|
|
107
|
+
load = server_load(args.metrics_url)
|
|
108
|
+
if load is None:
|
|
109
|
+
print("UNREACHABLE (vLLM metrics endpoint not up)")
|
|
110
|
+
return 2
|
|
111
|
+
clean = load == (0.0, 0.0)
|
|
112
|
+
print(f"now: running={load[0]:g} waiting={load[1]:g} -> {'CLEAN' if clean else 'BUSY'}")
|
|
113
|
+
return 0 if clean else 1
|
|
114
|
+
if dry_run:
|
|
115
|
+
print(f"(host) wait_for_idle({args.metrics_url}, confirm={args.confirm})")
|
|
116
|
+
return 0
|
|
117
|
+
ts = wait_for_idle(args.metrics_url, confirm=args.confirm, timeout_s=args.timeout)
|
|
118
|
+
if ts is None:
|
|
119
|
+
print(f"TIMEOUT: no {args.confirm}x consecutive 0/0 after {args.timeout:.0f}s", file=sys.stderr)
|
|
120
|
+
return 3
|
|
121
|
+
print(f"CLEAN WINDOW OPEN ({args.confirm} consecutive 0/0 samples) at t={_time.time() - ts:.2f}s ago")
|
|
122
|
+
return 0
|