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,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
@@ -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