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.
Files changed (99) hide show
  1. faden_cli/__init__.py +16 -0
  2. faden_cli/__main__.py +9 -0
  3. faden_cli/bundle/shared/.gitignore +25 -0
  4. faden_cli/bundle/shared/LICENSE +187 -0
  5. faden_cli/bundle/shared/NOTICE +15 -0
  6. faden_cli/bundle/shared/OFFICIAL.md +9 -0
  7. faden_cli/bundle/shared/README.md +16 -0
  8. faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
  9. faden_cli/bundle/shared/alloy/config.alloy +40 -0
  10. faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
  11. faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
  12. faden_cli/bundle/shared/docker-compose.yaml +998 -0
  13. faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
  14. faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
  15. faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
  16. faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
  17. faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
  18. faden_cli/bundle/shared/initdb/01-init.sql +32 -0
  19. faden_cli/bundle/shared/loki/local-config.yaml +32 -0
  20. faden_cli/bundle/shared/nginx/locations.conf +245 -0
  21. faden_cli/bundle/shared/nginx/maintenance.html +239 -0
  22. faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
  23. faden_cli/bundle/shared/nginx/nginx.conf +44 -0
  24. faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
  25. faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
  26. faden_cli/bundle/shared/nginx/select-config.sh +37 -0
  27. faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
  28. faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
  29. faden_cli/bundle/shared/tls/README.md +30 -0
  30. faden_cli/cli.py +158 -0
  31. faden_cli/commands/__init__.py +1 -0
  32. faden_cli/commands/admin.py +161 -0
  33. faden_cli/commands/backup.py +159 -0
  34. faden_cli/commands/config.py +146 -0
  35. faden_cli/commands/deploy.py +949 -0
  36. faden_cli/commands/dev/__init__.py +1 -0
  37. faden_cli/commands/dev/dev_doctor.py +46 -0
  38. faden_cli/commands/dev/dev_down.py +241 -0
  39. faden_cli/commands/dev/dev_group.py +24 -0
  40. faden_cli/commands/dev/dev_init.py +710 -0
  41. faden_cli/commands/dev/dev_pull.py +297 -0
  42. faden_cli/commands/dev/dev_status.py +151 -0
  43. faden_cli/commands/dev/dev_up.py +1392 -0
  44. faden_cli/commands/doctor.py +225 -0
  45. faden_cli/commands/down.py +75 -0
  46. faden_cli/commands/logs_cmd.py +41 -0
  47. faden_cli/commands/maintenance.py +74 -0
  48. faden_cli/commands/module.py +113 -0
  49. faden_cli/commands/observe.py +95 -0
  50. faden_cli/commands/ports.py +91 -0
  51. faden_cli/commands/rebuild.py +158 -0
  52. faden_cli/commands/restore.py +139 -0
  53. faden_cli/commands/runtime_images.py +174 -0
  54. faden_cli/commands/status.py +71 -0
  55. faden_cli/commands/tls.py +722 -0
  56. faden_cli/commands/tune.py +37 -0
  57. faden_cli/commands/up.py +64 -0
  58. faden_cli/commands/upgrade.py +470 -0
  59. faden_cli/commands/version.py +78 -0
  60. faden_cli/core/__init__.py +1 -0
  61. faden_cli/core/api_client.py +77 -0
  62. faden_cli/core/backup.py +717 -0
  63. faden_cli/core/bootstrap.py +291 -0
  64. faden_cli/core/bundle.py +110 -0
  65. faden_cli/core/clickhouse.py +61 -0
  66. faden_cli/core/compose.py +484 -0
  67. faden_cli/core/console.py +106 -0
  68. faden_cli/core/detect.py +580 -0
  69. faden_cli/core/dev_modules.py +149 -0
  70. faden_cli/core/docker_env.py +147 -0
  71. faden_cli/core/edge.py +94 -0
  72. faden_cli/core/env_gen.py +598 -0
  73. faden_cli/core/git.py +219 -0
  74. faden_cli/core/install.py +288 -0
  75. faden_cli/core/installation.py +186 -0
  76. faden_cli/core/local_node.py +719 -0
  77. faden_cli/core/maintenance.py +82 -0
  78. faden_cli/core/private_files.py +133 -0
  79. faden_cli/core/registry.py +574 -0
  80. faden_cli/core/release_feed.py +342 -0
  81. faden_cli/core/rmq.py +135 -0
  82. faden_cli/core/runtime_images.py +309 -0
  83. faden_cli/core/schedule.py +278 -0
  84. faden_cli/core/settings.py +159 -0
  85. faden_cli/core/sysinfo.py +303 -0
  86. faden_cli/core/tls.py +790 -0
  87. faden_cli/core/tune.py +154 -0
  88. faden_cli/core/update_check.py +254 -0
  89. faden_cli/core/workspace.py +155 -0
  90. faden_cli/templates/env.j2 +33 -0
  91. faden_cli/tui/__init__.py +1 -0
  92. faden_cli/tui/widgets/__init__.py +1 -0
  93. faden_cli/tui/wizard/__init__.py +1 -0
  94. fadenstack-0.4.2.dist-info/METADATA +304 -0
  95. fadenstack-0.4.2.dist-info/RECORD +99 -0
  96. fadenstack-0.4.2.dist-info/WHEEL +4 -0
  97. fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
  98. fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
  99. 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