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,710 @@
|
|
|
1
|
+
"""``faden dev init`` — bootstrap a full development workspace.
|
|
2
|
+
|
|
3
|
+
Clones all repositories, installs dependencies, starts shared
|
|
4
|
+
infrastructure, runs migrations, and generates a VS Code workspace file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
from click.core import ParameterSource
|
|
21
|
+
|
|
22
|
+
from faden_cli.core.compose import ComposeContext, up as compose_up
|
|
23
|
+
from faden_cli.core.console import console, success, warning, error, info
|
|
24
|
+
from faden_cli.core.detect import detect_docker
|
|
25
|
+
from faden_cli.core.env_gen import dev_env_vars, read_env_file, write_env_file
|
|
26
|
+
from faden_cli.core.git import clone_all_repos
|
|
27
|
+
from faden_cli.core.install import ensure_prerequisites
|
|
28
|
+
from faden_cli.core.registry import (
|
|
29
|
+
BACKEND_DEV_ENV,
|
|
30
|
+
DATABASES,
|
|
31
|
+
INFRA_SERVICES,
|
|
32
|
+
MODULES_COMPAT,
|
|
33
|
+
POSTGRES_CONTAINER,
|
|
34
|
+
backend_dev_env_for,
|
|
35
|
+
)
|
|
36
|
+
from faden_cli.core.rmq import (
|
|
37
|
+
write_definitions as _write_rmq_definitions,
|
|
38
|
+
)
|
|
39
|
+
from faden_cli.core.settings import (
|
|
40
|
+
DevConfig,
|
|
41
|
+
FadenConfig,
|
|
42
|
+
REPO_DIR_MAP,
|
|
43
|
+
load_config,
|
|
44
|
+
save_config,
|
|
45
|
+
)
|
|
46
|
+
from faden_cli.core.workspace import detect_workspace, find_service_dir, resolve_shared_compose
|
|
47
|
+
|
|
48
|
+
from .dev_group import dev_group
|
|
49
|
+
|
|
50
|
+
# Backwards-compatible aliases — the canonical implementations live in
|
|
51
|
+
# faden_cli.core.workspace so dev_up / dev_status resolve paths identically.
|
|
52
|
+
_find_service_dir = find_service_dir
|
|
53
|
+
_resolve_shared_compose = resolve_shared_compose
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _wait_for_postgres(container: str = POSTGRES_CONTAINER, timeout: int = 60) -> bool:
|
|
57
|
+
"""Block until the Postgres container reports healthy or running."""
|
|
58
|
+
import time
|
|
59
|
+
|
|
60
|
+
waited = 0
|
|
61
|
+
while waited < timeout:
|
|
62
|
+
try:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
[
|
|
65
|
+
"docker", "inspect", "--format",
|
|
66
|
+
"{{if .State.Health}}{{.State.Health.Status}}{{else}}running{{end}}",
|
|
67
|
+
container,
|
|
68
|
+
],
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
)
|
|
72
|
+
status = result.stdout.strip()
|
|
73
|
+
if status in ("healthy", "running"):
|
|
74
|
+
return True
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
time.sleep(3)
|
|
78
|
+
waited += 3
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
83
|
+
"""Parse a simple KEY=VALUE env file."""
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
values: dict[str, str] = {}
|
|
88
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
89
|
+
line = raw_line.strip()
|
|
90
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
91
|
+
continue
|
|
92
|
+
key, value = line.split("=", 1)
|
|
93
|
+
values[key.strip()] = value.strip()
|
|
94
|
+
return values
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _backend_db_creds(backend_dir: Path) -> tuple[str, str, str]:
|
|
98
|
+
"""Resolve backend DB user/password/database from backend .env or defaults."""
|
|
99
|
+
defaults = ("faden_backend", "faden_backend", "faden_backend")
|
|
100
|
+
env_values = _parse_env_file(backend_dir / ".env")
|
|
101
|
+
return (
|
|
102
|
+
env_values.get("FADEN_BACKEND_DB_USER", defaults[0]),
|
|
103
|
+
env_values.get("FADEN_BACKEND_DB_PASS", defaults[1]),
|
|
104
|
+
env_values.get("FADEN_BACKEND_DB_BASE", defaults[2]),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ensure_backend_role(backend_dir: Path, pg_user: str = "postgres") -> None:
|
|
109
|
+
"""Ensure backend DB role exists and can migrate the backend database."""
|
|
110
|
+
db_user, db_pass, db_name = _backend_db_creds(backend_dir)
|
|
111
|
+
role_lit = db_user.replace("'", "''")
|
|
112
|
+
pass_lit = db_pass.replace("'", "''")
|
|
113
|
+
db_lit = db_name.replace("'", "''")
|
|
114
|
+
role_ident = '"' + db_user.replace('"', '""') + '"'
|
|
115
|
+
|
|
116
|
+
create_or_update_role_sql = (
|
|
117
|
+
"DO $$ "
|
|
118
|
+
"BEGIN "
|
|
119
|
+
f" IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{role_lit}') THEN "
|
|
120
|
+
f" CREATE ROLE {role_ident} LOGIN PASSWORD '{pass_lit}'; "
|
|
121
|
+
" ELSE "
|
|
122
|
+
f" ALTER ROLE {role_ident} WITH LOGIN PASSWORD '{pass_lit}'; "
|
|
123
|
+
" END IF; "
|
|
124
|
+
"END "
|
|
125
|
+
"$$;"
|
|
126
|
+
)
|
|
127
|
+
role_result = subprocess.run(
|
|
128
|
+
[
|
|
129
|
+
"docker",
|
|
130
|
+
"exec",
|
|
131
|
+
POSTGRES_CONTAINER,
|
|
132
|
+
"psql",
|
|
133
|
+
"-U",
|
|
134
|
+
pg_user,
|
|
135
|
+
"-v",
|
|
136
|
+
"ON_ERROR_STOP=1",
|
|
137
|
+
"-d",
|
|
138
|
+
"postgres",
|
|
139
|
+
"-c",
|
|
140
|
+
create_or_update_role_sql,
|
|
141
|
+
],
|
|
142
|
+
capture_output=True,
|
|
143
|
+
text=True,
|
|
144
|
+
)
|
|
145
|
+
if role_result.returncode != 0:
|
|
146
|
+
warning(
|
|
147
|
+
"Could not ensure backend DB role before migrations:\n"
|
|
148
|
+
f"{(role_result.stderr or role_result.stdout).strip()}"
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
grant_db_sql = (
|
|
153
|
+
f"GRANT CONNECT ON DATABASE \"{db_lit}\" TO {role_ident}; "
|
|
154
|
+
f"GRANT ALL PRIVILEGES ON DATABASE \"{db_lit}\" TO {role_ident};"
|
|
155
|
+
)
|
|
156
|
+
subprocess.run(
|
|
157
|
+
[
|
|
158
|
+
"docker",
|
|
159
|
+
"exec",
|
|
160
|
+
POSTGRES_CONTAINER,
|
|
161
|
+
"psql",
|
|
162
|
+
"-U",
|
|
163
|
+
pg_user,
|
|
164
|
+
"-v",
|
|
165
|
+
"ON_ERROR_STOP=1",
|
|
166
|
+
"-d",
|
|
167
|
+
"postgres",
|
|
168
|
+
"-c",
|
|
169
|
+
grant_db_sql,
|
|
170
|
+
],
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
)
|
|
174
|
+
subprocess.run(
|
|
175
|
+
[
|
|
176
|
+
"docker",
|
|
177
|
+
"exec",
|
|
178
|
+
POSTGRES_CONTAINER,
|
|
179
|
+
"psql",
|
|
180
|
+
"-U",
|
|
181
|
+
pg_user,
|
|
182
|
+
"-v",
|
|
183
|
+
"ON_ERROR_STOP=1",
|
|
184
|
+
"-d",
|
|
185
|
+
db_name,
|
|
186
|
+
"-c",
|
|
187
|
+
f"GRANT ALL ON SCHEMA public TO {role_ident};",
|
|
188
|
+
],
|
|
189
|
+
capture_output=True,
|
|
190
|
+
text=True,
|
|
191
|
+
)
|
|
192
|
+
subprocess.run(
|
|
193
|
+
[
|
|
194
|
+
"docker",
|
|
195
|
+
"exec",
|
|
196
|
+
POSTGRES_CONTAINER,
|
|
197
|
+
"psql",
|
|
198
|
+
"-U",
|
|
199
|
+
pg_user,
|
|
200
|
+
"-v",
|
|
201
|
+
"ON_ERROR_STOP=1",
|
|
202
|
+
"-d",
|
|
203
|
+
db_name,
|
|
204
|
+
"-c",
|
|
205
|
+
(
|
|
206
|
+
f"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO {role_ident}; "
|
|
207
|
+
f"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO {role_ident}; "
|
|
208
|
+
f"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
|
|
209
|
+
f"GRANT ALL PRIVILEGES ON TABLES TO {role_ident}; "
|
|
210
|
+
f"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
|
|
211
|
+
f"GRANT ALL PRIVILEGES ON SEQUENCES TO {role_ident};"
|
|
212
|
+
),
|
|
213
|
+
],
|
|
214
|
+
capture_output=True,
|
|
215
|
+
text=True,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _ensure_database(db_name: str, pg_user: str = "postgres", *, quiet: bool = False) -> bool:
|
|
220
|
+
"""Ensure a database exists inside the shared Postgres container.
|
|
221
|
+
|
|
222
|
+
Returns True when this call created it. ``quiet`` suppresses the
|
|
223
|
+
per-database chatter for callers that run this on every invocation
|
|
224
|
+
(``dev up``) rather than once during setup.
|
|
225
|
+
"""
|
|
226
|
+
check_sql = f"SELECT 1 FROM pg_database WHERE datname='{db_name}'"
|
|
227
|
+
result = subprocess.run(
|
|
228
|
+
["docker", "exec", POSTGRES_CONTAINER, "psql", "-U", pg_user, "-tAc", check_sql],
|
|
229
|
+
capture_output=True,
|
|
230
|
+
text=True,
|
|
231
|
+
)
|
|
232
|
+
if "1" in (result.stdout or ""):
|
|
233
|
+
if not quiet:
|
|
234
|
+
info(f"Database '{db_name}' already exists.")
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
subprocess.run(
|
|
238
|
+
["docker", "exec", POSTGRES_CONTAINER, "psql", "-U", pg_user, "-c", f"CREATE DATABASE {db_name};"],
|
|
239
|
+
capture_output=True,
|
|
240
|
+
text=True,
|
|
241
|
+
)
|
|
242
|
+
if not quiet:
|
|
243
|
+
success(f"Created database '{db_name}'.")
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _install_backend_deps(backend_dir: Path) -> None:
|
|
248
|
+
"""Run ``uv sync`` in the backend directory."""
|
|
249
|
+
console.print("[cyan]Installing backend dependencies (uv sync)…[/cyan]")
|
|
250
|
+
result = subprocess.run(["uv", "sync", "--locked"], cwd=str(backend_dir))
|
|
251
|
+
if result.returncode != 0:
|
|
252
|
+
warning("uv sync --locked failed, retrying without --locked…")
|
|
253
|
+
result = subprocess.run(["uv", "sync"], cwd=str(backend_dir))
|
|
254
|
+
if result.returncode != 0:
|
|
255
|
+
error("uv sync failed.")
|
|
256
|
+
return
|
|
257
|
+
success("Backend dependencies installed.")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _install_frontend_deps(frontend_dir: Path) -> None:
|
|
261
|
+
"""Run ``npm install`` in the frontend directory."""
|
|
262
|
+
console.print("[cyan]Installing frontend dependencies (npm install)…[/cyan]")
|
|
263
|
+
result = subprocess.run(["npm", "install"], cwd=str(frontend_dir), shell=(os.name == "nt")) # noqa: S603, S607
|
|
264
|
+
if result.returncode != 0:
|
|
265
|
+
error("npm install failed.")
|
|
266
|
+
return
|
|
267
|
+
success("Frontend dependencies installed.")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _run_migrations(backend_dir: Path) -> None:
|
|
271
|
+
"""Run Alembic migrations for backend and API gateway."""
|
|
272
|
+
console.print("[cyan]Running database migrations…[/cyan]")
|
|
273
|
+
_ensure_backend_role(backend_dir)
|
|
274
|
+
db_user, db_pass, db_base = _backend_db_creds(backend_dir)
|
|
275
|
+
env = os.environ.copy()
|
|
276
|
+
# Ensure backend Alembic connects with the right credentials.
|
|
277
|
+
env.setdefault("FADEN_BACKEND_DB_HOST", "127.0.0.1") # not localhost: see INFRA_LOOPBACK
|
|
278
|
+
env.setdefault("FADEN_BACKEND_DB_PORT", "5432")
|
|
279
|
+
env.setdefault("FADEN_BACKEND_DB_USER", db_user)
|
|
280
|
+
env.setdefault("FADEN_BACKEND_DB_PASS", db_pass)
|
|
281
|
+
env.setdefault("FADEN_BACKEND_DB_BASE", db_base)
|
|
282
|
+
result = subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=str(backend_dir), env=env)
|
|
283
|
+
if result.returncode != 0:
|
|
284
|
+
warning("Backend Alembic migration exited with non-zero code.")
|
|
285
|
+
else:
|
|
286
|
+
success("Backend migrations up to date.")
|
|
287
|
+
|
|
288
|
+
# API gateway migrations (creates llm_gateway_request_log, etc.)
|
|
289
|
+
api_dir = backend_dir.parent / "gateway"
|
|
290
|
+
if api_dir.exists() and (api_dir / "alembic.ini").exists():
|
|
291
|
+
console.print("[cyan]Running API gateway migrations…[/cyan]")
|
|
292
|
+
result = subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=str(api_dir), env=env)
|
|
293
|
+
if result.returncode != 0:
|
|
294
|
+
warning("API gateway Alembic migration exited with non-zero code.")
|
|
295
|
+
else:
|
|
296
|
+
success("API gateway migrations up to date.")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _generate_vscode_workspace(workspace: Path) -> None:
|
|
300
|
+
"""Create a VS Code multi-root workspace file."""
|
|
301
|
+
workspace_file = workspace / "fadenstack.code-workspace"
|
|
302
|
+
|
|
303
|
+
folders = []
|
|
304
|
+
for _gh_name, local_name in sorted(REPO_DIR_MAP.items()):
|
|
305
|
+
repo_path = find_service_dir(workspace, local_name)
|
|
306
|
+
if repo_path.exists():
|
|
307
|
+
rel = os.path.relpath(repo_path, workspace).replace("\\", "/")
|
|
308
|
+
folders.append({"path": rel})
|
|
309
|
+
|
|
310
|
+
content = {
|
|
311
|
+
"folders": folders,
|
|
312
|
+
"settings": {
|
|
313
|
+
"python.defaultInterpreterPath": ".venv/bin/python",
|
|
314
|
+
"editor.formatOnSave": True,
|
|
315
|
+
"[python]": {
|
|
316
|
+
"editor.defaultFormatter": "charliermarsh.ruff",
|
|
317
|
+
},
|
|
318
|
+
"[typescript]": {
|
|
319
|
+
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
320
|
+
},
|
|
321
|
+
"[typescriptreact]": {
|
|
322
|
+
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
workspace_file.write_text(json.dumps(content, indent=2) + "\n", encoding="utf-8")
|
|
327
|
+
success(f"VS Code workspace file: {workspace_file}")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _resync_rmq_credentials(shared_dir: Path, *, skip_infra: bool) -> None:
|
|
331
|
+
"""Regenerate RMQ ``definitions.json`` from the shared ``.env`` and
|
|
332
|
+
restart the broker so the on-disk users match the in-env passwords.
|
|
333
|
+
|
|
334
|
+
``shared/rabbitmq/definitions.json`` is generated, not tracked. When it
|
|
335
|
+
is missing or older than the shared ``.env`` (new ``RABBITMQ_*_PASS``
|
|
336
|
+
values), the broker's users drift out of sync and the backend/worker —
|
|
337
|
+
which are told to log in as ``faden-backend`` / ``RABBITMQ_BACKEND_PASS``
|
|
338
|
+
— get ``ACCESS_REFUSED`` from the mgmt API / AMQP layer.
|
|
339
|
+
|
|
340
|
+
Skipped when ``--skip-infra`` (no broker to restart), docker is
|
|
341
|
+
unavailable, or the shared ``.env`` predates the per-service users.
|
|
342
|
+
"""
|
|
343
|
+
env_path = shared_dir / ".env"
|
|
344
|
+
env_vars = read_env_file(env_path)
|
|
345
|
+
admin_pass = env_vars.get("RABBITMQ_ADMIN_PASS")
|
|
346
|
+
backend_pass = env_vars.get("RABBITMQ_BACKEND_PASS")
|
|
347
|
+
if not admin_pass or not backend_pass:
|
|
348
|
+
warning("Shared .env has no RABBITMQ_*_PASS — skipping RMQ definitions resync.")
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
profiles = {
|
|
352
|
+
p.strip()
|
|
353
|
+
for p in env_vars.get("MODULES", "").split(",")
|
|
354
|
+
if p.strip()
|
|
355
|
+
}
|
|
356
|
+
defs_path = _write_rmq_definitions(
|
|
357
|
+
shared_dir,
|
|
358
|
+
admin_user=env_vars.get("RABBITMQ_ADMIN_USER", "admin"),
|
|
359
|
+
admin_pass=admin_pass,
|
|
360
|
+
backend_pass=backend_pass,
|
|
361
|
+
api_pass=env_vars.get("RABBITMQ_API_PASS", backend_pass),
|
|
362
|
+
pii_pass=env_vars.get("RABBITMQ_PII_PASS") if "pii" in profiles else None,
|
|
363
|
+
)
|
|
364
|
+
info(f"RMQ definitions regenerated: {defs_path}")
|
|
365
|
+
|
|
366
|
+
if skip_infra:
|
|
367
|
+
info("Skipping RMQ restart (--skip-infra); definitions load on next broker start.")
|
|
368
|
+
return
|
|
369
|
+
docker = shutil.which("docker")
|
|
370
|
+
if not docker or not _wait_for_postgres(timeout=10):
|
|
371
|
+
info("Docker not ready — RMQ definitions will load on next broker start.")
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
container = "faden-rmq" # explicit container_name in the shared compose
|
|
375
|
+
running = subprocess.run(
|
|
376
|
+
[docker, "inspect", "-f", "{{.State.Running}}", container],
|
|
377
|
+
capture_output=True,
|
|
378
|
+
text=True,
|
|
379
|
+
)
|
|
380
|
+
if running.returncode != 0:
|
|
381
|
+
info("RMQ container not found — definitions will load on first broker start.")
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# Recreate the broker: the container mounts ./rabbitmq/definitions.json,
|
|
385
|
+
# but load_definitions runs once at startup, so a plain restart of an
|
|
386
|
+
# already-running container keeps the old users.
|
|
387
|
+
if subprocess.run([docker, "rm", "-f", container], capture_output=True).returncode != 0:
|
|
388
|
+
warning("Could not remove the RMQ container — restart it manually to load new credentials.")
|
|
389
|
+
return
|
|
390
|
+
compose_file = next(
|
|
391
|
+
(p for p in (shared_dir / "docker-compose.yaml", shared_dir / "docker-compose.yml") if p.exists()),
|
|
392
|
+
None,
|
|
393
|
+
)
|
|
394
|
+
if not compose_file:
|
|
395
|
+
warning("Shared compose file not found for RMQ restart.")
|
|
396
|
+
return
|
|
397
|
+
ctx = ComposeContext(
|
|
398
|
+
compose_files=[str(compose_file)],
|
|
399
|
+
env_file=str(env_path),
|
|
400
|
+
project_dir=str(shared_dir),
|
|
401
|
+
)
|
|
402
|
+
rc = compose_up(ctx, services=["faden-rmq"], detach=True)
|
|
403
|
+
if rc != 0:
|
|
404
|
+
warning("docker compose up faden-rmq failed — retry after infra is up.")
|
|
405
|
+
return
|
|
406
|
+
# Wait until the broker accepts logins with the new service user.
|
|
407
|
+
# rabbitmqctl prints "… ok" (3.x) or "… Success" (4.x); either is
|
|
408
|
+
# accompanied by exit code 0, but we check both to be safe.
|
|
409
|
+
for _ in range(60):
|
|
410
|
+
time.sleep(2)
|
|
411
|
+
rc = subprocess.run(
|
|
412
|
+
[docker, "exec", container, "rabbitmqctl",
|
|
413
|
+
"authenticate_user", "faden-backend", backend_pass],
|
|
414
|
+
capture_output=True,
|
|
415
|
+
text=True,
|
|
416
|
+
)
|
|
417
|
+
out = (rc.stdout or "").lower()
|
|
418
|
+
if rc.returncode == 0 and ("ok" in out or "success" in out):
|
|
419
|
+
success("RMQ credentials resynced (faden-backend accepts login).")
|
|
420
|
+
return
|
|
421
|
+
warning("RMQ broker restarted; could not confirm faden-backend login within 120s.")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _resolve_init_workspace(workspace: str, *, explicit: bool) -> Path:
|
|
425
|
+
"""Decide where ``dev init`` sets up.
|
|
426
|
+
|
|
427
|
+
Adopts a checkout the developer already has rather than cloning a second
|
|
428
|
+
copy beside it — which is what happened whenever ``init`` was run from
|
|
429
|
+
somewhere other than the exact directory holding the code.
|
|
430
|
+
|
|
431
|
+
An *explicit* path is honoured as given: it is an instruction about where
|
|
432
|
+
the workspace should live, and silently relocating it to some ancestor
|
|
433
|
+
that happens to contain a checkout would be the wrong kind of clever.
|
|
434
|
+
Detection therefore applies only to the default argument.
|
|
435
|
+
"""
|
|
436
|
+
target = Path(workspace).resolve()
|
|
437
|
+
if explicit:
|
|
438
|
+
return target
|
|
439
|
+
detected = detect_workspace()
|
|
440
|
+
if detected and detected != target:
|
|
441
|
+
info(f"Existing checkout detected at {detected} — using it as the workspace.")
|
|
442
|
+
return detected
|
|
443
|
+
return target
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@dev_group.command("init")
|
|
447
|
+
@click.argument("workspace", type=click.Path(), default=".")
|
|
448
|
+
@click.option("--ssh", is_flag=True, help="Clone using SSH instead of HTTPS.")
|
|
449
|
+
@click.option("--branch", "-b", default="main", show_default=True, help="Branch to checkout after cloning.")
|
|
450
|
+
@click.option("--overwrite", is_flag=True, help="Remove and re-clone existing repos.")
|
|
451
|
+
@click.option("--skip-infra", is_flag=True, help="Skip starting shared infrastructure.")
|
|
452
|
+
@click.option("--skip-deps", is_flag=True, help="Skip installing dependencies.")
|
|
453
|
+
@click.option("--skip-migrations", is_flag=True, help="Skip running database migrations.")
|
|
454
|
+
@click.option(
|
|
455
|
+
"--modules",
|
|
456
|
+
default=None,
|
|
457
|
+
help="Comma-separated list of modules to enable (e.g. pii,auth).",
|
|
458
|
+
)
|
|
459
|
+
@click.option("--force-env", is_flag=True, help="Regenerate .env files even if they already exist.")
|
|
460
|
+
@click.option("--install-prereqs", is_flag=True, help="Auto-install missing prerequisites (uv, git, node).")
|
|
461
|
+
def dev_init(
|
|
462
|
+
workspace: str,
|
|
463
|
+
*,
|
|
464
|
+
ssh: bool,
|
|
465
|
+
branch: str,
|
|
466
|
+
overwrite: bool,
|
|
467
|
+
skip_infra: bool,
|
|
468
|
+
skip_deps: bool,
|
|
469
|
+
skip_migrations: bool,
|
|
470
|
+
modules: str | None,
|
|
471
|
+
force_env: bool,
|
|
472
|
+
install_prereqs: bool,
|
|
473
|
+
) -> None:
|
|
474
|
+
"""Bootstrap a full Fadenstack development workspace.
|
|
475
|
+
|
|
476
|
+
Clones all repositories, installs dependencies, starts shared
|
|
477
|
+
infrastructure, runs migrations, and generates a VS Code workspace.
|
|
478
|
+
|
|
479
|
+
\b
|
|
480
|
+
Steps:
|
|
481
|
+
1. Clone all Fadenstack repositories
|
|
482
|
+
2. Generate .env with dev credentials
|
|
483
|
+
3. Start shared infrastructure (Postgres, Redis, RabbitMQ, …)
|
|
484
|
+
4. Ensure databases exist
|
|
485
|
+
5. Install backend (uv) and frontend (npm) dependencies
|
|
486
|
+
6. Run Alembic migrations
|
|
487
|
+
7. Generate VS Code workspace file
|
|
488
|
+
|
|
489
|
+
Example:
|
|
490
|
+
|
|
491
|
+
faden dev init --workspace ~/projects/fadenstack --branch feature/my-work
|
|
492
|
+
"""
|
|
493
|
+
explicit = (
|
|
494
|
+
click.get_current_context().get_parameter_source("workspace")
|
|
495
|
+
is not ParameterSource.DEFAULT
|
|
496
|
+
)
|
|
497
|
+
workspace_path = _resolve_init_workspace(workspace, explicit=explicit)
|
|
498
|
+
workspace_path.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
|
|
500
|
+
# Parse --modules into profile list
|
|
501
|
+
profiles: list[str] = []
|
|
502
|
+
if modules:
|
|
503
|
+
for m in modules.split(","):
|
|
504
|
+
m = m.strip().lower()
|
|
505
|
+
if m in MODULES_COMPAT:
|
|
506
|
+
profiles.append(MODULES_COMPAT[m]["profile"])
|
|
507
|
+
else:
|
|
508
|
+
warning(f"Unknown module '{m}'. Available: {', '.join(MODULES_COMPAT)}")
|
|
509
|
+
|
|
510
|
+
console.print(f"\n[bold magenta]fadenstack Developer Workspace Setup[/bold magenta]")
|
|
511
|
+
console.print(f"[dim]Workspace: {workspace_path}[/dim]")
|
|
512
|
+
if profiles:
|
|
513
|
+
console.print(f"[dim]Modules: {', '.join(profiles)}[/dim]")
|
|
514
|
+
console.print()
|
|
515
|
+
|
|
516
|
+
# ── Prerequisites ─────────────────────────────────────────────
|
|
517
|
+
console.print("[bold cyan]Checking prerequisites…[/bold cyan]")
|
|
518
|
+
all_ok = ensure_prerequisites(install=install_prereqs)
|
|
519
|
+
if not all_ok:
|
|
520
|
+
error(
|
|
521
|
+
"Some prerequisites are missing.\n"
|
|
522
|
+
" Run: faden dev doctor --install"
|
|
523
|
+
)
|
|
524
|
+
sys.exit(1)
|
|
525
|
+
|
|
526
|
+
# Docker daemon must also be running
|
|
527
|
+
docker_info = detect_docker()
|
|
528
|
+
if not docker_info.daemon_running:
|
|
529
|
+
error(f"Docker daemon is not running. {docker_info.daemon_hint}")
|
|
530
|
+
if docker_info.error:
|
|
531
|
+
info(f" Detail: {docker_info.error}")
|
|
532
|
+
sys.exit(1)
|
|
533
|
+
|
|
534
|
+
success("Prerequisites OK.")
|
|
535
|
+
|
|
536
|
+
# ── 1. Clone repos ────────────────────────────────────────────
|
|
537
|
+
# Repos already present are skipped, so re-running against an existing
|
|
538
|
+
# workspace pulls only what is missing rather than touching the tree.
|
|
539
|
+
console.print("\n[bold cyan]Step 1: Cloning missing repositories…[/bold cyan]")
|
|
540
|
+
clone_method = "ssh" if ssh else "https"
|
|
541
|
+
|
|
542
|
+
# Resolve GitHub token for cloning private repos (if configured)
|
|
543
|
+
existing_cfg = load_config()
|
|
544
|
+
github_token = existing_cfg.dev.github_token if existing_cfg.dev else ""
|
|
545
|
+
|
|
546
|
+
results = clone_all_repos(list(REPO_DIR_MAP.keys()), target_dir=workspace_path, method=clone_method, branch=branch, force=overwrite, token=github_token)
|
|
547
|
+
|
|
548
|
+
cloned = sum(1 for r in results if r.cloned)
|
|
549
|
+
skipped = sum(1 for r in results if r.skipped)
|
|
550
|
+
errors = [r for r in results if r.error]
|
|
551
|
+
info(f"Cloned: {cloned}, Skipped (already exist): {skipped}, Errors: {len(errors)}")
|
|
552
|
+
for r in errors:
|
|
553
|
+
error(f" {r.repo}: {r.error}")
|
|
554
|
+
|
|
555
|
+
# ── 2. Generate .env ──────────────────────────────────────────
|
|
556
|
+
console.print("\n[bold cyan]Step 2: Generating .env files…[/bold cyan]")
|
|
557
|
+
|
|
558
|
+
# ── 2a. Shared infrastructure .env ────────────────────────────
|
|
559
|
+
shared_dir = workspace_path / "shared"
|
|
560
|
+
if not shared_dir.is_dir():
|
|
561
|
+
shared_dir = workspace_path / "fadenstack" / "shared"
|
|
562
|
+
env_path = shared_dir / ".env"
|
|
563
|
+
if env_path.exists() and not force_env:
|
|
564
|
+
warning(f".env already exists at {env_path} — skipping (use --force-env to regenerate).")
|
|
565
|
+
from faden_cli.commands.dev.dev_up import heal_dev_env
|
|
566
|
+
|
|
567
|
+
heal_dev_env(env_path)
|
|
568
|
+
else:
|
|
569
|
+
env_vars = dev_env_vars(profiles=profiles)
|
|
570
|
+
write_env_file(env_path, env_vars)
|
|
571
|
+
success(f".env written to {env_path}")
|
|
572
|
+
|
|
573
|
+
# ── 2b. Backend local .env (so uv run uses localhost) ─────────
|
|
574
|
+
backend_dir = _find_service_dir(workspace_path, "backend")
|
|
575
|
+
backend_env_path = backend_dir / ".env"
|
|
576
|
+
# Resolve the broker credentials from the shared env BEFORE writing the
|
|
577
|
+
# backend env, so the backend logs in as the per-service AMQP user that
|
|
578
|
+
# the shared RabbitMQ definitions create (faden-backend /
|
|
579
|
+
# RABBITMQ_BACKEND_PASS), not the hard-coded guest/guest default.
|
|
580
|
+
shared_env_path = env_path if env_path.exists() else (
|
|
581
|
+
workspace_path / "shared" / ".env"
|
|
582
|
+
)
|
|
583
|
+
backend_env = backend_dev_env_for(shared_env_path)
|
|
584
|
+
if backend_env_path.exists() and not force_env:
|
|
585
|
+
warning(f"Backend .env already exists at {backend_env_path} — skipping (use --force-env to regenerate).")
|
|
586
|
+
elif backend_dir.exists():
|
|
587
|
+
write_env_file(backend_env_path, backend_env)
|
|
588
|
+
success(f"Backend .env written to {backend_env_path}")
|
|
589
|
+
|
|
590
|
+
# ── 2c. Resync RabbitMQ credentials with the shared .env ──────
|
|
591
|
+
# definitions.json is not tracked: it holds the users' password
|
|
592
|
+
# hashes, written from the shared .env. Write it (and the snippet
|
|
593
|
+
# that loads it) and recreate the broker so the backend and worker
|
|
594
|
+
# can authenticate as faden-backend / RABBITMQ_BACKEND_PASS.
|
|
595
|
+
_resync_rmq_credentials(shared_dir, skip_infra=skip_infra)
|
|
596
|
+
|
|
597
|
+
# ── 3. Start shared infrastructure ────────────────────────────
|
|
598
|
+
if not skip_infra:
|
|
599
|
+
console.print("\n[bold cyan]Step 3: Starting shared infrastructure…[/bold cyan]")
|
|
600
|
+
compose_file = _resolve_shared_compose(workspace_path)
|
|
601
|
+
if compose_file:
|
|
602
|
+
ctx = ComposeContext(
|
|
603
|
+
compose_files=[str(compose_file)],
|
|
604
|
+
env_file=str(env_path) if env_path.exists() else None,
|
|
605
|
+
project_dir=str(compose_file.parent),
|
|
606
|
+
)
|
|
607
|
+
returncode = compose_up(ctx, services=INFRA_SERVICES, detach=True)
|
|
608
|
+
if returncode != 0:
|
|
609
|
+
error("docker compose up failed for shared infrastructure.")
|
|
610
|
+
else:
|
|
611
|
+
console.print("[cyan]Waiting for Postgres…[/cyan]")
|
|
612
|
+
if _wait_for_postgres():
|
|
613
|
+
success("Shared infrastructure is running.")
|
|
614
|
+
else:
|
|
615
|
+
warning("Postgres did not become ready in time.")
|
|
616
|
+
else:
|
|
617
|
+
warning("Could not find shared compose file. Skipping infrastructure startup.")
|
|
618
|
+
else:
|
|
619
|
+
info("Skipping infrastructure startup (--skip-infra).")
|
|
620
|
+
|
|
621
|
+
# ── 4. Ensure databases ───────────────────────────────────────
|
|
622
|
+
if not skip_infra:
|
|
623
|
+
console.print("\n[bold cyan]Step 4: Ensuring databases…[/bold cyan]")
|
|
624
|
+
for db_name in DATABASES:
|
|
625
|
+
_ensure_database(db_name)
|
|
626
|
+
if backend_dir.exists():
|
|
627
|
+
_ensure_backend_role(backend_dir)
|
|
628
|
+
|
|
629
|
+
# ── 5. Install dependencies ───────────────────────────────────
|
|
630
|
+
if not skip_deps:
|
|
631
|
+
console.print("\n[bold cyan]Step 5: Installing dependencies…[/bold cyan]")
|
|
632
|
+
backend_dir = _find_service_dir(workspace_path, "backend")
|
|
633
|
+
frontend_dir = _find_service_dir(workspace_path, "frontend")
|
|
634
|
+
|
|
635
|
+
if backend_dir.exists():
|
|
636
|
+
_install_backend_deps(backend_dir)
|
|
637
|
+
else:
|
|
638
|
+
warning("Backend directory not found. Skipping.")
|
|
639
|
+
|
|
640
|
+
if frontend_dir.exists():
|
|
641
|
+
_install_frontend_deps(frontend_dir)
|
|
642
|
+
else:
|
|
643
|
+
warning("Frontend directory not found. Skipping.")
|
|
644
|
+
else:
|
|
645
|
+
info("Skipping dependency installation (--skip-deps).")
|
|
646
|
+
|
|
647
|
+
# ── 6. Migrations ─────────────────────────────────────────────
|
|
648
|
+
if not skip_migrations and not skip_infra:
|
|
649
|
+
console.print("\n[bold cyan]Step 6: Running migrations…[/bold cyan]")
|
|
650
|
+
backend_dir = _find_service_dir(workspace_path, "backend")
|
|
651
|
+
if backend_dir.exists():
|
|
652
|
+
_run_migrations(backend_dir)
|
|
653
|
+
else:
|
|
654
|
+
warning("Backend directory not found. Skipping migrations.")
|
|
655
|
+
else:
|
|
656
|
+
info("Skipping migrations.")
|
|
657
|
+
|
|
658
|
+
# ── 7. VS Code workspace ─────────────────────────────────────
|
|
659
|
+
console.print("\n[bold cyan]Step 7: Generating VS Code workspace…[/bold cyan]")
|
|
660
|
+
_generate_vscode_workspace(workspace_path)
|
|
661
|
+
|
|
662
|
+
# ── Save config ───────────────────────────────────────────────
|
|
663
|
+
cfg = load_config()
|
|
664
|
+
cfg.install_dir = str(shared_dir)
|
|
665
|
+
cfg.profiles = sorted(set(cfg.profiles or []) | set(profiles))
|
|
666
|
+
cfg.dev = DevConfig(
|
|
667
|
+
workspace_dir=str(workspace_path),
|
|
668
|
+
clone_method=clone_method,
|
|
669
|
+
branch=branch,
|
|
670
|
+
repos=list(REPO_DIR_MAP.keys()),
|
|
671
|
+
)
|
|
672
|
+
save_config(cfg)
|
|
673
|
+
success("Configuration saved.")
|
|
674
|
+
|
|
675
|
+
# ── Summary ───────────────────────────────────────────────────
|
|
676
|
+
console.print("\n[bold green]✨ Development workspace ready![/bold green]")
|
|
677
|
+
|
|
678
|
+
from rich.panel import Panel # noqa: PLC0415
|
|
679
|
+
from rich.text import Text # noqa: PLC0415
|
|
680
|
+
|
|
681
|
+
cred_lines = Text()
|
|
682
|
+
cred_lines.append(" Admin email: ", style="bold")
|
|
683
|
+
cred_lines.append("admin@localhost", style="cyan")
|
|
684
|
+
cred_lines.append("\n Admin password: ", style="bold")
|
|
685
|
+
cred_lines.append("admin", style="cyan")
|
|
686
|
+
cred_lines.append("\n")
|
|
687
|
+
cred_lines.append(
|
|
688
|
+
"\n The dev admin user is created automatically when the\n"
|
|
689
|
+
" backend starts. These credentials are for local dev only.",
|
|
690
|
+
style="dim",
|
|
691
|
+
)
|
|
692
|
+
console.print()
|
|
693
|
+
console.print(
|
|
694
|
+
Panel(
|
|
695
|
+
cred_lines,
|
|
696
|
+
title="[bold cyan]Dev Credentials[/bold cyan]",
|
|
697
|
+
border_style="cyan",
|
|
698
|
+
padding=(1, 2),
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
console.print(
|
|
703
|
+
"\n[dim]Next steps:\n"
|
|
704
|
+
" cd {workspace}\n"
|
|
705
|
+
" faden dev up # start backend + frontend\n"
|
|
706
|
+
" faden dev status # check service status\n"
|
|
707
|
+
" code fadenstack.code-workspace # open in VS Code[/dim]".format(
|
|
708
|
+
workspace=workspace_path
|
|
709
|
+
)
|
|
710
|
+
)
|