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,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")