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
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Docker Compose subprocess wrapper.
|
|
2
|
+
|
|
3
|
+
Wraps ``docker compose`` (v2) as a thin Python layer. Every function
|
|
4
|
+
shells out rather than using a Docker SDK so the CLI has zero runtime
|
|
5
|
+
dependency on Docker libraries and works on any system that has the
|
|
6
|
+
``docker`` binary.
|
|
7
|
+
|
|
8
|
+
Environment and profile arguments are built from the
|
|
9
|
+
:class:`~faden_cli.core.settings.FadenConfig` dataclass so callers
|
|
10
|
+
never need to assemble raw CLI flags.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from faden_cli.core.console import console, error
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── GPU detection ─────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
_gpu_cache: bool | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def has_nvidia_gpu() -> bool:
|
|
31
|
+
"""Return True if an NVIDIA GPU is actually usable by Docker.
|
|
32
|
+
|
|
33
|
+
Checks two things:
|
|
34
|
+
1. The Docker daemon has the ``nvidia`` runtime registered.
|
|
35
|
+
2. ``nvidia-smi`` succeeds on the host, proving real GPU
|
|
36
|
+
adapters are present (the runtime alone may be installed
|
|
37
|
+
in WSL/CI without any physical GPU).
|
|
38
|
+
"""
|
|
39
|
+
global _gpu_cache # noqa: PLW0603
|
|
40
|
+
if _gpu_cache is not None:
|
|
41
|
+
return _gpu_cache
|
|
42
|
+
|
|
43
|
+
docker = shutil.which("docker")
|
|
44
|
+
if not docker:
|
|
45
|
+
_gpu_cache = False
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
# 1. Runtime check
|
|
49
|
+
try:
|
|
50
|
+
proc = subprocess.run(
|
|
51
|
+
[docker, "info", "--format", "{{.Runtimes}}"],
|
|
52
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
|
|
53
|
+
)
|
|
54
|
+
if "nvidia" not in (proc.stdout or "").lower():
|
|
55
|
+
_gpu_cache = False
|
|
56
|
+
return False
|
|
57
|
+
except Exception: # noqa: BLE001
|
|
58
|
+
_gpu_cache = False
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
# 2. Actual GPU adapter check
|
|
62
|
+
smi = shutil.which("nvidia-smi")
|
|
63
|
+
if not smi:
|
|
64
|
+
_gpu_cache = False
|
|
65
|
+
return False
|
|
66
|
+
try:
|
|
67
|
+
proc = subprocess.run(
|
|
68
|
+
[smi, "-L"],
|
|
69
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
|
|
70
|
+
)
|
|
71
|
+
_gpu_cache = proc.returncode == 0 and "GPU" in (proc.stdout or "")
|
|
72
|
+
except Exception: # noqa: BLE001
|
|
73
|
+
_gpu_cache = False
|
|
74
|
+
return _gpu_cache
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── Types ─────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ComposeService:
|
|
82
|
+
"""Parsed row from ``docker compose ps --format json``."""
|
|
83
|
+
|
|
84
|
+
name: str = ""
|
|
85
|
+
service: str = ""
|
|
86
|
+
status: str = "" # running, exited, created, …
|
|
87
|
+
health: str = "" # healthy, unhealthy, starting, ""
|
|
88
|
+
ports: str = ""
|
|
89
|
+
state: str = ""
|
|
90
|
+
exit_code: int = 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ComposeContext:
|
|
95
|
+
"""Everything needed to run a compose command."""
|
|
96
|
+
|
|
97
|
+
compose_files: list[Path] = field(default_factory=list)
|
|
98
|
+
env_file: Path | None = None
|
|
99
|
+
project_dir: Path | None = None
|
|
100
|
+
profiles: list[str] = field(default_factory=list)
|
|
101
|
+
|
|
102
|
+
def base_cmd(self) -> list[str]:
|
|
103
|
+
"""Build the ``docker compose`` prefix with all file/profile flags."""
|
|
104
|
+
docker = shutil.which("docker")
|
|
105
|
+
if not docker:
|
|
106
|
+
error("docker not found on PATH")
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
|
|
109
|
+
cmd: list[str] = [docker, "compose"]
|
|
110
|
+
for f in self.compose_files:
|
|
111
|
+
cmd.extend(["-f", str(f)])
|
|
112
|
+
if self.env_file and Path(str(self.env_file)).exists():
|
|
113
|
+
cmd.extend(["--env-file", str(self.env_file)])
|
|
114
|
+
if self.project_dir:
|
|
115
|
+
cmd.extend(["--project-directory", str(self.project_dir)])
|
|
116
|
+
for p in self.profiles:
|
|
117
|
+
cmd.extend(["--profile", p])
|
|
118
|
+
return cmd
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Constants ─────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
# Base images used in the platform base Dockerfile. Pre-pulling
|
|
124
|
+
# puts them in the local store so subsequent builds hit cache.
|
|
125
|
+
_BASE_IMAGES = [
|
|
126
|
+
"python:3.13-slim-bookworm",
|
|
127
|
+
"ghcr.io/astral-sh/uv:0.9.12",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── Public API ────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def pull_base_images() -> None:
|
|
135
|
+
"""Pre-pull shared base images so BuildKit finds them locally."""
|
|
136
|
+
docker = shutil.which("docker")
|
|
137
|
+
if not docker:
|
|
138
|
+
return
|
|
139
|
+
for image in _BASE_IMAGES:
|
|
140
|
+
subprocess.run( # noqa: S603
|
|
141
|
+
[docker, "pull", image],
|
|
142
|
+
check=False,
|
|
143
|
+
stdout=subprocess.DEVNULL,
|
|
144
|
+
stderr=subprocess.DEVNULL,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_base_image(ctx: ComposeContext, *, no_cache: bool = False) -> int:
|
|
149
|
+
"""Build the shared platform base image via compose.
|
|
150
|
+
|
|
151
|
+
Uses the ``faden-base`` service (gated behind the ``_build``
|
|
152
|
+
profile) so the image lands in the same BuildKit context that
|
|
153
|
+
subsequent service builds use.
|
|
154
|
+
"""
|
|
155
|
+
base_ctx = ComposeContext(
|
|
156
|
+
compose_files=ctx.compose_files,
|
|
157
|
+
env_file=ctx.env_file,
|
|
158
|
+
project_dir=ctx.project_dir,
|
|
159
|
+
profiles=["_build"],
|
|
160
|
+
)
|
|
161
|
+
return build(base_ctx, services=["faden-base"], no_cache=no_cache)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _run(
|
|
165
|
+
cmd: list[str],
|
|
166
|
+
*,
|
|
167
|
+
capture: bool = False,
|
|
168
|
+
check: bool = True,
|
|
169
|
+
stream: bool = False,
|
|
170
|
+
) -> subprocess.CompletedProcess[str]:
|
|
171
|
+
"""Execute a command, optionally streaming output to the console."""
|
|
172
|
+
if stream:
|
|
173
|
+
# Stream stdout/stderr to terminal in real time
|
|
174
|
+
result = subprocess.run(cmd, check=False) # noqa: S603
|
|
175
|
+
if check and result.returncode != 0:
|
|
176
|
+
error(f"Command failed with exit code {result.returncode}")
|
|
177
|
+
return subprocess.CompletedProcess(cmd, result.returncode, "", "")
|
|
178
|
+
|
|
179
|
+
return subprocess.run( # noqa: S603
|
|
180
|
+
cmd,
|
|
181
|
+
capture_output=capture,
|
|
182
|
+
text=True,
|
|
183
|
+
# Decode explicitly as UTF-8 and never raise. `text=True` alone
|
|
184
|
+
# decodes with the locale codec, which on a Windows console is
|
|
185
|
+
# cp1252: docker output containing any byte outside it (0x8d in a
|
|
186
|
+
# container name or a progress glyph) killed the reader thread, left
|
|
187
|
+
# `stdout` as None, and surfaced as `'NoneType' has no attribute
|
|
188
|
+
# 'strip'` in the caller rather than as a decoding problem.
|
|
189
|
+
encoding="utf-8",
|
|
190
|
+
errors="replace",
|
|
191
|
+
check=False,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def up(
|
|
196
|
+
ctx: ComposeContext,
|
|
197
|
+
*,
|
|
198
|
+
services: list[str] | None = None,
|
|
199
|
+
detach: bool = True,
|
|
200
|
+
build: bool = False,
|
|
201
|
+
pull: str = "",
|
|
202
|
+
wait: bool = False,
|
|
203
|
+
timeout: int = 0,
|
|
204
|
+
force_recreate: bool = False,
|
|
205
|
+
no_deps: bool = False,
|
|
206
|
+
) -> int:
|
|
207
|
+
"""Run ``docker compose up``.
|
|
208
|
+
|
|
209
|
+
*pull* accepts ``"always"``, ``"missing"``, or ``"never"``.
|
|
210
|
+
An empty string (default) leaves Docker Compose to decide.
|
|
211
|
+
*wait* blocks until services are healthy (implies ``-d``).
|
|
212
|
+
*timeout* sets the max wait time in seconds (requires *wait*).
|
|
213
|
+
*force_recreate* recreates containers even if config hasn't changed.
|
|
214
|
+
*no_deps* starts only *services*, not what they depend on.
|
|
215
|
+
"""
|
|
216
|
+
cmd = ctx.base_cmd() + ["up"]
|
|
217
|
+
if detach:
|
|
218
|
+
cmd.append("-d")
|
|
219
|
+
if no_deps:
|
|
220
|
+
cmd.append("--no-deps")
|
|
221
|
+
if build:
|
|
222
|
+
cmd.append("--build")
|
|
223
|
+
if pull:
|
|
224
|
+
cmd.append(f"--pull={pull}")
|
|
225
|
+
if force_recreate:
|
|
226
|
+
cmd.append("--force-recreate")
|
|
227
|
+
if wait:
|
|
228
|
+
cmd.append("--wait")
|
|
229
|
+
if timeout > 0:
|
|
230
|
+
cmd.append(f"--wait-timeout={timeout}")
|
|
231
|
+
if services:
|
|
232
|
+
cmd.extend(services)
|
|
233
|
+
result = _run(cmd, stream=True)
|
|
234
|
+
return result.returncode
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def service_names(ctx: ComposeContext) -> list[str]:
|
|
238
|
+
"""The services this compose setup runs (its active profiles included); empty when compose cannot say."""
|
|
239
|
+
result = _run(ctx.base_cmd() + ["config", "--services"], capture=True)
|
|
240
|
+
if result.returncode != 0:
|
|
241
|
+
return []
|
|
242
|
+
return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def down(
|
|
246
|
+
ctx: ComposeContext,
|
|
247
|
+
*,
|
|
248
|
+
volumes: bool = False,
|
|
249
|
+
remove_orphans: bool = False,
|
|
250
|
+
rmi: str | None = None,
|
|
251
|
+
) -> int:
|
|
252
|
+
"""Run ``docker compose down``.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
rmi: Remove images. ``"all"`` removes all images, ``"local"``
|
|
256
|
+
removes only images without a custom tag.
|
|
257
|
+
"""
|
|
258
|
+
cmd = ctx.base_cmd() + ["down"]
|
|
259
|
+
if volumes:
|
|
260
|
+
cmd.append("-v")
|
|
261
|
+
if remove_orphans:
|
|
262
|
+
cmd.append("--remove-orphans")
|
|
263
|
+
if rmi:
|
|
264
|
+
cmd.extend(["--rmi", rmi])
|
|
265
|
+
result = _run(cmd, stream=True)
|
|
266
|
+
return result.returncode
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def restart(ctx: ComposeContext, *, services: list[str] | None = None) -> int:
|
|
270
|
+
"""Run ``docker compose restart`` for the given services."""
|
|
271
|
+
cmd = ctx.base_cmd() + ["restart"]
|
|
272
|
+
if services:
|
|
273
|
+
cmd.extend(services)
|
|
274
|
+
result = _run(cmd, stream=True)
|
|
275
|
+
return result.returncode
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def pull(ctx: ComposeContext, *, services: list[str] | None = None) -> int:
|
|
279
|
+
"""Run ``docker compose pull``."""
|
|
280
|
+
cmd = ctx.base_cmd() + ["pull"]
|
|
281
|
+
if services:
|
|
282
|
+
cmd.extend(services)
|
|
283
|
+
result = _run(cmd, stream=True)
|
|
284
|
+
return result.returncode
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build(
|
|
288
|
+
ctx: ComposeContext,
|
|
289
|
+
*,
|
|
290
|
+
services: list[str] | None = None,
|
|
291
|
+
no_cache: bool = False,
|
|
292
|
+
pull: bool = False,
|
|
293
|
+
) -> int:
|
|
294
|
+
"""Run ``docker compose build``."""
|
|
295
|
+
cmd = ctx.base_cmd() + ["build"]
|
|
296
|
+
if no_cache:
|
|
297
|
+
cmd.append("--no-cache")
|
|
298
|
+
if pull:
|
|
299
|
+
cmd.append("--pull")
|
|
300
|
+
if services:
|
|
301
|
+
cmd.extend(services)
|
|
302
|
+
result = _run(cmd, stream=True)
|
|
303
|
+
return result.returncode
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def logs(
|
|
307
|
+
ctx: ComposeContext,
|
|
308
|
+
*,
|
|
309
|
+
services: list[str] | None = None,
|
|
310
|
+
follow: bool = True,
|
|
311
|
+
tail: int | None = 100,
|
|
312
|
+
timestamps: bool = False,
|
|
313
|
+
) -> int:
|
|
314
|
+
"""Run ``docker compose logs``."""
|
|
315
|
+
cmd = ctx.base_cmd() + ["logs"]
|
|
316
|
+
if follow:
|
|
317
|
+
cmd.append("-f")
|
|
318
|
+
if tail is not None:
|
|
319
|
+
cmd.extend(["--tail", str(tail)])
|
|
320
|
+
if timestamps:
|
|
321
|
+
cmd.append("--timestamps")
|
|
322
|
+
if services:
|
|
323
|
+
cmd.extend(services)
|
|
324
|
+
result = _run(cmd, stream=True)
|
|
325
|
+
return result.returncode
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def ps(ctx: ComposeContext) -> list[ComposeService]:
|
|
329
|
+
"""Run ``docker compose ps`` and parse the output."""
|
|
330
|
+
import json as _json
|
|
331
|
+
|
|
332
|
+
cmd = ctx.base_cmd() + ["ps", "--format", "json", "-a"]
|
|
333
|
+
result = _run(cmd, capture=True)
|
|
334
|
+
if result.returncode != 0:
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
services: list[ComposeService] = []
|
|
338
|
+
for line in result.stdout.strip().splitlines():
|
|
339
|
+
line = line.strip()
|
|
340
|
+
if not line:
|
|
341
|
+
continue
|
|
342
|
+
try:
|
|
343
|
+
obj = _json.loads(line)
|
|
344
|
+
svc = ComposeService(
|
|
345
|
+
name=obj.get("Name", ""),
|
|
346
|
+
service=obj.get("Service", ""),
|
|
347
|
+
status=obj.get("State", obj.get("Status", "")),
|
|
348
|
+
health=obj.get("Health", ""),
|
|
349
|
+
ports=obj.get("Publishers", obj.get("Ports", "")),
|
|
350
|
+
state=obj.get("State", ""),
|
|
351
|
+
exit_code=obj.get("ExitCode", 0),
|
|
352
|
+
)
|
|
353
|
+
# Format ports if it's a list of dicts
|
|
354
|
+
if isinstance(svc.ports, list):
|
|
355
|
+
port_strs = []
|
|
356
|
+
for p in svc.ports:
|
|
357
|
+
if isinstance(p, dict) and p.get("PublishedPort"):
|
|
358
|
+
port_strs.append(f"{p.get('URL', '0.0.0.0')}:{p['PublishedPort']}→{p['TargetPort']}")
|
|
359
|
+
svc.ports = ", ".join(port_strs) if port_strs else ""
|
|
360
|
+
services.append(svc)
|
|
361
|
+
except (ValueError, KeyError):
|
|
362
|
+
continue
|
|
363
|
+
return services
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def foreign_containers(ctx: ComposeContext) -> list[tuple[str, str]]:
|
|
367
|
+
"""Containers holding a name this compose file gives, made by something else.
|
|
368
|
+
|
|
369
|
+
Compose cannot replace a container it did not create: ``up`` stops with
|
|
370
|
+
"The container name ... is already in use". An upgrade met one -- a
|
|
371
|
+
Prometheus recreated by hand with ``docker run`` -- after building images
|
|
372
|
+
for a quarter of an hour. Returns ``(name, made by)`` pairs, where *made
|
|
373
|
+
by* is the other compose project, or ``""`` for a plain ``docker run``.
|
|
374
|
+
"""
|
|
375
|
+
config = _run(ctx.base_cmd() + ["config", "--format", "json"], capture=True)
|
|
376
|
+
if config.returncode != 0:
|
|
377
|
+
return []
|
|
378
|
+
try:
|
|
379
|
+
spec = json.loads(config.stdout or "{}")
|
|
380
|
+
except ValueError:
|
|
381
|
+
return []
|
|
382
|
+
project = spec.get("name", "")
|
|
383
|
+
wanted = {
|
|
384
|
+
s.get("container_name")
|
|
385
|
+
for s in (spec.get("services") or {}).values()
|
|
386
|
+
if isinstance(s, dict) and s.get("container_name")
|
|
387
|
+
}
|
|
388
|
+
docker = shutil.which("docker") or "docker"
|
|
389
|
+
listed = _run(
|
|
390
|
+
[docker, "ps", "-a", "--format", '{{.Names}}\t{{.Label "com.docker.compose.project"}}'],
|
|
391
|
+
capture=True,
|
|
392
|
+
)
|
|
393
|
+
found = []
|
|
394
|
+
for line in (listed.stdout or "").splitlines():
|
|
395
|
+
name, _, owner = line.partition("\t")
|
|
396
|
+
if name in wanted and owner != project:
|
|
397
|
+
found.append((name, owner))
|
|
398
|
+
return sorted(found)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def exec_cmd(
|
|
402
|
+
ctx: ComposeContext,
|
|
403
|
+
service: str,
|
|
404
|
+
command: list[str],
|
|
405
|
+
*,
|
|
406
|
+
capture: bool = False,
|
|
407
|
+
) -> subprocess.CompletedProcess[str]:
|
|
408
|
+
"""Run ``docker compose exec`` on a running service."""
|
|
409
|
+
cmd = ctx.base_cmd() + ["exec", service, *command]
|
|
410
|
+
return _run(cmd, capture=capture)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def run_cmd(
|
|
414
|
+
ctx: ComposeContext,
|
|
415
|
+
service: str,
|
|
416
|
+
command: list[str],
|
|
417
|
+
*,
|
|
418
|
+
capture: bool = False,
|
|
419
|
+
remove: bool = True,
|
|
420
|
+
) -> subprocess.CompletedProcess[str]:
|
|
421
|
+
"""Run ``docker compose run`` (one-off container)."""
|
|
422
|
+
cmd = ctx.base_cmd() + ["run"]
|
|
423
|
+
if remove:
|
|
424
|
+
cmd.append("--rm")
|
|
425
|
+
cmd.extend([service, *command])
|
|
426
|
+
return _run(cmd, capture=capture)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _resolve_compose_path(cfg: "FadenConfig") -> Path:
|
|
430
|
+
"""Resolve the compose file, falling back to shared/."""
|
|
431
|
+
primary = cfg.compose_path
|
|
432
|
+
if primary.exists():
|
|
433
|
+
return primary
|
|
434
|
+
# Check inside shared relative to install path
|
|
435
|
+
for name in ("docker-compose.yaml", "docker-compose.yml"):
|
|
436
|
+
candidate = cfg.install_path / "shared" / name
|
|
437
|
+
if candidate.exists():
|
|
438
|
+
return candidate
|
|
439
|
+
return primary
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def build_context_from_config(cfg: "FadenConfig", *, gpu: bool | None = None) -> ComposeContext:
|
|
443
|
+
"""Build a ComposeContext from the current config.
|
|
444
|
+
|
|
445
|
+
*gpu* controls whether the GPU overlay is included:
|
|
446
|
+
- ``None`` (default): auto-detect via :func:`has_nvidia_gpu`
|
|
447
|
+
- ``True``: force-include the GPU overlay
|
|
448
|
+
- ``False``: never include the GPU overlay
|
|
449
|
+
"""
|
|
450
|
+
compose_file = _resolve_compose_path(cfg)
|
|
451
|
+
files = [compose_file]
|
|
452
|
+
|
|
453
|
+
use_gpu = has_nvidia_gpu() if gpu is None else gpu
|
|
454
|
+
if use_gpu:
|
|
455
|
+
gpu_overlay = compose_file.parent / "docker-compose.gpu.yaml"
|
|
456
|
+
if gpu_overlay.exists():
|
|
457
|
+
files.append(gpu_overlay)
|
|
458
|
+
|
|
459
|
+
return ComposeContext(
|
|
460
|
+
compose_files=files,
|
|
461
|
+
env_file=cfg.env_path if cfg.env_path.exists() else None,
|
|
462
|
+
project_dir=compose_file.parent,
|
|
463
|
+
profiles=list(cfg.profiles),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def build_context_from_paths(
|
|
468
|
+
compose_file: Path,
|
|
469
|
+
*,
|
|
470
|
+
env_file: Path | None = None,
|
|
471
|
+
project_dir: Path | None = None,
|
|
472
|
+
profiles: list[str] | None = None,
|
|
473
|
+
dev_overlay: Path | None = None,
|
|
474
|
+
) -> ComposeContext:
|
|
475
|
+
"""Build a ComposeContext from explicit paths."""
|
|
476
|
+
files = [compose_file]
|
|
477
|
+
if dev_overlay and dev_overlay.exists():
|
|
478
|
+
files.append(dev_overlay)
|
|
479
|
+
return ComposeContext(
|
|
480
|
+
compose_files=files,
|
|
481
|
+
env_file=env_file,
|
|
482
|
+
project_dir=project_dir or compose_file.parent,
|
|
483
|
+
profiles=profiles or [],
|
|
484
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Rich console singleton and shared output helpers.
|
|
2
|
+
|
|
3
|
+
Provides a single ``Console`` instance used by every command so that
|
|
4
|
+
output is consistent (colours, width, Unicode support). Helper
|
|
5
|
+
functions wrap common patterns: success/warning/error panels, spinners,
|
|
6
|
+
tables, and key-value displays.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.theme import Theme
|
|
15
|
+
|
|
16
|
+
_THEME = Theme(
|
|
17
|
+
{
|
|
18
|
+
"info": "cyan",
|
|
19
|
+
"success": "bold green",
|
|
20
|
+
"warning": "bold yellow",
|
|
21
|
+
"error": "bold red",
|
|
22
|
+
"heading": "bold magenta",
|
|
23
|
+
"muted": "dim",
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
console = Console(theme=_THEME)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Shorthand helpers ─────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def heading(text: str) -> None:
|
|
34
|
+
"""Print a heading-styled line."""
|
|
35
|
+
console.print(f"\n[heading]{text}[/heading]")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def success(text: str) -> None:
|
|
39
|
+
"""Print a success-styled line."""
|
|
40
|
+
console.print(f"[success]✓[/success] {text}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def warning(text: str) -> None:
|
|
44
|
+
"""Print a warning-styled line."""
|
|
45
|
+
console.print(f"[warning]⚠[/warning] {text}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def error(text: str) -> None:
|
|
49
|
+
"""Print an error-styled line."""
|
|
50
|
+
console.print(f"[error]✗[/error] {text}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def info(text: str) -> None:
|
|
54
|
+
"""Print an info-styled line."""
|
|
55
|
+
console.print(f"[info]ℹ[/info] {text}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def kv_table(title: str, rows: list[tuple[str, str]]) -> None:
|
|
59
|
+
"""Print a two-column key/value table."""
|
|
60
|
+
table = Table(title=title, show_header=False, border_style="dim")
|
|
61
|
+
table.add_column("Key", style="bold", min_width=20)
|
|
62
|
+
table.add_column("Value")
|
|
63
|
+
for key, value in rows:
|
|
64
|
+
table.add_row(key, value)
|
|
65
|
+
console.print(table)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def banner(lines: list[str], *, title: str = "fadenstack", border_style: str = "cyan") -> None:
|
|
69
|
+
"""Print a bordered panel with the given lines."""
|
|
70
|
+
body = "\n".join(lines)
|
|
71
|
+
console.print(Panel(body, title=f"[bold]{title}[/bold]", border_style=border_style))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def service_table(rows: list[dict[str, str]], *, title: str = "Services") -> None:
|
|
75
|
+
"""Print a services status table.
|
|
76
|
+
|
|
77
|
+
Each row dict should have keys: name, status, health, ports, uptime.
|
|
78
|
+
"""
|
|
79
|
+
table = Table(title=title, border_style="dim")
|
|
80
|
+
table.add_column("Service", style="bold")
|
|
81
|
+
table.add_column("Status")
|
|
82
|
+
table.add_column("Health")
|
|
83
|
+
table.add_column("Ports", style="cyan")
|
|
84
|
+
table.add_column("Uptime", style="dim")
|
|
85
|
+
|
|
86
|
+
status_styles = {
|
|
87
|
+
"running": "green",
|
|
88
|
+
"exited": "red",
|
|
89
|
+
"created": "yellow",
|
|
90
|
+
"restarting": "yellow",
|
|
91
|
+
"paused": "yellow",
|
|
92
|
+
"removing": "red",
|
|
93
|
+
"dead": "red",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for row in rows:
|
|
97
|
+
status = row.get("status", "unknown")
|
|
98
|
+
style = status_styles.get(status, "white")
|
|
99
|
+
table.add_row(
|
|
100
|
+
row.get("name", ""),
|
|
101
|
+
f"[{style}]{status}[/{style}]",
|
|
102
|
+
row.get("health", ""),
|
|
103
|
+
row.get("ports", ""),
|
|
104
|
+
row.get("uptime", ""),
|
|
105
|
+
)
|
|
106
|
+
console.print(table)
|