nerdit 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.
- nerdit/__init__.py +3 -0
- nerdit/__main__.py +3 -0
- nerdit/cli/__init__.py +0 -0
- nerdit/cli/app.py +61 -0
- nerdit/cli/client.py +208 -0
- nerdit/cli/commands/__init__.py +0 -0
- nerdit/cli/commands/check_deps.py +363 -0
- nerdit/cli/commands/cleanup.py +30 -0
- nerdit/cli/commands/connect.py +65 -0
- nerdit/cli/commands/init.py +57 -0
- nerdit/cli/commands/logs.py +69 -0
- nerdit/cli/commands/run.py +157 -0
- nerdit/cli/commands/status.py +39 -0
- nerdit/cli/commands/stop.py +31 -0
- nerdit/cli/commands/token.py +20 -0
- nerdit/cli/display.py +101 -0
- nerdit/cli/upload.py +70 -0
- nerdit/config/__init__.py +0 -0
- nerdit/config/defaults.py +31 -0
- nerdit/config/project.py +167 -0
- nerdit/config/settings.py +140 -0
- nerdit/utils/__init__.py +0 -0
- nerdit/utils/ids.py +12 -0
- nerdit/utils/logging.py +15 -0
- nerdit-0.2.0.dist-info/METADATA +105 -0
- nerdit-0.2.0.dist-info/RECORD +29 -0
- nerdit-0.2.0.dist-info/WHEEL +4 -0
- nerdit-0.2.0.dist-info/entry_points.txt +2 -0
- nerdit-0.2.0.dist-info/licenses/LICENSE +191 -0
nerdit/__init__.py
ADDED
nerdit/__main__.py
ADDED
nerdit/cli/__init__.py
ADDED
|
File without changes
|
nerdit/cli/app.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Nerdit CLI application."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
import nerdit
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
name="nerdit",
|
|
9
|
+
help="The local AI engine — HPC orchestrator for local GPUs",
|
|
10
|
+
no_args_is_help=True,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def version_callback(value: bool) -> None:
|
|
15
|
+
if value:
|
|
16
|
+
typer.echo(f"nerdit {nerdit.__version__}")
|
|
17
|
+
raise typer.Exit()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.callback()
|
|
21
|
+
def main_callback(
|
|
22
|
+
version: bool = typer.Option(
|
|
23
|
+
False,
|
|
24
|
+
"--version",
|
|
25
|
+
"-v",
|
|
26
|
+
help="Show version",
|
|
27
|
+
callback=version_callback,
|
|
28
|
+
is_eager=True,
|
|
29
|
+
),
|
|
30
|
+
) -> None:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Import and register commands (deferred to avoid circular imports)
|
|
35
|
+
def _register_commands() -> None:
|
|
36
|
+
from nerdit.cli.commands.check_deps import check_deps # noqa: F811
|
|
37
|
+
from nerdit.cli.commands.cleanup import cleanup # noqa: F811
|
|
38
|
+
from nerdit.cli.commands.connect import connect # noqa: F811
|
|
39
|
+
from nerdit.cli.commands.init import init # noqa: F811
|
|
40
|
+
from nerdit.cli.commands.logs import logs # noqa: F811
|
|
41
|
+
from nerdit.cli.commands.run import run # noqa: F811
|
|
42
|
+
from nerdit.cli.commands.status import status # noqa: F811
|
|
43
|
+
from nerdit.cli.commands.stop import stop # noqa: F811
|
|
44
|
+
from nerdit.cli.commands.token import token # noqa: F811
|
|
45
|
+
|
|
46
|
+
app.command(name="check-deps")(check_deps)
|
|
47
|
+
app.command()(init)
|
|
48
|
+
app.command()(run)
|
|
49
|
+
app.command()(status)
|
|
50
|
+
app.command()(logs)
|
|
51
|
+
app.command()(stop)
|
|
52
|
+
app.command()(cleanup)
|
|
53
|
+
app.command()(connect)
|
|
54
|
+
app.command()(token)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_register_commands()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main() -> None:
|
|
61
|
+
app()
|
nerdit/cli/client.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""HTTP client for communicating with nerditd."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from nerdit.config.defaults import DEFAULT_HOST, DEFAULT_PORT
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NerditClient:
|
|
13
|
+
"""Async HTTP client wrapping the nerditd REST API."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
host: str = DEFAULT_HOST,
|
|
18
|
+
port: int = DEFAULT_PORT,
|
|
19
|
+
token: str | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
self._base_url = f"http://{host}:{port}"
|
|
22
|
+
self._headers: dict[str, str] = {}
|
|
23
|
+
if token:
|
|
24
|
+
self._headers["Authorization"] = f"Bearer {token}"
|
|
25
|
+
|
|
26
|
+
def _client(self, **kwargs) -> httpx.AsyncClient:
|
|
27
|
+
"""Create an httpx.AsyncClient with auth headers."""
|
|
28
|
+
return httpx.AsyncClient(headers=self._headers, **kwargs)
|
|
29
|
+
|
|
30
|
+
async def health(self) -> dict:
|
|
31
|
+
"""Call ``GET /health`` and return the parsed JSON response."""
|
|
32
|
+
async with self._client() as client:
|
|
33
|
+
resp = await client.get(f"{self._base_url}/health", timeout=5.0)
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
return resp.json()
|
|
36
|
+
|
|
37
|
+
async def list_gpus(self) -> list[dict]:
|
|
38
|
+
"""Fetch all GPUs from the daemon."""
|
|
39
|
+
async with self._client() as client:
|
|
40
|
+
resp = await client.get(f"{self._base_url}/gpus", timeout=5.0)
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
return resp.json()
|
|
43
|
+
|
|
44
|
+
async def create_job(
|
|
45
|
+
self,
|
|
46
|
+
script_path: str,
|
|
47
|
+
*,
|
|
48
|
+
name: str | None = None,
|
|
49
|
+
gpu_count: int = 1,
|
|
50
|
+
priority: int = 5,
|
|
51
|
+
image: str | None = None,
|
|
52
|
+
max_retries: int = 3,
|
|
53
|
+
volumes: dict[str, str] | None = None,
|
|
54
|
+
tags: list[str] | None = None,
|
|
55
|
+
preemptible: bool = False,
|
|
56
|
+
max_runtime: str | None = None,
|
|
57
|
+
time_window: str | None = None,
|
|
58
|
+
) -> dict:
|
|
59
|
+
"""Submit a new job via ``POST /jobs``."""
|
|
60
|
+
payload: dict = {
|
|
61
|
+
"script_path": script_path,
|
|
62
|
+
"gpu_count": gpu_count,
|
|
63
|
+
"priority": priority,
|
|
64
|
+
"max_retries": max_retries,
|
|
65
|
+
}
|
|
66
|
+
if name:
|
|
67
|
+
payload["name"] = name
|
|
68
|
+
if image:
|
|
69
|
+
payload["image"] = image
|
|
70
|
+
if volumes:
|
|
71
|
+
payload["volumes"] = volumes
|
|
72
|
+
if tags:
|
|
73
|
+
payload["tags"] = tags
|
|
74
|
+
if preemptible:
|
|
75
|
+
payload["preemptible"] = True
|
|
76
|
+
if max_runtime:
|
|
77
|
+
payload["max_runtime"] = max_runtime
|
|
78
|
+
if time_window:
|
|
79
|
+
payload["time_window"] = time_window
|
|
80
|
+
|
|
81
|
+
async with self._client() as client:
|
|
82
|
+
resp = await client.post(f"{self._base_url}/jobs", json=payload, timeout=10.0)
|
|
83
|
+
resp.raise_for_status()
|
|
84
|
+
return resp.json()
|
|
85
|
+
|
|
86
|
+
async def upload_job(
|
|
87
|
+
self,
|
|
88
|
+
zip_bytes: bytes,
|
|
89
|
+
script_name: str,
|
|
90
|
+
*,
|
|
91
|
+
name: str | None = None,
|
|
92
|
+
gpu_count: int = 1,
|
|
93
|
+
priority: int = 5,
|
|
94
|
+
image: str | None = None,
|
|
95
|
+
max_retries: int = 3,
|
|
96
|
+
volumes: dict[str, str] | None = None,
|
|
97
|
+
tags: list[str] | None = None,
|
|
98
|
+
preemptible: bool = False,
|
|
99
|
+
max_runtime: str | None = None,
|
|
100
|
+
time_window: str | None = None,
|
|
101
|
+
) -> dict:
|
|
102
|
+
"""Upload a ZIP archive and create a job on the remote daemon."""
|
|
103
|
+
import json
|
|
104
|
+
|
|
105
|
+
files = {"archive": ("scripts.zip", zip_bytes, "application/zip")}
|
|
106
|
+
data: dict[str, str] = {
|
|
107
|
+
"script_name": script_name,
|
|
108
|
+
"gpu_count": str(gpu_count),
|
|
109
|
+
"priority": str(priority),
|
|
110
|
+
"max_retries": str(max_retries),
|
|
111
|
+
}
|
|
112
|
+
if name:
|
|
113
|
+
data["name"] = name
|
|
114
|
+
if image:
|
|
115
|
+
data["image"] = image
|
|
116
|
+
if volumes:
|
|
117
|
+
data["volumes"] = json.dumps(volumes)
|
|
118
|
+
if tags:
|
|
119
|
+
data["tags"] = json.dumps(tags)
|
|
120
|
+
if preemptible:
|
|
121
|
+
data["preemptible"] = "true"
|
|
122
|
+
if max_runtime:
|
|
123
|
+
data["max_runtime"] = max_runtime
|
|
124
|
+
if time_window:
|
|
125
|
+
data["time_window"] = time_window
|
|
126
|
+
|
|
127
|
+
async with self._client() as client:
|
|
128
|
+
resp = await client.post(
|
|
129
|
+
f"{self._base_url}/jobs/upload",
|
|
130
|
+
files=files,
|
|
131
|
+
data=data,
|
|
132
|
+
timeout=120.0,
|
|
133
|
+
)
|
|
134
|
+
resp.raise_for_status()
|
|
135
|
+
return resp.json()
|
|
136
|
+
|
|
137
|
+
async def list_jobs(self, status: str | None = None) -> list[dict]:
|
|
138
|
+
"""List jobs, optionally filtered by status."""
|
|
139
|
+
params = {}
|
|
140
|
+
if status:
|
|
141
|
+
params["status"] = status
|
|
142
|
+
|
|
143
|
+
async with self._client() as client:
|
|
144
|
+
resp = await client.get(f"{self._base_url}/jobs", params=params, timeout=5.0)
|
|
145
|
+
resp.raise_for_status()
|
|
146
|
+
return resp.json()
|
|
147
|
+
|
|
148
|
+
async def get_job(self, job_id: str) -> dict:
|
|
149
|
+
"""Fetch a single job by ID."""
|
|
150
|
+
async with self._client() as client:
|
|
151
|
+
resp = await client.get(f"{self._base_url}/jobs/{job_id}", timeout=5.0)
|
|
152
|
+
resp.raise_for_status()
|
|
153
|
+
return resp.json()
|
|
154
|
+
|
|
155
|
+
async def stop_job(self, job_id: str) -> dict:
|
|
156
|
+
"""Request the daemon to stop a job."""
|
|
157
|
+
async with self._client() as client:
|
|
158
|
+
resp = await client.post(f"{self._base_url}/jobs/{job_id}/stop", timeout=10.0)
|
|
159
|
+
resp.raise_for_status()
|
|
160
|
+
return resp.json()
|
|
161
|
+
|
|
162
|
+
async def get_logs(self, job_id: str, since_id: int = 0) -> list[dict]:
|
|
163
|
+
"""Fetch log entries for a job since a given ID."""
|
|
164
|
+
async with self._client() as client:
|
|
165
|
+
resp = await client.get(
|
|
166
|
+
f"{self._base_url}/jobs/{job_id}/logs",
|
|
167
|
+
params={"since_id": since_id},
|
|
168
|
+
timeout=5.0,
|
|
169
|
+
)
|
|
170
|
+
resp.raise_for_status()
|
|
171
|
+
return resp.json()
|
|
172
|
+
|
|
173
|
+
async def stream_logs(self, job_id: str) -> AsyncIterator[dict]:
|
|
174
|
+
"""Consume SSE log stream from the daemon. Yields parsed event dicts."""
|
|
175
|
+
import httpx_sse
|
|
176
|
+
|
|
177
|
+
timeout = httpx.Timeout(5.0, read=None) # no read timeout for SSE
|
|
178
|
+
async with self._client(timeout=timeout) as client:
|
|
179
|
+
async with httpx_sse.aconnect_sse(
|
|
180
|
+
client, "GET", f"{self._base_url}/jobs/{job_id}/logs/stream"
|
|
181
|
+
) as sse:
|
|
182
|
+
async for event in sse.aiter_sse():
|
|
183
|
+
import json
|
|
184
|
+
|
|
185
|
+
if event.event == "done":
|
|
186
|
+
yield {"event": "done", "data": json.loads(event.data)}
|
|
187
|
+
return
|
|
188
|
+
if event.event == "log":
|
|
189
|
+
yield {"event": "log", "data": json.loads(event.data)}
|
|
190
|
+
|
|
191
|
+
async def cleanup(self) -> dict:
|
|
192
|
+
"""Request the daemon to stop all active jobs."""
|
|
193
|
+
async with self._client() as client:
|
|
194
|
+
resp = await client.post(f"{self._base_url}/cleanup", timeout=10.0)
|
|
195
|
+
resp.raise_for_status()
|
|
196
|
+
return resp.json()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_configured_client() -> NerditClient:
|
|
200
|
+
"""Create a NerditClient from ~/.nerdit/config.toml settings.
|
|
201
|
+
|
|
202
|
+
If [client].remote_host is set, connects to the remote daemon with token.
|
|
203
|
+
Otherwise falls back to localhost (v0.1 compat).
|
|
204
|
+
"""
|
|
205
|
+
from nerdit.config.settings import get_client_config
|
|
206
|
+
|
|
207
|
+
host, port, token = get_client_config()
|
|
208
|
+
return NerditClient(host=host, port=port, token=token)
|
|
File without changes
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""nerdit check-deps — Check and optionally install system dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from nerdit.cli.display import console
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Check functions — each returns (ok, detail_string)
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
_LIB_DIRS = ["/usr/lib/x86_64-linux-gnu", "/usr/lib64", "/usr/lib"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_python() -> tuple[bool, str]:
|
|
23
|
+
"""Check Python >= 3.11."""
|
|
24
|
+
major, minor, micro = sys.version_info[0], sys.version_info[1], sys.version_info[2]
|
|
25
|
+
ver = f"{major}.{minor}.{micro}"
|
|
26
|
+
if major >= 3 and minor >= 11:
|
|
27
|
+
return True, ver
|
|
28
|
+
return False, f"{ver} (need >=3.11)"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_nvidia_driver() -> tuple[bool, str]:
|
|
32
|
+
"""Check NVIDIA driver via nvidia-smi."""
|
|
33
|
+
if shutil.which("nvidia-smi") is None:
|
|
34
|
+
return False, "nvidia-smi not found"
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=10,
|
|
41
|
+
)
|
|
42
|
+
if result.returncode != 0:
|
|
43
|
+
return False, "nvidia-smi failed"
|
|
44
|
+
driver_ver = result.stdout.strip().splitlines()[0]
|
|
45
|
+
major = int(driver_ver.split(".")[0])
|
|
46
|
+
if major >= 535:
|
|
47
|
+
return True, driver_ver
|
|
48
|
+
return False, f"{driver_ver} (need >=535)"
|
|
49
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError, IndexError):
|
|
50
|
+
return False, "nvidia-smi error"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_nvidia_lib() -> tuple[bool, str]:
|
|
54
|
+
"""Check libnvidia-ml.so.1 on the host."""
|
|
55
|
+
for d in _LIB_DIRS:
|
|
56
|
+
p = Path(d) / "libnvidia-ml.so.1"
|
|
57
|
+
if p.exists():
|
|
58
|
+
return True, str(p)
|
|
59
|
+
return False, "not found"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_docker() -> tuple[bool, str]:
|
|
63
|
+
"""Check Docker Engine is running."""
|
|
64
|
+
if shutil.which("docker") is None:
|
|
65
|
+
return False, "docker not found"
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
["docker", "info"],
|
|
69
|
+
capture_output=True,
|
|
70
|
+
timeout=10,
|
|
71
|
+
)
|
|
72
|
+
if result.returncode != 0:
|
|
73
|
+
return False, "not running or no permission"
|
|
74
|
+
ver_result = subprocess.run(
|
|
75
|
+
["docker", "version", "--format", "{{.Server.Version}}"],
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
timeout=10,
|
|
79
|
+
)
|
|
80
|
+
ver = ver_result.stdout.strip() if ver_result.returncode == 0 else "unknown"
|
|
81
|
+
return True, ver
|
|
82
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
83
|
+
return False, "error"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _check_nvidia_toolkit() -> tuple[bool, str]:
|
|
87
|
+
"""Check NVIDIA Container Toolkit in Docker runtimes."""
|
|
88
|
+
# If Docker CLI itself is not available, skip this check.
|
|
89
|
+
if shutil.which("docker") is None:
|
|
90
|
+
return False, "skipped (Docker not installed)"
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["docker", "info", "--format", "{{json .Runtimes}}"],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=10,
|
|
97
|
+
)
|
|
98
|
+
if result.returncode != 0:
|
|
99
|
+
detail = "skipped (docker info failed; is Docker running / do you have permission?)"
|
|
100
|
+
if result.stderr:
|
|
101
|
+
stderr = result.stderr.strip()
|
|
102
|
+
if stderr:
|
|
103
|
+
detail = f"{detail}: {stderr}"
|
|
104
|
+
return False, detail
|
|
105
|
+
if "nvidia" in result.stdout:
|
|
106
|
+
return True, "registered"
|
|
107
|
+
return False, "not found in Docker runtimes"
|
|
108
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
109
|
+
return False, "skipped (Docker not available)"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _check_runtime_image() -> tuple[bool, str]:
|
|
113
|
+
"""Check if nerdit-runtime:0.1 Docker image exists."""
|
|
114
|
+
try:
|
|
115
|
+
result = subprocess.run(
|
|
116
|
+
["docker", "image", "inspect", "nerdit-runtime:0.1"],
|
|
117
|
+
capture_output=True,
|
|
118
|
+
timeout=10,
|
|
119
|
+
)
|
|
120
|
+
if result.returncode == 0:
|
|
121
|
+
return True, "available"
|
|
122
|
+
return False, "image not found"
|
|
123
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
124
|
+
return False, "skipped (Docker not available)"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Install helpers
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _run_install(description: str, cmd: list[str], *, shell: bool = False) -> bool:
|
|
133
|
+
"""Run an install command with user-visible output. Returns success."""
|
|
134
|
+
console.print(f" [dim]Running: {' '.join(cmd)}[/dim]")
|
|
135
|
+
try:
|
|
136
|
+
if shell:
|
|
137
|
+
result = subprocess.run(
|
|
138
|
+
" ".join(cmd),
|
|
139
|
+
shell=True,
|
|
140
|
+
timeout=300, # noqa: S602
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
result = subprocess.run(cmd, timeout=300)
|
|
144
|
+
return result.returncode == 0
|
|
145
|
+
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
146
|
+
console.print(f" [red]Failed: {exc}[/red]")
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _install_python() -> bool:
|
|
151
|
+
return _run_install(
|
|
152
|
+
"Python 3.11",
|
|
153
|
+
["sudo", "apt-get", "install", "-y", "python3.11"],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _install_nvidia_driver() -> bool:
|
|
158
|
+
ok = _run_install(
|
|
159
|
+
"NVIDIA Driver",
|
|
160
|
+
["sudo", "apt-get", "install", "-y", "nvidia-driver-535"],
|
|
161
|
+
)
|
|
162
|
+
if ok:
|
|
163
|
+
console.print(" [yellow]A reboot may be required.[/yellow]")
|
|
164
|
+
return ok
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _install_docker() -> bool:
|
|
168
|
+
ok = _run_install(
|
|
169
|
+
"Docker Engine",
|
|
170
|
+
["sh", "-c", "curl -fsSL https://get.docker.com | sudo sh"],
|
|
171
|
+
)
|
|
172
|
+
if ok:
|
|
173
|
+
subprocess.run(
|
|
174
|
+
["sudo", "usermod", "-aG", "docker", str(Path.home().name)],
|
|
175
|
+
timeout=10,
|
|
176
|
+
)
|
|
177
|
+
console.print(" [yellow]Log out and back in for group changes to take effect.[/yellow]")
|
|
178
|
+
return ok
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _install_nvidia_toolkit() -> bool:
|
|
182
|
+
cmds = [
|
|
183
|
+
"curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey"
|
|
184
|
+
" | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg",
|
|
185
|
+
"curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/"
|
|
186
|
+
"nvidia-container-toolkit.list"
|
|
187
|
+
" | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/"
|
|
188
|
+
"nvidia-container-toolkit-keyring.gpg] https://#g'"
|
|
189
|
+
" | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list"
|
|
190
|
+
" > /dev/null",
|
|
191
|
+
"sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit",
|
|
192
|
+
"sudo nvidia-ctk runtime configure --runtime=docker",
|
|
193
|
+
"sudo systemctl restart docker",
|
|
194
|
+
]
|
|
195
|
+
for cmd in cmds:
|
|
196
|
+
result = subprocess.run(cmd, shell=True, timeout=120) # noqa: S602
|
|
197
|
+
if result.returncode != 0:
|
|
198
|
+
console.print(f" [red]Step failed: {cmd}[/red]")
|
|
199
|
+
return False
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _find_dockerfile() -> Path | None:
|
|
204
|
+
candidates = [
|
|
205
|
+
Path("docker/Dockerfile"),
|
|
206
|
+
Path.cwd() / "docker" / "Dockerfile",
|
|
207
|
+
]
|
|
208
|
+
pkg_root = Path(__file__).resolve().parents[4]
|
|
209
|
+
candidates.append(pkg_root / "docker" / "Dockerfile")
|
|
210
|
+
for path in candidates:
|
|
211
|
+
resolved = path.resolve()
|
|
212
|
+
if resolved.is_file():
|
|
213
|
+
return resolved
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _install_runtime_image() -> bool:
|
|
218
|
+
dockerfile = _find_dockerfile()
|
|
219
|
+
if dockerfile is None:
|
|
220
|
+
console.print(" [red]Dockerfile not found. Cannot build image.[/red]")
|
|
221
|
+
return False
|
|
222
|
+
return _run_install(
|
|
223
|
+
"nerdit-runtime:0.1",
|
|
224
|
+
["docker", "build", "-t", "nerdit-runtime:0.1", str(dockerfile.parent)],
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Dependency registry
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
_DEPS: list[
|
|
233
|
+
tuple[str, callable, callable | None, str] # type: ignore[type-arg]
|
|
234
|
+
] = [
|
|
235
|
+
(
|
|
236
|
+
"Python >=3.11",
|
|
237
|
+
_check_python,
|
|
238
|
+
_install_python,
|
|
239
|
+
"sudo apt install python3.11",
|
|
240
|
+
),
|
|
241
|
+
(
|
|
242
|
+
"NVIDIA Driver (>=535)",
|
|
243
|
+
_check_nvidia_driver,
|
|
244
|
+
_install_nvidia_driver,
|
|
245
|
+
"sudo apt install nvidia-driver-535",
|
|
246
|
+
),
|
|
247
|
+
(
|
|
248
|
+
"libnvidia-ml.so.1",
|
|
249
|
+
_check_nvidia_lib,
|
|
250
|
+
None, # installed with driver
|
|
251
|
+
"Provided by NVIDIA driver — try: sudo ldconfig",
|
|
252
|
+
),
|
|
253
|
+
(
|
|
254
|
+
"Docker Engine",
|
|
255
|
+
_check_docker,
|
|
256
|
+
_install_docker,
|
|
257
|
+
"curl -fsSL https://get.docker.com | sudo sh",
|
|
258
|
+
),
|
|
259
|
+
(
|
|
260
|
+
"NVIDIA Container Toolkit",
|
|
261
|
+
_check_nvidia_toolkit,
|
|
262
|
+
_install_nvidia_toolkit,
|
|
263
|
+
"sudo apt install nvidia-container-toolkit",
|
|
264
|
+
),
|
|
265
|
+
(
|
|
266
|
+
"nerdit-runtime:0.1",
|
|
267
|
+
_check_runtime_image,
|
|
268
|
+
_install_runtime_image,
|
|
269
|
+
"docker build -t nerdit-runtime:0.1 docker/",
|
|
270
|
+
),
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Command
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def check_deps(
|
|
280
|
+
install: bool = typer.Option(
|
|
281
|
+
False,
|
|
282
|
+
"--install",
|
|
283
|
+
"-i",
|
|
284
|
+
help="Offer to install missing dependencies (with confirmation)",
|
|
285
|
+
),
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Check system dependencies and optionally install missing ones."""
|
|
288
|
+
results: list[tuple[str, bool, str]] = []
|
|
289
|
+
for name, check_fn, _, _ in _DEPS:
|
|
290
|
+
ok, detail = check_fn()
|
|
291
|
+
results.append((name, ok, detail))
|
|
292
|
+
|
|
293
|
+
# Display table
|
|
294
|
+
table = Table(title="Nerdit — Dependency Check")
|
|
295
|
+
table.add_column("Dependency", style="bold")
|
|
296
|
+
table.add_column("Status", justify="center")
|
|
297
|
+
table.add_column("Detail")
|
|
298
|
+
|
|
299
|
+
for name, ok, detail in results:
|
|
300
|
+
status = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
301
|
+
detail_style = "" if ok else "[yellow]"
|
|
302
|
+
detail_end = "" if ok else "[/yellow]"
|
|
303
|
+
table.add_row(name, status, f"{detail_style}{detail}{detail_end}")
|
|
304
|
+
|
|
305
|
+
console.print()
|
|
306
|
+
console.print(table)
|
|
307
|
+
console.print()
|
|
308
|
+
|
|
309
|
+
missing = [(i, name) for i, (name, ok, _) in enumerate(results) if not ok]
|
|
310
|
+
if not missing:
|
|
311
|
+
console.print("[bold green]All dependencies are installed.[/bold green]")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
console.print(
|
|
315
|
+
f"[yellow]{len(missing)} missing dependenc{'y' if len(missing) == 1 else 'ies'}.[/yellow]"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if not install:
|
|
319
|
+
console.print("[dim]Run with --install to interactively install missing deps.[/dim]")
|
|
320
|
+
console.print()
|
|
321
|
+
for idx, name in missing:
|
|
322
|
+
_, _, _, hint = _DEPS[idx]
|
|
323
|
+
console.print(f" [dim]{name}:[/dim] {hint}")
|
|
324
|
+
raise typer.Exit(1)
|
|
325
|
+
|
|
326
|
+
# Interactive install flow
|
|
327
|
+
console.print()
|
|
328
|
+
any_installed = False
|
|
329
|
+
for idx, name in missing:
|
|
330
|
+
_, _, install_fn, hint = _DEPS[idx]
|
|
331
|
+
if install_fn is None:
|
|
332
|
+
console.print(f"[dim]{name}: no automatic installer — {hint}[/dim]")
|
|
333
|
+
continue
|
|
334
|
+
if typer.confirm(f"Install {name}?", default=False):
|
|
335
|
+
success = install_fn()
|
|
336
|
+
if success:
|
|
337
|
+
console.print(f" [green]✓ {name} installed[/green]")
|
|
338
|
+
any_installed = True
|
|
339
|
+
else:
|
|
340
|
+
console.print(f" [red]✗ {name} installation failed[/red]")
|
|
341
|
+
else:
|
|
342
|
+
console.print(f" [dim]Skipped {name}[/dim]")
|
|
343
|
+
|
|
344
|
+
# Re-check if anything was installed
|
|
345
|
+
if any_installed:
|
|
346
|
+
console.print("\n[bold]Re-checking...[/bold]\n")
|
|
347
|
+
still_missing = 0
|
|
348
|
+
for name, check_fn, _, _ in _DEPS:
|
|
349
|
+
ok, detail = check_fn()
|
|
350
|
+
status = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
351
|
+
console.print(f" {status} {name}: {detail}")
|
|
352
|
+
if not ok:
|
|
353
|
+
still_missing += 1
|
|
354
|
+
if still_missing == 0:
|
|
355
|
+
console.print("\n[bold green]All dependencies are now installed.[/bold green]")
|
|
356
|
+
else:
|
|
357
|
+
console.print(
|
|
358
|
+
f"\n[yellow]{still_missing} dependenc{'y' if still_missing == 1 else 'ies'}"
|
|
359
|
+
" still missing.[/yellow]"
|
|
360
|
+
)
|
|
361
|
+
raise typer.Exit(1)
|
|
362
|
+
else:
|
|
363
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""nerdit cleanup — Force-stop all running jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from nerdit.cli.display import console
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cleanup() -> None:
|
|
13
|
+
"""Force-stop all jobs and release resources."""
|
|
14
|
+
asyncio.run(_cleanup_async())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _cleanup_async() -> None:
|
|
18
|
+
from nerdit.cli.client import get_configured_client
|
|
19
|
+
|
|
20
|
+
client = get_configured_client()
|
|
21
|
+
try:
|
|
22
|
+
result = await client.cleanup()
|
|
23
|
+
count = result.get("stopped", 0)
|
|
24
|
+
if count > 0:
|
|
25
|
+
console.print(f"[green]{count} job(s) stopped[/green]")
|
|
26
|
+
else:
|
|
27
|
+
console.print("[dim]No running jobs to stop[/dim]")
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
30
|
+
raise typer.Exit(1)
|