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/git.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Git clone and checkout helpers for dev mode.
|
|
2
|
+
|
|
3
|
+
Provides functions to clone repositories from the ``fadenstack`` GitHub
|
|
4
|
+
organisation, with support for HTTPS (default) and SSH clone methods.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from faden_cli.core.console import console, error, info, success, warning
|
|
16
|
+
from faden_cli.core.registry import CORE_REPO, REPO_DIR_MAP, core_repo_clone_url, repo_clone_url
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CloneResult:
|
|
21
|
+
"""Result of a single repo clone operation."""
|
|
22
|
+
|
|
23
|
+
repo: str
|
|
24
|
+
local_dir: str
|
|
25
|
+
cloned: bool = False
|
|
26
|
+
skipped: bool = False
|
|
27
|
+
error: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def clone_repo(
|
|
31
|
+
repo: str,
|
|
32
|
+
*,
|
|
33
|
+
target_dir: Path,
|
|
34
|
+
method: str = "https",
|
|
35
|
+
branch: str | None = None,
|
|
36
|
+
force: bool = False,
|
|
37
|
+
token: str = "",
|
|
38
|
+
) -> CloneResult:
|
|
39
|
+
"""Clone a single repo into ``target_dir / local_name``.
|
|
40
|
+
|
|
41
|
+
Skips if the directory already exists unless *force* is True,
|
|
42
|
+
in which case it runs ``git pull`` to update instead.
|
|
43
|
+
"""
|
|
44
|
+
local_name = REPO_DIR_MAP.get(repo, repo)
|
|
45
|
+
dest = target_dir / local_name
|
|
46
|
+
result = CloneResult(repo=repo, local_dir=str(dest))
|
|
47
|
+
|
|
48
|
+
if dest.exists():
|
|
49
|
+
if force:
|
|
50
|
+
git = shutil.which("git")
|
|
51
|
+
if not git:
|
|
52
|
+
result.error = "git not found on PATH"
|
|
53
|
+
return result
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.run(
|
|
56
|
+
[git, "pull"],
|
|
57
|
+
cwd=str(dest),
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
timeout=120,
|
|
61
|
+
)
|
|
62
|
+
if proc.returncode == 0:
|
|
63
|
+
result.cloned = True # updated
|
|
64
|
+
else:
|
|
65
|
+
result.error = proc.stderr.strip() or f"git pull exited with code {proc.returncode}"
|
|
66
|
+
except subprocess.TimeoutExpired:
|
|
67
|
+
result.error = "git pull timed out after 120s"
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
result.error = str(exc)
|
|
70
|
+
return result
|
|
71
|
+
else:
|
|
72
|
+
result.skipped = True
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
git = shutil.which("git")
|
|
76
|
+
if not git:
|
|
77
|
+
result.error = "git not found on PATH"
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
url = repo_clone_url(repo, method=method, token=token)
|
|
81
|
+
cmd: list[str] = [git, "clone", url, str(dest)]
|
|
82
|
+
if branch:
|
|
83
|
+
cmd.extend(["--branch", branch])
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) # noqa: S603
|
|
87
|
+
if proc.returncode == 0:
|
|
88
|
+
result.cloned = True
|
|
89
|
+
else:
|
|
90
|
+
result.error = proc.stderr.strip() or f"git clone exited with code {proc.returncode}"
|
|
91
|
+
except subprocess.TimeoutExpired:
|
|
92
|
+
result.error = "git clone timed out after 120s"
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
result.error = str(exc)
|
|
95
|
+
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def clone_all_repos(
|
|
100
|
+
repos: list[str],
|
|
101
|
+
*,
|
|
102
|
+
target_dir: Path,
|
|
103
|
+
method: str = "https",
|
|
104
|
+
branch: str | None = None,
|
|
105
|
+
force: bool = False,
|
|
106
|
+
token: str = "",
|
|
107
|
+
) -> list[CloneResult]:
|
|
108
|
+
"""Clone all repos into the target directory.
|
|
109
|
+
|
|
110
|
+
Clones the core monorepo once and all subdirectories are available
|
|
111
|
+
immediately. Falls back to individual repo clones for repos not
|
|
112
|
+
in the monorepo (e.g. EE add-ons).
|
|
113
|
+
"""
|
|
114
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
results: list[CloneResult] = []
|
|
116
|
+
|
|
117
|
+
# Clone the core monorepo first
|
|
118
|
+
with console.status("[bold cyan]Cloning core monorepo…") as _status:
|
|
119
|
+
core_dest = target_dir / CORE_REPO
|
|
120
|
+
core_result = CloneResult(repo=CORE_REPO, local_dir=str(core_dest))
|
|
121
|
+
|
|
122
|
+
if core_dest.exists():
|
|
123
|
+
if force:
|
|
124
|
+
git = shutil.which("git")
|
|
125
|
+
if git:
|
|
126
|
+
try:
|
|
127
|
+
proc = subprocess.run(
|
|
128
|
+
[git, "pull"], cwd=str(core_dest),
|
|
129
|
+
capture_output=True, text=True, timeout=120,
|
|
130
|
+
)
|
|
131
|
+
core_result.cloned = proc.returncode == 0
|
|
132
|
+
if not core_result.cloned:
|
|
133
|
+
core_result.error = proc.stderr.strip()
|
|
134
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
135
|
+
core_result.error = str(exc)
|
|
136
|
+
else:
|
|
137
|
+
core_result.error = "git not found on PATH"
|
|
138
|
+
else:
|
|
139
|
+
core_result.skipped = True
|
|
140
|
+
else:
|
|
141
|
+
git = shutil.which("git")
|
|
142
|
+
if not git:
|
|
143
|
+
core_result.error = "git not found on PATH"
|
|
144
|
+
else:
|
|
145
|
+
url = core_repo_clone_url(method=method, token=token)
|
|
146
|
+
cmd: list[str] = [git, "clone", url, str(core_dest)]
|
|
147
|
+
if branch:
|
|
148
|
+
cmd.extend(["--branch", branch])
|
|
149
|
+
try:
|
|
150
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300) # noqa: S603
|
|
151
|
+
core_result.cloned = proc.returncode == 0
|
|
152
|
+
if not core_result.cloned:
|
|
153
|
+
core_result.error = proc.stderr.strip()
|
|
154
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
155
|
+
core_result.error = str(exc)
|
|
156
|
+
|
|
157
|
+
results.append(core_result)
|
|
158
|
+
if core_result.skipped:
|
|
159
|
+
warning(f"{CORE_REPO} → already exists, skipped")
|
|
160
|
+
elif core_result.cloned:
|
|
161
|
+
success(f"{CORE_REPO} → cloned")
|
|
162
|
+
else:
|
|
163
|
+
error(f"{CORE_REPO} → {core_result.error}")
|
|
164
|
+
|
|
165
|
+
# Clone any remaining repos not in the monorepo (e.g. EE repos)
|
|
166
|
+
monorepo_dirs = {r.local_dir for r in __import__("faden_cli.core.registry", fromlist=["REPOS"]).REPOS}
|
|
167
|
+
extra_repos = [r for r in repos if REPO_DIR_MAP.get(r, r) not in monorepo_dirs]
|
|
168
|
+
|
|
169
|
+
if extra_repos:
|
|
170
|
+
with console.status("[bold cyan]Cloning additional repositories…") as _status:
|
|
171
|
+
for repo in extra_repos:
|
|
172
|
+
_status.update(f"Cloning [bold]{repo}[/bold]…")
|
|
173
|
+
res = clone_repo(repo, target_dir=target_dir, method=method, branch=branch, force=force, token=token)
|
|
174
|
+
results.append(res)
|
|
175
|
+
|
|
176
|
+
if res.skipped:
|
|
177
|
+
warning(f"{repo} → already exists, skipped")
|
|
178
|
+
elif res.cloned:
|
|
179
|
+
success(f"{repo} → cloned")
|
|
180
|
+
else:
|
|
181
|
+
error(f"{repo} → {res.error}")
|
|
182
|
+
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def checkout_branch(repo_dir: Path, branch: str) -> bool:
|
|
187
|
+
"""Checkout a branch in an existing repo."""
|
|
188
|
+
git = shutil.which("git")
|
|
189
|
+
if not git:
|
|
190
|
+
return False
|
|
191
|
+
try:
|
|
192
|
+
result = subprocess.run( # noqa: S603
|
|
193
|
+
[git, "checkout", branch],
|
|
194
|
+
cwd=str(repo_dir),
|
|
195
|
+
capture_output=True,
|
|
196
|
+
text=True,
|
|
197
|
+
timeout=30,
|
|
198
|
+
)
|
|
199
|
+
return result.returncode == 0
|
|
200
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def current_branch(repo_dir: Path) -> str:
|
|
205
|
+
"""Get the current branch of a repo."""
|
|
206
|
+
git = shutil.which("git")
|
|
207
|
+
if not git:
|
|
208
|
+
return "unknown"
|
|
209
|
+
try:
|
|
210
|
+
result = subprocess.run( # noqa: S603
|
|
211
|
+
[git, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
212
|
+
cwd=str(repo_dir),
|
|
213
|
+
capture_output=True,
|
|
214
|
+
text=True,
|
|
215
|
+
timeout=10,
|
|
216
|
+
)
|
|
217
|
+
return result.stdout.strip() if result.returncode == 0 else "unknown"
|
|
218
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
219
|
+
return "unknown"
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Cross-platform prerequisite installer.
|
|
2
|
+
|
|
3
|
+
Supports Windows (winget / scoop), macOS (brew), and
|
|
4
|
+
Linux (apt / dnf / pacman / zypper). Each tool declares
|
|
5
|
+
install commands per platform+manager pair. Docker is
|
|
6
|
+
intentionally excluded — it requires admin, reboots, and
|
|
7
|
+
kernel-level setup that can't be safely automated.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import platform
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
from faden_cli.core.console import console, error, info, success, warning
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── Types ─────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Prerequisite:
|
|
26
|
+
"""A required CLI tool with platform-specific install commands."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
check_cmd: str # binary name on PATH
|
|
30
|
+
required: bool = True # False = optional (warn only)
|
|
31
|
+
install_commands: dict[str, list[str]] = field(default_factory=dict)
|
|
32
|
+
post_install_hint: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Platform / package-manager detection ──────────────────────────
|
|
36
|
+
|
|
37
|
+
def _detect_platform_key() -> str:
|
|
38
|
+
"""Return a key like ``windows/winget``, ``darwin/brew``, ``linux/apt``."""
|
|
39
|
+
system = platform.system().lower()
|
|
40
|
+
|
|
41
|
+
if system == "windows":
|
|
42
|
+
for mgr in ("winget", "scoop", "choco"):
|
|
43
|
+
if shutil.which(mgr):
|
|
44
|
+
return f"windows/{mgr}"
|
|
45
|
+
return "windows/winget" # fallback label
|
|
46
|
+
|
|
47
|
+
if system == "darwin":
|
|
48
|
+
if shutil.which("brew"):
|
|
49
|
+
return "darwin/brew"
|
|
50
|
+
return "darwin/brew"
|
|
51
|
+
|
|
52
|
+
# Linux — detect package manager
|
|
53
|
+
for mgr in ("apt", "dnf", "pacman", "zypper"):
|
|
54
|
+
if shutil.which(mgr):
|
|
55
|
+
return f"linux/{mgr}"
|
|
56
|
+
return "linux/unknown"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── Tool definitions ─────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
_UV = Prerequisite(
|
|
62
|
+
name="uv",
|
|
63
|
+
check_cmd="uv",
|
|
64
|
+
install_commands={
|
|
65
|
+
"windows/winget": ["powershell", "-NoProfile", "-Command",
|
|
66
|
+
"irm https://astral.sh/uv/install.ps1 | iex"],
|
|
67
|
+
"windows/scoop": ["scoop", "install", "uv"],
|
|
68
|
+
"windows/choco": ["choco", "install", "uv", "-y"],
|
|
69
|
+
"darwin/brew": ["brew", "install", "uv"],
|
|
70
|
+
"linux/apt": ["sh", "-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"],
|
|
71
|
+
"linux/dnf": ["sh", "-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"],
|
|
72
|
+
"linux/pacman": ["sh", "-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"],
|
|
73
|
+
"linux/zypper": ["sh", "-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"],
|
|
74
|
+
},
|
|
75
|
+
post_install_hint="You may need to restart your shell for uv to be on PATH.",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_GIT = Prerequisite(
|
|
79
|
+
name="git",
|
|
80
|
+
check_cmd="git",
|
|
81
|
+
install_commands={
|
|
82
|
+
"windows/winget": ["winget", "install", "--id", "Git.Git",
|
|
83
|
+
"--source", "winget", "--accept-package-agreements",
|
|
84
|
+
"--accept-source-agreements"],
|
|
85
|
+
"windows/scoop": ["scoop", "install", "git"],
|
|
86
|
+
"windows/choco": ["choco", "install", "git", "-y"],
|
|
87
|
+
"darwin/brew": ["brew", "install", "git"],
|
|
88
|
+
"linux/apt": ["sudo", "apt", "install", "-y", "git"],
|
|
89
|
+
"linux/dnf": ["sudo", "dnf", "install", "-y", "git"],
|
|
90
|
+
"linux/pacman": ["sudo", "pacman", "-S", "--noconfirm", "git"],
|
|
91
|
+
"linux/zypper": ["sudo", "zypper", "install", "-y", "git"],
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
_NODE = Prerequisite(
|
|
96
|
+
name="node",
|
|
97
|
+
check_cmd="node",
|
|
98
|
+
install_commands={
|
|
99
|
+
"windows/winget": ["winget", "install", "--id", "OpenJS.NodeJS.LTS",
|
|
100
|
+
"--source", "winget", "--accept-package-agreements",
|
|
101
|
+
"--accept-source-agreements"],
|
|
102
|
+
"windows/scoop": ["scoop", "install", "nodejs-lts"],
|
|
103
|
+
"windows/choco": ["choco", "install", "nodejs-lts", "-y"],
|
|
104
|
+
"darwin/brew": ["brew", "install", "node"],
|
|
105
|
+
"linux/apt": ["sudo", "apt", "install", "-y", "nodejs", "npm"],
|
|
106
|
+
"linux/dnf": ["sudo", "dnf", "install", "-y", "nodejs", "npm"],
|
|
107
|
+
"linux/pacman": ["sudo", "pacman", "-S", "--noconfirm", "nodejs", "npm"],
|
|
108
|
+
"linux/zypper": ["sudo", "zypper", "install", "-y", "nodejs", "npm"],
|
|
109
|
+
},
|
|
110
|
+
post_install_hint="You may need to restart your shell for node/npm to be on PATH.",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
_DOCKER = Prerequisite(
|
|
114
|
+
name="docker",
|
|
115
|
+
check_cmd="docker",
|
|
116
|
+
install_commands={}, # intentionally empty — manual only
|
|
117
|
+
post_install_hint=(
|
|
118
|
+
"Docker requires manual installation:\n"
|
|
119
|
+
" Windows / macOS: https://docs.docker.com/desktop/\n"
|
|
120
|
+
" Linux: https://docs.docker.com/engine/install/"
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
DEV_PREREQUISITES: list[Prerequisite] = [_DOCKER, _GIT, _UV, _NODE]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Check logic ──────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class CheckResult:
|
|
131
|
+
"""Result of checking a single prerequisite."""
|
|
132
|
+
|
|
133
|
+
prereq: Prerequisite
|
|
134
|
+
found: bool
|
|
135
|
+
version: str = ""
|
|
136
|
+
installable: bool = False # True if we have an install command for this platform
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def check_prerequisites(
|
|
140
|
+
prereqs: list[Prerequisite] | None = None,
|
|
141
|
+
) -> list[CheckResult]:
|
|
142
|
+
"""Check which prerequisites are present on the system."""
|
|
143
|
+
from faden_cli.core.detect import check_tool # noqa: PLC0415
|
|
144
|
+
|
|
145
|
+
prereqs = prereqs or DEV_PREREQUISITES
|
|
146
|
+
platform_key = _detect_platform_key()
|
|
147
|
+
results: list[CheckResult] = []
|
|
148
|
+
|
|
149
|
+
for p in prereqs:
|
|
150
|
+
tc = check_tool(p.check_cmd)
|
|
151
|
+
installable = bool(p.install_commands.get(platform_key))
|
|
152
|
+
results.append(
|
|
153
|
+
CheckResult(
|
|
154
|
+
prereq=p,
|
|
155
|
+
found=tc.found,
|
|
156
|
+
version=tc.version,
|
|
157
|
+
installable=installable,
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
return results
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── Install logic ────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def _add_to_session_path(directory: str) -> None:
|
|
166
|
+
"""Prepend a tool directory to this process's PATH.
|
|
167
|
+
|
|
168
|
+
Lets a freshly installed binary (e.g. uv in ~/.local/bin) be invoked
|
|
169
|
+
by later subprocess calls in the same CLI run without a shell restart.
|
|
170
|
+
"""
|
|
171
|
+
current = os.environ.get("PATH", "")
|
|
172
|
+
parts = current.split(os.pathsep)
|
|
173
|
+
if directory in parts:
|
|
174
|
+
return
|
|
175
|
+
os.environ["PATH"] = directory + (os.pathsep + current if current else "")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run_install(cmd: list[str], name: str) -> bool:
|
|
179
|
+
"""Run an install command and return True on success."""
|
|
180
|
+
console.print(f" [cyan]Installing {name}…[/cyan]")
|
|
181
|
+
try:
|
|
182
|
+
result = subprocess.run( # noqa: S603
|
|
183
|
+
cmd,
|
|
184
|
+
timeout=300,
|
|
185
|
+
check=False,
|
|
186
|
+
)
|
|
187
|
+
return result.returncode == 0
|
|
188
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
189
|
+
error(f" Install command failed: {exc}")
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def install_missing(
|
|
194
|
+
results: list[CheckResult],
|
|
195
|
+
*,
|
|
196
|
+
auto_confirm: bool = False,
|
|
197
|
+
) -> list[CheckResult]:
|
|
198
|
+
"""Attempt to install missing prerequisites.
|
|
199
|
+
|
|
200
|
+
Returns the subset that were successfully installed.
|
|
201
|
+
"""
|
|
202
|
+
platform_key = _detect_platform_key()
|
|
203
|
+
missing = [r for r in results if not r.found]
|
|
204
|
+
|
|
205
|
+
if not missing:
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
installable = [r for r in missing if r.installable]
|
|
209
|
+
manual_only = [r for r in missing if not r.installable]
|
|
210
|
+
|
|
211
|
+
if manual_only:
|
|
212
|
+
for r in manual_only:
|
|
213
|
+
warning(f" {r.prereq.name}: cannot be auto-installed on this platform.")
|
|
214
|
+
if r.prereq.post_install_hint:
|
|
215
|
+
console.print(f" [dim]{r.prereq.post_install_hint}[/dim]")
|
|
216
|
+
|
|
217
|
+
if not installable:
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
names = ", ".join(r.prereq.name for r in installable)
|
|
221
|
+
if not auto_confirm:
|
|
222
|
+
import click # noqa: PLC0415
|
|
223
|
+
|
|
224
|
+
if not click.confirm(f"Install missing tools ({names})?", default=True):
|
|
225
|
+
info("Skipping prerequisite installation.")
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
installed: list[CheckResult] = []
|
|
229
|
+
for r in installable:
|
|
230
|
+
cmd = r.prereq.install_commands[platform_key]
|
|
231
|
+
if _run_install(cmd, r.prereq.name):
|
|
232
|
+
# Re-check after install — the binary might not be on
|
|
233
|
+
# the current PATH yet (e.g. uv installer adds to
|
|
234
|
+
# ~/.local/bin which isn't in this session's PATH).
|
|
235
|
+
from faden_cli.core.detect import check_tool # noqa: PLC0415
|
|
236
|
+
|
|
237
|
+
tc = check_tool(r.prereq.check_cmd)
|
|
238
|
+
if tc.found:
|
|
239
|
+
# Make the tool usable by this run's subprocess calls too.
|
|
240
|
+
_add_to_session_path(os.path.dirname(tc.path))
|
|
241
|
+
success(f" {r.prereq.name} {tc.version} ✓")
|
|
242
|
+
installed.append(r)
|
|
243
|
+
else:
|
|
244
|
+
warning(f" {r.prereq.name} installed but not yet on PATH.")
|
|
245
|
+
if r.prereq.post_install_hint:
|
|
246
|
+
console.print(f" [dim]{r.prereq.post_install_hint}[/dim]")
|
|
247
|
+
installed.append(r) # still count as installed
|
|
248
|
+
else:
|
|
249
|
+
error(f" Failed to install {r.prereq.name}.")
|
|
250
|
+
return installed
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ── Convenience — run full check + optional install ──────────────
|
|
254
|
+
|
|
255
|
+
def ensure_prerequisites(
|
|
256
|
+
*,
|
|
257
|
+
install: bool = False,
|
|
258
|
+
auto_confirm: bool = False,
|
|
259
|
+
) -> bool:
|
|
260
|
+
"""Check prerequisites and optionally install missing ones.
|
|
261
|
+
|
|
262
|
+
Returns True if all required tools are available (or were installed).
|
|
263
|
+
"""
|
|
264
|
+
results = check_prerequisites()
|
|
265
|
+
|
|
266
|
+
has_missing = False
|
|
267
|
+
for r in results:
|
|
268
|
+
mark = "[green]✓[/green]" if r.found else "[red]✗[/red]"
|
|
269
|
+
ver = f" {r.version}" if r.version else ""
|
|
270
|
+
console.print(f" {mark} {r.prereq.name}{ver}")
|
|
271
|
+
if not r.found and r.prereq.required:
|
|
272
|
+
has_missing = True
|
|
273
|
+
|
|
274
|
+
if not has_missing:
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
if install:
|
|
278
|
+
installed = install_missing(results, auto_confirm=auto_confirm)
|
|
279
|
+
# Re-check required tools
|
|
280
|
+
remaining = check_prerequisites()
|
|
281
|
+
still_missing = [r for r in remaining if not r.found and r.prereq.required]
|
|
282
|
+
if not still_missing:
|
|
283
|
+
return True
|
|
284
|
+
for r in still_missing:
|
|
285
|
+
error(f" {r.prereq.name} is still missing.")
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
return False
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""What a Fadenstack installation runs: its release, or the code its images were built from.
|
|
2
|
+
|
|
3
|
+
An install made from the published images carries the release it was unpacked from (``.faden-release``,
|
|
4
|
+
:mod:`faden_cli.core.bundle`); its images are that release's. A source checkout builds its images from
|
|
5
|
+
whatever the checkout holds at the time, and the checkout can move on afterwards -- so ``faden upgrade`` and
|
|
6
|
+
``faden deploy`` record, when they build, which commit the images came from (:data:`BUILD_RECORD`).
|
|
7
|
+
``faden version`` reads both, so an operator can always say what the server runs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from faden_cli import __version__
|
|
19
|
+
from faden_cli.core.bundle import installed_release
|
|
20
|
+
|
|
21
|
+
#: Written into the install directory when its images are built from a checkout.
|
|
22
|
+
BUILD_RECORD = ".faden-build"
|
|
23
|
+
|
|
24
|
+
#: Tracked files the running install rewrites itself: a change to them is not a local change to the code.
|
|
25
|
+
GENERATED = frozenset({
|
|
26
|
+
"shared/prometheus/targets.json", "shared/rabbitmq/definitions.json", "shared/rabbitmq/10-faden.conf",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
_AGENT_VERSION = re.compile(r'^AGENT_VERSION\s*=\s*"([^"]+)"', re.MULTILINE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def checkout_of(install_dir: Path) -> Path | None:
|
|
33
|
+
"""The source checkout an install directory belongs to (``<checkout>/shared``), or ``None``."""
|
|
34
|
+
for candidate in (install_dir, *install_dir.parents[:2]):
|
|
35
|
+
if (candidate / ".git").exists():
|
|
36
|
+
return candidate
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _git(repo: Path, *args: str) -> str | None:
|
|
41
|
+
try:
|
|
42
|
+
out = subprocess.run( # noqa: S603
|
|
43
|
+
["git", "-C", str(repo), *args], # noqa: S607
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
timeout=10,
|
|
47
|
+
check=False,
|
|
48
|
+
)
|
|
49
|
+
except (OSError, subprocess.SubprocessError):
|
|
50
|
+
return None
|
|
51
|
+
return out.stdout.strip() if out.returncode == 0 and out.stdout.strip() else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def checkout_state(repo: Path) -> dict[str, str | bool | None]:
|
|
55
|
+
"""The checkout's commit, a readable name for it, and whether it has local changes.
|
|
56
|
+
|
|
57
|
+
The name is the release the commit follows (``v0.4.1-3-gabc1234``), or the commit when no release tag is
|
|
58
|
+
reachable; the repository's other tags (the agent's, older ones) say nothing about the server.
|
|
59
|
+
"""
|
|
60
|
+
changed = (_git(repo, "diff", "--name-only", "HEAD") or "").splitlines()
|
|
61
|
+
commit = _git(repo, "rev-parse", "--short=8", "HEAD")
|
|
62
|
+
return {
|
|
63
|
+
"commit": commit,
|
|
64
|
+
"describe": _git(repo, "describe", "--tags", "--match", "v[0-9]*") or commit,
|
|
65
|
+
"branch": _git(repo, "symbolic-ref", "--short", "-q", "HEAD"),
|
|
66
|
+
"modified": any(path not in GENERATED for path in changed),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def record_build(install_dir: Path, *, complete: bool = True) -> dict | None:
|
|
71
|
+
"""Note which commit this install's images were just built from; ``None`` outside a checkout.
|
|
72
|
+
|
|
73
|
+
``complete`` is false when some images failed to build: those still run the code of an earlier build.
|
|
74
|
+
"""
|
|
75
|
+
repo = checkout_of(install_dir)
|
|
76
|
+
if repo is None:
|
|
77
|
+
return None
|
|
78
|
+
record = {
|
|
79
|
+
**checkout_state(repo),
|
|
80
|
+
"built_at": datetime.now(tz=UTC).isoformat(timespec="seconds"),
|
|
81
|
+
"complete": complete,
|
|
82
|
+
"cli": __version__,
|
|
83
|
+
}
|
|
84
|
+
try:
|
|
85
|
+
(install_dir / BUILD_RECORD).write_text(json.dumps(record) + "\n", encoding="utf-8")
|
|
86
|
+
except OSError:
|
|
87
|
+
return None
|
|
88
|
+
return record
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_build(install_dir: Path) -> dict | None:
|
|
92
|
+
"""What :func:`record_build` noted last, or ``None``."""
|
|
93
|
+
try:
|
|
94
|
+
return json.loads((install_dir / BUILD_RECORD).read_text(encoding="utf-8"))
|
|
95
|
+
except (OSError, ValueError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _env_value(env_file: Path, key: str) -> str | None:
|
|
100
|
+
try:
|
|
101
|
+
for line in env_file.read_text(encoding="utf-8").splitlines():
|
|
102
|
+
if line.startswith(f"{key}="):
|
|
103
|
+
return line.split("=", 1)[1].strip().strip('"').strip("'") or None
|
|
104
|
+
except OSError:
|
|
105
|
+
return None
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _agent_version(repo: Path | None) -> str | None:
|
|
110
|
+
if repo is None:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
text = (repo / "backend/faden_backend/web/api/install/__init__.py").read_text(encoding="utf-8")
|
|
114
|
+
except OSError:
|
|
115
|
+
return None
|
|
116
|
+
m = _AGENT_VERSION.search(text)
|
|
117
|
+
return m.group(1) if m else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _runtimes(repo: Path | None) -> list[str]:
|
|
121
|
+
"""The runtimes the server offers its machines, from the runtime manifests it ships."""
|
|
122
|
+
if repo is None:
|
|
123
|
+
return []
|
|
124
|
+
out = []
|
|
125
|
+
for path in sorted((repo / "runtime-image").glob("runtime-manifest*.json")):
|
|
126
|
+
try:
|
|
127
|
+
m = json.loads(path.read_text(encoding="utf-8"))
|
|
128
|
+
except (OSError, ValueError):
|
|
129
|
+
continue
|
|
130
|
+
cert = m.get("certification") if isinstance(m.get("certification"), dict) else {}
|
|
131
|
+
status = cert.get("overall_status") or m.get("overall_status") or "uncertified"
|
|
132
|
+
channel = cert.get("channel")
|
|
133
|
+
out.append(f"{m.get('runtime_id', path.stem)} ({status}{', ' + channel if channel else ''})")
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def describe(install_dir: Path) -> list[tuple[str, str]]:
|
|
138
|
+
"""Rows for ``faden version``: what this installation runs."""
|
|
139
|
+
rows: list[tuple[str, str]] = [("Install directory", str(install_dir))]
|
|
140
|
+
if not install_dir.is_dir():
|
|
141
|
+
rows.append(
|
|
142
|
+
("Installed", "[red]not found -- run 'faden deploy', or set install_dir in faden.yaml[/red]")
|
|
143
|
+
)
|
|
144
|
+
return rows
|
|
145
|
+
release = installed_release(install_dir)
|
|
146
|
+
repo = checkout_of(install_dir)
|
|
147
|
+
if release is not None:
|
|
148
|
+
rows.append(("Installed", f"release {release} (published images)"))
|
|
149
|
+
elif repo is not None:
|
|
150
|
+
state = checkout_state(repo)
|
|
151
|
+
now = state.get("describe") or state.get("commit") or "unknown"
|
|
152
|
+
where = f" on {state['branch']}" if state.get("branch") else " (detached)"
|
|
153
|
+
changed = ", with local changes" if state.get("modified") else ""
|
|
154
|
+
rows.append(("Installed", f"source checkout at {now}{where}{changed}"))
|
|
155
|
+
build = read_build(install_dir)
|
|
156
|
+
if build:
|
|
157
|
+
built = build.get("describe") or build.get("commit") or "unknown"
|
|
158
|
+
notes = []
|
|
159
|
+
if build.get("modified"):
|
|
160
|
+
notes.append("with local changes")
|
|
161
|
+
if build.get("complete") is False:
|
|
162
|
+
notes.append("[yellow]some images failed to build[/yellow]")
|
|
163
|
+
if build.get("commit") != state.get("commit"):
|
|
164
|
+
notes.append("[yellow]checkout has moved since[/yellow]")
|
|
165
|
+
note = f" ({'; '.join(notes)})" if notes else ""
|
|
166
|
+
rows.append(("Images built from", f"{built}, {build.get('built_at', '?')}{note}"))
|
|
167
|
+
else:
|
|
168
|
+
rows.append(
|
|
169
|
+
(
|
|
170
|
+
"Images built from",
|
|
171
|
+
"not recorded (built before this was kept; the next upgrade records it)",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
rows.append(
|
|
176
|
+
("Installed", "[yellow]unknown (no release marker, not a source checkout)[/yellow]"),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
tag = _env_value(install_dir / ".env", "VERSION")
|
|
180
|
+
rows.append(("Image tag", tag or "latest"))
|
|
181
|
+
agent = _agent_version(repo)
|
|
182
|
+
if agent:
|
|
183
|
+
rows.append(("Machine agent", f"{agent} (handed out by the install line)"))
|
|
184
|
+
for i, runtime in enumerate(_runtimes(repo)):
|
|
185
|
+
rows.append(("Runtimes" if i == 0 else "", runtime))
|
|
186
|
+
return rows
|