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,719 @@
|
|
|
1
|
+
"""Local node-agent provisioning helpers for dev/deploy workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from faden_cli.core.console import error, info, success, warning
|
|
17
|
+
from faden_cli.core.private_files import write_private
|
|
18
|
+
from faden_cli.core.registry import CORE_REPO, core_repo_clone_url, repo_clone_url
|
|
19
|
+
|
|
20
|
+
_NODE_AGENT_REPO = "faden-node-agent"
|
|
21
|
+
_NODE_AGENT_DIR = "agent"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_enrollment_token(
|
|
25
|
+
backend_url: str,
|
|
26
|
+
api_token: str,
|
|
27
|
+
*,
|
|
28
|
+
note: str = "Auto-enrolled local node",
|
|
29
|
+
) -> str | None:
|
|
30
|
+
"""Create an enrollment token via the backend API.
|
|
31
|
+
|
|
32
|
+
Returns the plaintext token string, or None on failure.
|
|
33
|
+
"""
|
|
34
|
+
url = f"{backend_url.rstrip('/')}/api/admin/system/nodes/enrollment-tokens"
|
|
35
|
+
headers = {
|
|
36
|
+
"Authorization": f"Bearer {api_token}",
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
}
|
|
39
|
+
try:
|
|
40
|
+
resp = httpx.post(url, json={"note": note}, headers=headers, timeout=15)
|
|
41
|
+
if resp.status_code in (200, 201):
|
|
42
|
+
token = resp.json().get("token", "")
|
|
43
|
+
if token:
|
|
44
|
+
success("Enrollment token created for local node.")
|
|
45
|
+
return token
|
|
46
|
+
error("Enrollment token response missing 'token' field.")
|
|
47
|
+
return None
|
|
48
|
+
error(f"Failed to create enrollment token (HTTP {resp.status_code}): {resp.text}")
|
|
49
|
+
return None
|
|
50
|
+
except httpx.HTTPError as exc:
|
|
51
|
+
error(f"Could not reach backend for enrollment token: {exc}")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def remove_local_node_agent(*, workspace: Path, use_sudo: bool = True) -> bool:
|
|
56
|
+
"""Stop the local node agent (systemd service and/or running process).
|
|
57
|
+
|
|
58
|
+
The cloned source directory is intentionally left in place so that
|
|
59
|
+
unpushed work is never destroyed by ``faden down --all``.
|
|
60
|
+
|
|
61
|
+
Returns True if cleanup succeeded (or nothing to clean up).
|
|
62
|
+
"""
|
|
63
|
+
removed_anything = False
|
|
64
|
+
|
|
65
|
+
# ── Stop and remove systemd service (Linux only) ──────────
|
|
66
|
+
if platform.system() == "Linux" and use_sudo:
|
|
67
|
+
systemctl = shutil.which("systemctl")
|
|
68
|
+
if systemctl:
|
|
69
|
+
# Check if the service unit exists before trying to stop it
|
|
70
|
+
probe = subprocess.run( # noqa: S603
|
|
71
|
+
["systemctl", "list-unit-files", "faden-agent.service"],
|
|
72
|
+
capture_output=True, text=True,
|
|
73
|
+
)
|
|
74
|
+
if "faden-agent.service" in (probe.stdout or ""):
|
|
75
|
+
info("Stopping faden-agent systemd service…")
|
|
76
|
+
_run(["sudo", "systemctl", "disable", "--now", "faden-agent"], check=False)
|
|
77
|
+
_run(["sudo", "rm", "-f", "/etc/systemd/system/faden-agent.service"], check=False)
|
|
78
|
+
_run(["sudo", "rm", "-f", "/etc/faden-agent.env"], check=False)
|
|
79
|
+
_run(["sudo", "systemctl", "daemon-reload"], check=False)
|
|
80
|
+
removed_anything = True
|
|
81
|
+
|
|
82
|
+
# ── Kill any running agent process (cross-platform) ───────
|
|
83
|
+
if platform.system() == "Windows":
|
|
84
|
+
# Best-effort: kill by process name
|
|
85
|
+
kill_result = subprocess.run( # noqa: S603
|
|
86
|
+
["taskkill", "/F", "/IM", "faden-agent.exe"],
|
|
87
|
+
capture_output=True, text=True,
|
|
88
|
+
)
|
|
89
|
+
if kill_result.returncode == 0:
|
|
90
|
+
removed_anything = True
|
|
91
|
+
else:
|
|
92
|
+
kill_result = subprocess.run( # noqa: S603
|
|
93
|
+
["pkill", "-f", "fadenstack.node.agent"],
|
|
94
|
+
capture_output=True, text=True,
|
|
95
|
+
)
|
|
96
|
+
if kill_result.returncode == 0:
|
|
97
|
+
removed_anything = True
|
|
98
|
+
|
|
99
|
+
if removed_anything:
|
|
100
|
+
success("Local node agent stopped.")
|
|
101
|
+
else:
|
|
102
|
+
info("No local node agent service found to remove.")
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _derive_loki_url(backend_url: str) -> str:
|
|
107
|
+
"""Derive Loki push URL from the backend URL (same host, port 3100)."""
|
|
108
|
+
parsed = urlparse(backend_url)
|
|
109
|
+
host = parsed.hostname or "127.0.0.1"
|
|
110
|
+
scheme = parsed.scheme or "http"
|
|
111
|
+
return f"{scheme}://{host}:3100"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_env_lines(
|
|
115
|
+
backend_url: str,
|
|
116
|
+
advertise_host: str,
|
|
117
|
+
enrollment_token: str,
|
|
118
|
+
) -> list[str]:
|
|
119
|
+
"""Build the common env lines for all provisioning paths."""
|
|
120
|
+
loki_url = _derive_loki_url(backend_url)
|
|
121
|
+
lines = [
|
|
122
|
+
f"FADEN_NODE_AGENT_BACKEND_URL={backend_url}",
|
|
123
|
+
f"FADEN_NODE_AGENT_LOKI_URL={loki_url}",
|
|
124
|
+
]
|
|
125
|
+
if advertise_host:
|
|
126
|
+
lines.append(f"FADEN_NODE_AGENT_ADVERTISE_HOST={advertise_host}")
|
|
127
|
+
if enrollment_token:
|
|
128
|
+
lines.append(f"FADEN_NODE_AGENT_ENROLLMENT_TOKEN={enrollment_token}")
|
|
129
|
+
else:
|
|
130
|
+
warning("No enrollment token provided. Agent will require later onboarding.")
|
|
131
|
+
return lines
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _find_agent_binary(workspace: Path) -> Path | None:
|
|
135
|
+
"""Locate a pre-built node agent binary in the workspace."""
|
|
136
|
+
candidates = [
|
|
137
|
+
workspace / _NODE_AGENT_DIR / "dist" / "faden-agent",
|
|
138
|
+
workspace / _NODE_AGENT_DIR / "dist" / "faden-agent.exe",
|
|
139
|
+
workspace / "dist" / "faden-agent",
|
|
140
|
+
workspace / "dist" / "faden-agent.exe",
|
|
141
|
+
]
|
|
142
|
+
for path in candidates:
|
|
143
|
+
if path.is_file():
|
|
144
|
+
return path
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def provision_local_node_agent(
|
|
149
|
+
*,
|
|
150
|
+
workspace: Path,
|
|
151
|
+
branch: str,
|
|
152
|
+
backend_url: str,
|
|
153
|
+
advertise_host: str,
|
|
154
|
+
enrollment_token: str,
|
|
155
|
+
remote_host: str | None,
|
|
156
|
+
use_sudo: bool,
|
|
157
|
+
method: str = "https",
|
|
158
|
+
github_token: str = "",
|
|
159
|
+
workdir_override: str | None = None,
|
|
160
|
+
) -> bool:
|
|
161
|
+
"""Clone/update and install the node agent on local or remote host."""
|
|
162
|
+
# Prefer monorepo clone; fall back to standalone repo
|
|
163
|
+
monorepo_agent_dir = workspace / CORE_REPO / _NODE_AGENT_DIR
|
|
164
|
+
if monorepo_agent_dir.is_dir():
|
|
165
|
+
repo_url = core_repo_clone_url(method=method, token=github_token)
|
|
166
|
+
else:
|
|
167
|
+
repo_url = repo_clone_url(_NODE_AGENT_REPO, method=method, token=github_token)
|
|
168
|
+
branch = branch.strip() or "main"
|
|
169
|
+
backend_url = backend_url.strip() or "http://127.0.0.1:8000"
|
|
170
|
+
advertise_host = advertise_host.strip()
|
|
171
|
+
enrollment_token = enrollment_token.strip()
|
|
172
|
+
|
|
173
|
+
if remote_host:
|
|
174
|
+
return _provision_remote(
|
|
175
|
+
remote_host=remote_host,
|
|
176
|
+
repo_url=repo_url,
|
|
177
|
+
branch=branch,
|
|
178
|
+
backend_url=backend_url,
|
|
179
|
+
advertise_host=advertise_host,
|
|
180
|
+
enrollment_token=enrollment_token,
|
|
181
|
+
use_sudo=use_sudo,
|
|
182
|
+
workdir_override=workdir_override,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Try binary-first: if a pre-built executable exists, skip clone/venv.
|
|
186
|
+
binary = _find_agent_binary(workspace)
|
|
187
|
+
if binary:
|
|
188
|
+
return _provision_local_binary(
|
|
189
|
+
binary=binary,
|
|
190
|
+
workspace=workspace,
|
|
191
|
+
backend_url=backend_url,
|
|
192
|
+
advertise_host=advertise_host,
|
|
193
|
+
enrollment_token=enrollment_token,
|
|
194
|
+
use_sudo=use_sudo,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return _provision_local(
|
|
198
|
+
workspace=workspace,
|
|
199
|
+
repo_url=repo_url,
|
|
200
|
+
branch=branch,
|
|
201
|
+
backend_url=backend_url,
|
|
202
|
+
advertise_host=advertise_host,
|
|
203
|
+
enrollment_token=enrollment_token,
|
|
204
|
+
use_sudo=use_sudo,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _provision_local(
|
|
209
|
+
*,
|
|
210
|
+
workspace: Path,
|
|
211
|
+
repo_url: str,
|
|
212
|
+
branch: str,
|
|
213
|
+
backend_url: str,
|
|
214
|
+
advertise_host: str,
|
|
215
|
+
enrollment_token: str,
|
|
216
|
+
use_sudo: bool,
|
|
217
|
+
) -> bool:
|
|
218
|
+
repo_dir = workspace / _NODE_AGENT_DIR
|
|
219
|
+
info(f"Provisioning local node agent in {repo_dir}")
|
|
220
|
+
if not _clone_or_update_repo(repo_dir=repo_dir, repo_url=repo_url, branch=branch):
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
if not _install_agent_dependencies(repo_dir):
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
env_lines = _build_env_lines(backend_url, advertise_host, enrollment_token)
|
|
227
|
+
|
|
228
|
+
if platform.system() != "Linux":
|
|
229
|
+
return _start_agent_foreground(repo_dir, env_lines)
|
|
230
|
+
|
|
231
|
+
if not use_sudo:
|
|
232
|
+
info("No sudo — starting node agent as user background process.")
|
|
233
|
+
return _start_agent_foreground(repo_dir, env_lines)
|
|
234
|
+
|
|
235
|
+
service_file = repo_dir / "deploy" / "systemd" / "faden-agent.service"
|
|
236
|
+
if not service_file.exists():
|
|
237
|
+
error(f"Systemd service file missing: {service_file}")
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
# Pre-check: verify sudo access before attempting systemd install.
|
|
241
|
+
if _run(["sudo", "-n", "true"], check=False) != 0:
|
|
242
|
+
warning("sudo not available — falling back to user background process.")
|
|
243
|
+
return _start_agent_foreground(repo_dir, env_lines)
|
|
244
|
+
|
|
245
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle:
|
|
246
|
+
handle.write("\n".join(env_lines))
|
|
247
|
+
handle.write("\n")
|
|
248
|
+
temp_env = Path(handle.name)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
if _run(["sudo", "install", "-m", "0644", str(service_file), "/etc/systemd/system/faden-agent.service"]) != 0:
|
|
252
|
+
return False
|
|
253
|
+
if _run(["sudo", "install", "-m", "0600", str(temp_env), "/etc/faden-agent.env"]) != 0:
|
|
254
|
+
return False
|
|
255
|
+
if _run(["sudo", "systemctl", "daemon-reload"]) != 0:
|
|
256
|
+
return False
|
|
257
|
+
if _run(["sudo", "systemctl", "enable", "--now", "faden-agent"]) != 0:
|
|
258
|
+
return False
|
|
259
|
+
finally:
|
|
260
|
+
try:
|
|
261
|
+
temp_env.unlink(missing_ok=True)
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
success("Local node agent installed and started via systemd.")
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _provision_local_binary(
|
|
270
|
+
*,
|
|
271
|
+
binary: Path,
|
|
272
|
+
workspace: Path,
|
|
273
|
+
backend_url: str,
|
|
274
|
+
advertise_host: str,
|
|
275
|
+
enrollment_token: str,
|
|
276
|
+
use_sudo: bool,
|
|
277
|
+
) -> bool:
|
|
278
|
+
"""Install a pre-built node agent binary (no Python/venv required)."""
|
|
279
|
+
info(f"Found pre-built binary: {binary}")
|
|
280
|
+
env_lines = _build_env_lines(backend_url, advertise_host, enrollment_token)
|
|
281
|
+
|
|
282
|
+
is_linux = platform.system() == "Linux"
|
|
283
|
+
install_path = Path("/usr/local/bin/faden-agent") if is_linux else binary
|
|
284
|
+
|
|
285
|
+
if is_linux and use_sudo:
|
|
286
|
+
# Install binary to /usr/local/bin
|
|
287
|
+
if _run(["sudo", "-n", "true"], check=False) != 0:
|
|
288
|
+
warning("sudo not available — running binary in foreground.")
|
|
289
|
+
return _start_binary_foreground(binary, env_lines)
|
|
290
|
+
|
|
291
|
+
if _run(["sudo", "install", "-m", "0755", str(binary), str(install_path)]) != 0:
|
|
292
|
+
return False
|
|
293
|
+
info(f"Installed binary to {install_path}")
|
|
294
|
+
|
|
295
|
+
# Write env file
|
|
296
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle:
|
|
297
|
+
handle.write("\n".join(env_lines) + "\n")
|
|
298
|
+
temp_env = Path(handle.name)
|
|
299
|
+
|
|
300
|
+
# Install systemd service (use the template from workspace if available)
|
|
301
|
+
service_src = workspace / _NODE_AGENT_DIR / "deploy" / "systemd" / "faden-agent.service"
|
|
302
|
+
if not service_src.exists():
|
|
303
|
+
# Write a minimal service file inline
|
|
304
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".service") as sf:
|
|
305
|
+
sf.write(
|
|
306
|
+
"[Unit]\n"
|
|
307
|
+
"Description=fadenstack node agent\n"
|
|
308
|
+
"After=network-online.target docker.service\n"
|
|
309
|
+
"Wants=network-online.target\n"
|
|
310
|
+
# A start that can never succeed must stop retrying and say so.
|
|
311
|
+
# The agent handles a backend outage itself -- it reconnects with backoff
|
|
312
|
+
# and never exits for that -- so a failed *start* means something
|
|
313
|
+
# structural, most often a second agent already holding the state lock.
|
|
314
|
+
# Without a limit systemd retries every 5s forever while reporting
|
|
315
|
+
# "activating (auto-restart)", which reads like a slow boot rather than
|
|
316
|
+
# a wedged service: observed on the DGX head at restart counter 9259,
|
|
317
|
+
# roughly fifteen hours of looping that nothing surfaced.
|
|
318
|
+
"StartLimitIntervalSec=300\n"
|
|
319
|
+
"StartLimitBurst=10\n\n"
|
|
320
|
+
"[Service]\n"
|
|
321
|
+
"Type=simple\n"
|
|
322
|
+
"EnvironmentFile=-/etc/faden-agent.env\n"
|
|
323
|
+
f"ExecStart={install_path}\n"
|
|
324
|
+
"Restart=always\n"
|
|
325
|
+
"RestartSec=5\n\n"
|
|
326
|
+
"[Install]\n"
|
|
327
|
+
"WantedBy=multi-user.target\n"
|
|
328
|
+
)
|
|
329
|
+
service_src = Path(sf.name)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
if _run(["sudo", "install", "-m", "0644", str(service_src), "/etc/systemd/system/faden-agent.service"]) != 0:
|
|
333
|
+
return False
|
|
334
|
+
if _run(["sudo", "install", "-m", "0600", str(temp_env), "/etc/faden-agent.env"]) != 0:
|
|
335
|
+
return False
|
|
336
|
+
if _run(["sudo", "systemctl", "daemon-reload"]) != 0:
|
|
337
|
+
return False
|
|
338
|
+
if _run(["sudo", "systemctl", "enable", "--now", "faden-agent"]) != 0:
|
|
339
|
+
return False
|
|
340
|
+
finally:
|
|
341
|
+
try:
|
|
342
|
+
temp_env.unlink(missing_ok=True)
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
success("Node agent binary installed and started via systemd.")
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
return _start_binary_foreground(binary, env_lines)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _start_binary_foreground(binary: Path, env_lines: list[str]) -> bool:
|
|
353
|
+
"""Start a pre-built binary as a detached background process."""
|
|
354
|
+
proc_env = {**os.environ}
|
|
355
|
+
for line in env_lines:
|
|
356
|
+
if "=" in line:
|
|
357
|
+
k, v = line.split("=", 1)
|
|
358
|
+
proc_env[k] = v
|
|
359
|
+
|
|
360
|
+
if platform.system() == "Windows":
|
|
361
|
+
creationflags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
|
362
|
+
proc = subprocess.Popen( # noqa: S603
|
|
363
|
+
[str(binary)],
|
|
364
|
+
env=proc_env,
|
|
365
|
+
creationflags=creationflags,
|
|
366
|
+
stdout=subprocess.DEVNULL,
|
|
367
|
+
stderr=subprocess.DEVNULL,
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
proc = subprocess.Popen( # noqa: S603
|
|
371
|
+
[str(binary)],
|
|
372
|
+
env=proc_env,
|
|
373
|
+
start_new_session=True,
|
|
374
|
+
stdout=subprocess.DEVNULL,
|
|
375
|
+
stderr=subprocess.DEVNULL,
|
|
376
|
+
)
|
|
377
|
+
success(f"Node agent binary started (PID {proc.pid}).")
|
|
378
|
+
return True
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _start_agent_foreground(repo_dir: Path, env_lines: list[str]) -> bool:
|
|
382
|
+
"""Write a .env file and start the node agent as a background process (non-Linux)."""
|
|
383
|
+
# Write env file
|
|
384
|
+
env_file = repo_dir / ".env"
|
|
385
|
+
# Owner-only: it carries the enrollment token.
|
|
386
|
+
write_private(env_file, ("\n".join(env_lines) + "\n").encode("utf-8"))
|
|
387
|
+
info(f"Wrote agent env to {env_file}")
|
|
388
|
+
|
|
389
|
+
# Resolve the Python interpreter from the uv venv
|
|
390
|
+
if platform.system() == "Windows":
|
|
391
|
+
venv_python = repo_dir / ".venv" / "Scripts" / "python.exe"
|
|
392
|
+
else:
|
|
393
|
+
venv_python = repo_dir / ".venv" / "bin" / "python"
|
|
394
|
+
|
|
395
|
+
if not venv_python.exists():
|
|
396
|
+
# Fallback to system python
|
|
397
|
+
python = shutil.which("python3") or shutil.which("python")
|
|
398
|
+
if not python:
|
|
399
|
+
error("Cannot locate Python to start node agent.")
|
|
400
|
+
return False
|
|
401
|
+
venv_python = Path(python)
|
|
402
|
+
|
|
403
|
+
# Build environment with the agent env vars
|
|
404
|
+
proc_env = {**os.environ}
|
|
405
|
+
for line in env_lines:
|
|
406
|
+
if "=" in line:
|
|
407
|
+
k, v = line.split("=", 1)
|
|
408
|
+
proc_env[k] = v
|
|
409
|
+
|
|
410
|
+
# When running as a non-root user, override the default state path
|
|
411
|
+
# (/var/lib/...) to a writable location inside the repo dir.
|
|
412
|
+
if "FADEN_NODE_AGENT_STATE_PATH" not in proc_env:
|
|
413
|
+
state_dir = repo_dir / ".agent-state"
|
|
414
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
proc_env["FADEN_NODE_AGENT_STATE_PATH"] = str(state_dir / "state.json")
|
|
416
|
+
|
|
417
|
+
# Launch as detached background process
|
|
418
|
+
cmd = [str(venv_python), "-m", "faden_agent"]
|
|
419
|
+
if platform.system() == "Windows":
|
|
420
|
+
# DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
|
421
|
+
creationflags = 0x00000008 | 0x00000200
|
|
422
|
+
proc = subprocess.Popen( # noqa: S603
|
|
423
|
+
cmd,
|
|
424
|
+
cwd=str(repo_dir),
|
|
425
|
+
env=proc_env,
|
|
426
|
+
creationflags=creationflags,
|
|
427
|
+
stdout=subprocess.DEVNULL,
|
|
428
|
+
stderr=subprocess.DEVNULL,
|
|
429
|
+
)
|
|
430
|
+
else:
|
|
431
|
+
proc = subprocess.Popen( # noqa: S603
|
|
432
|
+
cmd,
|
|
433
|
+
cwd=str(repo_dir),
|
|
434
|
+
env=proc_env,
|
|
435
|
+
start_new_session=True,
|
|
436
|
+
stdout=subprocess.DEVNULL,
|
|
437
|
+
stderr=subprocess.DEVNULL,
|
|
438
|
+
)
|
|
439
|
+
success(f"Node agent started (PID {proc.pid}).")
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _provision_remote(
|
|
444
|
+
*,
|
|
445
|
+
remote_host: str,
|
|
446
|
+
repo_url: str,
|
|
447
|
+
branch: str,
|
|
448
|
+
backend_url: str,
|
|
449
|
+
advertise_host: str,
|
|
450
|
+
enrollment_token: str,
|
|
451
|
+
use_sudo: bool,
|
|
452
|
+
workdir_override: str | None,
|
|
453
|
+
) -> bool:
|
|
454
|
+
ssh = shutil.which("ssh")
|
|
455
|
+
if not ssh:
|
|
456
|
+
error("ssh is not installed or not on PATH.")
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
workdir = (workdir_override or "").strip() or "/opt/faden_agent"
|
|
460
|
+
script = _build_remote_script(
|
|
461
|
+
repo_url=repo_url,
|
|
462
|
+
branch=branch,
|
|
463
|
+
workdir=workdir,
|
|
464
|
+
backend_url=backend_url,
|
|
465
|
+
advertise_host=advertise_host,
|
|
466
|
+
enrollment_token=enrollment_token,
|
|
467
|
+
use_sudo=use_sudo,
|
|
468
|
+
)
|
|
469
|
+
info(f"Provisioning node agent on remote host '{remote_host}'")
|
|
470
|
+
proc = subprocess.run( # noqa: S603
|
|
471
|
+
[ssh, remote_host, "bash -s"],
|
|
472
|
+
input=script,
|
|
473
|
+
text=True,
|
|
474
|
+
capture_output=True,
|
|
475
|
+
)
|
|
476
|
+
if proc.returncode != 0:
|
|
477
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
478
|
+
error("Remote node-agent provisioning failed.")
|
|
479
|
+
if detail:
|
|
480
|
+
error(detail)
|
|
481
|
+
if use_sudo:
|
|
482
|
+
warning(
|
|
483
|
+
"Remote install used sudo. Ensure remote user has passwordless sudo for "
|
|
484
|
+
"install/systemctl, or rerun with --local-node-no-sudo and a writable --local-node-workdir."
|
|
485
|
+
)
|
|
486
|
+
return False
|
|
487
|
+
|
|
488
|
+
if proc.stdout.strip():
|
|
489
|
+
info(proc.stdout.strip())
|
|
490
|
+
success(f"Remote node agent provisioned on {remote_host}.")
|
|
491
|
+
return True
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _build_remote_script(
|
|
495
|
+
*,
|
|
496
|
+
repo_url: str,
|
|
497
|
+
branch: str,
|
|
498
|
+
workdir: str,
|
|
499
|
+
backend_url: str,
|
|
500
|
+
advertise_host: str,
|
|
501
|
+
enrollment_token: str,
|
|
502
|
+
use_sudo: bool,
|
|
503
|
+
) -> str:
|
|
504
|
+
q = shlex.quote
|
|
505
|
+
use_sudo_int = "1" if use_sudo else "0"
|
|
506
|
+
return f"""set -euo pipefail
|
|
507
|
+
REPO_URL={q(repo_url)}
|
|
508
|
+
BRANCH={q(branch)}
|
|
509
|
+
WORKDIR={q(workdir)}
|
|
510
|
+
BACKEND_URL={q(backend_url)}
|
|
511
|
+
ADVERTISE_HOST={q(advertise_host)}
|
|
512
|
+
ENROLLMENT_TOKEN={q(enrollment_token)}
|
|
513
|
+
USE_SUDO={use_sudo_int}
|
|
514
|
+
|
|
515
|
+
mkdir -p "$(dirname "$WORKDIR")"
|
|
516
|
+
if [ -d "$WORKDIR/.git" ]; then
|
|
517
|
+
git -C "$WORKDIR" fetch --all --prune
|
|
518
|
+
elif [ -d "$WORKDIR" ] && [ -n "$(ls -A "$WORKDIR" 2>/dev/null)" ]; then
|
|
519
|
+
# Directory exists but is not a git repo (e.g. rsynced without .git/).
|
|
520
|
+
# Skip clone — use the existing source tree as-is.
|
|
521
|
+
echo "Directory $WORKDIR exists without .git — using existing source."
|
|
522
|
+
else
|
|
523
|
+
git clone "$REPO_URL" "$WORKDIR"
|
|
524
|
+
fi
|
|
525
|
+
|
|
526
|
+
if [ -d "$WORKDIR/.git" ] && [ -n "$BRANCH" ]; then
|
|
527
|
+
git -C "$WORKDIR" checkout "$BRANCH" || git -C "$WORKDIR" checkout -b "$BRANCH" "origin/$BRANCH" || true
|
|
528
|
+
git -C "$WORKDIR" pull --ff-only origin "$BRANCH" || true
|
|
529
|
+
fi
|
|
530
|
+
|
|
531
|
+
if command -v uv >/dev/null 2>&1; then
|
|
532
|
+
(cd "$WORKDIR" && uv sync)
|
|
533
|
+
else
|
|
534
|
+
python3 -m pip install --user -e "$WORKDIR"
|
|
535
|
+
fi
|
|
536
|
+
|
|
537
|
+
if [ "$USE_SUDO" != "1" ]; then
|
|
538
|
+
echo "Skipping systemd install (--local-node-no-sudo)."
|
|
539
|
+
exit 0
|
|
540
|
+
fi
|
|
541
|
+
|
|
542
|
+
if ! command -v systemctl >/dev/null 2>&1; then
|
|
543
|
+
echo "systemctl not available on remote host; skipping service install."
|
|
544
|
+
exit 0
|
|
545
|
+
fi
|
|
546
|
+
|
|
547
|
+
SUDO_BIN="sudo"
|
|
548
|
+
if [ "$(id -u)" -eq 0 ]; then
|
|
549
|
+
SUDO_BIN=""
|
|
550
|
+
fi
|
|
551
|
+
|
|
552
|
+
if [ -n "$SUDO_BIN" ]; then
|
|
553
|
+
$SUDO_BIN -n true >/dev/null 2>&1 || {{
|
|
554
|
+
echo "sudo permission check failed (passwordless sudo required for non-interactive install)."
|
|
555
|
+
exit 2
|
|
556
|
+
}}
|
|
557
|
+
fi
|
|
558
|
+
|
|
559
|
+
# Render the systemd service template by replacing @@PLACEHOLDER@@ values
|
|
560
|
+
SVC_USER="$(id -un)"
|
|
561
|
+
SVC_GROUP="$(id -gn)"
|
|
562
|
+
HOME_DIR="$(eval echo ~"$SVC_USER")"
|
|
563
|
+
STATE_DIR="$HOME_DIR/.local/share/faden-agent"
|
|
564
|
+
[ "$(id -u)" -eq 0 ] && STATE_DIR="/var/lib/faden-agent"
|
|
565
|
+
CONFIG_DIR="$HOME_DIR/.config/faden-agent"
|
|
566
|
+
# Model store: standard HuggingFace cache in the service user's home, so the
|
|
567
|
+
# agent owns it without a root-owned /srv path (see
|
|
568
|
+
# faden_agent.config._default_model_store_root).
|
|
569
|
+
MODEL_STORE="$HOME_DIR/.cache/huggingface"
|
|
570
|
+
PROTECT_HOME="yes"
|
|
571
|
+
RW_PATHS="$MODEL_STORE $STATE_DIR"
|
|
572
|
+
case "$STATE_DIR" in "$HOME_DIR"*) PROTECT_HOME="read-only"; RW_PATHS="$RW_PATHS $CONFIG_DIR" ;; esac
|
|
573
|
+
case "$MODEL_STORE" in "$HOME_DIR"*) PROTECT_HOME="read-only" ;; esac
|
|
574
|
+
USER_ENV_FILE="$CONFIG_DIR/agent.env"
|
|
575
|
+
|
|
576
|
+
# Find the agent binary for ExecStart
|
|
577
|
+
AGENT_BIN=""
|
|
578
|
+
if [ -x "$WORKDIR/.venv/bin/faden-agent" ]; then
|
|
579
|
+
AGENT_BIN="$WORKDIR/.venv/bin/faden-agent"
|
|
580
|
+
elif command -v faden-agent >/dev/null 2>&1; then
|
|
581
|
+
AGENT_BIN="$(command -v faden-agent)"
|
|
582
|
+
else
|
|
583
|
+
AGENT_BIN="/usr/local/bin/faden-agent"
|
|
584
|
+
fi
|
|
585
|
+
|
|
586
|
+
TMP_SVC="$(mktemp)"
|
|
587
|
+
sed -e "s|@@USER@@|$SVC_USER|g" \
|
|
588
|
+
-e "s|@@GROUP@@|$SVC_GROUP|g" \
|
|
589
|
+
-e "s|@@PROTECT_HOME@@|$PROTECT_HOME|g" \
|
|
590
|
+
-e "s|@@READ_WRITE_PATHS@@|$RW_PATHS|g" \
|
|
591
|
+
-e "s|@@USER_ENV_FILE@@|$USER_ENV_FILE|g" \
|
|
592
|
+
-e "s|^ExecStart=.*|ExecStart=$AGENT_BIN run|" \
|
|
593
|
+
"$WORKDIR/deploy/systemd/faden-agent.service" > "$TMP_SVC"
|
|
594
|
+
|
|
595
|
+
if [ -n "$SUDO_BIN" ]; then
|
|
596
|
+
$SUDO_BIN install -m 0644 "$TMP_SVC" /etc/systemd/system/faden-agent.service
|
|
597
|
+
else
|
|
598
|
+
install -m 0644 "$TMP_SVC" /etc/systemd/system/faden-agent.service
|
|
599
|
+
fi
|
|
600
|
+
rm -f "$TMP_SVC"
|
|
601
|
+
|
|
602
|
+
# Ensure state and model directories exist with correct ownership
|
|
603
|
+
if [ -n "$SUDO_BIN" ]; then
|
|
604
|
+
$SUDO_BIN mkdir -p "$STATE_DIR" || true
|
|
605
|
+
$SUDO_BIN chown "$SVC_USER:$SVC_GROUP" "$STATE_DIR" || true
|
|
606
|
+
$SUDO_BIN mkdir -p "$MODEL_STORE" || true
|
|
607
|
+
$SUDO_BIN chown "$SVC_USER:$SVC_GROUP" "$MODEL_STORE" || true
|
|
608
|
+
else
|
|
609
|
+
mkdir -p "$STATE_DIR" || true
|
|
610
|
+
mkdir -p "$MODEL_STORE" || true
|
|
611
|
+
fi
|
|
612
|
+
|
|
613
|
+
# Derive Loki URL from backend (same host, port 3100)
|
|
614
|
+
BACKEND_HOST=$(echo "$BACKEND_URL" | sed -E 's|^https?://([^:/]+).*|\1|')
|
|
615
|
+
BACKEND_SCHEME=$(echo "$BACKEND_URL" | sed -E 's|^(https?)://.*|\1|')
|
|
616
|
+
LOKI_URL="${{BACKEND_SCHEME}}://${{BACKEND_HOST}}:3100"
|
|
617
|
+
|
|
618
|
+
TMP_ENV="$(mktemp)"
|
|
619
|
+
{{
|
|
620
|
+
printf "FADEN_NODE_AGENT_BACKEND_URL=%s\\n" "$BACKEND_URL"
|
|
621
|
+
printf "FADEN_NODE_AGENT_LOKI_URL=%s\\n" "$LOKI_URL"
|
|
622
|
+
if [ -n "$ADVERTISE_HOST" ]; then
|
|
623
|
+
printf "FADEN_NODE_AGENT_ADVERTISE_HOST=%s\\n" "$ADVERTISE_HOST"
|
|
624
|
+
fi
|
|
625
|
+
if [ -n "$ENROLLMENT_TOKEN" ]; then
|
|
626
|
+
printf "FADEN_NODE_AGENT_ENROLLMENT_TOKEN=%s\\n" "$ENROLLMENT_TOKEN"
|
|
627
|
+
fi
|
|
628
|
+
}} >"$TMP_ENV"
|
|
629
|
+
|
|
630
|
+
if [ -n "$SUDO_BIN" ]; then
|
|
631
|
+
$SUDO_BIN install -m 0600 "$TMP_ENV" /etc/faden-agent.env
|
|
632
|
+
$SUDO_BIN systemctl daemon-reload
|
|
633
|
+
$SUDO_BIN systemctl enable --now faden-agent
|
|
634
|
+
else
|
|
635
|
+
install -m 0600 "$TMP_ENV" /etc/faden-agent.env
|
|
636
|
+
systemctl daemon-reload
|
|
637
|
+
systemctl enable --now faden-agent
|
|
638
|
+
fi
|
|
639
|
+
rm -f "$TMP_ENV"
|
|
640
|
+
|
|
641
|
+
echo "Node agent service installed and started."
|
|
642
|
+
"""
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _clone_or_update_repo(*, repo_dir: Path, repo_url: str, branch: str) -> bool:
|
|
646
|
+
git = shutil.which("git")
|
|
647
|
+
if not git:
|
|
648
|
+
error("git is not installed or not on PATH.")
|
|
649
|
+
return False
|
|
650
|
+
|
|
651
|
+
repo_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
652
|
+
if (repo_dir / ".git").exists():
|
|
653
|
+
# Repo already cloned — try to fetch latest, but don't abort
|
|
654
|
+
# if it fails (the existing code is sufficient to run).
|
|
655
|
+
if _run([git, "-C", str(repo_dir), "fetch", "--all", "--prune"], check=False) != 0:
|
|
656
|
+
warning("git fetch failed — continuing with existing source.")
|
|
657
|
+
elif repo_dir.exists() and any(repo_dir.iterdir()):
|
|
658
|
+
# Directory exists but is not a git repo (e.g. rsynced without .git/).
|
|
659
|
+
# Use the existing source tree as-is — no clone needed.
|
|
660
|
+
info("Directory exists without .git — using existing source.")
|
|
661
|
+
else:
|
|
662
|
+
if _run([git, "clone", repo_url, str(repo_dir)]) != 0:
|
|
663
|
+
return False
|
|
664
|
+
|
|
665
|
+
if (repo_dir / ".git").exists():
|
|
666
|
+
if _run([git, "-C", str(repo_dir), "checkout", branch], check=False) != 0:
|
|
667
|
+
_run([git, "-C", str(repo_dir), "checkout", "-b", branch, f"origin/{branch}"], check=False)
|
|
668
|
+
_run([git, "-C", str(repo_dir), "pull", "--ff-only", "origin", branch], check=False)
|
|
669
|
+
return True
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _install_agent_dependencies(repo_dir: Path) -> bool:
|
|
673
|
+
uv = shutil.which("uv")
|
|
674
|
+
if uv:
|
|
675
|
+
if _run([uv, "sync"], cwd=repo_dir) == 0:
|
|
676
|
+
success("Node agent dependencies installed via uv.")
|
|
677
|
+
return True
|
|
678
|
+
return False
|
|
679
|
+
|
|
680
|
+
python = shutil.which("python3") or shutil.which("python")
|
|
681
|
+
if not python:
|
|
682
|
+
error("Neither uv nor python is available for node agent install.")
|
|
683
|
+
return False
|
|
684
|
+
if _run([python, "-m", "pip", "install", "-e", "."], cwd=repo_dir) != 0:
|
|
685
|
+
return False
|
|
686
|
+
success("Node agent installed via pip editable mode.")
|
|
687
|
+
return True
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _run(cmd: list[str], *, cwd: Path | None = None, check: bool = True) -> int:
|
|
691
|
+
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
|
692
|
+
proc = subprocess.run( # noqa: S603
|
|
693
|
+
cmd,
|
|
694
|
+
cwd=str(cwd) if cwd else None,
|
|
695
|
+
capture_output=True,
|
|
696
|
+
text=True,
|
|
697
|
+
env=env,
|
|
698
|
+
)
|
|
699
|
+
if check and proc.returncode != 0:
|
|
700
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
701
|
+
error(f"Command failed: {' '.join(cmd)}")
|
|
702
|
+
if detail:
|
|
703
|
+
error(detail)
|
|
704
|
+
return proc.returncode
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _has_uncommitted_changes(repo_dir: Path) -> bool:
|
|
708
|
+
"""Return True if *repo_dir* is a git repo with uncommitted changes."""
|
|
709
|
+
git = shutil.which("git")
|
|
710
|
+
if not git or not (repo_dir / ".git").exists():
|
|
711
|
+
# Not a git repo — be safe and assume there are changes.
|
|
712
|
+
return True
|
|
713
|
+
proc = subprocess.run( # noqa: S603
|
|
714
|
+
[git, "status", "--porcelain"],
|
|
715
|
+
cwd=str(repo_dir),
|
|
716
|
+
capture_output=True,
|
|
717
|
+
text=True,
|
|
718
|
+
)
|
|
719
|
+
return bool(proc.stdout.strip())
|