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,949 @@
|
|
|
1
|
+
"""``faden deploy`` — full production deployment in one command.
|
|
2
|
+
|
|
3
|
+
Runs the complete production flow:
|
|
4
|
+
1. Pre-flight checks (Docker, Compose, disk, RAM)
|
|
5
|
+
2. Locate or set up the install directory
|
|
6
|
+
3. Generate ``.env`` with random secrets, and size the services for
|
|
7
|
+
this host (what ``faden tune`` does)
|
|
8
|
+
4. Enable requested modules (compose profiles)
|
|
9
|
+
and choose the ports and whether to serve HTTPS
|
|
10
|
+
5. Pull pre-built container images from the registry
|
|
11
|
+
6. Start all services (infra + app + modules)
|
|
12
|
+
7. Print endpoint summary
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import secrets
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
|
|
23
|
+
from faden_cli import __version__
|
|
24
|
+
from faden_cli.core.bundle import DEFAULT_INSTALL_DIR, bundled_files, installed_release, unpack
|
|
25
|
+
from faden_cli.core.compose import (
|
|
26
|
+
ComposeContext,
|
|
27
|
+
build as compose_build,
|
|
28
|
+
build_base_image,
|
|
29
|
+
build_context_from_config,
|
|
30
|
+
pull_base_images,
|
|
31
|
+
up as compose_up,
|
|
32
|
+
)
|
|
33
|
+
from faden_cli.core import installation
|
|
34
|
+
from faden_cli.core.console import console, error, info, success, warning
|
|
35
|
+
from faden_cli.core.detect import detect_docker
|
|
36
|
+
from faden_cli.core.env_gen import default_env_vars, ensure_bootstrap_token, read_env_file, write_env_file
|
|
37
|
+
from faden_cli.core.private_files import private_dir, secure_install
|
|
38
|
+
from faden_cli.core.registry import MODULES_COMPAT as KNOWN_MODULES
|
|
39
|
+
from faden_cli.core.rmq import write_definitions as write_rmq_definitions
|
|
40
|
+
from faden_cli.core.settings import FadenConfig, load_config, save_config
|
|
41
|
+
from faden_cli.core.tune import tune_env
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _regenerate_rmq_definitions(
|
|
45
|
+
shared_dir: Path,
|
|
46
|
+
env_vars: dict[str, str],
|
|
47
|
+
profiles: set[str] | list[str],
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Write ``rabbitmq/definitions.json`` from current env vars."""
|
|
50
|
+
write_rmq_definitions(
|
|
51
|
+
shared_dir,
|
|
52
|
+
admin_user=env_vars.get("RABBITMQ_ADMIN_USER", "admin"),
|
|
53
|
+
admin_pass=env_vars.get("RABBITMQ_ADMIN_PASS", "guest"),
|
|
54
|
+
backend_pass=env_vars.get("RABBITMQ_BACKEND_PASS", "guest"),
|
|
55
|
+
api_pass=env_vars.get("RABBITMQ_API_PASS", "guest"),
|
|
56
|
+
pii_pass=env_vars.get("RABBITMQ_PII_PASS") if "pii" in set(profiles) else None,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pin_release(env_path: Path) -> None:
|
|
61
|
+
"""Run the published images of this CLI's version (an install made from the bundle).
|
|
62
|
+
|
|
63
|
+
Unpinned, the compose file asks for ``latest``: an install would pull
|
|
64
|
+
whatever was published last, and two machines deployed a day apart
|
|
65
|
+
would run different releases under the same CLI.
|
|
66
|
+
"""
|
|
67
|
+
record_installer(env_path, None)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def record_installer(env_path: Path, source: Path | None) -> None:
|
|
71
|
+
"""Pin a release install to this CLI's version, and say how the install is upgraded.
|
|
72
|
+
|
|
73
|
+
The console's update notice shows the commands for it (FADEN_CLI_INSTALLER): the
|
|
74
|
+
way this CLI was installed, or ``source`` for an install built from the checkout
|
|
75
|
+
*source*, which a newer release reaches by ``git pull``, not by a newer CLI.
|
|
76
|
+
"""
|
|
77
|
+
from faden_cli.core.update_check import installer # noqa: PLC0415
|
|
78
|
+
|
|
79
|
+
env_vars = read_env_file(env_path)
|
|
80
|
+
if source:
|
|
81
|
+
pins = {"FADEN_CLI_INSTALLER": "source"}
|
|
82
|
+
else:
|
|
83
|
+
pins = {"VERSION": __version__, "FADEN_CLI_INSTALLER": installer()}
|
|
84
|
+
if any(env_vars.get(k) != v for k, v in pins.items()):
|
|
85
|
+
env_vars.update(pins)
|
|
86
|
+
write_env_file(env_path, env_vars)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def update_check_step(env_path: Path, cfg: FadenConfig, *, choice: bool | None, yes: bool) -> None:
|
|
90
|
+
"""Ask once whether this install hears about new releases; the CLI and the backend both follow it.
|
|
91
|
+
|
|
92
|
+
The answer goes into faden.yaml (the CLI) and .env (the backend's default; an
|
|
93
|
+
administrator can change it later under Settings > Updates).
|
|
94
|
+
"""
|
|
95
|
+
from faden_cli.core.release_feed import FEED_URL # noqa: PLC0415
|
|
96
|
+
|
|
97
|
+
env_vars = read_env_file(env_path)
|
|
98
|
+
if choice is None:
|
|
99
|
+
if "FADEN_UPDATE_CHECK" in env_vars:
|
|
100
|
+
choice = env_vars["FADEN_UPDATE_CHECK"].strip().lower() == "true"
|
|
101
|
+
elif yes:
|
|
102
|
+
choice = True
|
|
103
|
+
else:
|
|
104
|
+
console.print("\n[bold cyan]Step 3e: New releases…[/bold cyan]")
|
|
105
|
+
console.print(
|
|
106
|
+
f" Once a day the server and this CLI read {FEED_URL},\n"
|
|
107
|
+
" the public list of releases. They send nothing about this server: the list is\n"
|
|
108
|
+
" compared here, and administrators see in the console when an update is out."
|
|
109
|
+
)
|
|
110
|
+
choice = click.confirm(" Check for new releases?", default=True)
|
|
111
|
+
if env_vars.get("FADEN_UPDATE_CHECK") != ("true" if choice else "false"):
|
|
112
|
+
env_vars["FADEN_UPDATE_CHECK"] = "true" if choice else "false"
|
|
113
|
+
write_env_file(env_path, env_vars)
|
|
114
|
+
cfg.update_check = choice
|
|
115
|
+
if not choice:
|
|
116
|
+
info("Update check off (update_check in faden.yaml; Settings > Updates in the console).")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _sync_postgres_password(ctx: ComposeContext, env_path: Path) -> None:
|
|
120
|
+
"""Ensure the Postgres superuser password matches ``.env``.
|
|
121
|
+
|
|
122
|
+
The official ``postgres`` image only reads ``POSTGRES_PASSWORD``
|
|
123
|
+
during the **first** initialisation of the data volume. If the
|
|
124
|
+
volume already exists and ``.env`` contains a different password,
|
|
125
|
+
every service that connects with the new password will fail with
|
|
126
|
+
``InvalidPasswordError``.
|
|
127
|
+
|
|
128
|
+
This function boots only the ``postgres`` service, waits for it to
|
|
129
|
+
become healthy, then runs ``ALTER USER`` via local socket auth
|
|
130
|
+
(which never requires a password) to bring the DB in sync.
|
|
131
|
+
"""
|
|
132
|
+
import shutil
|
|
133
|
+
import subprocess
|
|
134
|
+
|
|
135
|
+
from faden_cli.core.env_gen import read_env_file
|
|
136
|
+
|
|
137
|
+
docker = shutil.which("docker")
|
|
138
|
+
if not docker:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
env_vars = read_env_file(env_path)
|
|
142
|
+
pg_user = env_vars.get("POSTGRES_USER", "postgres")
|
|
143
|
+
pg_pass = env_vars.get("POSTGRES_PASSWORD", "")
|
|
144
|
+
if not pg_pass:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
info("Syncing Postgres password with .env…")
|
|
148
|
+
|
|
149
|
+
# Start only postgres so we can ALTER before migrators run.
|
|
150
|
+
compose_up(ctx, services=["postgres"], detach=True, wait=True, timeout=60)
|
|
151
|
+
|
|
152
|
+
# ALTER via local socket auth (no password needed inside container).
|
|
153
|
+
# Use stdin pipe to avoid shell-quoting issues with special chars.
|
|
154
|
+
# Escape single quotes in password for SQL literal safety.
|
|
155
|
+
safe_pass = pg_pass.replace("'", "''")
|
|
156
|
+
alter_sql = f"ALTER USER {pg_user} PASSWORD '{safe_pass}';\n"
|
|
157
|
+
result = subprocess.run( # noqa: S603
|
|
158
|
+
[docker, "exec", "-i", "faden-postgres", "psql", "-U", pg_user],
|
|
159
|
+
input=alter_sql,
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
check=False,
|
|
163
|
+
)
|
|
164
|
+
if result.returncode == 0:
|
|
165
|
+
success("Postgres password synchronised.")
|
|
166
|
+
else:
|
|
167
|
+
warning(f"Could not sync Postgres password: {result.stderr.strip()}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
#: Logins the initdb script of earlier releases made on every install, with
|
|
171
|
+
#: the same fixed passwords everywhere (llm_user could read every backend
|
|
172
|
+
#: table). No service of an install uses them; a development stack makes the
|
|
173
|
+
#: one it needs itself (faden dev up).
|
|
174
|
+
FIXED_LOGINS = ("llm_user", "faden_backend")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def retire_fixed_logins(env_path: Path) -> list[str]:
|
|
178
|
+
"""Switch off the fixed-password logins an install made before still has; returns which.
|
|
179
|
+
|
|
180
|
+
``ALTER ROLE ... NOLOGIN``: the roles and their grants stay, nobody can sign
|
|
181
|
+
in with them. Postgres must be running (after :func:`_sync_postgres_password`).
|
|
182
|
+
Only an install whose ``.env`` says FADEN_BACKEND_ENVIRONMENT=production (deploy
|
|
183
|
+
writes it) is touched: a development stack's host-run backend signs in as
|
|
184
|
+
``faden_backend``, and an ``.env`` that says nothing may belong to one.
|
|
185
|
+
"""
|
|
186
|
+
import shutil
|
|
187
|
+
import subprocess
|
|
188
|
+
|
|
189
|
+
env_vars = read_env_file(env_path)
|
|
190
|
+
if env_vars.get("FADEN_BACKEND_ENVIRONMENT") != "production":
|
|
191
|
+
return []
|
|
192
|
+
docker = shutil.which("docker")
|
|
193
|
+
if not docker:
|
|
194
|
+
return []
|
|
195
|
+
pg_user = env_vars.get("POSTGRES_USER", "postgres")
|
|
196
|
+
roles = [role for role in FIXED_LOGINS if role != pg_user]
|
|
197
|
+
if not roles:
|
|
198
|
+
return []
|
|
199
|
+
names = ", ".join(f"'{role}'" for role in roles)
|
|
200
|
+
sql = (
|
|
201
|
+
f"SELECT coalesce(string_agg(rolname, ','), '') FROM pg_roles "
|
|
202
|
+
f"WHERE rolname IN ({names}) AND rolcanlogin;\n"
|
|
203
|
+
"DO $$ DECLARE r text; BEGIN "
|
|
204
|
+
f"FOR r IN SELECT rolname FROM pg_roles WHERE rolname IN ({names}) AND rolcanlogin LOOP "
|
|
205
|
+
"EXECUTE format('ALTER ROLE %I NOLOGIN', r); END LOOP; END $$;\n"
|
|
206
|
+
)
|
|
207
|
+
result = subprocess.run( # noqa: S603
|
|
208
|
+
[docker, "exec", "-i", "faden-postgres", "psql", "-U", pg_user, "-v", "ON_ERROR_STOP=1", "-At"],
|
|
209
|
+
input=sql,
|
|
210
|
+
capture_output=True,
|
|
211
|
+
text=True,
|
|
212
|
+
check=False,
|
|
213
|
+
)
|
|
214
|
+
if result.returncode != 0:
|
|
215
|
+
warning(f"Could not switch off the old fixed Postgres logins: {result.stderr.strip()}")
|
|
216
|
+
return []
|
|
217
|
+
retired = [name for name in (result.stdout.splitlines() or [""])[0].split(",") if name]
|
|
218
|
+
if retired:
|
|
219
|
+
success(f"Old Postgres logins with fixed passwords switched off: {', '.join(retired)}.")
|
|
220
|
+
return retired
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _resolve_compose_file(install_dir: Path) -> Path | None:
|
|
224
|
+
"""Find the shared docker-compose file."""
|
|
225
|
+
for candidate in (
|
|
226
|
+
install_dir / "docker-compose.yaml",
|
|
227
|
+
install_dir / "docker-compose.yml",
|
|
228
|
+
):
|
|
229
|
+
if candidate.exists():
|
|
230
|
+
return candidate
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _find_shared_dir(workspace: Path) -> Path | None:
|
|
235
|
+
"""Locate the shared directory relative to workspace.
|
|
236
|
+
|
|
237
|
+
Searches in order:
|
|
238
|
+
1. workspace/shared (monorepo layout)
|
|
239
|
+
2. workspace/fadenstack/shared (monorepo cloned)
|
|
240
|
+
3. workspace/../shared (legacy polyrepo sibling)
|
|
241
|
+
"""
|
|
242
|
+
for candidate in (
|
|
243
|
+
workspace / "shared",
|
|
244
|
+
workspace / "fadenstack" / "shared",
|
|
245
|
+
workspace.parent / "shared",
|
|
246
|
+
):
|
|
247
|
+
if candidate.is_dir():
|
|
248
|
+
return candidate
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _login_for_api_token(backend_url: str, email: str) -> str | None:
|
|
253
|
+
"""Prompt for password, login via the auth endpoint, and return an access token."""
|
|
254
|
+
import click # noqa: PLC0415
|
|
255
|
+
|
|
256
|
+
import httpx # noqa: PLC0415
|
|
257
|
+
|
|
258
|
+
from faden_cli.core.console import error, success # noqa: PLC0415
|
|
259
|
+
from faden_cli.core.edge import verify_for # noqa: PLC0415
|
|
260
|
+
|
|
261
|
+
password = click.prompt(f" Password for {email}", hide_input=True, show_default=False)
|
|
262
|
+
try:
|
|
263
|
+
resp = httpx.post(
|
|
264
|
+
f"{backend_url.rstrip('/')}/api/auth/jwt/login",
|
|
265
|
+
data={"username": email, "password": password},
|
|
266
|
+
timeout=15,
|
|
267
|
+
verify=verify_for(backend_url),
|
|
268
|
+
)
|
|
269
|
+
if resp.status_code == 200: # noqa: PLR2004
|
|
270
|
+
token = resp.json().get("access_token", "")
|
|
271
|
+
if token:
|
|
272
|
+
success("Logged in — creating enrollment token…")
|
|
273
|
+
return token
|
|
274
|
+
error(f"Login failed (HTTP {resp.status_code})")
|
|
275
|
+
return None
|
|
276
|
+
except httpx.HTTPError as exc:
|
|
277
|
+
error(f"Could not reach backend for login: {exc}")
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@click.command("deploy")
|
|
282
|
+
@click.argument(
|
|
283
|
+
"install_dir",
|
|
284
|
+
required=False,
|
|
285
|
+
type=click.Path(file_okay=False, resolve_path=True),
|
|
286
|
+
)
|
|
287
|
+
@click.option(
|
|
288
|
+
"--modules", "-m",
|
|
289
|
+
default="",
|
|
290
|
+
help="Comma-separated modules to enable (EE only, e.g. pii,auth).",
|
|
291
|
+
)
|
|
292
|
+
@click.option("--build", is_flag=True, help="Build images from source instead of pulling pre-built images.")
|
|
293
|
+
@click.option("--no-cache", is_flag=True, help="Build images without Docker cache (implies --build).")
|
|
294
|
+
@click.option("--gpu/--no-gpu", default=None, help="Include GPU (NVIDIA) compose overlay. Default: auto-detect.")
|
|
295
|
+
@click.option("--force-env", is_flag=True, help="Regenerate .env even if it exists.")
|
|
296
|
+
@click.option(
|
|
297
|
+
"--runtime-images",
|
|
298
|
+
"runtime_images",
|
|
299
|
+
default=None,
|
|
300
|
+
metavar="ARCHS",
|
|
301
|
+
help=(
|
|
302
|
+
"Runtime images to fetch for the machines that will run models: all (default), none, "
|
|
303
|
+
"or architectures such as x86_64,aarch64. Remembered for upgrades."
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
@click.option(
|
|
307
|
+
"--http-port", type=click.IntRange(1, 65535), default=None, help="Host port for plain HTTP (default 80)."
|
|
308
|
+
)
|
|
309
|
+
@click.option(
|
|
310
|
+
"--https-port", type=click.IntRange(1, 65535), default=None, help="Host port for HTTPS (default 443)."
|
|
311
|
+
)
|
|
312
|
+
@click.option(
|
|
313
|
+
"--tls",
|
|
314
|
+
"tls_mode",
|
|
315
|
+
type=click.Choice(["off", "generate", "own"]),
|
|
316
|
+
default=None,
|
|
317
|
+
help="HTTPS: generate (a certificate made here for this machine; the default), own (--tls-cert/--tls-key), "
|
|
318
|
+
"or off. Default: ask; with --yes, generate unless HTTPS was switched off on purpose.",
|
|
319
|
+
)
|
|
320
|
+
@click.option(
|
|
321
|
+
"--tls-cert", type=click.Path(dir_okay=False, path_type=Path), default=None,
|
|
322
|
+
help="Your certificate (PEM).",
|
|
323
|
+
)
|
|
324
|
+
@click.option(
|
|
325
|
+
"--tls-key", type=click.Path(dir_okay=False, path_type=Path), default=None, help="Its private key (PEM)."
|
|
326
|
+
)
|
|
327
|
+
@click.option(
|
|
328
|
+
"--tls-chain", type=click.Path(dir_okay=False, path_type=Path), default=None,
|
|
329
|
+
help="Intermediate certificates (PEM), unless --tls-cert already carries them.",
|
|
330
|
+
)
|
|
331
|
+
@click.option(
|
|
332
|
+
"--tls-ca", type=click.Path(dir_okay=False, path_type=Path), default=None,
|
|
333
|
+
help="With --tls own: a private issuer's root (PEM), given to the machines so they trust the certificate.",
|
|
334
|
+
)
|
|
335
|
+
@click.option(
|
|
336
|
+
"--tls-name", "tls_names", multiple=True, metavar="NAME",
|
|
337
|
+
help="With --tls generate: another DNS name or address the console is reached by (repeatable).",
|
|
338
|
+
)
|
|
339
|
+
@click.option(
|
|
340
|
+
"--update-check/--no-update-check",
|
|
341
|
+
"update_check",
|
|
342
|
+
default=None,
|
|
343
|
+
help="Read the public list of releases once a day and say when one is out (sends nothing about "
|
|
344
|
+
"this server). Default: ask; with --yes, on.",
|
|
345
|
+
)
|
|
346
|
+
@click.option(
|
|
347
|
+
"--skip-doctor", is_flag=True,
|
|
348
|
+
help="Skip pre-flight system checks.",
|
|
349
|
+
)
|
|
350
|
+
@click.option(
|
|
351
|
+
"--yes", "-y", is_flag=True,
|
|
352
|
+
help="Auto-confirm all prompts.",
|
|
353
|
+
)
|
|
354
|
+
@click.option(
|
|
355
|
+
"--local-node",
|
|
356
|
+
is_flag=True,
|
|
357
|
+
help="Provision faden_agent locally or over SSH during deployment.",
|
|
358
|
+
)
|
|
359
|
+
@click.option(
|
|
360
|
+
"--local-node-host",
|
|
361
|
+
default="",
|
|
362
|
+
help="SSH host for node-agent provisioning (example: ubuntu@10.0.0.12).",
|
|
363
|
+
)
|
|
364
|
+
@click.option(
|
|
365
|
+
"--local-node-workdir",
|
|
366
|
+
default="",
|
|
367
|
+
help="Install directory for node-agent repo on target host.",
|
|
368
|
+
)
|
|
369
|
+
@click.option(
|
|
370
|
+
"--local-node-branch",
|
|
371
|
+
default="",
|
|
372
|
+
help="Git branch for faden-node-agent (default: config dev.branch).",
|
|
373
|
+
)
|
|
374
|
+
@click.option(
|
|
375
|
+
"--local-node-backend-url",
|
|
376
|
+
default="",
|
|
377
|
+
help="Backend URL written to node-agent environment. Default: same as deploy target.",
|
|
378
|
+
)
|
|
379
|
+
@click.option(
|
|
380
|
+
"--local-node-advertise-host",
|
|
381
|
+
default="",
|
|
382
|
+
help="Host/IP that node agent advertises for runtime endpoints.",
|
|
383
|
+
)
|
|
384
|
+
@click.option(
|
|
385
|
+
"--local-node-enrollment-token",
|
|
386
|
+
default="",
|
|
387
|
+
help="Optional one-time enrollment token for initial node onboarding.",
|
|
388
|
+
)
|
|
389
|
+
@click.option(
|
|
390
|
+
"--local-node-sudo/--local-node-no-sudo",
|
|
391
|
+
default=True,
|
|
392
|
+
show_default=True,
|
|
393
|
+
help="Use sudo for systemd installation in node-agent provisioning.",
|
|
394
|
+
)
|
|
395
|
+
def deploy_cmd(
|
|
396
|
+
install_dir: str | None,
|
|
397
|
+
*,
|
|
398
|
+
runtime_images: str | None,
|
|
399
|
+
modules: str,
|
|
400
|
+
build: bool,
|
|
401
|
+
no_cache: bool,
|
|
402
|
+
gpu: bool | None,
|
|
403
|
+
force_env: bool,
|
|
404
|
+
update_check: bool | None,
|
|
405
|
+
skip_doctor: bool,
|
|
406
|
+
yes: bool,
|
|
407
|
+
http_port: int | None,
|
|
408
|
+
https_port: int | None,
|
|
409
|
+
tls_mode: str | None,
|
|
410
|
+
tls_cert: Path | None,
|
|
411
|
+
tls_key: Path | None,
|
|
412
|
+
tls_chain: Path | None,
|
|
413
|
+
tls_names: tuple[str, ...],
|
|
414
|
+
tls_ca: Path | None,
|
|
415
|
+
local_node: bool,
|
|
416
|
+
local_node_host: str,
|
|
417
|
+
local_node_workdir: str,
|
|
418
|
+
local_node_branch: str,
|
|
419
|
+
local_node_backend_url: str,
|
|
420
|
+
local_node_advertise_host: str,
|
|
421
|
+
local_node_enrollment_token: str,
|
|
422
|
+
local_node_sudo: bool,
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Deploy Fadenstack to a local production environment.
|
|
425
|
+
|
|
426
|
+
Performs a complete deployment: pre-flight checks, environment
|
|
427
|
+
generation, image builds, database migrations, and service startup.
|
|
428
|
+
|
|
429
|
+
INSTALL_DIR defaults to the current directory or the configured
|
|
430
|
+
install path.
|
|
431
|
+
|
|
432
|
+
\b
|
|
433
|
+
Examples:
|
|
434
|
+
faden deploy # deploy the published images (into ~/fadenstack)
|
|
435
|
+
faden deploy /opt/faden # deploy to specific directory
|
|
436
|
+
faden deploy --build # from a source checkout: build the images
|
|
437
|
+
faden deploy --force-env # regenerate secrets
|
|
438
|
+
faden deploy --tls generate # HTTPS with a certificate made here
|
|
439
|
+
faden deploy --tls own --tls-cert fullchain.pem --tls-key key.pem
|
|
440
|
+
faden deploy --http-port 8080 --https-port 8443
|
|
441
|
+
"""
|
|
442
|
+
console.print("\n[bold magenta]fadenstack — Production Deployment[/bold magenta]\n")
|
|
443
|
+
|
|
444
|
+
# ── 0. Resolve install directory ──────────────────────────────
|
|
445
|
+
#
|
|
446
|
+
# Priority order:
|
|
447
|
+
# 1. Explicit CLI argument (faden deploy /path)
|
|
448
|
+
# 2. Current working directory — if it contains shared
|
|
449
|
+
# 3. Saved config install_dir (from a previous dev init / deploy)
|
|
450
|
+
# 4. ~/fadenstack, unpacked from the files this CLI carries
|
|
451
|
+
# 5. CWD as final fallback
|
|
452
|
+
#
|
|
453
|
+
# This ensures that running ``faden deploy`` from a development
|
|
454
|
+
# workspace always builds from the local source, even when the
|
|
455
|
+
# config still points at a different (possibly stale) clone.
|
|
456
|
+
cfg = load_config()
|
|
457
|
+
|
|
458
|
+
if install_dir:
|
|
459
|
+
workspace = Path(install_dir)
|
|
460
|
+
else:
|
|
461
|
+
cwd = Path.cwd()
|
|
462
|
+
cwd_shared = _find_shared_dir(cwd)
|
|
463
|
+
if cwd_shared:
|
|
464
|
+
workspace = cwd
|
|
465
|
+
elif cfg.install_dir:
|
|
466
|
+
workspace = Path(cfg.install_dir)
|
|
467
|
+
elif bundled_files() is not None:
|
|
468
|
+
workspace = DEFAULT_INSTALL_DIR
|
|
469
|
+
else:
|
|
470
|
+
workspace = cwd
|
|
471
|
+
|
|
472
|
+
shared_dir = _find_shared_dir(workspace)
|
|
473
|
+
if not shared_dir:
|
|
474
|
+
# If install_dir itself IS the shared dir
|
|
475
|
+
if (workspace / "docker-compose.yaml").exists() or (workspace / "docker-compose.yml").exists():
|
|
476
|
+
shared_dir = workspace
|
|
477
|
+
elif bundled_files() is not None:
|
|
478
|
+
# No checkout: the deployment files this CLI carries.
|
|
479
|
+
unpack(workspace)
|
|
480
|
+
shared_dir = workspace
|
|
481
|
+
success(f"Deployment files for release {__version__} written to {workspace}")
|
|
482
|
+
else:
|
|
483
|
+
error(
|
|
484
|
+
f"Cannot find shared in {workspace}.\n"
|
|
485
|
+
" Run from the workspace root, or pass the install directory as an argument.\n"
|
|
486
|
+
" If you haven't cloned the repos yet, run: faden dev init <dir>"
|
|
487
|
+
)
|
|
488
|
+
sys.exit(1)
|
|
489
|
+
|
|
490
|
+
# An install made from the bundle runs one release: the CLI's.
|
|
491
|
+
release = installed_release(shared_dir)
|
|
492
|
+
if release is not None:
|
|
493
|
+
if release != __version__:
|
|
494
|
+
error(
|
|
495
|
+
f"{shared_dir} runs release {release}; this CLI is {__version__}.\n"
|
|
496
|
+
" To move it to this release, run: faden upgrade"
|
|
497
|
+
)
|
|
498
|
+
sys.exit(1)
|
|
499
|
+
if build or no_cache:
|
|
500
|
+
error(
|
|
501
|
+
"--build needs a source checkout; this install runs the published images.\n"
|
|
502
|
+
" Deploy without --build, or deploy from a checkout of fadenstack."
|
|
503
|
+
)
|
|
504
|
+
sys.exit(1)
|
|
505
|
+
unpack(shared_dir) # puts back anything deleted since
|
|
506
|
+
|
|
507
|
+
compose_file = _resolve_compose_file(shared_dir)
|
|
508
|
+
if not compose_file:
|
|
509
|
+
error(f"No docker-compose.yaml found in {shared_dir}.")
|
|
510
|
+
sys.exit(1)
|
|
511
|
+
|
|
512
|
+
info(f"Install directory: {shared_dir}")
|
|
513
|
+
|
|
514
|
+
# ── 1. Pre-flight checks ─────────────────────────────────────
|
|
515
|
+
if not skip_doctor:
|
|
516
|
+
console.print("\n[bold cyan]Step 1: Pre-flight checks…[/bold cyan]")
|
|
517
|
+
|
|
518
|
+
docker = detect_docker()
|
|
519
|
+
preflight_errors: list[str] = []
|
|
520
|
+
|
|
521
|
+
if not docker.installed:
|
|
522
|
+
msg = docker.error or "Docker Engine 24+ is required."
|
|
523
|
+
preflight_errors.append(
|
|
524
|
+
f"Docker is not available. {msg}\n"
|
|
525
|
+
f" Install: {docker.install_hint}"
|
|
526
|
+
)
|
|
527
|
+
if docker.installed and not docker.compose_installed:
|
|
528
|
+
preflight_errors.append(
|
|
529
|
+
"Docker Compose V2 is not installed.\n"
|
|
530
|
+
f" Install: {docker.install_hint}"
|
|
531
|
+
)
|
|
532
|
+
if docker.installed and not docker.daemon_running:
|
|
533
|
+
hint = docker.error or docker.daemon_hint
|
|
534
|
+
preflight_errors.append(f"Docker daemon is not running. {hint}")
|
|
535
|
+
|
|
536
|
+
if preflight_errors:
|
|
537
|
+
for msg in preflight_errors:
|
|
538
|
+
error(msg)
|
|
539
|
+
sys.exit(1)
|
|
540
|
+
|
|
541
|
+
success(
|
|
542
|
+
f"Docker {docker.version}, "
|
|
543
|
+
f"Compose {docker.compose_version}, "
|
|
544
|
+
f"daemon running"
|
|
545
|
+
)
|
|
546
|
+
else:
|
|
547
|
+
console.print("\n[dim]Skipping pre-flight checks (--skip-doctor).[/dim]")
|
|
548
|
+
|
|
549
|
+
# ── 2. Parse requested modules ────────────────────────────────
|
|
550
|
+
console.print("\n[bold cyan]Step 2: Module configuration…[/bold cyan]")
|
|
551
|
+
|
|
552
|
+
known_profiles = {m["profile"] for m in KNOWN_MODULES.values()}
|
|
553
|
+
profiles: set[str] = {p for p in (cfg.profiles or []) if p in known_profiles}
|
|
554
|
+
|
|
555
|
+
if modules:
|
|
556
|
+
for mod in modules.split(","):
|
|
557
|
+
mod = mod.strip().lower()
|
|
558
|
+
if not mod:
|
|
559
|
+
continue
|
|
560
|
+
if mod not in KNOWN_MODULES:
|
|
561
|
+
warning(f"Unknown module: {mod}. Available: {', '.join(sorted(KNOWN_MODULES))}")
|
|
562
|
+
continue
|
|
563
|
+
profiles.add(KNOWN_MODULES[mod]["profile"])
|
|
564
|
+
info(f"Enabled module: {mod}")
|
|
565
|
+
|
|
566
|
+
if profiles:
|
|
567
|
+
console.print(f" Active profiles: [bold]{', '.join(sorted(profiles))}[/bold]")
|
|
568
|
+
else:
|
|
569
|
+
console.print(" No optional modules enabled.")
|
|
570
|
+
|
|
571
|
+
# ── 3. Generate .env file ─────────────────────────────────────
|
|
572
|
+
console.print("\n[bold cyan]Step 3: Environment configuration…[/bold cyan]")
|
|
573
|
+
|
|
574
|
+
env_path = shared_dir / ".env"
|
|
575
|
+
if env_path.exists() and not force_env:
|
|
576
|
+
info(f".env already exists at {env_path} (use --force-env to regenerate)")
|
|
577
|
+
# What the compose file requires and an older .env lacks; stored data keeps its key.
|
|
578
|
+
from faden_cli.core.env_gen import fill_required_env # noqa: PLC0415
|
|
579
|
+
|
|
580
|
+
for note in fill_required_env(env_path, profiles=sorted(profiles)):
|
|
581
|
+
warning(note)
|
|
582
|
+
else:
|
|
583
|
+
env_vars = default_env_vars(profiles=sorted(profiles))
|
|
584
|
+
write_env_file(env_path, env_vars, preserve_secrets=not force_env)
|
|
585
|
+
success(f"Environment file written to {env_path}")
|
|
586
|
+
# The backend creates the first administrator only for whoever sends this; an .env from before it
|
|
587
|
+
# existed gets one, so a deploy that stopped before the administrator was made can finish.
|
|
588
|
+
if ensure_bootstrap_token(env_path):
|
|
589
|
+
info("Bootstrap token added to .env.")
|
|
590
|
+
|
|
591
|
+
# Ensure host HuggingFace cache directory exists and is configured
|
|
592
|
+
existing = read_env_file(env_path)
|
|
593
|
+
if "HF_CACHE_DIR" not in existing:
|
|
594
|
+
hf_default = Path.home() / ".cache" / "huggingface" / "hub"
|
|
595
|
+
hf_default.mkdir(parents=True, exist_ok=True)
|
|
596
|
+
# Docker Desktop on Windows needs POSIX-style paths
|
|
597
|
+
# (e.g. /c/Users/…) because ':' is used as the volume
|
|
598
|
+
# mount delimiter in compose files.
|
|
599
|
+
hf_str = str(hf_default)
|
|
600
|
+
if sys.platform == "win32":
|
|
601
|
+
hf_str = hf_default.as_posix()
|
|
602
|
+
# C:/Users/… → /c/Users/…
|
|
603
|
+
if len(hf_str) >= 2 and hf_str[1] == ":":
|
|
604
|
+
hf_str = "/" + hf_str[0].lower() + hf_str[2:]
|
|
605
|
+
with env_path.open("a", encoding="utf-8") as f:
|
|
606
|
+
f.write("\n# HuggingFace cache — mount into backend\n")
|
|
607
|
+
f.write(f"HF_CACHE_DIR={hf_str}\n")
|
|
608
|
+
info(f"HF cache directory: {hf_default}")
|
|
609
|
+
# Ensure empty fallback directory exists for compose
|
|
610
|
+
fallback_dir = shared_dir / ".empty-hf-cache"
|
|
611
|
+
fallback_dir.mkdir(exist_ok=True)
|
|
612
|
+
|
|
613
|
+
if release is not None:
|
|
614
|
+
pin_release(env_path)
|
|
615
|
+
info(f"Images: release {__version__}")
|
|
616
|
+
else:
|
|
617
|
+
record_installer(env_path, shared_dir)
|
|
618
|
+
|
|
619
|
+
# ── 3a. Size the services for this host ───────────────────────
|
|
620
|
+
# On every deploy, not only with a new .env: an install deployed before
|
|
621
|
+
# a fix to the sizing gets it too. Profile and CPU share are what
|
|
622
|
+
# ``faden tune`` last chose.
|
|
623
|
+
console.print("\n[bold cyan]Step 3a: Sizing the services for this host…[/bold cyan]")
|
|
624
|
+
tune_env(env_path)
|
|
625
|
+
|
|
626
|
+
# ── 3b. Migrate old single-credential RabbitMQ env vars ───────
|
|
627
|
+
existing = read_env_file(env_path)
|
|
628
|
+
if "RABBITMQ_BACKEND_PASS" not in existing:
|
|
629
|
+
from faden_cli.core.env_gen import _random_password # noqa: PLC0415
|
|
630
|
+
|
|
631
|
+
info("Migrating RabbitMQ credentials to per-service format…")
|
|
632
|
+
existing["RABBITMQ_ADMIN_USER"] = existing.pop("RABBITMQ_USER", "admin")
|
|
633
|
+
existing["RABBITMQ_ADMIN_PASS"] = existing.pop("RABBITMQ_PASS", _random_password(24))
|
|
634
|
+
existing["RABBITMQ_BACKEND_PASS"] = _random_password(24)
|
|
635
|
+
existing["RABBITMQ_API_PASS"] = _random_password(24)
|
|
636
|
+
existing["RABBITMQ_PII_PASS"] = _random_password(24)
|
|
637
|
+
write_env_file(env_path, existing)
|
|
638
|
+
success("RabbitMQ credentials migrated to per-service format.")
|
|
639
|
+
|
|
640
|
+
# ── 3c. Generate RabbitMQ definitions.json ─────────────────────
|
|
641
|
+
existing = read_env_file(env_path)
|
|
642
|
+
_regenerate_rmq_definitions(shared_dir, existing, profiles)
|
|
643
|
+
success("RabbitMQ definitions.json generated.")
|
|
644
|
+
|
|
645
|
+
# .env, the saved credentials and the backups, readable by this user only -- an install made
|
|
646
|
+
# before kept them readable by every local user.
|
|
647
|
+
secure_install(shared_dir)
|
|
648
|
+
|
|
649
|
+
# ── 3d. Ports and HTTPS ───────────────────────────────────────
|
|
650
|
+
# Before anything starts: nginx picks plain HTTP or HTTPS when it starts.
|
|
651
|
+
console.print("\n[bold cyan]Step 3d: Ports and HTTPS…[/bold cyan]")
|
|
652
|
+
from faden_cli.commands.tls import deploy_step as edge_step # noqa: PLC0415
|
|
653
|
+
|
|
654
|
+
ca_to_trust = edge_step(
|
|
655
|
+
shared_dir,
|
|
656
|
+
env_path,
|
|
657
|
+
yes=yes,
|
|
658
|
+
http_port=http_port,
|
|
659
|
+
https_port=https_port,
|
|
660
|
+
mode=tls_mode,
|
|
661
|
+
cert=tls_cert,
|
|
662
|
+
key=tls_key,
|
|
663
|
+
chain=tls_chain,
|
|
664
|
+
names=tls_names,
|
|
665
|
+
ca=tls_ca,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
# ── 3e. New releases ──────────────────────────────────────────
|
|
669
|
+
update_check_step(env_path, cfg, choice=update_check, yes=yes)
|
|
670
|
+
|
|
671
|
+
# ── 4. Save config ────────────────────────────────────────────
|
|
672
|
+
cfg.install_dir = str(shared_dir)
|
|
673
|
+
cfg.compose_file = compose_file.name
|
|
674
|
+
cfg.profiles = sorted(profiles)
|
|
675
|
+
save_config(cfg)
|
|
676
|
+
|
|
677
|
+
# ── 5. Build images ───────────────────────────────────────────
|
|
678
|
+
from faden_cli.core.compose import has_nvidia_gpu # noqa: PLC0415
|
|
679
|
+
|
|
680
|
+
use_gpu = has_nvidia_gpu() if gpu is None else gpu
|
|
681
|
+
compose_files = [compose_file]
|
|
682
|
+
gpu_overlay = shared_dir / "docker-compose.gpu.yaml"
|
|
683
|
+
if use_gpu and gpu_overlay.exists():
|
|
684
|
+
compose_files.append(gpu_overlay)
|
|
685
|
+
info("NVIDIA GPU detected — including GPU overlay.")
|
|
686
|
+
elif gpu_overlay.exists():
|
|
687
|
+
info("GPU overlay skipped (no NVIDIA runtime detected or --no-gpu).")
|
|
688
|
+
|
|
689
|
+
ctx = ComposeContext(
|
|
690
|
+
compose_files=compose_files,
|
|
691
|
+
env_file=env_path if env_path.exists() else None,
|
|
692
|
+
project_dir=shared_dir,
|
|
693
|
+
profiles=sorted(profiles),
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
if no_cache:
|
|
697
|
+
build = True
|
|
698
|
+
|
|
699
|
+
if build:
|
|
700
|
+
console.print("\n[bold cyan]Step 4: Building container images…[/bold cyan]")
|
|
701
|
+
console.print("[dim]This may take several minutes on first run.[/dim]")
|
|
702
|
+
|
|
703
|
+
pull_base_images()
|
|
704
|
+
|
|
705
|
+
# Build the shared platform base image first (same BuildKit context)
|
|
706
|
+
info("Building platform base image…")
|
|
707
|
+
rc = build_base_image(ctx, no_cache=no_cache)
|
|
708
|
+
if rc != 0:
|
|
709
|
+
warning("Base image build failed — service builds may fail.")
|
|
710
|
+
else:
|
|
711
|
+
success("Base image built.")
|
|
712
|
+
|
|
713
|
+
rc = compose_build(ctx, no_cache=no_cache)
|
|
714
|
+
installation.record_build(shared_dir, complete=rc == 0)
|
|
715
|
+
if rc != 0:
|
|
716
|
+
warning(
|
|
717
|
+
"Some images failed to build. Services with successful"
|
|
718
|
+
" builds will still start."
|
|
719
|
+
)
|
|
720
|
+
else:
|
|
721
|
+
success("All images built successfully.")
|
|
722
|
+
else:
|
|
723
|
+
console.print("\n[bold cyan]Step 4: Pulling pre-built images…[/bold cyan]")
|
|
724
|
+
from faden_cli.core.compose import pull as compose_pull # noqa: PLC0415
|
|
725
|
+
|
|
726
|
+
rc = compose_pull(ctx)
|
|
727
|
+
if rc != 0:
|
|
728
|
+
warning("Some images failed to pull. Will try to start with available images.")
|
|
729
|
+
else:
|
|
730
|
+
success("All images pulled successfully.")
|
|
731
|
+
|
|
732
|
+
# ── 6. Start services ─────────────────────────────────────────
|
|
733
|
+
console.print("\n[bold cyan]Step 5: Starting services…[/bold cyan]")
|
|
734
|
+
|
|
735
|
+
# Start Postgres first and sync its password.
|
|
736
|
+
# The POSTGRES_PASSWORD env var only takes effect on first volume
|
|
737
|
+
# initialisation. If the data volume already exists with a
|
|
738
|
+
# different password, migrators will fail with InvalidPasswordError.
|
|
739
|
+
# We boot Postgres alone, ALTER USER to match .env, then start
|
|
740
|
+
# everything else.
|
|
741
|
+
_sync_postgres_password(ctx, env_path)
|
|
742
|
+
retire_fixed_logins(env_path)
|
|
743
|
+
|
|
744
|
+
# Start infrastructure services and wait for them to become healthy
|
|
745
|
+
# BEFORE starting application containers. This avoids a race
|
|
746
|
+
# condition where workers start before Docker's embedded DNS has
|
|
747
|
+
# registered the RMQ container, causing permanent AMQP connection
|
|
748
|
+
# failures.
|
|
749
|
+
infra_services = ["redis", "faden-rmq", "minio"]
|
|
750
|
+
info("Starting infrastructure services (Redis, RabbitMQ, MinIO)…")
|
|
751
|
+
rc = compose_up(ctx, services=infra_services, detach=True, wait=True, timeout=60)
|
|
752
|
+
if rc != 0:
|
|
753
|
+
error(f"Infrastructure services failed to start (exit code {rc}).")
|
|
754
|
+
sys.exit(rc)
|
|
755
|
+
success("Infrastructure services healthy.")
|
|
756
|
+
|
|
757
|
+
rc = compose_up(ctx, detach=True, build=False, pull="missing", wait=True, timeout=180)
|
|
758
|
+
if rc != 0:
|
|
759
|
+
error(f"docker compose up failed (exit code {rc}).")
|
|
760
|
+
sys.exit(rc)
|
|
761
|
+
success("All services started.")
|
|
762
|
+
|
|
763
|
+
# ── 6. Initial admin setup ────────────────────────────────────
|
|
764
|
+
console.print("\n[bold cyan]Step 6: Initial admin setup…[/bold cyan]")
|
|
765
|
+
|
|
766
|
+
from faden_cli.core.bootstrap import bootstrap_interactive, wait_for_backend # noqa: PLC0415
|
|
767
|
+
from faden_cli.core.edge import console_url # noqa: PLC0415
|
|
768
|
+
|
|
769
|
+
# HTTPS as soon as the install holds a certificate: plain HTTP only redirects then.
|
|
770
|
+
backend_url = console_url(shared_dir, read_env_file(env_path) if env_path.exists() else {})
|
|
771
|
+
console.print(" [dim]Waiting for backend to become healthy…[/dim]")
|
|
772
|
+
creds = None
|
|
773
|
+
if wait_for_backend(backend_url):
|
|
774
|
+
creds = bootstrap_interactive(
|
|
775
|
+
backend_url,
|
|
776
|
+
shared_dir,
|
|
777
|
+
auto_confirm=yes,
|
|
778
|
+
)
|
|
779
|
+
if creds:
|
|
780
|
+
cfg.admin_email = creds["email"]
|
|
781
|
+
save_config(cfg)
|
|
782
|
+
|
|
783
|
+
# ── Sync credentials to Grafana, Langfuse & RabbitMQ ──
|
|
784
|
+
from faden_cli.core.compose import up as compose_up_svc # noqa: PLC0415
|
|
785
|
+
|
|
786
|
+
env_vars = read_env_file(env_path)
|
|
787
|
+
env_vars["GRAFANA_ADMIN_USER"] = creds["email"]
|
|
788
|
+
env_vars["GRAFANA_ADMIN_PASSWORD"] = creds["password"]
|
|
789
|
+
# Only the management/admin password is synced to the admin
|
|
790
|
+
# credential. Per-service AMQP passwords (RABBITMQ_BACKEND_PASS,
|
|
791
|
+
# etc.) are never overwritten — they are stable random secrets
|
|
792
|
+
# set during env generation.
|
|
793
|
+
env_vars["RABBITMQ_ADMIN_PASS"] = creds["password"]
|
|
794
|
+
env_vars["LANGFUSE_INIT_USER_EMAIL"] = creds["email"]
|
|
795
|
+
env_vars["LANGFUSE_INIT_USER_PASSWORD"] = creds["password"]
|
|
796
|
+
env_vars["LANGFUSE_INIT_USER_NAME"] = "Admin"
|
|
797
|
+
env_vars["LANGFUSE_INIT_PROJECT_NAME"] = "fadenstack"
|
|
798
|
+
# Langfuse's first project gets a secret of its own. It was the setup token's first 40
|
|
799
|
+
# characters -- the JWT header, the same on every install.
|
|
800
|
+
if not env_vars.get("LANGFUSE_INIT_PROJECT_SECRET_KEY"):
|
|
801
|
+
env_vars["LANGFUSE_INIT_PROJECT_SECRET_KEY"] = f"sk-lf-{secrets.token_hex(20)}"
|
|
802
|
+
env_vars["LANGFUSE_INIT_PROJECT_PUBLIC_KEY"] = "pk-lf-faden"
|
|
803
|
+
|
|
804
|
+
write_env_file(env_path, env_vars)
|
|
805
|
+
info("Synced admin credentials to Grafana, Langfuse & RabbitMQ.")
|
|
806
|
+
|
|
807
|
+
# Regenerate definitions.json with the new admin password.
|
|
808
|
+
_regenerate_rmq_definitions(shared_dir, env_vars, profiles)
|
|
809
|
+
|
|
810
|
+
# Force-recreate so containers pick up the new env vars.
|
|
811
|
+
# RabbitMQ loads definitions.json on startup which now
|
|
812
|
+
# includes the admin password. Service workers are NOT
|
|
813
|
+
# recreated because their per-service passwords have not
|
|
814
|
+
# changed.
|
|
815
|
+
recreate_services = ["grafana", "langfuse-web", "faden-rmq"]
|
|
816
|
+
compose_up_svc(
|
|
817
|
+
ctx,
|
|
818
|
+
services=recreate_services,
|
|
819
|
+
detach=True,
|
|
820
|
+
force_recreate=True,
|
|
821
|
+
wait=True,
|
|
822
|
+
timeout=60,
|
|
823
|
+
)
|
|
824
|
+
success("Grafana, Langfuse & RabbitMQ recreated with shared credentials.")
|
|
825
|
+
else:
|
|
826
|
+
warning("Backend did not become healthy in time — skipping admin setup.")
|
|
827
|
+
console.print(
|
|
828
|
+
" [dim]Check 'faden status' and 'faden logs faden-backend', fix what they show, then run"
|
|
829
|
+
" 'faden deploy' again: it keeps the .env and creates the administrator.[/dim]"
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
# ── 7. Runtime images ─────────────────────────────────────────
|
|
833
|
+
# Machines download the container they run models in from this server,
|
|
834
|
+
# so it has to be here before the first machine starts a cluster.
|
|
835
|
+
console.print("\n[bold cyan]Step 7: Runtime images for machines…[/bold cyan]")
|
|
836
|
+
from faden_cli.commands.runtime_images import run_step # noqa: PLC0415
|
|
837
|
+
|
|
838
|
+
run_step(cfg, value=runtime_images)
|
|
839
|
+
|
|
840
|
+
# ── 8. Optional local-node provisioning ───────────────────────
|
|
841
|
+
if local_node:
|
|
842
|
+
console.print("\n[bold cyan]Step 8: Local node-agent provisioning…[/bold cyan]")
|
|
843
|
+
from faden_cli.core.local_node import ( # noqa: PLC0415
|
|
844
|
+
create_enrollment_token,
|
|
845
|
+
provision_local_node_agent,
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
# Auto-create enrollment token if none was provided and
|
|
849
|
+
# bootstrap gave us an API token.
|
|
850
|
+
enrollment_token = local_node_enrollment_token
|
|
851
|
+
if not enrollment_token.strip() and creds and creds.get("api_token"):
|
|
852
|
+
info("No enrollment token provided — creating one automatically…")
|
|
853
|
+
enrollment_token = create_enrollment_token(backend_url, creds["api_token"]) or ""
|
|
854
|
+
if not enrollment_token.strip():
|
|
855
|
+
# Bootstrap already happened — try logging in to get a token.
|
|
856
|
+
info("No API token from bootstrap — attempting login…")
|
|
857
|
+
api_token = _login_for_api_token(backend_url, cfg.admin_email)
|
|
858
|
+
if api_token:
|
|
859
|
+
enrollment_token = create_enrollment_token(backend_url, api_token) or ""
|
|
860
|
+
if not enrollment_token.strip():
|
|
861
|
+
warning(
|
|
862
|
+
"No enrollment token available. Provide one with"
|
|
863
|
+
" --local-node-enrollment-token or re-run after bootstrap."
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
branch = local_node_branch.strip() or (cfg.dev.branch if cfg.dev and cfg.dev.branch else "main")
|
|
867
|
+
method = cfg.dev.clone_method if cfg.dev and cfg.dev.clone_method else "https"
|
|
868
|
+
github_token = cfg.dev.github_token if cfg.dev else ""
|
|
869
|
+
remote_host = local_node_host.strip() or None
|
|
870
|
+
workspace_for_node = shared_dir.parent
|
|
871
|
+
|
|
872
|
+
# Default to the resolved backend URL (via nginx) if not explicitly set.
|
|
873
|
+
node_backend = local_node_backend_url.strip() or backend_url
|
|
874
|
+
|
|
875
|
+
ok = provision_local_node_agent(
|
|
876
|
+
workspace=workspace_for_node,
|
|
877
|
+
branch=branch,
|
|
878
|
+
backend_url=node_backend,
|
|
879
|
+
advertise_host=local_node_advertise_host,
|
|
880
|
+
enrollment_token=enrollment_token,
|
|
881
|
+
remote_host=remote_host,
|
|
882
|
+
use_sudo=local_node_sudo,
|
|
883
|
+
method=method,
|
|
884
|
+
github_token=github_token,
|
|
885
|
+
workdir_override=local_node_workdir.strip() or None,
|
|
886
|
+
)
|
|
887
|
+
if not ok:
|
|
888
|
+
error("Local-node provisioning failed.")
|
|
889
|
+
sys.exit(1)
|
|
890
|
+
|
|
891
|
+
# ── 7b. Nightly backups ───────────────────────────────────────
|
|
892
|
+
from faden_cli.core import schedule # noqa: PLC0415
|
|
893
|
+
|
|
894
|
+
if schedule.available() and schedule.current() is None:
|
|
895
|
+
console.print()
|
|
896
|
+
if yes or click.confirm(" Back up every night at 03:00, keeping the last 7?", default=True):
|
|
897
|
+
backups = private_dir(shared_dir / "backups")
|
|
898
|
+
try:
|
|
899
|
+
how, notes = schedule.install(hour=3, minute=0, retain=7, log=backups / "backup.log")
|
|
900
|
+
except (RuntimeError, OSError) as exc:
|
|
901
|
+
warning(f"Could not schedule nightly backups: {exc}")
|
|
902
|
+
else:
|
|
903
|
+
success(
|
|
904
|
+
f"Nightly backups into {backups} ({how}; change: faden backup schedule --at HH:MM,"
|
|
905
|
+
" stop: --off)."
|
|
906
|
+
)
|
|
907
|
+
for note in notes:
|
|
908
|
+
warning(note)
|
|
909
|
+
|
|
910
|
+
# ── 8. Endpoint summary ───────────────────────────────────────
|
|
911
|
+
console.print("\n[bold green]✨ Deployment complete![/bold green]\n")
|
|
912
|
+
|
|
913
|
+
from rich.table import Table
|
|
914
|
+
|
|
915
|
+
table = Table(title="Service Endpoints", show_header=True, header_style="bold cyan")
|
|
916
|
+
table.add_column("Service", style="bold")
|
|
917
|
+
table.add_column("URL")
|
|
918
|
+
|
|
919
|
+
base = backend_url
|
|
920
|
+
endpoints = [
|
|
921
|
+
("Application", base),
|
|
922
|
+
("API Docs", f"{base}/api/docs"),
|
|
923
|
+
("OpenAI-compat Gateway", f"{base}/v1/"),
|
|
924
|
+
("Grafana", "http://localhost:3001 (local only)"),
|
|
925
|
+
("Langfuse", "http://localhost:3002 (local only)"),
|
|
926
|
+
("RabbitMQ Management", "http://localhost:15672 (local only)"),
|
|
927
|
+
]
|
|
928
|
+
|
|
929
|
+
for name, url in endpoints:
|
|
930
|
+
table.add_row(name, url)
|
|
931
|
+
|
|
932
|
+
console.print(table)
|
|
933
|
+
|
|
934
|
+
from faden_cli.commands.tls import print_addresses, print_trust_hints # noqa: PLC0415
|
|
935
|
+
|
|
936
|
+
print_addresses(shared_dir)
|
|
937
|
+
if ca_to_trust is not None:
|
|
938
|
+
print_trust_hints(shared_dir, ca_to_trust)
|
|
939
|
+
|
|
940
|
+
console.print(
|
|
941
|
+
"\n[dim]Useful commands:\n"
|
|
942
|
+
" faden status — container status\n"
|
|
943
|
+
" faden logs -f — follow logs\n"
|
|
944
|
+
" faden module list — show enabled modules\n"
|
|
945
|
+
" faden down — stop all services\n"
|
|
946
|
+
" faden tls status — HTTPS and its certificate\n"
|
|
947
|
+
" faden ports — the HTTP and HTTPS ports\n"
|
|
948
|
+
" faden deploy --help — deployment options[/dim]"
|
|
949
|
+
)
|