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
faden_cli/core/tune.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Size the services for this host and write it to ``.env``.
|
|
2
|
+
|
|
3
|
+
The one place that does it: ``faden tune`` calls it, and so do ``deploy``,
|
|
4
|
+
``upgrade`` and ``up --gen-env``. Deploy had its own copy -- the sizing baked
|
|
5
|
+
into a newly generated ``.env`` -- so an existing install was never sized
|
|
6
|
+
again, and a fix to the sizing reached no one who had deployed already.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from faden_cli.core.console import console, success
|
|
16
|
+
from faden_cli.core.env_gen import read_env_file, write_env_file
|
|
17
|
+
from faden_cli.core.sysinfo import (
|
|
18
|
+
ALL_SERVICES,
|
|
19
|
+
DB_SERVICES,
|
|
20
|
+
DEFAULT_RESOURCE_PCT,
|
|
21
|
+
RABBIT_SERVICES,
|
|
22
|
+
TuneProfile,
|
|
23
|
+
calculate_tune_profile,
|
|
24
|
+
detect_system,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
#: The CPU share ``faden tune --resource-pct`` accepts.
|
|
28
|
+
_PCT_MIN, _PCT_MAX = 0.1, 1.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _profile_of(env: dict[str, str]) -> str:
|
|
32
|
+
"""The profile the install was tuned with, else the one its services run in."""
|
|
33
|
+
if env.get("FADEN_TUNE_PROFILE") in {"dev", "prod"}:
|
|
34
|
+
return env["FADEN_TUNE_PROFILE"]
|
|
35
|
+
return "dev" if env.get("FADEN_BACKEND_ENVIRONMENT") == "dev" else "prod"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#: The flag in ``.env`` that switches each optional module on.
|
|
39
|
+
_MODULE_FLAGS = {
|
|
40
|
+
"pii": "FADEN_BACKEND_PII_ENABLED",
|
|
41
|
+
"mcp": "FADEN_BACKEND_MCP_ENABLED",
|
|
42
|
+
"skills": "FADEN_BACKEND_SKILLS_ENABLED",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _modules_of(env: dict[str, str]) -> frozenset[str]:
|
|
47
|
+
"""The optional modules the install has switched on."""
|
|
48
|
+
return frozenset(m for m, flag in _MODULE_FLAGS.items() if env.get(flag, "").strip().lower() == "true")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resource_pct_of(env: dict[str, str]) -> float:
|
|
52
|
+
try:
|
|
53
|
+
pct = float(env.get("FADEN_TUNE_RESOURCE_PCT") or DEFAULT_RESOURCE_PCT)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return DEFAULT_RESOURCE_PCT
|
|
56
|
+
return pct if _PCT_MIN <= pct <= _PCT_MAX else DEFAULT_RESOURCE_PCT
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def tune_env(
|
|
60
|
+
env_path: Path,
|
|
61
|
+
*,
|
|
62
|
+
profile: str | None = None,
|
|
63
|
+
resource_pct: float | None = None,
|
|
64
|
+
dry_run: bool = False,
|
|
65
|
+
) -> TuneProfile:
|
|
66
|
+
"""Size the services for this host, show it, and write it to *env_path*.
|
|
67
|
+
|
|
68
|
+
*profile* and *resource_pct* default to what the install was last tuned
|
|
69
|
+
with, so deploy and upgrade keep what ``faden tune`` chose. The rest of
|
|
70
|
+
the file -- secrets included -- is kept.
|
|
71
|
+
"""
|
|
72
|
+
env = read_env_file(env_path)
|
|
73
|
+
profile = profile or _profile_of(env)
|
|
74
|
+
resource_pct = resource_pct or _resource_pct_of(env)
|
|
75
|
+
tp = calculate_tune_profile(
|
|
76
|
+
profile, system=detect_system(), resource_pct=resource_pct, modules=_modules_of(env),
|
|
77
|
+
)
|
|
78
|
+
show(tp)
|
|
79
|
+
if dry_run:
|
|
80
|
+
console.print("[dim]Dry run — no files written.[/dim]")
|
|
81
|
+
return tp
|
|
82
|
+
env.update(tp.to_env_dict())
|
|
83
|
+
write_env_file(env_path, env, preserve_secrets=True)
|
|
84
|
+
success(f"Scalability settings written to {env_path}")
|
|
85
|
+
return tp
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def show(tp: TuneProfile) -> None:
|
|
89
|
+
"""The host, the settings for each service, and the database connections they add up to."""
|
|
90
|
+
sys_info = tp.system
|
|
91
|
+
hw_table = Table(title="Detected Hardware", show_header=False, title_style="bold cyan")
|
|
92
|
+
hw_table.add_column("Metric", style="dim")
|
|
93
|
+
hw_table.add_column("Value", style="bold")
|
|
94
|
+
hw_table.add_row("Physical CPU cores", str(sys_info.physical_cores))
|
|
95
|
+
hw_table.add_row("Logical CPU cores", str(sys_info.logical_cores))
|
|
96
|
+
hw_table.add_row("Total RAM", f"{sys_info.total_ram_gb} GB")
|
|
97
|
+
hw_table.add_row("Profile", tp.profile)
|
|
98
|
+
if tp.profile == "prod":
|
|
99
|
+
hw_table.add_row("Resource budget", f"{int(tp.resource_pct * 100)}% ({tp.budget_cores} cores)")
|
|
100
|
+
console.print(hw_table)
|
|
101
|
+
console.print()
|
|
102
|
+
|
|
103
|
+
svc_table = Table(title="Computed Scalability Settings", title_style="bold cyan")
|
|
104
|
+
svc_table.add_column("Service", style="bold")
|
|
105
|
+
svc_table.add_column("Workers", justify="right")
|
|
106
|
+
svc_table.add_column("DB Pool", justify="right")
|
|
107
|
+
svc_table.add_column("DB Overflow", justify="right")
|
|
108
|
+
svc_table.add_column("RMQ Pool", justify="right")
|
|
109
|
+
svc_table.add_column("RMQ Channels", justify="right")
|
|
110
|
+
for svc in ALL_SERVICES:
|
|
111
|
+
svc_table.add_row(
|
|
112
|
+
svc,
|
|
113
|
+
str(tp.workers[svc]),
|
|
114
|
+
str(tp.db_pool_size[svc]) if svc in DB_SERVICES else "—",
|
|
115
|
+
str(tp.db_max_overflow[svc]) if svc in DB_SERVICES else "—",
|
|
116
|
+
str(tp.rabbit_pool_size[svc]) if svc in RABBIT_SERVICES else "—",
|
|
117
|
+
str(tp.rabbit_channel_pool_size[svc]) if svc in RABBIT_SERVICES else "—",
|
|
118
|
+
)
|
|
119
|
+
console.print(svc_table)
|
|
120
|
+
|
|
121
|
+
peaks = tp.db_connections()
|
|
122
|
+
console.print(
|
|
123
|
+
f"Database connections at peak: {sum(peaks.values())} "
|
|
124
|
+
f"({', '.join(f'{name} {n}' for name, n in peaks.items())}); "
|
|
125
|
+
f"Postgres max_connections {tp.max_connections}."
|
|
126
|
+
)
|
|
127
|
+
console.print()
|
|
128
|
+
show_memory(tp)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def show_memory(tp: TuneProfile) -> None:
|
|
132
|
+
"""What the sizing takes of the memory, and whether the host has it."""
|
|
133
|
+
parts = tp.memory_mb()
|
|
134
|
+
mem_table = Table(title="Memory (estimate)", show_header=False, title_style="bold cyan")
|
|
135
|
+
mem_table.add_column("Part", style="dim")
|
|
136
|
+
mem_table.add_column("MB", justify="right")
|
|
137
|
+
for name, mb in parts.items():
|
|
138
|
+
mem_table.add_row(name, str(mb))
|
|
139
|
+
mem_table.add_row("[bold]total[/bold]", f"[bold]{sum(parts.values())}[/bold]")
|
|
140
|
+
mem_table.add_row("available to Fadenstack", str(tp.memory_budget_mb))
|
|
141
|
+
console.print(mem_table)
|
|
142
|
+
workers = max(tp.workers.values())
|
|
143
|
+
if workers < tp.cpu_workers:
|
|
144
|
+
console.print(
|
|
145
|
+
f"[yellow]Workers cut from {tp.cpu_workers} to {workers} to fit "
|
|
146
|
+
f"{tp.system.total_ram_gb} GB of memory.[/yellow]"
|
|
147
|
+
)
|
|
148
|
+
if tp.memory_short_mb:
|
|
149
|
+
console.print(
|
|
150
|
+
f"[bold red]This host is {tp.memory_short_mb} MB short of memory for what is switched on.[/bold red] "
|
|
151
|
+
f"Expect swapping, and processes killed when a model downloads. Give it more memory, "
|
|
152
|
+
f"or switch modules off (faden module disable ...)."
|
|
153
|
+
)
|
|
154
|
+
console.print()
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Telling the operator, from the CLI, that a newer Fadenstack release is out.
|
|
2
|
+
|
|
3
|
+
The way gh and pip do it: every command reads what the last check found
|
|
4
|
+
(``update-check.json`` next to faden.yaml); when that is a day old, a thread
|
|
5
|
+
fetches the release feed (release_feed.FEED_URL) while the command runs, and
|
|
6
|
+
the command ends with one line on stderr if a newer release exists. A
|
|
7
|
+
command never waits for the network beyond a moment at its end, and an
|
|
8
|
+
offline server just hears nothing.
|
|
9
|
+
|
|
10
|
+
Fetching the feed sends nothing about this server: it is a plain GET of a
|
|
11
|
+
public file, compared here. Off with ``update_check: false`` in faden.yaml
|
|
12
|
+
(``faden deploy`` asks) or FADEN_NO_UPDATE_CHECK=1; never in CI or when
|
|
13
|
+
stderr is not a terminal (timers, scripts).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from datetime import UTC, datetime, timedelta
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from faden_cli import __version__
|
|
29
|
+
from faden_cli.core import release_feed
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
import click
|
|
33
|
+
|
|
34
|
+
from faden_cli.core.settings import FadenConfig
|
|
35
|
+
|
|
36
|
+
CACHE_NAME = "update-check.json"
|
|
37
|
+
#: How old the last check may be before the next.
|
|
38
|
+
STALE = timedelta(hours=24)
|
|
39
|
+
FETCH_TIMEOUT_S = 3.0
|
|
40
|
+
#: The most a command waits at its end for a check it started.
|
|
41
|
+
JOIN_BUDGET_S = 1.5
|
|
42
|
+
ENV_OFF = "FADEN_NO_UPDATE_CHECK"
|
|
43
|
+
#: Commands that say more themselves (version, doctor, upgrade) or are not about an install (dev).
|
|
44
|
+
QUIET_COMMANDS = frozenset({"upgrade", "version", "doctor", "dev"})
|
|
45
|
+
USER_AGENT = "fadenstack-update-check"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def installer() -> str:
|
|
49
|
+
"""How this CLI was installed: pipx, uv, pip, binary, or source (a checkout)."""
|
|
50
|
+
if getattr(sys, "frozen", False):
|
|
51
|
+
return "binary"
|
|
52
|
+
prefix = Path(sys.prefix).as_posix().lower()
|
|
53
|
+
if "/pipx/" in prefix:
|
|
54
|
+
return "pipx"
|
|
55
|
+
if "/uv/tools/" in prefix:
|
|
56
|
+
return "uv"
|
|
57
|
+
if (Path(__file__).resolve().parents[3] / "pyproject.toml").is_file():
|
|
58
|
+
return "source"
|
|
59
|
+
return "pip"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def upgrade_kind(cfg: FadenConfig) -> str:
|
|
63
|
+
"""How the install this CLI looks after moves to a new release: like the console says it.
|
|
64
|
+
|
|
65
|
+
An install built from a checkout gets a release by ``git pull`` (``source``), however
|
|
66
|
+
the CLI itself was installed; any other by a newer CLI, the way this one was installed.
|
|
67
|
+
"""
|
|
68
|
+
if cfg.install_dir and current_release(cfg) is None:
|
|
69
|
+
from faden_cli.core.installation import checkout_of # noqa: PLC0415
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
if checkout_of(cfg.install_path) is not None:
|
|
73
|
+
return "source"
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
return installer()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def enabled(cfg: FadenConfig) -> bool:
|
|
80
|
+
"""Whether this CLI checks for releases at all."""
|
|
81
|
+
if os.environ.get(ENV_OFF, "").strip().lower() not in ("", "0", "false", "no"):
|
|
82
|
+
return False
|
|
83
|
+
return bool(cfg.update_check)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cache_path() -> Path:
|
|
87
|
+
"""Where the last check is kept: beside faden.yaml."""
|
|
88
|
+
from faden_cli.core.settings import config_path # noqa: PLC0415
|
|
89
|
+
|
|
90
|
+
return config_path().parent / CACHE_NAME
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def feed_url(cfg: FadenConfig) -> str:
|
|
94
|
+
"""The feed this CLI reads: the configured mirror, else fadenstack.com."""
|
|
95
|
+
return (cfg.update_feed_url or "").strip() or release_feed.FEED_URL
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_cache() -> dict[str, Any]:
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(cache_path().read_text(encoding="utf-8"))
|
|
101
|
+
except (OSError, ValueError):
|
|
102
|
+
return {}
|
|
103
|
+
return data if isinstance(data, dict) else {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_cache(data: dict[str, Any]) -> None:
|
|
107
|
+
path = cache_path()
|
|
108
|
+
try:
|
|
109
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
tmp = path.with_suffix(".tmp")
|
|
111
|
+
tmp.write_text(json.dumps(data), encoding="utf-8")
|
|
112
|
+
tmp.replace(path)
|
|
113
|
+
except OSError:
|
|
114
|
+
pass # a read-only home: the check runs again next time
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _checked_at(data: dict[str, Any]) -> datetime | None:
|
|
118
|
+
try:
|
|
119
|
+
return datetime.fromisoformat(str(data["checked_at"]))
|
|
120
|
+
except (KeyError, ValueError):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_stale(now: datetime | None = None) -> bool:
|
|
125
|
+
"""Whether the last check (or failed attempt) is older than :data:`STALE`."""
|
|
126
|
+
checked = _checked_at(_read_cache())
|
|
127
|
+
return checked is None or (now or datetime.now(UTC)) - checked >= STALE
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cached_feed() -> release_feed.Feed | None:
|
|
131
|
+
"""The feed the last successful check kept, verified again; None if there is none."""
|
|
132
|
+
data = _read_cache()
|
|
133
|
+
try:
|
|
134
|
+
return release_feed.verify(data["index"].encode(), data["signature"].encode())
|
|
135
|
+
except (KeyError, AttributeError, release_feed.FeedError):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fetch(url: str, *, timeout: float = FETCH_TIMEOUT_S) -> tuple[bytes, bytes]:
|
|
140
|
+
"""The feed and its signature, as published at *url*."""
|
|
141
|
+
import httpx # noqa: PLC0415
|
|
142
|
+
|
|
143
|
+
with httpx.Client(timeout=timeout, follow_redirects=True, headers={"User-Agent": USER_AGENT}) as client:
|
|
144
|
+
index = client.get(url)
|
|
145
|
+
index.raise_for_status()
|
|
146
|
+
signature = client.get(release_feed.signature_url(url))
|
|
147
|
+
signature.raise_for_status()
|
|
148
|
+
return index.content, signature.content
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def refresh(cfg: FadenConfig, *, timeout: float = FETCH_TIMEOUT_S) -> release_feed.Feed:
|
|
152
|
+
"""Fetch and verify the feed now, and keep it. Raises on failure (and remembers the attempt)."""
|
|
153
|
+
previous = _read_cache()
|
|
154
|
+
now = datetime.now(UTC)
|
|
155
|
+
try:
|
|
156
|
+
index, signature = fetch(feed_url(cfg), timeout=timeout)
|
|
157
|
+
feed = release_feed.verify(index, signature)
|
|
158
|
+
seen = cached_feed()
|
|
159
|
+
release_feed.not_older(feed, seen.generated_at if seen else None)
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
# Tried: the next try waits a day too, so an offline server is not asked on every command.
|
|
162
|
+
_write_cache({**previous, "checked_at": now.isoformat(), "error": str(exc) or type(exc).__name__})
|
|
163
|
+
raise
|
|
164
|
+
_write_cache({
|
|
165
|
+
"checked_at": now.isoformat(),
|
|
166
|
+
"url": feed_url(cfg),
|
|
167
|
+
"index": index.decode(),
|
|
168
|
+
"signature": signature.decode(),
|
|
169
|
+
})
|
|
170
|
+
return feed
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def current_release(cfg: FadenConfig) -> str | None:
|
|
174
|
+
"""The release the install this CLI looks after runs; None without one made from a release."""
|
|
175
|
+
if not cfg.install_dir:
|
|
176
|
+
return None
|
|
177
|
+
from faden_cli.core.bundle import installed_release # noqa: PLC0415
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
return installed_release(cfg.install_path)
|
|
181
|
+
except OSError:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def message(feed: release_feed.Feed, cfg: FadenConfig) -> str | None:
|
|
186
|
+
"""What to tell the operator, in one or two lines; None when there is nothing to tell."""
|
|
187
|
+
installed = current_release(cfg)
|
|
188
|
+
# The CLI is already newer than the install: the step left is the upgrade itself.
|
|
189
|
+
if installed and release_feed.is_newer(__version__, installed):
|
|
190
|
+
return f"This CLI is {__version__}; the install still runs {installed}. Upgrade it: faden upgrade"
|
|
191
|
+
assessment = release_feed.assess(feed, __version__, cfg.update_channel)
|
|
192
|
+
if not assessment.update_available or assessment.latest is None:
|
|
193
|
+
if assessment.unsupported:
|
|
194
|
+
return f"Fadenstack {__version__} no longer gets fixes (since {assessment.supported_until})."
|
|
195
|
+
return None
|
|
196
|
+
urgency = {
|
|
197
|
+
"critical": " -- a critical fix: upgrade now",
|
|
198
|
+
"security": " -- it fixes security problems",
|
|
199
|
+
}.get(assessment.severity, "")
|
|
200
|
+
steps = release_feed.upgrade_commands(upgrade_kind(cfg), assessment)
|
|
201
|
+
lines = [f"Fadenstack {assessment.latest.version} is available (you have {__version__}){urgency}."]
|
|
202
|
+
if assessment.path and len(assessment.path) > 1:
|
|
203
|
+
lines.append(f"Upgrade one release at a time: {' -> '.join(assessment.path)}.")
|
|
204
|
+
if steps:
|
|
205
|
+
lines.append(f"Upgrade: {steps[0]}, then faden upgrade")
|
|
206
|
+
return "\n".join(lines)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def interactive() -> bool:
|
|
210
|
+
"""Whether someone watches stderr: a terminal, and not CI."""
|
|
211
|
+
try:
|
|
212
|
+
return sys.stderr.isatty() and not os.environ.get("CI")
|
|
213
|
+
except (AttributeError, ValueError):
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def start(ctx: click.Context, *, quiet: bool) -> None:
|
|
218
|
+
"""Check in the background while the command runs, and tell the operator at its end."""
|
|
219
|
+
command = ctx.invoked_subcommand
|
|
220
|
+
if quiet or command is None or command in QUIET_COMMANDS or not interactive():
|
|
221
|
+
return
|
|
222
|
+
from faden_cli.core.settings import load_config # noqa: PLC0415
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
cfg = load_config()
|
|
226
|
+
except Exception: # a broken faden.yaml is the command's to report
|
|
227
|
+
return
|
|
228
|
+
if not enabled(cfg):
|
|
229
|
+
return
|
|
230
|
+
started = time.monotonic()
|
|
231
|
+
worker: threading.Thread | None = None
|
|
232
|
+
if is_stale():
|
|
233
|
+
worker = threading.Thread(
|
|
234
|
+
target=_refresh_quietly, args=(cfg,), name="faden-update-check", daemon=True
|
|
235
|
+
)
|
|
236
|
+
worker.start()
|
|
237
|
+
|
|
238
|
+
def tell() -> None:
|
|
239
|
+
if worker is not None:
|
|
240
|
+
worker.join(max(0.0, JOIN_BUDGET_S - (time.monotonic() - started)))
|
|
241
|
+
feed = cached_feed()
|
|
242
|
+
text = message(feed, cfg) if feed is not None else None
|
|
243
|
+
if text:
|
|
244
|
+
from rich.console import Console # noqa: PLC0415
|
|
245
|
+
|
|
246
|
+
Console(stderr=True).print(f"\n[yellow]{text}[/yellow]", highlight=False)
|
|
247
|
+
|
|
248
|
+
ctx.call_on_close(tell)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _refresh_quietly(cfg: FadenConfig) -> None:
|
|
252
|
+
# An offline server hears nothing; the attempt is remembered.
|
|
253
|
+
with contextlib.suppress(Exception):
|
|
254
|
+
refresh(cfg)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Shared workspace helpers for ``faden dev`` commands.
|
|
2
|
+
|
|
3
|
+
``dev init`` clones the Fadenstack repos as a *monorepo* at
|
|
4
|
+
``<workspace>/fadenstack/<local_name>``. Consumers (``dev up``,
|
|
5
|
+
``dev status``, …) must resolve service paths the same way, which is
|
|
6
|
+
why the helpers live here instead of being duplicated per command.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
CORE_DIRNAME = "fadenstack"
|
|
15
|
+
|
|
16
|
+
# Well-known Fadenstack dev processes and the log files they write to in
|
|
17
|
+
# headless (no terminal-emulator) mode.
|
|
18
|
+
LOG_LABELS = {
|
|
19
|
+
"backend": "faden-backend.log",
|
|
20
|
+
"worker": "faden-taskiq-worker.log",
|
|
21
|
+
"frontend": "faden-frontend.log",
|
|
22
|
+
"gateway": "faden-gateway.log",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_service_dir(workspace: Path, name: str) -> Path:
|
|
27
|
+
"""Return the path to a service directory, checking the monorepo first.
|
|
28
|
+
|
|
29
|
+
``faden dev init`` clones everything under
|
|
30
|
+
``<workspace>/fadenstack/<name>``. Legacy/flat layouts that keep
|
|
31
|
+
the service directly in ``<workspace>/<name>`` still work.
|
|
32
|
+
"""
|
|
33
|
+
monorepo = workspace / CORE_DIRNAME / name
|
|
34
|
+
if monorepo.is_dir():
|
|
35
|
+
return monorepo
|
|
36
|
+
return workspace / name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
#: A directory counts as a workspace when these services resolve beneath it,
|
|
40
|
+
#: in either layout ``find_service_dir`` understands. Backend + shared are
|
|
41
|
+
#: enough to tell a real checkout from a directory that merely has a similar
|
|
42
|
+
#: name: the first is the service ``dev up`` launches, the second holds the
|
|
43
|
+
#: compose file and the ``.env`` everything else is derived from.
|
|
44
|
+
WORKSPACE_MARKERS = ("backend", "shared")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_workspace(path: Path) -> bool:
|
|
48
|
+
"""True when *path* already holds a Fadenstack checkout."""
|
|
49
|
+
return all(find_service_dir(path, name).is_dir() for name in WORKSPACE_MARKERS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def detect_workspace(start: Path | None = None) -> Path | None:
|
|
53
|
+
"""Find the nearest workspace root at or above *start* (default: cwd).
|
|
54
|
+
|
|
55
|
+
This is what lets a developer who cloned the code themselves skip
|
|
56
|
+
``dev init``'s clone step: the commands find the checkout instead of
|
|
57
|
+
requiring config written by a previous ``dev init``.
|
|
58
|
+
|
|
59
|
+
Searching upwards matters because the directory a developer is standing
|
|
60
|
+
in is usually a service, not the workspace root. The *nearest* match
|
|
61
|
+
wins, so a nested monorepo checkout resolves to itself rather than to
|
|
62
|
+
some unrelated ancestor. Returns ``None`` when nothing above *start*
|
|
63
|
+
looks like a checkout, so callers keep their previous behaviour.
|
|
64
|
+
"""
|
|
65
|
+
origin = (start or Path.cwd()).resolve()
|
|
66
|
+
for candidate in (origin, *origin.parents):
|
|
67
|
+
if is_workspace(candidate):
|
|
68
|
+
return candidate
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_workspace(*, remember: bool = False) -> Path:
|
|
73
|
+
"""The workspace the ``dev`` commands should act on.
|
|
74
|
+
|
|
75
|
+
One implementation for ``up`` / ``down`` / ``status``, which previously
|
|
76
|
+
each resolved this themselves and would drift apart the moment one of
|
|
77
|
+
them learned something the others did not.
|
|
78
|
+
|
|
79
|
+
Order: the configured workspace when it holds a checkout, then detection
|
|
80
|
+
from the cwd, then the configured path (or cwd) unchanged so a caller
|
|
81
|
+
still fails the way it used to rather than in some new way.
|
|
82
|
+
|
|
83
|
+
``remember`` persists a detected workspace to config, so the commands
|
|
84
|
+
that only read it agree with the one that found it.
|
|
85
|
+
"""
|
|
86
|
+
from faden_cli.core.console import info, warning
|
|
87
|
+
from faden_cli.core.settings import load_config
|
|
88
|
+
|
|
89
|
+
cfg = load_config()
|
|
90
|
+
configured = Path(cfg.dev.workspace_dir) if cfg.dev and cfg.dev.workspace_dir else None
|
|
91
|
+
if configured and is_workspace(configured):
|
|
92
|
+
return configured
|
|
93
|
+
|
|
94
|
+
detected = detect_workspace()
|
|
95
|
+
if detected:
|
|
96
|
+
if configured:
|
|
97
|
+
warning(f"Configured workspace {configured} holds no checkout — using {detected}")
|
|
98
|
+
else:
|
|
99
|
+
info(f"Detected workspace at {detected}")
|
|
100
|
+
if remember:
|
|
101
|
+
_remember_workspace(detected)
|
|
102
|
+
return detected
|
|
103
|
+
|
|
104
|
+
return configured or Path.cwd()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _remember_workspace(workspace: Path) -> None:
|
|
108
|
+
"""Persist a detected workspace so the read-only dev commands agree."""
|
|
109
|
+
from faden_cli.core.console import warning
|
|
110
|
+
from faden_cli.core.settings import DevConfig, load_config, save_config
|
|
111
|
+
|
|
112
|
+
cfg = load_config()
|
|
113
|
+
if cfg.dev and cfg.dev.workspace_dir == str(workspace):
|
|
114
|
+
return
|
|
115
|
+
cfg.dev = cfg.dev or DevConfig()
|
|
116
|
+
cfg.dev.workspace_dir = str(workspace)
|
|
117
|
+
try:
|
|
118
|
+
save_config(cfg)
|
|
119
|
+
except OSError as exc: # noqa: BLE001 — a read-only config must not stop a dev run
|
|
120
|
+
warning(f"Could not save the detected workspace to config: {exc}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_shared_compose(workspace: Path) -> Path | None:
|
|
124
|
+
"""Locate the shared docker-compose file for a dev workspace."""
|
|
125
|
+
for candidate in (
|
|
126
|
+
workspace / "shared" / "docker-compose.yaml",
|
|
127
|
+
workspace / "shared" / "docker-compose.yml",
|
|
128
|
+
workspace / CORE_DIRNAME / "shared" / "docker-compose.yaml",
|
|
129
|
+
workspace / CORE_DIRNAME / "shared" / "docker-compose.yml",
|
|
130
|
+
workspace / "infra" / "shared" / "docker-compose.yaml",
|
|
131
|
+
workspace / "infra" / "shared" / "docker-compose.yml",
|
|
132
|
+
):
|
|
133
|
+
if candidate.exists():
|
|
134
|
+
return candidate
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def log_filename(label: str) -> str:
|
|
139
|
+
"""Log file name for a dev process label (``backend``/``worker``/...).
|
|
140
|
+
|
|
141
|
+
Unmapped labels are slugified rather than interpolated raw. A label with a
|
|
142
|
+
space fell through this fallback and produced ``faden-api gateway.log`` -- a
|
|
143
|
+
space in a path every caller then has to quote, and which nothing in the
|
|
144
|
+
tooling did.
|
|
145
|
+
"""
|
|
146
|
+
known = LOG_LABELS.get(label)
|
|
147
|
+
if known is not None:
|
|
148
|
+
return known
|
|
149
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", label).strip("-").lower() or "process"
|
|
150
|
+
return f"faden-{slug}.log"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def dev_logs_dir() -> Path:
|
|
154
|
+
"""Directory holding background (headless) dev process log files."""
|
|
155
|
+
return Path.home() / ".faden-dev" / "logs"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────
|
|
2
|
+
# Fadenstack — Environment Configuration
|
|
3
|
+
# Generated by faden CLI v{{ version }} • {{ timestamp }}
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
# ── Postgres ──────────────────────────────────────────────────────
|
|
7
|
+
POSTGRES_USER={{ postgres_user }}
|
|
8
|
+
POSTGRES_PASSWORD={{ postgres_password }}
|
|
9
|
+
POSTGRES_DB={{ postgres_db }}
|
|
10
|
+
|
|
11
|
+
# ── Redis ─────────────────────────────────────────────────────────
|
|
12
|
+
REDIS_PASSWORD={{ redis_password }}
|
|
13
|
+
|
|
14
|
+
# ── RabbitMQ ──────────────────────────────────────────────────────
|
|
15
|
+
RABBITMQ_DEFAULT_USER={{ rabbitmq_user }}
|
|
16
|
+
RABBITMQ_DEFAULT_PASS={{ rabbitmq_password }}
|
|
17
|
+
|
|
18
|
+
# ── MinIO (S3-compatible object storage) ──────────────────────────
|
|
19
|
+
MINIO_ROOT_USER={{ minio_root_user }}
|
|
20
|
+
MINIO_ROOT_PASSWORD={{ minio_root_password }}
|
|
21
|
+
|
|
22
|
+
# ── Langfuse (LLM observability) ─────────────────────────────────
|
|
23
|
+
LANGFUSE_SECRET_KEY={{ langfuse_secret_key }}
|
|
24
|
+
LANGFUSE_NEXT_AUTH_SECRET={{ langfuse_next_auth_secret }}
|
|
25
|
+
LANGFUSE_SALT={{ langfuse_salt }}
|
|
26
|
+
|
|
27
|
+
# ── JWT ───────────────────────────────────────────────────────────
|
|
28
|
+
JWT_SECRET={{ jwt_secret }}
|
|
29
|
+
|
|
30
|
+
# ── Module flags ──────────────────────────────────────────────────
|
|
31
|
+
{% for key, value in module_vars.items() -%}
|
|
32
|
+
{{ key }}={{ value }}
|
|
33
|
+
{% endfor %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TUI components built on Textual."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Custom Textual widgets for the faden TUI."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Init wizard screens for the Textual TUI."""
|