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,225 @@
|
|
|
1
|
+
"""``faden doctor`` — diagnose the host environment.
|
|
2
|
+
|
|
3
|
+
Runs comprehensive checks on the system and reports whether all
|
|
4
|
+
prerequisites are satisfied for running Fadenstack.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from faden_cli.core.console import console
|
|
16
|
+
from faden_cli.core.detect import (
|
|
17
|
+
check_install_ports,
|
|
18
|
+
full_report,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_mark(ok: bool) -> str:
|
|
23
|
+
return "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.command("doctor")
|
|
27
|
+
@click.option("--ports/--no-ports", default=True, help="Check port availability.")
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def doctor_cmd(ctx: click.Context, *, ports: bool) -> None:
|
|
30
|
+
"""Run system health checks and report readiness."""
|
|
31
|
+
verbose = ctx.obj.get("verbose", False)
|
|
32
|
+
|
|
33
|
+
with console.status("[bold cyan]Running diagnostics…[/bold cyan]"):
|
|
34
|
+
report = full_report(check_ports=False)
|
|
35
|
+
port_checks = check_install_ports(*_install_compose()) if ports else []
|
|
36
|
+
|
|
37
|
+
# ── OS ────────────────────────────────────────────────────────
|
|
38
|
+
os_info = report.os
|
|
39
|
+
console.print(
|
|
40
|
+
Panel(
|
|
41
|
+
f"[bold]{os_info.system}[/bold] {os_info.release} — {os_info.machine}",
|
|
42
|
+
title="Operating System",
|
|
43
|
+
border_style="cyan",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ── RAM ───────────────────────────────────────────────────────
|
|
48
|
+
ram = report.ram
|
|
49
|
+
# An "8 GB" machine reports 7.5-7.8 GB once the kernel has its share;
|
|
50
|
+
# the release test VM ran everything in 7 GB.
|
|
51
|
+
ram_ok = ram.total_gb >= 7.0
|
|
52
|
+
console.print(
|
|
53
|
+
f"\n{_check_mark(ram_ok)} RAM: [bold]{ram.total_gb:.1f} GB[/bold] total, "
|
|
54
|
+
f"{ram.available_gb:.1f} GB available "
|
|
55
|
+
f"{'(8 GB recommended)' if not ram_ok else ''}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# ── Disk ──────────────────────────────────────────────────────
|
|
59
|
+
disk = report.disk
|
|
60
|
+
disk_ok = disk.free_gb >= 20.0
|
|
61
|
+
console.print(
|
|
62
|
+
f"{_check_mark(disk_ok)} Disk: [bold]{disk.free_gb:.1f} GB[/bold] free of "
|
|
63
|
+
f"{disk.total_gb:.1f} GB "
|
|
64
|
+
f"{'(≥20 GB recommended)' if not disk_ok else ''}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# ── Docker ────────────────────────────────────────────────────
|
|
68
|
+
docker = report.docker
|
|
69
|
+
docker_ver = docker.version or f'[red]not found[/red] → {docker.install_hint}'
|
|
70
|
+
console.print(
|
|
71
|
+
f"\n{_check_mark(docker.installed)} Docker: "
|
|
72
|
+
f"{docker_ver}"
|
|
73
|
+
)
|
|
74
|
+
if docker.installed and not docker.daemon_running and docker.error:
|
|
75
|
+
console.print(f" [dim]{docker.error}[/dim]")
|
|
76
|
+
compose_ver = docker.compose_version or f'[red]not found[/red] → {docker.install_hint}'
|
|
77
|
+
console.print(
|
|
78
|
+
f"{_check_mark(docker.compose_installed)} Compose: "
|
|
79
|
+
f"{compose_ver}"
|
|
80
|
+
)
|
|
81
|
+
console.print(
|
|
82
|
+
f"{_check_mark(docker.daemon_running)} Docker daemon: "
|
|
83
|
+
f"{'running' if docker.daemon_running else '[red]not running[/red]'}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ── GPU ───────────────────────────────────────────────────────
|
|
87
|
+
# The server needs no GPU: models run on the machines added to it.
|
|
88
|
+
gpu = report.gpu
|
|
89
|
+
if gpu.has_gpu:
|
|
90
|
+
for dev in gpu.devices:
|
|
91
|
+
console.print(
|
|
92
|
+
f"[cyan]i[/cyan] GPU: [bold]{dev.name}[/bold] "
|
|
93
|
+
f"({dev.vram_mb} MB) -- add this server as a machine to use it"
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
console.print("[cyan]i[/cyan] GPU: none on this server (models run on the machines you add)")
|
|
97
|
+
|
|
98
|
+
# ── Ports ─────────────────────────────────────────────────────
|
|
99
|
+
# Developer tools (git, uv, node) are `faden dev doctor`'s: a server
|
|
100
|
+
# running the published images needs none of them.
|
|
101
|
+
conflicts = [pc for pc in port_checks if pc.in_use and not pc.ours]
|
|
102
|
+
if port_checks:
|
|
103
|
+
console.print()
|
|
104
|
+
port_table = Table(title="Ports this install publishes", show_header=True, header_style="bold")
|
|
105
|
+
port_table.add_column("Port", justify="right")
|
|
106
|
+
port_table.add_column("Service")
|
|
107
|
+
port_table.add_column("Status")
|
|
108
|
+
|
|
109
|
+
for pc in port_checks:
|
|
110
|
+
if not pc.in_use:
|
|
111
|
+
status = "[green]available[/green]"
|
|
112
|
+
elif pc.ours:
|
|
113
|
+
status = "[green]Fadenstack[/green]"
|
|
114
|
+
else:
|
|
115
|
+
status = "[red]in use by something else[/red]"
|
|
116
|
+
port_table.add_row(str(pc.port), pc.label, status)
|
|
117
|
+
console.print(port_table)
|
|
118
|
+
if conflicts:
|
|
119
|
+
console.print(
|
|
120
|
+
"[dim] A port held by something else stops `faden deploy`: free it, or change\n"
|
|
121
|
+
" the port (faden ports --http/--https; PROM_PORT, ... in .env).[/dim]"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# ── HTTPS ─────────────────────────────────────────────────────
|
|
125
|
+
_https_check()
|
|
126
|
+
|
|
127
|
+
# ── Release ───────────────────────────────────────────────────
|
|
128
|
+
_release_check()
|
|
129
|
+
|
|
130
|
+
# ── Verdict ───────────────────────────────────────────────────
|
|
131
|
+
all_ok = (
|
|
132
|
+
docker.installed
|
|
133
|
+
and docker.compose_installed
|
|
134
|
+
and docker.daemon_running
|
|
135
|
+
and ram_ok
|
|
136
|
+
and disk_ok
|
|
137
|
+
and not conflicts
|
|
138
|
+
)
|
|
139
|
+
console.print()
|
|
140
|
+
if all_ok:
|
|
141
|
+
console.print("[bold green]All core prerequisites met.[/bold green] ✨")
|
|
142
|
+
else:
|
|
143
|
+
console.print("[bold red]Some prerequisites are missing — see above.[/bold red]")
|
|
144
|
+
|
|
145
|
+
console.print("[dim]Developer tools: faden dev doctor[/dim]")
|
|
146
|
+
if verbose:
|
|
147
|
+
console.print(f"\n[dim]Report generated with {len(port_checks)} port checks.[/dim]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _install_compose() -> tuple[Path | None, Path | None]:
|
|
151
|
+
"""The compose file and .env of the configured install, or of this CLI's release."""
|
|
152
|
+
from faden_cli.core.bundle import bundled_files # noqa: PLC0415
|
|
153
|
+
from faden_cli.core.settings import load_config # noqa: PLC0415
|
|
154
|
+
|
|
155
|
+
cfg = load_config()
|
|
156
|
+
if cfg.install_dir and cfg.compose_path.is_file():
|
|
157
|
+
return cfg.compose_path, cfg.env_path
|
|
158
|
+
bundled = bundled_files()
|
|
159
|
+
if bundled is not None:
|
|
160
|
+
return Path(str(bundled / "docker-compose.yaml")), None
|
|
161
|
+
return None, None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _release_check() -> None:
|
|
165
|
+
"""Whether a newer release is out (the release feed, read now; the last check when offline)."""
|
|
166
|
+
from faden_cli import __version__ # noqa: PLC0415
|
|
167
|
+
from faden_cli.core import release_feed, update_check # noqa: PLC0415
|
|
168
|
+
from faden_cli.core.settings import load_config # noqa: PLC0415
|
|
169
|
+
|
|
170
|
+
cfg = load_config()
|
|
171
|
+
if not update_check.enabled(cfg):
|
|
172
|
+
console.print(f"{_check_mark(True)} Release: {__version__} (update check off)")
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
feed: release_feed.Feed | None = update_check.refresh(cfg)
|
|
176
|
+
except Exception as exc: # offline is reported, not fatal
|
|
177
|
+
feed = update_check.cached_feed()
|
|
178
|
+
console.print(f"[cyan]i[/cyan] Release feed not reachable ({exc}); showing the last check")
|
|
179
|
+
if feed is None:
|
|
180
|
+
console.print(f"[cyan]i[/cyan] Release: {__version__} (never checked)")
|
|
181
|
+
return
|
|
182
|
+
found = release_feed.assess(feed, __version__, cfg.update_channel)
|
|
183
|
+
if found.unsupported:
|
|
184
|
+
console.print(
|
|
185
|
+
f"{_check_mark(False)} Release: {__version__} no longer gets fixes "
|
|
186
|
+
f"(since {found.supported_until})"
|
|
187
|
+
)
|
|
188
|
+
if not found.update_available or found.latest is None:
|
|
189
|
+
if not found.unsupported:
|
|
190
|
+
console.print(f"{_check_mark(True)} Release: {__version__}, the newest")
|
|
191
|
+
return
|
|
192
|
+
urgent = found.severity in ("security", "critical")
|
|
193
|
+
console.print(
|
|
194
|
+
f"{_check_mark(not urgent)} Release: {__version__}; {found.latest.version} is available"
|
|
195
|
+
f"{f' ({found.severity})' if found.severity != 'normal' else ''}"
|
|
196
|
+
" -- faden version shows how to upgrade"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _https_check() -> None:
|
|
201
|
+
"""HTTPS on or off, how long the certificate lasts, and whether it renews by itself."""
|
|
202
|
+
from datetime import UTC, datetime # noqa: PLC0415
|
|
203
|
+
|
|
204
|
+
from faden_cli.commands.tls import renewal_scheduled # noqa: PLC0415
|
|
205
|
+
from faden_cli.core import tls # noqa: PLC0415
|
|
206
|
+
from faden_cli.core.settings import load_config # noqa: PLC0415
|
|
207
|
+
|
|
208
|
+
cfg = load_config()
|
|
209
|
+
if not cfg.install_dir:
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
served = tls.current(Path(cfg.install_dir))
|
|
213
|
+
except tls.TlsError as exc:
|
|
214
|
+
console.print(f"\n{_check_mark(False)} HTTPS: {exc}")
|
|
215
|
+
return
|
|
216
|
+
if served is None:
|
|
217
|
+
console.print(f"\n{_check_mark(True)} HTTPS: off (faden tls generate switches it on)")
|
|
218
|
+
return
|
|
219
|
+
left = (served.not_after - datetime.now(UTC)).days
|
|
220
|
+
renews = renewal_scheduled() and (served.made_here or tls.source(Path(cfg.install_dir)) is not None)
|
|
221
|
+
ok = left >= tls.EXPIRY_WARNING_DAYS or (renews and left > 0)
|
|
222
|
+
how = "renews by itself" if renews else "renewed by hand (faden tls status)"
|
|
223
|
+
console.print(
|
|
224
|
+
f"\n{_check_mark(ok)} HTTPS: on until [bold]{served.not_after:%Y-%m-%d}[/bold] ({left} days), {how}"
|
|
225
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""``faden down`` — stop Fadenstack services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from faden_cli.core.compose import build_context_from_config, down as compose_down
|
|
12
|
+
from faden_cli.core.console import console, success, error, info
|
|
13
|
+
from faden_cli.core.settings import load_config
|
|
14
|
+
|
|
15
|
+
# Image prefixes that belong to the project (built from source).
|
|
16
|
+
_PROJECT_IMAGE_PREFIX = "ghcr.io/fadenstack/"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _remove_project_images() -> int:
|
|
20
|
+
"""Remove only locally-built faden/* images. Return count removed."""
|
|
21
|
+
docker = shutil.which("docker")
|
|
22
|
+
if not docker:
|
|
23
|
+
return 0
|
|
24
|
+
result = subprocess.run( # noqa: S603
|
|
25
|
+
[docker, "images", "--format", "{{.Repository}}:{{.Tag}}",
|
|
26
|
+
"--filter", f"reference={_PROJECT_IMAGE_PREFIX}*"],
|
|
27
|
+
capture_output=True, text=True,
|
|
28
|
+
)
|
|
29
|
+
images = [img for img in result.stdout.strip().splitlines() if img]
|
|
30
|
+
if not images:
|
|
31
|
+
return 0
|
|
32
|
+
subprocess.run([docker, "image", "rm", "-f", *images], # noqa: S603
|
|
33
|
+
capture_output=True, text=True)
|
|
34
|
+
return len(images)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.command("down")
|
|
38
|
+
@click.option("--volumes/--no-volumes", default=False, help="Remove named volumes declared in the compose file.")
|
|
39
|
+
@click.option("--remove-orphans/--no-remove-orphans", default=True, help="Remove containers for services not defined in the compose file.")
|
|
40
|
+
@click.option("--all", "nuke", is_flag=True, help="Remove everything: containers, networks, volumes, and built images (keeps third-party images).")
|
|
41
|
+
def down_cmd(*, volumes: bool, remove_orphans: bool, nuke: bool) -> None:
|
|
42
|
+
"""Stop and remove Fadenstack containers and networks."""
|
|
43
|
+
cfg = load_config()
|
|
44
|
+
ctx = build_context_from_config(cfg)
|
|
45
|
+
|
|
46
|
+
if nuke:
|
|
47
|
+
volumes = True
|
|
48
|
+
|
|
49
|
+
console.print("[bold cyan]Stopping Fadenstack services…[/bold cyan]")
|
|
50
|
+
|
|
51
|
+
with console.status("[bold cyan]docker compose down[/bold cyan]"):
|
|
52
|
+
returncode = compose_down(
|
|
53
|
+
ctx,
|
|
54
|
+
volumes=volumes,
|
|
55
|
+
remove_orphans=remove_orphans,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if returncode == 0:
|
|
59
|
+
success("Services stopped.")
|
|
60
|
+
else:
|
|
61
|
+
error(f"docker compose down exited with code {returncode}.")
|
|
62
|
+
sys.exit(returncode)
|
|
63
|
+
|
|
64
|
+
if nuke:
|
|
65
|
+
removed = _remove_project_images()
|
|
66
|
+
if removed:
|
|
67
|
+
info(f"Removed {removed} faden image(s).")
|
|
68
|
+
else:
|
|
69
|
+
info("No faden images to remove.")
|
|
70
|
+
|
|
71
|
+
# Stop and remove the local node agent if present.
|
|
72
|
+
from faden_cli.core.local_node import remove_local_node_agent # noqa: PLC0415
|
|
73
|
+
|
|
74
|
+
workspace = cfg.install_path.parent
|
|
75
|
+
remove_local_node_agent(workspace=workspace)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""``faden logs`` — stream container logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from faden_cli.core.compose import build_context_from_config, logs as compose_logs
|
|
10
|
+
from faden_cli.core.settings import load_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("logs")
|
|
14
|
+
@click.option("-f", "--follow", is_flag=True, help="Follow log output.")
|
|
15
|
+
@click.option("-n", "--tail", default=100, show_default=True, help="Number of lines to show from the end.")
|
|
16
|
+
@click.option("--timestamps/--no-timestamps", default=False, help="Show timestamps.")
|
|
17
|
+
@click.argument("services", nargs=-1)
|
|
18
|
+
def logs_cmd(
|
|
19
|
+
*,
|
|
20
|
+
follow: bool,
|
|
21
|
+
tail: int,
|
|
22
|
+
timestamps: bool,
|
|
23
|
+
services: tuple[str, ...],
|
|
24
|
+
) -> None:
|
|
25
|
+
"""View logs from Fadenstack services.
|
|
26
|
+
|
|
27
|
+
Pass one or more SERVICE names to filter, or omit to see all.
|
|
28
|
+
"""
|
|
29
|
+
cfg = load_config()
|
|
30
|
+
ctx = build_context_from_config(cfg)
|
|
31
|
+
|
|
32
|
+
returncode = compose_logs(
|
|
33
|
+
ctx,
|
|
34
|
+
services=list(services) if services else None,
|
|
35
|
+
follow=follow,
|
|
36
|
+
tail=tail,
|
|
37
|
+
timestamps=timestamps,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if returncode != 0:
|
|
41
|
+
sys.exit(returncode)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""``faden maintenance`` — show the maintenance page instead of the platform.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
faden maintenance on # every page says "under maintenance"
|
|
5
|
+
faden maintenance on -m "Back at 15:00" # ... with a note for the users
|
|
6
|
+
faden maintenance status
|
|
7
|
+
faden maintenance off
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from faden_cli.core import maintenance
|
|
18
|
+
from faden_cli.core.console import console, error, info, success
|
|
19
|
+
from faden_cli.core.settings import load_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _install_dir() -> Path:
|
|
23
|
+
cfg = load_config()
|
|
24
|
+
if not cfg.install_dir:
|
|
25
|
+
error(
|
|
26
|
+
"No install directory configured.\n"
|
|
27
|
+
" Run 'faden deploy' first, or set install_dir in faden.yaml."
|
|
28
|
+
)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
return cfg.install_path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.group("maintenance")
|
|
34
|
+
def maintenance_group() -> None:
|
|
35
|
+
"""Switch the maintenance page on or off.
|
|
36
|
+
|
|
37
|
+
While it is on, nginx answers every page with a maintenance page that
|
|
38
|
+
reloads itself once maintenance ends, and the APIs (/api, /v1) with 503.
|
|
39
|
+
The services keep running; only the front door changes. `faden upgrade`
|
|
40
|
+
does this by itself for its restart.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@maintenance_group.command("on")
|
|
45
|
+
@click.option("-m", "--message", default=None, help="A note shown on the page, e.g. when it will be back.")
|
|
46
|
+
def on_cmd(*, message: str | None) -> None:
|
|
47
|
+
"""Answer every page with the maintenance page."""
|
|
48
|
+
install_dir = _install_dir()
|
|
49
|
+
path = maintenance.enter(install_dir, reason="manual", message=message)
|
|
50
|
+
success(f"Maintenance on: every page now shows the maintenance page ({path}).")
|
|
51
|
+
info("Switch it off with: faden maintenance off")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@maintenance_group.command("off")
|
|
55
|
+
def off_cmd() -> None:
|
|
56
|
+
"""Back to the platform."""
|
|
57
|
+
if maintenance.leave(_install_dir()):
|
|
58
|
+
success("Maintenance off: the platform answers again.")
|
|
59
|
+
else:
|
|
60
|
+
info("Maintenance was not on.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@maintenance_group.command("status")
|
|
64
|
+
def status_cmd() -> None:
|
|
65
|
+
"""Whether the maintenance page is up, since when and why."""
|
|
66
|
+
found = maintenance.state(_install_dir())
|
|
67
|
+
if found is None:
|
|
68
|
+
console.print("Maintenance: [green]off[/green]")
|
|
69
|
+
return
|
|
70
|
+
console.print(
|
|
71
|
+
f"Maintenance: [yellow]on[/yellow] ({found.get('reason', 'manual')}, since {found.get('since', '?')})"
|
|
72
|
+
)
|
|
73
|
+
if found.get("message"):
|
|
74
|
+
console.print(f" Note: {found['message']}")
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""``faden module`` — enable / disable optional platform modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from faden_cli.core.console import console, info, success, warning
|
|
9
|
+
from faden_cli.core.env_gen import set_module_env
|
|
10
|
+
from faden_cli.core.registry import MODULES_COMPAT as MODULES
|
|
11
|
+
from faden_cli.core.settings import FadenConfig, load_config, save_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _write_env(cfg: FadenConfig, *, enable: list[str], disable: list[str]) -> None:
|
|
15
|
+
"""Carry the change into the install's ``.env``, where the services read it.
|
|
16
|
+
|
|
17
|
+
Recording a module in faden.yaml only starts its containers; the
|
|
18
|
+
gateway and the backend switch it on from ``.env``.
|
|
19
|
+
"""
|
|
20
|
+
env_path = cfg.install_path / ".env"
|
|
21
|
+
if not env_path.exists():
|
|
22
|
+
return
|
|
23
|
+
changed = set_module_env(env_path, enable=enable, disable=disable)
|
|
24
|
+
if changed:
|
|
25
|
+
info(f"Updated {env_path}: {', '.join(sorted(changed))}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group("module")
|
|
29
|
+
def module_group() -> None:
|
|
30
|
+
"""Manage optional Fadenstack modules (pii, mcp, skills)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@module_group.command("list")
|
|
34
|
+
def module_list() -> None:
|
|
35
|
+
"""List available modules and their status."""
|
|
36
|
+
cfg = load_config()
|
|
37
|
+
active_profiles = set(cfg.profiles or [])
|
|
38
|
+
|
|
39
|
+
table = Table(
|
|
40
|
+
title="Fadenstack Modules",
|
|
41
|
+
show_header=True,
|
|
42
|
+
header_style="bold cyan",
|
|
43
|
+
)
|
|
44
|
+
table.add_column("Module", style="bold")
|
|
45
|
+
table.add_column("Status")
|
|
46
|
+
table.add_column("Description")
|
|
47
|
+
|
|
48
|
+
for name, module in MODULES.items():
|
|
49
|
+
enabled = module["profile"] in active_profiles
|
|
50
|
+
status = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]"
|
|
51
|
+
table.add_row(name, status, module["description"])
|
|
52
|
+
|
|
53
|
+
console.print(table)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@module_group.command("enable")
|
|
57
|
+
@click.argument("modules", nargs=-1, required=True)
|
|
58
|
+
def module_enable(modules: tuple[str, ...]) -> None:
|
|
59
|
+
"""Enable one or more modules.
|
|
60
|
+
|
|
61
|
+
Example: faden module enable pii auth
|
|
62
|
+
"""
|
|
63
|
+
cfg = load_config()
|
|
64
|
+
profiles = set(cfg.profiles or [])
|
|
65
|
+
enabled: list[str] = []
|
|
66
|
+
|
|
67
|
+
for mod in modules:
|
|
68
|
+
mod_lower = mod.lower()
|
|
69
|
+
if mod_lower not in MODULES:
|
|
70
|
+
warning(f"Unknown module: {mod_lower}. Available: {', '.join(MODULES)}")
|
|
71
|
+
continue
|
|
72
|
+
profile = MODULES[mod_lower]["profile"]
|
|
73
|
+
profiles.add(profile)
|
|
74
|
+
enabled.append(mod_lower)
|
|
75
|
+
success(f"Enabled module: {mod_lower}")
|
|
76
|
+
|
|
77
|
+
cfg.profiles = sorted(profiles)
|
|
78
|
+
save_config(cfg)
|
|
79
|
+
_write_env(cfg, enable=enabled, disable=[])
|
|
80
|
+
console.print(
|
|
81
|
+
"\n[dim]Run [bold]faden up[/bold] to apply changes "
|
|
82
|
+
"(or [bold]faden dev up[/bold] in a dev workspace).[/dim]",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@module_group.command("disable")
|
|
87
|
+
@click.argument("modules", nargs=-1, required=True)
|
|
88
|
+
def module_disable(modules: tuple[str, ...]) -> None:
|
|
89
|
+
"""Disable one or more modules.
|
|
90
|
+
|
|
91
|
+
Example: faden module disable pii
|
|
92
|
+
"""
|
|
93
|
+
cfg = load_config()
|
|
94
|
+
profiles = set(cfg.profiles or [])
|
|
95
|
+
disabled: list[str] = []
|
|
96
|
+
|
|
97
|
+
for mod in modules:
|
|
98
|
+
mod_lower = mod.lower()
|
|
99
|
+
if mod_lower not in MODULES:
|
|
100
|
+
warning(f"Unknown module: {mod_lower}. Available: {', '.join(MODULES)}")
|
|
101
|
+
continue
|
|
102
|
+
profile = MODULES[mod_lower]["profile"]
|
|
103
|
+
profiles.discard(profile)
|
|
104
|
+
disabled.append(mod_lower)
|
|
105
|
+
success(f"Disabled module: {mod_lower}")
|
|
106
|
+
|
|
107
|
+
cfg.profiles = sorted(profiles)
|
|
108
|
+
save_config(cfg)
|
|
109
|
+
_write_env(cfg, enable=[], disable=disabled)
|
|
110
|
+
console.print(
|
|
111
|
+
"\n[dim]Run [bold]faden down && faden up[/bold] to apply changes "
|
|
112
|
+
"(or [bold]faden dev up[/bold] in a dev workspace).[/dim]",
|
|
113
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""``faden observe`` — observability commands for cost tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from faden_cli.core.api_client import ApiClient
|
|
11
|
+
from faden_cli.core.console import console, error, info, success
|
|
12
|
+
from faden_cli.core.settings import load_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ensure_token(cfg) -> str | None:
|
|
16
|
+
"""Return the configured API token, or prompt for login credentials."""
|
|
17
|
+
if cfg.api_token:
|
|
18
|
+
return cfg.api_token
|
|
19
|
+
# No token stored — prompt the user for credentials.
|
|
20
|
+
email = click.prompt(" Admin email", default=cfg.admin_email or "admin@localhost")
|
|
21
|
+
password = click.prompt(f" Password for {email}", hide_input=True, show_default=False)
|
|
22
|
+
try:
|
|
23
|
+
resp = httpx.post(
|
|
24
|
+
f"{cfg.api_url.rstrip('/')}/api/auth/jwt/login",
|
|
25
|
+
data={"username": email, "password": password},
|
|
26
|
+
timeout=15,
|
|
27
|
+
)
|
|
28
|
+
if resp.status_code == 200: # noqa: PLR2004
|
|
29
|
+
token = resp.json().get("access_token", "")
|
|
30
|
+
if token:
|
|
31
|
+
return token
|
|
32
|
+
error(f"Login failed (HTTP {resp.status_code})")
|
|
33
|
+
return None
|
|
34
|
+
except httpx.HTTPError as exc:
|
|
35
|
+
error(f"Could not reach backend: {exc}")
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.group("observe")
|
|
40
|
+
def observe_group() -> None:
|
|
41
|
+
"""Observability commands — cost tracking and analytics."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@observe_group.command("force-calculate")
|
|
45
|
+
@click.option(
|
|
46
|
+
"-y", "--yes",
|
|
47
|
+
is_flag=True,
|
|
48
|
+
help="Skip confirmation prompt.",
|
|
49
|
+
)
|
|
50
|
+
def force_calculate_cmd(*, yes: bool) -> None:
|
|
51
|
+
"""Force-recalculate costs for all historical LLM requests.
|
|
52
|
+
|
|
53
|
+
Re-applies the current price catalog to every row in the request
|
|
54
|
+
log. Rows with a matching model in the price catalog get updated
|
|
55
|
+
cost estimates; rows without a match are marked as unavailable.
|
|
56
|
+
"""
|
|
57
|
+
cfg = load_config()
|
|
58
|
+
|
|
59
|
+
if not yes:
|
|
60
|
+
click.confirm(
|
|
61
|
+
"This will recalculate cost estimates for ALL historical requests "
|
|
62
|
+
"using the current price catalog. Continue?",
|
|
63
|
+
abort=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
info("Authenticating…")
|
|
67
|
+
token = _ensure_token(cfg)
|
|
68
|
+
if not token:
|
|
69
|
+
error("Authentication failed. Cannot proceed.")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
# Temporarily set the token for this session.
|
|
73
|
+
cfg.api_token = token
|
|
74
|
+
|
|
75
|
+
with ApiClient(cfg) as client:
|
|
76
|
+
if not client.healthy():
|
|
77
|
+
error("Backend is not reachable. Is Fadenstack running?")
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
info("Recalculating costs — this may take a moment…")
|
|
81
|
+
|
|
82
|
+
with console.status("[info]Recalculating…[/info]"):
|
|
83
|
+
resp = client.post("/api/admin/observability/recalculate-costs")
|
|
84
|
+
|
|
85
|
+
if resp.status_code != 200: # noqa: PLR2004
|
|
86
|
+
error(f"Recalculation failed (HTTP {resp.status_code}): {resp.text}")
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
result = resp.json()
|
|
90
|
+
recalculated = result.get("recalculated", 0)
|
|
91
|
+
unavailable = result.get("unavailable", 0)
|
|
92
|
+
|
|
93
|
+
success("Cost recalculation complete.")
|
|
94
|
+
console.print(f" [green]{recalculated}[/green] requests updated with current pricing")
|
|
95
|
+
console.print(f" [yellow]{unavailable}[/yellow] requests have no matching price catalog entry")
|