fadenstack 0.4.2__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.
- faden_cli/__init__.py +16 -0
- faden_cli/__main__.py +9 -0
- faden_cli/bundle/shared/.gitignore +25 -0
- faden_cli/bundle/shared/LICENSE +187 -0
- faden_cli/bundle/shared/NOTICE +15 -0
- faden_cli/bundle/shared/OFFICIAL.md +9 -0
- faden_cli/bundle/shared/README.md +16 -0
- faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
- faden_cli/bundle/shared/alloy/config.alloy +40 -0
- faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
- faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
- faden_cli/bundle/shared/docker-compose.yaml +998 -0
- faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
- faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
- faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
- faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
- faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
- faden_cli/bundle/shared/initdb/01-init.sql +32 -0
- faden_cli/bundle/shared/loki/local-config.yaml +32 -0
- faden_cli/bundle/shared/nginx/locations.conf +245 -0
- faden_cli/bundle/shared/nginx/maintenance.html +239 -0
- faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
- faden_cli/bundle/shared/nginx/nginx.conf +44 -0
- faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
- faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
- faden_cli/bundle/shared/nginx/select-config.sh +37 -0
- faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
- faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
- faden_cli/bundle/shared/tls/README.md +30 -0
- faden_cli/cli.py +158 -0
- faden_cli/commands/__init__.py +1 -0
- faden_cli/commands/admin.py +161 -0
- faden_cli/commands/backup.py +159 -0
- faden_cli/commands/config.py +146 -0
- faden_cli/commands/deploy.py +949 -0
- faden_cli/commands/dev/__init__.py +1 -0
- faden_cli/commands/dev/dev_doctor.py +46 -0
- faden_cli/commands/dev/dev_down.py +241 -0
- faden_cli/commands/dev/dev_group.py +24 -0
- faden_cli/commands/dev/dev_init.py +710 -0
- faden_cli/commands/dev/dev_pull.py +297 -0
- faden_cli/commands/dev/dev_status.py +151 -0
- faden_cli/commands/dev/dev_up.py +1392 -0
- faden_cli/commands/doctor.py +225 -0
- faden_cli/commands/down.py +75 -0
- faden_cli/commands/logs_cmd.py +41 -0
- faden_cli/commands/maintenance.py +74 -0
- faden_cli/commands/module.py +113 -0
- faden_cli/commands/observe.py +95 -0
- faden_cli/commands/ports.py +91 -0
- faden_cli/commands/rebuild.py +158 -0
- faden_cli/commands/restore.py +139 -0
- faden_cli/commands/runtime_images.py +174 -0
- faden_cli/commands/status.py +71 -0
- faden_cli/commands/tls.py +722 -0
- faden_cli/commands/tune.py +37 -0
- faden_cli/commands/up.py +64 -0
- faden_cli/commands/upgrade.py +470 -0
- faden_cli/commands/version.py +78 -0
- faden_cli/core/__init__.py +1 -0
- faden_cli/core/api_client.py +77 -0
- faden_cli/core/backup.py +717 -0
- faden_cli/core/bootstrap.py +291 -0
- faden_cli/core/bundle.py +110 -0
- faden_cli/core/clickhouse.py +61 -0
- faden_cli/core/compose.py +484 -0
- faden_cli/core/console.py +106 -0
- faden_cli/core/detect.py +580 -0
- faden_cli/core/dev_modules.py +149 -0
- faden_cli/core/docker_env.py +147 -0
- faden_cli/core/edge.py +94 -0
- faden_cli/core/env_gen.py +598 -0
- faden_cli/core/git.py +219 -0
- faden_cli/core/install.py +288 -0
- faden_cli/core/installation.py +186 -0
- faden_cli/core/local_node.py +719 -0
- faden_cli/core/maintenance.py +82 -0
- faden_cli/core/private_files.py +133 -0
- faden_cli/core/registry.py +574 -0
- faden_cli/core/release_feed.py +342 -0
- faden_cli/core/rmq.py +135 -0
- faden_cli/core/runtime_images.py +309 -0
- faden_cli/core/schedule.py +278 -0
- faden_cli/core/settings.py +159 -0
- faden_cli/core/sysinfo.py +303 -0
- faden_cli/core/tls.py +790 -0
- faden_cli/core/tune.py +154 -0
- faden_cli/core/update_check.py +254 -0
- faden_cli/core/workspace.py +155 -0
- faden_cli/templates/env.j2 +33 -0
- faden_cli/tui/__init__.py +1 -0
- faden_cli/tui/widgets/__init__.py +1 -0
- faden_cli/tui/wizard/__init__.py +1 -0
- fadenstack-0.4.2.dist-info/METADATA +304 -0
- fadenstack-0.4.2.dist-info/RECORD +99 -0
- fadenstack-0.4.2.dist-info/WHEEL +4 -0
- fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
- fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
- fadenstack-0.4.2.dist-info/licenses/NOTICE +15 -0
faden_cli/core/detect.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""System detection — OS, Docker, GPU, ports, disk, RAM.
|
|
2
|
+
|
|
3
|
+
Every detection function returns a typed result dataclass so callers
|
|
4
|
+
can render them however suits (Rich table, Textual widget, JSON, etc.).
|
|
5
|
+
All detection is synchronous and subprocess-based — no Docker SDK
|
|
6
|
+
dependency, just ``docker`` and ``nvidia-smi`` / ``rocm-smi`` on PATH.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import platform
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import socket
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import psutil
|
|
20
|
+
|
|
21
|
+
# ── Result types ──────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class OSInfo:
|
|
26
|
+
"""Operating system details."""
|
|
27
|
+
|
|
28
|
+
system: str # "Linux", "Windows", "Darwin"
|
|
29
|
+
release: str
|
|
30
|
+
version: str
|
|
31
|
+
machine: str # "x86_64", "aarch64"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def display(self) -> str:
|
|
35
|
+
"""Human-friendly OS string."""
|
|
36
|
+
return f"{self.system} {self.release} ({self.machine})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class DockerInfo:
|
|
41
|
+
"""Docker Engine + Compose availability."""
|
|
42
|
+
|
|
43
|
+
installed: bool = False
|
|
44
|
+
version: str = ""
|
|
45
|
+
compose_installed: bool = False
|
|
46
|
+
compose_version: str = ""
|
|
47
|
+
daemon_running: bool = False
|
|
48
|
+
error: str = ""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def ok(self) -> bool:
|
|
52
|
+
"""Docker, Compose v2, and daemon are all available."""
|
|
53
|
+
return self.installed and self.compose_installed and self.daemon_running
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def daemon_hint(self) -> str:
|
|
57
|
+
"""Cross-platform hint for starting the Docker daemon."""
|
|
58
|
+
system = platform.system()
|
|
59
|
+
if system in ("Darwin", "Windows"):
|
|
60
|
+
# Docker Desktop or its open-source alternative, Rancher
|
|
61
|
+
# Desktop. The CLI automatically falls back to the Rancher
|
|
62
|
+
# Desktop endpoint when the active one is not reachable.
|
|
63
|
+
if self.error and "dockerDesktop" in self.error:
|
|
64
|
+
return (
|
|
65
|
+
"Start Docker Desktop, or start Rancher Desktop "
|
|
66
|
+
"(with Docker support enabled)."
|
|
67
|
+
)
|
|
68
|
+
return "Start Docker Desktop (or Rancher Desktop)."
|
|
69
|
+
return "Start the Docker daemon: sudo systemctl start docker"
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def install_hint(self) -> str:
|
|
73
|
+
"""Platform-specific install URL for Docker."""
|
|
74
|
+
system = platform.system()
|
|
75
|
+
if system == "Windows":
|
|
76
|
+
return (
|
|
77
|
+
"https://docs.docker.com/desktop/setup/install/windows-install/ "
|
|
78
|
+
"or https://docs.rancherdesktop.io/"
|
|
79
|
+
)
|
|
80
|
+
if system == "Darwin":
|
|
81
|
+
return "https://docs.docker.com/desktop/setup/install/mac-install/"
|
|
82
|
+
return "https://docs.docker.com/engine/install/"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class GpuDevice:
|
|
87
|
+
"""Single GPU device."""
|
|
88
|
+
|
|
89
|
+
index: int
|
|
90
|
+
name: str
|
|
91
|
+
vram_mb: int
|
|
92
|
+
driver_version: str
|
|
93
|
+
cuda_version: str = ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class GpuInfo:
|
|
98
|
+
"""GPU detection result."""
|
|
99
|
+
|
|
100
|
+
vendor: str = "none" # "nvidia", "amd", "none"
|
|
101
|
+
devices: list[GpuDevice] = field(default_factory=list)
|
|
102
|
+
driver_version: str = ""
|
|
103
|
+
cuda_version: str = ""
|
|
104
|
+
rocm_version: str = ""
|
|
105
|
+
error: str = ""
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def has_gpu(self) -> bool:
|
|
109
|
+
"""At least one GPU detected."""
|
|
110
|
+
return len(self.devices) > 0
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def total_vram_mb(self) -> int:
|
|
114
|
+
"""Sum of VRAM across all devices."""
|
|
115
|
+
return sum(d.vram_mb for d in self.devices)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def display(self) -> str:
|
|
119
|
+
"""Short human-friendly GPU summary."""
|
|
120
|
+
if not self.has_gpu:
|
|
121
|
+
return "None detected"
|
|
122
|
+
count = len(self.devices)
|
|
123
|
+
name = self.devices[0].name
|
|
124
|
+
vram = self.total_vram_mb
|
|
125
|
+
suffix = f" × {count}" if count > 1 else ""
|
|
126
|
+
return f"{name}{suffix} · {vram} MB VRAM"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class DiskInfo:
|
|
131
|
+
"""Disk space for a given path."""
|
|
132
|
+
|
|
133
|
+
path: str
|
|
134
|
+
total_gb: float
|
|
135
|
+
free_gb: float
|
|
136
|
+
used_pct: float
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class RamInfo:
|
|
141
|
+
"""System memory."""
|
|
142
|
+
|
|
143
|
+
total_gb: float
|
|
144
|
+
available_gb: float
|
|
145
|
+
used_pct: float
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class PortCheck:
|
|
150
|
+
"""Result of checking whether a port is in use."""
|
|
151
|
+
|
|
152
|
+
port: int
|
|
153
|
+
label: str
|
|
154
|
+
in_use: bool
|
|
155
|
+
#: Held by one of Fadenstack's own containers: not a conflict.
|
|
156
|
+
ours: bool = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class ToolCheck:
|
|
161
|
+
"""Result of checking whether a CLI tool is on PATH."""
|
|
162
|
+
|
|
163
|
+
name: str
|
|
164
|
+
found: bool
|
|
165
|
+
version: str = ""
|
|
166
|
+
path: str = ""
|
|
167
|
+
install_hint: str = ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── Install hints for common tools ────────────────────────────────
|
|
171
|
+
|
|
172
|
+
TOOL_INSTALL_HINTS: dict[str, str] = {
|
|
173
|
+
"docker": "https://docs.docker.com/engine/install/",
|
|
174
|
+
"git": "https://git-scm.com/downloads",
|
|
175
|
+
"uv": "pip install uv",
|
|
176
|
+
"node": "https://nodejs.org/en/download",
|
|
177
|
+
"npm": "https://nodejs.org/en/download",
|
|
178
|
+
"poetry": "pip install poetry",
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class SystemReport:
|
|
184
|
+
"""Aggregated detection results."""
|
|
185
|
+
|
|
186
|
+
os: OSInfo
|
|
187
|
+
docker: DockerInfo
|
|
188
|
+
gpu: GpuInfo
|
|
189
|
+
ram: RamInfo
|
|
190
|
+
disk: DiskInfo
|
|
191
|
+
ports: list[PortCheck] = field(default_factory=list)
|
|
192
|
+
tools: list[ToolCheck] = field(default_factory=list)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def all_clear(self) -> bool:
|
|
196
|
+
"""No blocking issues found."""
|
|
197
|
+
return self.docker.ok and not any(p.in_use for p in self.ports)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ── Detection functions ───────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def detect_os() -> OSInfo:
|
|
204
|
+
"""Detect the operating system."""
|
|
205
|
+
return OSInfo(
|
|
206
|
+
system=platform.system(),
|
|
207
|
+
release=platform.release(),
|
|
208
|
+
version=platform.version(),
|
|
209
|
+
machine=platform.machine(),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _run(
|
|
214
|
+
cmd: list[str], *, timeout: int = 10, env: dict[str, str] | None = None,
|
|
215
|
+
) -> subprocess.CompletedProcess[str]:
|
|
216
|
+
"""Run a subprocess, capturing output."""
|
|
217
|
+
return subprocess.run( # noqa: S603
|
|
218
|
+
cmd,
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
timeout=timeout,
|
|
222
|
+
env=env,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def detect_docker() -> DockerInfo:
|
|
227
|
+
"""Detect Docker Engine and Docker Compose v2.
|
|
228
|
+
|
|
229
|
+
Runs against the endpoint chosen by
|
|
230
|
+
:func:`faden_cli.core.docker_env.ensure_docker_host` (the CLI's default
|
|
231
|
+
context first, a live Rancher Desktop endpoint as a fallback). That
|
|
232
|
+
endpoint is exported to ``os.environ`` at CLI startup, so every
|
|
233
|
+
``docker`` subprocess here inherits it automatically — no re-plumbing.
|
|
234
|
+
"""
|
|
235
|
+
info = DockerInfo()
|
|
236
|
+
|
|
237
|
+
docker_bin = shutil.which("docker")
|
|
238
|
+
if not docker_bin:
|
|
239
|
+
info.error = "docker not found on PATH"
|
|
240
|
+
return info
|
|
241
|
+
|
|
242
|
+
# Client-only check (no daemon needed)
|
|
243
|
+
try:
|
|
244
|
+
result = _run([docker_bin, "version", "--format", "{{.Client.Version}}"])
|
|
245
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
246
|
+
info.installed = True
|
|
247
|
+
info.version = result.stdout.strip()
|
|
248
|
+
else:
|
|
249
|
+
stderr = result.stderr.strip()
|
|
250
|
+
# Permission denied means Docker is installed but user lacks socket access
|
|
251
|
+
if "permission denied" in stderr.lower():
|
|
252
|
+
info.installed = True
|
|
253
|
+
info.daemon_running = False
|
|
254
|
+
info.error = (
|
|
255
|
+
"Permission denied on Docker socket. "
|
|
256
|
+
"Add your user to the docker group: "
|
|
257
|
+
"sudo usermod -aG docker $USER && newgrp docker"
|
|
258
|
+
)
|
|
259
|
+
return info
|
|
260
|
+
info.error = stderr or "docker version failed"
|
|
261
|
+
return info
|
|
262
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
263
|
+
info.error = str(exc)
|
|
264
|
+
return info
|
|
265
|
+
|
|
266
|
+
# Daemon reachability + server version in one call
|
|
267
|
+
try:
|
|
268
|
+
result = _run([docker_bin, "version", "--format", "{{.Server.Version}}"], timeout=30)
|
|
269
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
270
|
+
info.daemon_running = True
|
|
271
|
+
info.version = result.stdout.strip()
|
|
272
|
+
else:
|
|
273
|
+
info.daemon_running = False
|
|
274
|
+
stderr = result.stderr.strip()
|
|
275
|
+
if "permission denied" in stderr.lower():
|
|
276
|
+
info.error = (
|
|
277
|
+
"Permission denied on Docker socket. "
|
|
278
|
+
"Add your user to the docker group: "
|
|
279
|
+
"sudo usermod -aG docker $USER && newgrp docker"
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
info.error = stderr
|
|
283
|
+
except subprocess.TimeoutExpired:
|
|
284
|
+
info.daemon_running = False
|
|
285
|
+
info.error = "docker daemon did not respond within 30 s"
|
|
286
|
+
except (FileNotFoundError, OSError):
|
|
287
|
+
info.daemon_running = False
|
|
288
|
+
|
|
289
|
+
# Docker Compose v2 (docker compose subcommand)
|
|
290
|
+
try:
|
|
291
|
+
result = _run([docker_bin, "compose", "version", "--short"])
|
|
292
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
293
|
+
info.compose_installed = True
|
|
294
|
+
info.compose_version = result.stdout.strip().lstrip("v")
|
|
295
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
return info
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def detect_gpu() -> GpuInfo:
|
|
302
|
+
"""Detect GPU vendor and devices (NVIDIA via nvidia-smi, AMD via rocm-smi)."""
|
|
303
|
+
info = GpuInfo()
|
|
304
|
+
|
|
305
|
+
# Try NVIDIA first
|
|
306
|
+
nvidia_smi = shutil.which("nvidia-smi")
|
|
307
|
+
if nvidia_smi:
|
|
308
|
+
try:
|
|
309
|
+
result = _run([
|
|
310
|
+
nvidia_smi,
|
|
311
|
+
"--query-gpu=index,name,memory.total,driver_version",
|
|
312
|
+
"--format=csv,noheader,nounits",
|
|
313
|
+
])
|
|
314
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
315
|
+
info.vendor = "nvidia"
|
|
316
|
+
for line in result.stdout.strip().splitlines():
|
|
317
|
+
parts = [p.strip() for p in line.split(",")]
|
|
318
|
+
if len(parts) >= 4: # noqa: PLR2004
|
|
319
|
+
try:
|
|
320
|
+
vram = int(float(parts[2]))
|
|
321
|
+
except (ValueError, TypeError):
|
|
322
|
+
vram = 0
|
|
323
|
+
device = GpuDevice(
|
|
324
|
+
index=int(parts[0]),
|
|
325
|
+
name=parts[1],
|
|
326
|
+
vram_mb=vram,
|
|
327
|
+
driver_version=parts[3],
|
|
328
|
+
)
|
|
329
|
+
info.devices.append(device)
|
|
330
|
+
info.driver_version = parts[3]
|
|
331
|
+
|
|
332
|
+
# Get CUDA version
|
|
333
|
+
cuda_result = _run([nvidia_smi])
|
|
334
|
+
if cuda_result.returncode == 0:
|
|
335
|
+
match = re.search(r"CUDA Version:\s*([\d.]+)", cuda_result.stdout)
|
|
336
|
+
if match:
|
|
337
|
+
info.cuda_version = match.group(1)
|
|
338
|
+
for d in info.devices:
|
|
339
|
+
d.cuda_version = match.group(1)
|
|
340
|
+
return info
|
|
341
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as exc:
|
|
342
|
+
info.error = str(exc)
|
|
343
|
+
|
|
344
|
+
# Try AMD ROCm
|
|
345
|
+
rocm_smi = shutil.which("rocm-smi")
|
|
346
|
+
if rocm_smi:
|
|
347
|
+
try:
|
|
348
|
+
result = _run([rocm_smi, "--showproductname", "--showmeminfo", "vram", "--csv"])
|
|
349
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
350
|
+
info.vendor = "amd"
|
|
351
|
+
# ROCm CSV parsing is more complex; simplified version
|
|
352
|
+
for i, line in enumerate(result.stdout.strip().splitlines()[1:]):
|
|
353
|
+
parts = [p.strip() for p in line.split(",")]
|
|
354
|
+
if len(parts) >= 2: # noqa: PLR2004
|
|
355
|
+
info.devices.append(
|
|
356
|
+
GpuDevice(
|
|
357
|
+
index=i,
|
|
358
|
+
name=parts[1] if len(parts) > 1 else f"AMD GPU {i}",
|
|
359
|
+
vram_mb=0, # parsed separately in real impl
|
|
360
|
+
driver_version="",
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
# ROCm version
|
|
364
|
+
rocm_ver = _run(["rocm-smi", "--version"])
|
|
365
|
+
if rocm_ver.returncode == 0:
|
|
366
|
+
info.rocm_version = rocm_ver.stdout.strip()
|
|
367
|
+
return info
|
|
368
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
369
|
+
info.error = str(exc)
|
|
370
|
+
|
|
371
|
+
return info
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def detect_ram() -> RamInfo:
|
|
375
|
+
"""Detect system memory."""
|
|
376
|
+
mem = psutil.virtual_memory()
|
|
377
|
+
return RamInfo(
|
|
378
|
+
total_gb=round(mem.total / (1024**3), 1),
|
|
379
|
+
available_gb=round(mem.available / (1024**3), 1),
|
|
380
|
+
used_pct=mem.percent,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def detect_disk(path: str = "/") -> DiskInfo:
|
|
385
|
+
"""Detect disk space for the given path."""
|
|
386
|
+
if platform.system() == "Windows":
|
|
387
|
+
path = "C:\\"
|
|
388
|
+
usage = psutil.disk_usage(path)
|
|
389
|
+
return DiskInfo(
|
|
390
|
+
path=path,
|
|
391
|
+
total_gb=round(usage.total / (1024**3), 1),
|
|
392
|
+
free_gb=round(usage.free / (1024**3), 1),
|
|
393
|
+
used_pct=usage.percent,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ── Port map (all ports used by Fadenstack) ─────────────────────────
|
|
398
|
+
|
|
399
|
+
from faden_cli.core.registry import KNOWN_PORTS
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def check_port(port: int, label: str = "") -> PortCheck:
|
|
403
|
+
"""Check if a TCP port is already in use on localhost."""
|
|
404
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
405
|
+
s.settimeout(0.5)
|
|
406
|
+
in_use = s.connect_ex(("127.0.0.1", port)) == 0
|
|
407
|
+
return PortCheck(port=port, label=label, in_use=in_use)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def check_known_ports() -> list[PortCheck]:
|
|
411
|
+
"""Check all known Fadenstack ports."""
|
|
412
|
+
return [check_port(port, label) for port, label in KNOWN_PORTS]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
#: What a production install publishes on the host, for when the compose file
|
|
416
|
+
#: cannot be asked (no Docker yet). ``check_install_ports`` reads the real list.
|
|
417
|
+
FALLBACK_INSTALL_PORTS: list[tuple[int, str]] = [
|
|
418
|
+
(80, "nginx: console, API, gateway"),
|
|
419
|
+
(443, "nginx: the same over HTTPS, once a certificate is installed"),
|
|
420
|
+
(3001, "grafana"),
|
|
421
|
+
(3002, "langfuse-web"),
|
|
422
|
+
(3100, "loki"),
|
|
423
|
+
(9099, "prometheus"),
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _published_ports(compose_file: Path, env_file: Path | None) -> list[tuple[int, str]] | None:
|
|
428
|
+
"""The host ports *compose_file* publishes, with the service, or ``None`` if Docker cannot say."""
|
|
429
|
+
import json # noqa: PLC0415
|
|
430
|
+
|
|
431
|
+
docker = shutil.which("docker")
|
|
432
|
+
if not docker:
|
|
433
|
+
return None
|
|
434
|
+
cmd = [docker, "compose", "-f", str(compose_file)]
|
|
435
|
+
if env_file is not None and env_file.is_file():
|
|
436
|
+
cmd += ["--env-file", str(env_file)]
|
|
437
|
+
# The compose file requires its secrets (${NAME:?...}) and stops without them, before
|
|
438
|
+
# deploy has written any. Ports do not depend on them: placeholders let it answer, and
|
|
439
|
+
# the real ones never reach this process's output.
|
|
440
|
+
import os # noqa: PLC0415
|
|
441
|
+
|
|
442
|
+
from faden_cli.core.env_gen import REQUIRED_KEYS # noqa: PLC0415
|
|
443
|
+
|
|
444
|
+
env = {**os.environ, **dict.fromkeys(REQUIRED_KEYS, "placeholder")}
|
|
445
|
+
try:
|
|
446
|
+
out = _run([*cmd, "config", "--format", "json"], timeout=30, env=env)
|
|
447
|
+
spec = json.loads(out.stdout or "{}") if out.returncode == 0 else None
|
|
448
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
449
|
+
return None
|
|
450
|
+
if not spec:
|
|
451
|
+
return None
|
|
452
|
+
ports: dict[int, str] = {}
|
|
453
|
+
for name, service in (spec.get("services") or {}).items():
|
|
454
|
+
for mapping in (service or {}).get("ports") or []:
|
|
455
|
+
try:
|
|
456
|
+
ports.setdefault(int(mapping.get("published")), name)
|
|
457
|
+
except (TypeError, ValueError):
|
|
458
|
+
continue
|
|
459
|
+
return sorted(ports.items())
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _ports_held_by_faden() -> set[int]:
|
|
463
|
+
"""Host ports Fadenstack's own containers publish (``faden-*``)."""
|
|
464
|
+
docker = shutil.which("docker")
|
|
465
|
+
if not docker:
|
|
466
|
+
return set()
|
|
467
|
+
try:
|
|
468
|
+
out = _run([docker, "ps", "--format", "{{.Names}}\t{{.Ports}}"], timeout=15)
|
|
469
|
+
except (OSError, subprocess.SubprocessError):
|
|
470
|
+
return set()
|
|
471
|
+
held: set[int] = set()
|
|
472
|
+
for line in (out.stdout or "").splitlines():
|
|
473
|
+
name, _, ports = line.partition("\t")
|
|
474
|
+
if not name.startswith("faden-"):
|
|
475
|
+
continue
|
|
476
|
+
# "0.0.0.0:80->80/tcp, [::]:80->80/tcp, 127.0.0.1:9099->9090/tcp"
|
|
477
|
+
for match in re.finditer(r":(\d+)->", ports):
|
|
478
|
+
held.add(int(match.group(1)))
|
|
479
|
+
return held
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def check_install_ports(compose_file: Path | None, env_file: Path | None = None) -> list[PortCheck]:
|
|
483
|
+
"""The host ports an install needs, and whether something else holds them.
|
|
484
|
+
|
|
485
|
+
Read from the compose file the install runs, so the list is what
|
|
486
|
+
``docker compose up`` will bind -- a port held by something else stops
|
|
487
|
+
it ("port is already allocated"). Ports Fadenstack's own containers hold
|
|
488
|
+
are marked as such, not as conflicts.
|
|
489
|
+
"""
|
|
490
|
+
published = _published_ports(compose_file, env_file) if compose_file else None
|
|
491
|
+
wanted = published if published is not None else FALLBACK_INSTALL_PORTS
|
|
492
|
+
ours = _ports_held_by_faden()
|
|
493
|
+
checks = []
|
|
494
|
+
for port, label in wanted:
|
|
495
|
+
check = check_port(port, label)
|
|
496
|
+
check.ours = check.in_use and port in ours
|
|
497
|
+
checks.append(check)
|
|
498
|
+
return checks
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# Directories probed for freshly installed tools that are not yet on
|
|
502
|
+
# the session PATH (e.g. the uv installer drops into ~/.local/bin).
|
|
503
|
+
EXTRA_TOOL_DIRS: tuple[str, ...] = (
|
|
504
|
+
str(Path.home() / ".local" / "bin"),
|
|
505
|
+
str(Path.home() / ".cargo" / "bin"),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def ensure_tool_path() -> None:
|
|
510
|
+
"""Prepend common tool dirs to this process's PATH (in-place).
|
|
511
|
+
|
|
512
|
+
Call once at CLI startup so child subprocesses (``uv sync``, ``npm``…)
|
|
513
|
+
can find tools that the user's shell put in ``~/.local/bin`` /
|
|
514
|
+
``~/.cargo/bin`` but that are missing from the current PATH
|
|
515
|
+
(headless SSH sessions, cron, GUI-launched terminals).
|
|
516
|
+
"""
|
|
517
|
+
import os
|
|
518
|
+
|
|
519
|
+
path_parts = os.environ.get("PATH", "").split(os.pathsep)
|
|
520
|
+
for extra in reversed(EXTRA_TOOL_DIRS):
|
|
521
|
+
if extra not in path_parts:
|
|
522
|
+
path_parts.insert(0, extra)
|
|
523
|
+
os.environ["PATH"] = os.pathsep.join(path_parts)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def find_tool_path(name: str) -> str | None:
|
|
527
|
+
"""Locate a CLI tool on PATH or in common post-install locations."""
|
|
528
|
+
path = shutil.which(name)
|
|
529
|
+
if path:
|
|
530
|
+
return path
|
|
531
|
+
for extra in EXTRA_TOOL_DIRS:
|
|
532
|
+
candidate = Path(extra) / name
|
|
533
|
+
if candidate.is_file() and candidate.stat().st_mode & 0o111:
|
|
534
|
+
return str(candidate)
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def check_tool(name: str, *, version_flag: str = "--version") -> ToolCheck:
|
|
539
|
+
"""Check if a CLI tool exists on PATH (or a known install dir) + version."""
|
|
540
|
+
hint = TOOL_INSTALL_HINTS.get(name, "")
|
|
541
|
+
path = find_tool_path(name)
|
|
542
|
+
if not path:
|
|
543
|
+
return ToolCheck(name=name, found=False, install_hint=hint)
|
|
544
|
+
try:
|
|
545
|
+
result = _run([path, version_flag])
|
|
546
|
+
version = result.stdout.strip() or result.stderr.strip()
|
|
547
|
+
# Extract just the version number from common formats
|
|
548
|
+
match = re.search(r"(\d+\.\d+[\.\d]*)", version)
|
|
549
|
+
ver = match.group(1) if match else version[:60]
|
|
550
|
+
return ToolCheck(name=name, found=True, version=ver, path=path, install_hint=hint)
|
|
551
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
552
|
+
return ToolCheck(name=name, found=True, path=path, install_hint=hint)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def check_dev_tools() -> list[ToolCheck]:
|
|
556
|
+
"""Check all tools needed for dev mode."""
|
|
557
|
+
return [
|
|
558
|
+
check_tool("docker"),
|
|
559
|
+
check_tool("git"),
|
|
560
|
+
check_tool("uv"),
|
|
561
|
+
check_tool("node"),
|
|
562
|
+
check_tool("npm"),
|
|
563
|
+
check_tool("poetry"),
|
|
564
|
+
]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ── Full system report ────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def full_report(*, check_ports: bool = True, install_path: str = "/") -> SystemReport:
|
|
571
|
+
"""Run all detection checks and return a unified report."""
|
|
572
|
+
return SystemReport(
|
|
573
|
+
os=detect_os(),
|
|
574
|
+
docker=detect_docker(),
|
|
575
|
+
gpu=detect_gpu(),
|
|
576
|
+
ram=detect_ram(),
|
|
577
|
+
disk=detect_disk(install_path),
|
|
578
|
+
ports=check_known_ports() if check_ports else [],
|
|
579
|
+
tools=check_dev_tools(),
|
|
580
|
+
)
|