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
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."""