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,291 @@
|
|
|
1
|
+
"""First-admin bootstrap — calls ``POST /api/bootstrap``.
|
|
2
|
+
|
|
3
|
+
Used by ``faden deploy`` and ``faden dev up --local-node`` to create the
|
|
4
|
+
initial admin user after services are up. The raw credentials are
|
|
5
|
+
displayed once and optionally saved to a local file.
|
|
6
|
+
|
|
7
|
+
The backend creates the first administrator only for a caller with the
|
|
8
|
+
install's bootstrap token (``FADEN_BACKEND_BOOTSTRAP_TOKEN`` in ``.env``), which
|
|
9
|
+
is sent in the ``X-Faden-Bootstrap-Token`` header.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from faden_cli.core.console import console, error, success, warning
|
|
21
|
+
from faden_cli.core.edge import verify_for
|
|
22
|
+
from faden_cli.core.env_gen import BOOTSTRAP_TOKEN_KEY, read_env_file
|
|
23
|
+
from faden_cli.core.private_files import write_private
|
|
24
|
+
|
|
25
|
+
#: The header the backend reads the install's bootstrap token from.
|
|
26
|
+
BOOTSTRAP_TOKEN_HEADER = "X-Faden-Bootstrap-Token" # noqa: S105 - a header name
|
|
27
|
+
|
|
28
|
+
#: Fewest characters the backend takes for a new password (faden_backend/services/password_policy.py).
|
|
29
|
+
MIN_PASSWORD_LENGTH = 8
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def wait_for_backend(base_url: str, *, timeout: int = 120) -> bool:
|
|
33
|
+
"""Poll the backend health endpoint until it responds 200."""
|
|
34
|
+
deadline = time.monotonic() + timeout
|
|
35
|
+
health_url = f"{base_url}/api/health"
|
|
36
|
+
while time.monotonic() < deadline:
|
|
37
|
+
try:
|
|
38
|
+
resp = httpx.get(health_url, timeout=5, verify=verify_for(base_url))
|
|
39
|
+
if resp.status_code == 200: # noqa: PLR2004
|
|
40
|
+
return True
|
|
41
|
+
except httpx.HTTPError:
|
|
42
|
+
pass
|
|
43
|
+
time.sleep(2)
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_needs_bootstrap(base_url: str) -> bool | None:
|
|
48
|
+
"""Return True if the system needs bootstrap, False if already done, None on error."""
|
|
49
|
+
try:
|
|
50
|
+
resp = httpx.get(f"{base_url}/api/bootstrap/status", timeout=10, verify=verify_for(base_url))
|
|
51
|
+
if resp.status_code == 200: # noqa: PLR2004
|
|
52
|
+
return resp.json().get("needs_bootstrap", False)
|
|
53
|
+
except httpx.HTTPError:
|
|
54
|
+
pass
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_bootstrap_token(install_dir: Path) -> str | None:
|
|
59
|
+
"""The bootstrap token in *install_dir*'s ``.env``, or ``None`` when it has none."""
|
|
60
|
+
return read_env_file(Path(install_dir) / ".env").get(BOOTSTRAP_TOKEN_KEY) or None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_bootstrap(
|
|
64
|
+
base_url: str,
|
|
65
|
+
*,
|
|
66
|
+
email: str = "admin@localhost",
|
|
67
|
+
password: str | None = None,
|
|
68
|
+
generate_api_token: bool = True,
|
|
69
|
+
tenant_id: str = "default",
|
|
70
|
+
token: str | None = None,
|
|
71
|
+
) -> dict | None:
|
|
72
|
+
"""Call the bootstrap endpoint and return the credentials dict, or None on failure.
|
|
73
|
+
|
|
74
|
+
*token* is the install's bootstrap token; the backend refuses without it.
|
|
75
|
+
"""
|
|
76
|
+
payload: dict = {
|
|
77
|
+
"email": email,
|
|
78
|
+
"generate_api_token": generate_api_token,
|
|
79
|
+
"tenant_id": tenant_id,
|
|
80
|
+
}
|
|
81
|
+
if password:
|
|
82
|
+
payload["password"] = password
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
resp = httpx.post(
|
|
86
|
+
f"{base_url}/api/bootstrap",
|
|
87
|
+
json=payload,
|
|
88
|
+
headers={BOOTSTRAP_TOKEN_HEADER: token} if token else None,
|
|
89
|
+
timeout=30,
|
|
90
|
+
verify=verify_for(base_url),
|
|
91
|
+
)
|
|
92
|
+
except httpx.HTTPError as exc:
|
|
93
|
+
error(f"Could not reach backend: {exc}")
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if resp.status_code == 201: # noqa: PLR2004
|
|
97
|
+
return resp.json()
|
|
98
|
+
|
|
99
|
+
if resp.status_code == 409: # noqa: PLR2004
|
|
100
|
+
warning("System is already bootstrapped — admin users exist.")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
if resp.status_code == 403: # noqa: PLR2004
|
|
104
|
+
try:
|
|
105
|
+
detail = resp.json().get("detail") or resp.text
|
|
106
|
+
except ValueError:
|
|
107
|
+
detail = resp.text
|
|
108
|
+
error(
|
|
109
|
+
f"The backend refused to create the administrator: {detail}\n"
|
|
110
|
+
f" The token is {BOOTSTRAP_TOKEN_KEY} in the install's .env (faden deploy adds one when it is"
|
|
111
|
+
" missing), and the backend reads it when it starts: run `faden up`, then `faden deploy` again."
|
|
112
|
+
)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
if resp.status_code == 422: # noqa: PLR2004
|
|
116
|
+
try:
|
|
117
|
+
detail = resp.json().get("detail") or resp.text
|
|
118
|
+
except ValueError:
|
|
119
|
+
detail = resp.text
|
|
120
|
+
error(f"The backend refused the administrator's password or e-mail: {detail}")
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
error(f"Bootstrap failed (HTTP {resp.status_code}): {resp.text}")
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def prompt_admin_password() -> str | None:
|
|
128
|
+
"""Ask for the administrator's password; ``None`` (blank) has the backend generate one.
|
|
129
|
+
|
|
130
|
+
Asked again until it meets the backend's minimum length: refused there, the deploy
|
|
131
|
+
stopped at the very end, with every service already running.
|
|
132
|
+
"""
|
|
133
|
+
import click # noqa: PLC0415
|
|
134
|
+
|
|
135
|
+
while True:
|
|
136
|
+
value = click.prompt(
|
|
137
|
+
" Admin password (leave blank to auto-generate)",
|
|
138
|
+
default="",
|
|
139
|
+
hide_input=True,
|
|
140
|
+
show_default=False,
|
|
141
|
+
)
|
|
142
|
+
if not value:
|
|
143
|
+
return None
|
|
144
|
+
if len(value) >= MIN_PASSWORD_LENGTH:
|
|
145
|
+
return value
|
|
146
|
+
warning(f"The password needs at least {MIN_PASSWORD_LENGTH} characters (or leave it blank).")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def display_credentials(creds: dict) -> None:
|
|
150
|
+
"""Print credentials in a prominent panel with a save warning."""
|
|
151
|
+
from rich.panel import Panel # noqa: PLC0415
|
|
152
|
+
from rich.text import Text # noqa: PLC0415
|
|
153
|
+
|
|
154
|
+
lines = Text()
|
|
155
|
+
lines.append(" Admin email: ", style="bold")
|
|
156
|
+
lines.append(creds["email"], style="cyan")
|
|
157
|
+
lines.append("\n Admin password: ", style="bold")
|
|
158
|
+
lines.append(creds["password"], style="cyan")
|
|
159
|
+
lines.append("\n")
|
|
160
|
+
lines.append(
|
|
161
|
+
"\n Store these in a password manager or secure vault.\n"
|
|
162
|
+
" The password is hashed and cannot be retrieved from the system after this point.\n"
|
|
163
|
+
" Make API tokens in your profile in the console.",
|
|
164
|
+
style="dim",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
console.print()
|
|
168
|
+
console.print(
|
|
169
|
+
Panel(
|
|
170
|
+
lines,
|
|
171
|
+
title="[bold yellow]⚠ SAVE THESE CREDENTIALS — THEY CANNOT BE RECOVERED ⚠[/bold yellow]",
|
|
172
|
+
border_style="yellow",
|
|
173
|
+
padding=(1, 2),
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def sign_in_with_saved_credentials(backend_url: str, directory: Path) -> str | None:
|
|
179
|
+
"""A sign-in for the administrator saved in *directory*'s ``.bootstrap-credentials``, or ``None``."""
|
|
180
|
+
creds_path = directory / ".bootstrap-credentials"
|
|
181
|
+
try:
|
|
182
|
+
saved = dict(
|
|
183
|
+
line.split("=", 1) for line in creds_path.read_text(encoding="utf-8").splitlines()
|
|
184
|
+
if "=" in line and not line.startswith("#")
|
|
185
|
+
)
|
|
186
|
+
except OSError:
|
|
187
|
+
return None
|
|
188
|
+
email, password = saved.get("ADMIN_EMAIL", "").strip(), saved.get("ADMIN_PASSWORD", "").strip()
|
|
189
|
+
if not email or not password:
|
|
190
|
+
return None
|
|
191
|
+
try:
|
|
192
|
+
resp = httpx.post(
|
|
193
|
+
f"{backend_url.rstrip('/')}/api/auth/jwt/login",
|
|
194
|
+
data={"username": email, "password": password},
|
|
195
|
+
timeout=15,
|
|
196
|
+
verify=verify_for(backend_url),
|
|
197
|
+
)
|
|
198
|
+
except httpx.HTTPError as exc:
|
|
199
|
+
warning(f"Could not sign in with the saved credentials: {exc}")
|
|
200
|
+
return None
|
|
201
|
+
if resp.status_code != 200: # noqa: PLR2004
|
|
202
|
+
warning(f"Signing in with the saved credentials failed (HTTP {resp.status_code}).")
|
|
203
|
+
return None
|
|
204
|
+
return resp.json().get("access_token") or None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def save_credentials_file(creds: dict, directory: Path) -> Path | None:
|
|
208
|
+
"""Write credentials to a restricted file (chmod 600). Returns the path."""
|
|
209
|
+
creds_path = directory / ".bootstrap-credentials"
|
|
210
|
+
# Not the setup token: it lasts an hour, the time it takes to finish setting up.
|
|
211
|
+
content_lines = [
|
|
212
|
+
f"# Fadenstack bootstrap credentials — generated {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
213
|
+
f"ADMIN_EMAIL={creds['email']}",
|
|
214
|
+
f"ADMIN_PASSWORD={creds['password']}",
|
|
215
|
+
]
|
|
216
|
+
content_lines.append(
|
|
217
|
+
"\n# DELETE THIS FILE after storing the credentials in a secure vault."
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
# Readable by its owner only from the start: it was written with the default mode, then narrowed.
|
|
222
|
+
write_private(creds_path, ("\n".join(content_lines) + "\n").encode("utf-8"))
|
|
223
|
+
return creds_path
|
|
224
|
+
except OSError as exc:
|
|
225
|
+
error(f"Could not save credentials file: {exc}")
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def bootstrap_interactive(
|
|
230
|
+
base_url: str,
|
|
231
|
+
install_dir: Path,
|
|
232
|
+
*,
|
|
233
|
+
auto_confirm: bool = False,
|
|
234
|
+
default_email: str = "admin@localhost",
|
|
235
|
+
default_password: str | None = None,
|
|
236
|
+
) -> dict | None:
|
|
237
|
+
"""Full interactive bootstrap flow (prompt → call API → display → save).
|
|
238
|
+
|
|
239
|
+
If *auto_confirm* is True, uses defaults and auto-saves without prompting.
|
|
240
|
+
Returns the credentials dict if successful, None otherwise.
|
|
241
|
+
"""
|
|
242
|
+
import click # noqa: PLC0415
|
|
243
|
+
|
|
244
|
+
# ── Check if bootstrap is needed ──────────────────────────
|
|
245
|
+
needs = check_needs_bootstrap(base_url)
|
|
246
|
+
if needs is None:
|
|
247
|
+
warning("Could not determine bootstrap status — skipping admin setup.")
|
|
248
|
+
return None
|
|
249
|
+
if not needs:
|
|
250
|
+
console.print("[dim] Admin user already exists — skipping bootstrap.[/dim]")
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
# ── Gather inputs ─────────────────────────────────────────
|
|
254
|
+
if auto_confirm:
|
|
255
|
+
email = default_email
|
|
256
|
+
password = default_password
|
|
257
|
+
else:
|
|
258
|
+
email = click.prompt(" Admin email", default=default_email)
|
|
259
|
+
password = prompt_admin_password()
|
|
260
|
+
|
|
261
|
+
# ── Call bootstrap API ────────────────────────────────────
|
|
262
|
+
creds = run_bootstrap(
|
|
263
|
+
base_url,
|
|
264
|
+
email=email,
|
|
265
|
+
password=password,
|
|
266
|
+
generate_api_token=True,
|
|
267
|
+
token=read_bootstrap_token(install_dir),
|
|
268
|
+
)
|
|
269
|
+
if not creds:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
success("Admin user created.")
|
|
273
|
+
|
|
274
|
+
# ── Display credentials ───────────────────────────────────
|
|
275
|
+
display_credentials(creds)
|
|
276
|
+
|
|
277
|
+
# ── Save to file ──────────────────────────────────────────
|
|
278
|
+
if auto_confirm:
|
|
279
|
+
save_file = True
|
|
280
|
+
else:
|
|
281
|
+
save_file = click.confirm("\n Save to file?", default=False)
|
|
282
|
+
|
|
283
|
+
if save_file:
|
|
284
|
+
saved = save_credentials_file(creds, install_dir)
|
|
285
|
+
if saved:
|
|
286
|
+
success(f"Saved to {saved}")
|
|
287
|
+
if os.name != "nt":
|
|
288
|
+
console.print(f" [dim](permissions: 600 — owner read/write only)[/dim]")
|
|
289
|
+
warning("Delete this file after storing the credentials elsewhere.")
|
|
290
|
+
|
|
291
|
+
return creds
|
faden_cli/core/bundle.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The deployment files the CLI carries, for installs without a source checkout.
|
|
2
|
+
|
|
3
|
+
``pip install fadenstack`` brings the compose file and everything it mounts
|
|
4
|
+
(packed from ``shared`` at build time, see ``hatch_build.py``).
|
|
5
|
+
``faden deploy`` unpacks them into an install directory and runs the
|
|
6
|
+
published images of the CLI's own version; ``faden upgrade`` with a newer
|
|
7
|
+
CLI replaces them and moves the images to the new version.
|
|
8
|
+
|
|
9
|
+
An install made this way is marked with :data:`MARKER`. Without the marker
|
|
10
|
+
the install is a source checkout, which builds its own images.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from importlib.resources.abc import Traversable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from faden_cli import __version__
|
|
22
|
+
|
|
23
|
+
#: Written into an install made from the bundle: which release it runs.
|
|
24
|
+
MARKER = ".faden-release"
|
|
25
|
+
|
|
26
|
+
#: Where ``faden deploy`` puts an install when it is given no directory.
|
|
27
|
+
DEFAULT_INSTALL_DIR = Path.home() / "fadenstack"
|
|
28
|
+
|
|
29
|
+
#: Files an install changes for itself. The bundle's copy is only a
|
|
30
|
+
#: starting point: an upgrade never replaces them once they exist.
|
|
31
|
+
_KEPT_ON_UPGRADE = {
|
|
32
|
+
# Only ever a sample; deploy writes the real one from .env.
|
|
33
|
+
"rabbitmq/definitions.json.example",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#: Files the bundle does not carry but the compose file mounts. They must
|
|
37
|
+
#: exist before the first start, or Docker creates a *directory* in their place.
|
|
38
|
+
_STARTING_CONTENT = {
|
|
39
|
+
# The backend writes the Prometheus scrape targets here at run time.
|
|
40
|
+
"prometheus/targets.json": "[]\n",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def bundled_files() -> Traversable | None:
|
|
45
|
+
"""The deployment files inside the installed CLI, or ``None`` when it has none.
|
|
46
|
+
|
|
47
|
+
A CLI run from a source checkout (``pip install -e``) has none: the
|
|
48
|
+
checkout's ``shared`` is used instead.
|
|
49
|
+
"""
|
|
50
|
+
root = resources.files("faden_cli") / "bundle" / "shared"
|
|
51
|
+
return root if root.is_dir() and (root / "docker-compose.yaml").is_file() else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _walk(node: Traversable, prefix: str = "") -> list[tuple[str, Traversable]]:
|
|
55
|
+
files: list[tuple[str, Traversable]] = []
|
|
56
|
+
for child in node.iterdir():
|
|
57
|
+
rel = f"{prefix}{child.name}"
|
|
58
|
+
if child.is_dir():
|
|
59
|
+
files.extend(_walk(child, f"{rel}/"))
|
|
60
|
+
else:
|
|
61
|
+
files.append((rel, child))
|
|
62
|
+
return files
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def installed_release(install_dir: Path) -> str | None:
|
|
66
|
+
"""The release an install made from the bundle runs, or ``None`` for a source checkout."""
|
|
67
|
+
marker = install_dir / MARKER
|
|
68
|
+
if not marker.is_file():
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
return str(json.loads(marker.read_text(encoding="utf-8"))["version"])
|
|
72
|
+
except (ValueError, KeyError, OSError):
|
|
73
|
+
return "unknown"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def unpack(install_dir: Path) -> list[str]:
|
|
77
|
+
"""Put this CLI's deployment files in *install_dir*; return what was written.
|
|
78
|
+
|
|
79
|
+
Safe on an existing install: ``.env``, backups, credentials and what the
|
|
80
|
+
services generated are not in the bundle and are never touched, and the
|
|
81
|
+
few bundle files an install adapts are only written when missing.
|
|
82
|
+
Anything else from the bundle is replaced by this release's copy.
|
|
83
|
+
"""
|
|
84
|
+
root = bundled_files()
|
|
85
|
+
if root is None:
|
|
86
|
+
msg = "This faden CLI carries no deployment files (it runs from a source checkout)."
|
|
87
|
+
raise RuntimeError(msg)
|
|
88
|
+
install_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
written: list[str] = []
|
|
90
|
+
for rel, source in _walk(root):
|
|
91
|
+
target = install_dir / rel
|
|
92
|
+
if rel in _KEPT_ON_UPGRADE and target.exists():
|
|
93
|
+
continue
|
|
94
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
data = source.read_bytes()
|
|
96
|
+
if not target.is_file() or target.read_bytes() != data:
|
|
97
|
+
target.write_bytes(data)
|
|
98
|
+
written.append(rel)
|
|
99
|
+
for rel, content in _STARTING_CONTENT.items():
|
|
100
|
+
target = install_dir / rel
|
|
101
|
+
if not target.exists():
|
|
102
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
target.write_text(content, encoding="utf-8")
|
|
104
|
+
written.append(rel)
|
|
105
|
+
(install_dir / MARKER).write_text(
|
|
106
|
+
json.dumps({"version": __version__, "unpacked_at": datetime.now(tz=UTC).isoformat(timespec="seconds")})
|
|
107
|
+
+ "\n",
|
|
108
|
+
encoding="utf-8",
|
|
109
|
+
)
|
|
110
|
+
return written
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""ClickHouse housekeeping: its own diagnostic logs.
|
|
2
|
+
|
|
3
|
+
``shared/clickhouse/config.d/system-logs.xml`` turns off the system
|
|
4
|
+
log tables Langfuse never reads. Off, a table stops growing but keeps what it
|
|
5
|
+
holds -- 41 GB of ``trace_log`` on one install, which had filled its disk --
|
|
6
|
+
so an upgrade drops them. So too the numbered copies (``query_log_0``)
|
|
7
|
+
ClickHouse leaves behind whenever it changes a log table's schema.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from faden_cli.core.compose import ComposeContext, _run
|
|
15
|
+
|
|
16
|
+
#: The system log tables ``system-logs.xml`` turns off.
|
|
17
|
+
DISABLED_LOGS = (
|
|
18
|
+
"trace_log",
|
|
19
|
+
"text_log",
|
|
20
|
+
"opentelemetry_span_log",
|
|
21
|
+
"asynchronous_metric_log",
|
|
22
|
+
"metric_log",
|
|
23
|
+
"latency_log",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_DISABLED = re.compile(rf"^({'|'.join(DISABLED_LOGS)})(_\d+)?$")
|
|
27
|
+
_RENAMED = re.compile(r"^\w+_log_\d+$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stale_log_tables(names: list[str]) -> list[str]:
|
|
31
|
+
"""Of the ``system`` tables *names*, the logs that are off or left behind."""
|
|
32
|
+
return [n for n in names if _DISABLED.match(n) or _RENAMED.match(n)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _query(ctx: ComposeContext, sql: str) -> tuple[int, str]:
|
|
36
|
+
"""Run *sql* in the ClickHouse container, as the user the container was made with."""
|
|
37
|
+
cmd = ctx.base_cmd() + [
|
|
38
|
+
"exec", "-T", "clickhouse", "sh", "-c",
|
|
39
|
+
'clickhouse-client -u "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" -q "$1"',
|
|
40
|
+
"sh", sql,
|
|
41
|
+
]
|
|
42
|
+
result = _run(cmd, capture=True)
|
|
43
|
+
return result.returncode, (result.stdout or "") + (result.stderr or "")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def drop_stale_logs(ctx: ComposeContext) -> list[str]:
|
|
47
|
+
"""Drop the diagnostic log tables ClickHouse no longer writes; the names dropped.
|
|
48
|
+
|
|
49
|
+
Raises ``RuntimeError`` when ClickHouse cannot be asked -- the caller
|
|
50
|
+
decides whether that stops it.
|
|
51
|
+
"""
|
|
52
|
+
code, out = _query(ctx, "SELECT name FROM system.tables WHERE database = 'system'")
|
|
53
|
+
if code != 0:
|
|
54
|
+
raise RuntimeError(out.strip() or f"clickhouse-client exited with {code}")
|
|
55
|
+
dropped = []
|
|
56
|
+
for name in stale_log_tables(out.split()):
|
|
57
|
+
code, out = _query(ctx, f"DROP TABLE IF EXISTS system.`{name}` SYNC")
|
|
58
|
+
if code != 0:
|
|
59
|
+
raise RuntimeError(f"system.{name}: {out.strip()}")
|
|
60
|
+
dropped.append(name)
|
|
61
|
+
return dropped
|