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
@@ -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())