fadenstack 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. faden_cli/__init__.py +16 -0
  2. faden_cli/__main__.py +9 -0
  3. faden_cli/bundle/shared/.gitignore +25 -0
  4. faden_cli/bundle/shared/LICENSE +187 -0
  5. faden_cli/bundle/shared/NOTICE +15 -0
  6. faden_cli/bundle/shared/OFFICIAL.md +9 -0
  7. faden_cli/bundle/shared/README.md +16 -0
  8. faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
  9. faden_cli/bundle/shared/alloy/config.alloy +40 -0
  10. faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
  11. faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
  12. faden_cli/bundle/shared/docker-compose.yaml +998 -0
  13. faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
  14. faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
  15. faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
  16. faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
  17. faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
  18. faden_cli/bundle/shared/initdb/01-init.sql +32 -0
  19. faden_cli/bundle/shared/loki/local-config.yaml +32 -0
  20. faden_cli/bundle/shared/nginx/locations.conf +245 -0
  21. faden_cli/bundle/shared/nginx/maintenance.html +239 -0
  22. faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
  23. faden_cli/bundle/shared/nginx/nginx.conf +44 -0
  24. faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
  25. faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
  26. faden_cli/bundle/shared/nginx/select-config.sh +37 -0
  27. faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
  28. faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
  29. faden_cli/bundle/shared/tls/README.md +30 -0
  30. faden_cli/cli.py +158 -0
  31. faden_cli/commands/__init__.py +1 -0
  32. faden_cli/commands/admin.py +161 -0
  33. faden_cli/commands/backup.py +159 -0
  34. faden_cli/commands/config.py +146 -0
  35. faden_cli/commands/deploy.py +949 -0
  36. faden_cli/commands/dev/__init__.py +1 -0
  37. faden_cli/commands/dev/dev_doctor.py +46 -0
  38. faden_cli/commands/dev/dev_down.py +241 -0
  39. faden_cli/commands/dev/dev_group.py +24 -0
  40. faden_cli/commands/dev/dev_init.py +710 -0
  41. faden_cli/commands/dev/dev_pull.py +297 -0
  42. faden_cli/commands/dev/dev_status.py +151 -0
  43. faden_cli/commands/dev/dev_up.py +1392 -0
  44. faden_cli/commands/doctor.py +225 -0
  45. faden_cli/commands/down.py +75 -0
  46. faden_cli/commands/logs_cmd.py +41 -0
  47. faden_cli/commands/maintenance.py +74 -0
  48. faden_cli/commands/module.py +113 -0
  49. faden_cli/commands/observe.py +95 -0
  50. faden_cli/commands/ports.py +91 -0
  51. faden_cli/commands/rebuild.py +158 -0
  52. faden_cli/commands/restore.py +139 -0
  53. faden_cli/commands/runtime_images.py +174 -0
  54. faden_cli/commands/status.py +71 -0
  55. faden_cli/commands/tls.py +722 -0
  56. faden_cli/commands/tune.py +37 -0
  57. faden_cli/commands/up.py +64 -0
  58. faden_cli/commands/upgrade.py +470 -0
  59. faden_cli/commands/version.py +78 -0
  60. faden_cli/core/__init__.py +1 -0
  61. faden_cli/core/api_client.py +77 -0
  62. faden_cli/core/backup.py +717 -0
  63. faden_cli/core/bootstrap.py +291 -0
  64. faden_cli/core/bundle.py +110 -0
  65. faden_cli/core/clickhouse.py +61 -0
  66. faden_cli/core/compose.py +484 -0
  67. faden_cli/core/console.py +106 -0
  68. faden_cli/core/detect.py +580 -0
  69. faden_cli/core/dev_modules.py +149 -0
  70. faden_cli/core/docker_env.py +147 -0
  71. faden_cli/core/edge.py +94 -0
  72. faden_cli/core/env_gen.py +598 -0
  73. faden_cli/core/git.py +219 -0
  74. faden_cli/core/install.py +288 -0
  75. faden_cli/core/installation.py +186 -0
  76. faden_cli/core/local_node.py +719 -0
  77. faden_cli/core/maintenance.py +82 -0
  78. faden_cli/core/private_files.py +133 -0
  79. faden_cli/core/registry.py +574 -0
  80. faden_cli/core/release_feed.py +342 -0
  81. faden_cli/core/rmq.py +135 -0
  82. faden_cli/core/runtime_images.py +309 -0
  83. faden_cli/core/schedule.py +278 -0
  84. faden_cli/core/settings.py +159 -0
  85. faden_cli/core/sysinfo.py +303 -0
  86. faden_cli/core/tls.py +790 -0
  87. faden_cli/core/tune.py +154 -0
  88. faden_cli/core/update_check.py +254 -0
  89. faden_cli/core/workspace.py +155 -0
  90. faden_cli/templates/env.j2 +33 -0
  91. faden_cli/tui/__init__.py +1 -0
  92. faden_cli/tui/widgets/__init__.py +1 -0
  93. faden_cli/tui/wizard/__init__.py +1 -0
  94. fadenstack-0.4.2.dist-info/METADATA +304 -0
  95. fadenstack-0.4.2.dist-info/RECORD +99 -0
  96. fadenstack-0.4.2.dist-info/WHEEL +4 -0
  97. fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
  98. fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
  99. fadenstack-0.4.2.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,598 @@
1
+ """.env file generation from Jinja2 templates.
2
+
3
+ Generates a production ``.env`` file with cryptographically random
4
+ secrets replacing every ``CHANGEME_*`` placeholder in the template.
5
+ The user can then override individual values via ``faden config set``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import secrets
11
+ import string
12
+ from pathlib import Path
13
+
14
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
15
+
16
+ from faden_cli.core.private_files import write_private
17
+ from faden_cli.core.registry import module_env_vars
18
+ from faden_cli.core.sysinfo import calculate_tune_profile
19
+
20
+ # Keys that contain cryptographic secrets. These must never be replaced
21
+ # when regenerating an .env file because changing them would make
22
+ # previously encrypted data (chat content, DB settings, etc.) unreadable.
23
+ SECRET_KEYS: frozenset[str] = frozenset({
24
+ "POSTGRES_PASSWORD",
25
+ "REDIS_AUTH",
26
+ "MINIO_ROOT_PASSWORD",
27
+ "CLICKHOUSE_PASSWORD",
28
+ "GRAFANA_ADMIN_PASSWORD",
29
+ "RABBITMQ_ADMIN_PASS",
30
+ "RABBITMQ_BACKEND_PASS",
31
+ "RABBITMQ_API_PASS",
32
+ "RABBITMQ_PII_PASS",
33
+ "LANGFUSE_NEXTAUTH_SECRET",
34
+ "LANGFUSE_SALT",
35
+ "LANGFUSE_ENCRYPTION_KEY",
36
+ "FADEN_BACKEND_SETTINGS_MASTER_KEY",
37
+ "FADEN_GATEWAY_ENCRYPTION_KEY",
38
+ "FADEN_MCP_ENCRYPTION_KEY",
39
+ "USERS_SECRET",
40
+ # Inter-service auth tokens — each downstream service has one shared
41
+ # value across (api → service), (backend → service), (service receiver).
42
+ # All three keys per service MUST stay in lock-step, so they're listed
43
+ # together here to be preserved as a group on .env regeneration.
44
+ "FADEN_GATEWAY_MCP_SERVICE_TOKEN",
45
+ "FADEN_BACKEND_MCP_SERVICE_TOKEN",
46
+ "FADEN_MCP_SERVICE_TOKEN",
47
+ "FADEN_GATEWAY_SKILLS_SERVICE_TOKEN",
48
+ "FADEN_BACKEND_SKILLS_SERVICE_TOKEN",
49
+ "FADEN_SKILLS_SERVICE_TOKEN",
50
+ "FADEN_BACKEND_RAG_SERVICE_TOKEN",
51
+ "FADEN_RAG_SERVICE_TOKEN",
52
+ # The PII service reports its scan events to the backend with this.
53
+ "FADEN_BACKEND_INTERNAL_TOKEN",
54
+ "FADEN_PII_BACKEND_TOKEN",
55
+ "FADEN_BACKEND_BOOTSTRAP_TOKEN",
56
+ })
57
+
58
+ #: What the backend asks for before it creates the first administrator (``POST /api/bootstrap``), sent by
59
+ #: ``faden deploy``. Without it bootstrap is refused: the first admin went to whoever asked first.
60
+ BOOTSTRAP_TOKEN_KEY = "FADEN_BACKEND_BOOTSTRAP_TOKEN" # noqa: S105 - the variable's name
61
+
62
+ #: What the compose file has no fallback for (``${NAME:?...}``): compose stops on any of them missing, for
63
+ #: every command, whichever services it starts. It fell back to development values published with the
64
+ #: source code; ``faden deploy`` writes them all, ``faden upgrade`` adds what an older ``.env`` lacks
65
+ #: (:func:`fill_required_env`), and ``faden dev up`` gives a development ``.env`` the development ones.
66
+ REQUIRED_KEYS: tuple[str, ...] = (
67
+ "POSTGRES_PASSWORD",
68
+ "REDIS_AUTH",
69
+ "MINIO_ROOT_PASSWORD",
70
+ "CLICKHOUSE_PASSWORD",
71
+ "LANGFUSE_NEXTAUTH_SECRET",
72
+ "LANGFUSE_SALT",
73
+ "LANGFUSE_ENCRYPTION_KEY",
74
+ "RABBITMQ_ADMIN_PASS",
75
+ "RABBITMQ_API_PASS",
76
+ "RABBITMQ_BACKEND_PASS",
77
+ "RABBITMQ_PII_PASS",
78
+ "GRAFANA_ADMIN_PASSWORD",
79
+ "FADEN_BACKEND_SETTINGS_MASTER_KEY",
80
+ "FADEN_MCP_ENCRYPTION_KEY",
81
+ "FADEN_GATEWAY_MCP_SERVICE_TOKEN",
82
+ "FADEN_BACKEND_MCP_SERVICE_TOKEN",
83
+ "FADEN_MCP_SERVICE_TOKEN",
84
+ "FADEN_GATEWAY_SKILLS_SERVICE_TOKEN",
85
+ "FADEN_BACKEND_SKILLS_SERVICE_TOKEN",
86
+ "FADEN_SKILLS_SERVICE_TOKEN",
87
+ )
88
+
89
+ #: Keys that seal stored data, with the value the compose file used when ``.env`` had none. They are
90
+ #: published with the source code, and an install that ran on one reads its data only with it: an upgrade
91
+ #: that finds one missing writes this value, never a random one.
92
+ PUBLISHED_DATA_KEYS: dict[str, str] = {
93
+ "FADEN_BACKEND_SETTINGS_MASTER_KEY": "dev-settings-master-key-change-me",
94
+ "FADEN_MCP_ENCRYPTION_KEY": "dev-mcp-encryption-key-change-me-32b",
95
+ "LANGFUSE_SALT": "mysalt",
96
+ "LANGFUSE_ENCRYPTION_KEY": "0" * 64,
97
+ }
98
+
99
+ #: Lets the backend, the gateway and MCP start with a published key from :data:`PUBLISHED_DATA_KEYS`
100
+ #: (which they refuse otherwise), for an install that stored its data under it.
101
+ ALLOW_PUBLISHED_KEYS = "FADEN_ALLOW_PUBLISHED_KEYS"
102
+
103
+ #: Of the published keys, the ones the services refuse without :data:`ALLOW_PUBLISHED_KEYS`.
104
+ _REFUSED_DATA_KEYS = ("FADEN_BACKEND_SETTINGS_MASTER_KEY", "FADEN_MCP_ENCRYPTION_KEY")
105
+
106
+ #: Keys that must hold one value: what the gateway and the backend present, and what the module expects.
107
+ _TOKEN_GROUPS: tuple[tuple[str, ...], ...] = (
108
+ ("FADEN_GATEWAY_MCP_SERVICE_TOKEN", "FADEN_BACKEND_MCP_SERVICE_TOKEN", "FADEN_MCP_SERVICE_TOKEN"),
109
+ ("FADEN_GATEWAY_SKILLS_SERVICE_TOKEN", "FADEN_BACKEND_SKILLS_SERVICE_TOKEN", "FADEN_SKILLS_SERVICE_TOKEN"),
110
+ )
111
+
112
+
113
+ def read_env_file(path: Path) -> dict[str, str]:
114
+ """Parse a ``.env`` file into a dict, ignoring comments and blanks."""
115
+ result: dict[str, str] = {}
116
+ if not path.exists():
117
+ return result
118
+ for line in path.read_text(encoding="utf-8").splitlines():
119
+ line = line.strip()
120
+ if not line or line.startswith("#"):
121
+ continue
122
+ key, _, value = line.partition("=")
123
+ if key:
124
+ result[key.strip()] = value.strip()
125
+ return result
126
+
127
+
128
+ def _random_password(length: int = 32) -> str:
129
+ """Generate a URL-safe random password."""
130
+ alphabet = string.ascii_letters + string.digits
131
+ return "".join(secrets.choice(alphabet) for _ in range(length))
132
+
133
+
134
+ def _random_hex(length: int = 64) -> str:
135
+ """Generate a hex string of the given length."""
136
+ return secrets.token_hex(length // 2)
137
+
138
+
139
+ def _random_secret(length: int = 48) -> str:
140
+ """Generate a URL-safe base64 secret."""
141
+ return secrets.token_urlsafe(length)
142
+
143
+
144
+ # ── Dev-mode defaults (matching existing shared .env) ─────────────
145
+
146
+
147
+ def dev_env_vars(
148
+ *,
149
+ profiles: list[str] | None = None,
150
+ ) -> dict[str, str]:
151
+ """Build env vars with standard dev credentials.
152
+
153
+ Uses ``devpassword`` everywhere — matches the existing
154
+ ``shared/.env`` used during development.
155
+ """
156
+ pw = "devpassword"
157
+
158
+ env = {
159
+ "POSTGRES_USER": "postgres",
160
+ "POSTGRES_PASSWORD": pw,
161
+ "POSTGRES_DB": "postgres",
162
+ "GRAFANA_ADMIN_USER": "admin",
163
+ "GRAFANA_ADMIN_PASSWORD": pw,
164
+ "LANGFUSE_NEXTAUTH_SECRET": "dev-langfuse-nextauth-secret",
165
+ "LANGFUSE_SALT": "dev-langfuse-salt",
166
+ "LANGFUSE_ENCRYPTION_KEY": "0" * 64,
167
+ "LANGFUSE_DATABASE_URL": f"postgresql://postgres:{pw}@postgres:5432/langfuse",
168
+ "CLICKHOUSE_USER": "clickhouse",
169
+ "CLICKHOUSE_PASSWORD": pw,
170
+ "CLICKHOUSE_MIGRATION_URL": "clickhouse://clickhouse:9000",
171
+ "CLICKHOUSE_URL": "http://clickhouse:8123",
172
+ "REDIS_AUTH": pw,
173
+ "MINIO_ROOT_USER": "minio",
174
+ "MINIO_ROOT_PASSWORD": pw,
175
+ "LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT": "http://minio:9000",
176
+ "LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT": "http://minio:9000",
177
+ # RabbitMQ — admin for management console, per-service for AMQP
178
+ "RABBITMQ_ADMIN_USER": "admin",
179
+ "RABBITMQ_ADMIN_PASS": pw,
180
+ "RABBITMQ_BACKEND_PASS": pw,
181
+ "RABBITMQ_API_PASS": pw,
182
+ "RABBITMQ_PII_PASS": pw,
183
+ # Settings master key — used to encrypt/decrypt secrets stored in the DB.
184
+ # Must match FADEN_BACKEND_SETTINGS_MASTER_KEY in the backend .env.
185
+ # JWT secrets are seeded automatically in the DB on first startup.
186
+ "FADEN_BACKEND_SETTINGS_MASTER_KEY": "dev-settings-master-key-change-me",
187
+ "FADEN_GATEWAY_ENCRYPTION_KEY": "dev-encryption-key-change-me",
188
+ # The modules' development tokens and key, the ones `faden dev up` runs them with
189
+ # (registry.MODULES); the compose file requires them although dev mode runs the
190
+ # modules on the host.
191
+ **_dev_module_secrets(),
192
+ "FADEN_GATEWAY_LANGFUSE_ENABLED": "false",
193
+ "FADEN_BACKEND_GATEWAY_URL": "http://faden-gateway:8000",
194
+ # Dev stacks opt IN to dev mode (seeded admin, /auth/dev-login); the
195
+ # services and compose default to production.
196
+ "FADEN_BACKEND_ENVIRONMENT": "dev",
197
+ "FADEN_GATEWAY_ENVIRONMENT": "dev",
198
+ "FADEN_PII_ENVIRONMENT": "dev",
199
+ "FADEN_MCP_ENVIRONMENT": "dev",
200
+ "FADEN_SKILLS_ENVIRONMENT": "dev",
201
+ # Langfuse initial admin seeding
202
+ "LANGFUSE_INIT_USER_EMAIL": "",
203
+ "LANGFUSE_INIT_USER_PASSWORD": "",
204
+ "LANGFUSE_INIT_USER_NAME": "Admin",
205
+ "LANGFUSE_INIT_ORG_ID": "faden-org",
206
+ "LANGFUSE_INIT_ORG_NAME": "fadenstack",
207
+ "LANGFUSE_INIT_PROJECT_ID": "faden-project",
208
+ "LANGFUSE_INIT_PROJECT_NAME": "fadenstack",
209
+ "LANGFUSE_INIT_PROJECT_PUBLIC_KEY": "",
210
+ "LANGFUSE_INIT_PROJECT_SECRET_KEY": "",
211
+ }
212
+
213
+ # Scalability tuning (conservative dev defaults)
214
+ env.update(calculate_tune_profile("dev").to_env_dict())
215
+
216
+ profs = profiles or []
217
+ for prof in profs:
218
+ env.update(module_env_vars(prof))
219
+
220
+ return env
221
+
222
+
223
+ def _dev_module_secrets() -> dict[str, str]:
224
+ """The MCP and skills tokens and the MCP key a development stack uses."""
225
+ from faden_cli.core.registry import MODULES # noqa: PLC0415
226
+
227
+ mcp, skills = MODULES["mcp"], MODULES["skills"]
228
+ return {
229
+ "FADEN_GATEWAY_MCP_SERVICE_TOKEN": mcp.dev_service_token,
230
+ "FADEN_BACKEND_MCP_SERVICE_TOKEN": mcp.dev_service_token,
231
+ "FADEN_MCP_SERVICE_TOKEN": mcp.dev_service_token,
232
+ "FADEN_GATEWAY_SKILLS_SERVICE_TOKEN": skills.dev_service_token,
233
+ "FADEN_BACKEND_SKILLS_SERVICE_TOKEN": skills.dev_service_token,
234
+ "FADEN_SKILLS_SERVICE_TOKEN": skills.dev_service_token,
235
+ "FADEN_MCP_ENCRYPTION_KEY": dict(mcp.dev_settings)["ENCRYPTION_KEY"],
236
+ }
237
+
238
+
239
+ # ── Production defaults (random secrets) ──────────────────────────
240
+
241
+
242
+ def default_env_vars(
243
+ *,
244
+ admin_email: str = "admin@example.com",
245
+ admin_password: str = "",
246
+ profiles: list[str] | None = None,
247
+ ) -> dict[str, str]:
248
+ """Build a dict of env vars with generated secrets.
249
+
250
+ These correspond to the placeholders in the ``.env.j2`` template.
251
+ """
252
+ postgres_password = _random_password(24)
253
+ redis_password = _random_password(24)
254
+ minio_password = _random_password(24)
255
+
256
+ # Inter-service auth tokens. One random value per downstream service,
257
+ # written to all three env vars (api → service, backend → service,
258
+ # service receiver) so they stay in lock-step. The receiver-side
259
+ # ``verify_service_token`` fails open when its env var is empty, so it
260
+ # is critical that these are generated and propagated for production
261
+ # deployments.
262
+ mcp_service_token = _random_secret()
263
+ skills_service_token = _random_secret()
264
+ rag_service_token = _random_secret()
265
+ internal_token = _random_secret()
266
+
267
+ env = {
268
+ # Postgres
269
+ "POSTGRES_USER": "postgres",
270
+ "POSTGRES_PASSWORD": postgres_password,
271
+ "POSTGRES_DB": "postgres",
272
+ # Grafana
273
+ "GRAFANA_ADMIN_USER": "admin",
274
+ "GRAFANA_ADMIN_PASSWORD": admin_password or _random_password(16),
275
+ # Langfuse
276
+ "LANGFUSE_NEXTAUTH_SECRET": _random_secret(),
277
+ "LANGFUSE_SALT": _random_secret(32),
278
+ "LANGFUSE_ENCRYPTION_KEY": _random_hex(64),
279
+ "LANGFUSE_DATABASE_URL": f"postgresql://postgres:{postgres_password}@postgres:5432/langfuse",
280
+ # ClickHouse
281
+ "CLICKHOUSE_USER": "clickhouse",
282
+ "CLICKHOUSE_PASSWORD": _random_password(24),
283
+ # Redis
284
+ "REDIS_AUTH": redis_password,
285
+ # MinIO
286
+ "MINIO_ROOT_USER": "minio",
287
+ "MINIO_ROOT_PASSWORD": minio_password,
288
+ # RabbitMQ — admin for management console, per-service for AMQP
289
+ "RABBITMQ_ADMIN_USER": "admin",
290
+ "RABBITMQ_ADMIN_PASS": _random_password(24),
291
+ "RABBITMQ_BACKEND_PASS": _random_password(24),
292
+ "RABBITMQ_API_PASS": _random_password(24),
293
+ "RABBITMQ_PII_PASS": _random_password(24),
294
+ # Settings master key — used to encrypt/decrypt secrets stored in the DB.
295
+ # JWT secrets are seeded automatically in the DB on first startup.
296
+ "FADEN_BACKEND_SETTINGS_MASTER_KEY": _random_secret(),
297
+ "FADEN_GATEWAY_ENCRYPTION_KEY": _random_secret(),
298
+ "FADEN_GATEWAY_LANGFUSE_ENABLED": "false",
299
+ "FADEN_BACKEND_GATEWAY_URL": "http://faden-gateway:8000",
300
+ "FADEN_BACKEND_ENVIRONMENT": "production",
301
+ "FADEN_GATEWAY_ENVIRONMENT": "production",
302
+ "FADEN_PII_ENVIRONMENT": "production",
303
+ "FADEN_MCP_ENVIRONMENT": "production",
304
+ "FADEN_SKILLS_ENVIRONMENT": "production",
305
+ "USERS_SECRET": _random_secret(),
306
+ # Inter-service auth tokens (see comment above). Each pair of
307
+ # caller env vars must equal the receiver's env var.
308
+ "FADEN_GATEWAY_MCP_SERVICE_TOKEN": mcp_service_token,
309
+ "FADEN_BACKEND_MCP_SERVICE_TOKEN": mcp_service_token,
310
+ "FADEN_MCP_SERVICE_TOKEN": mcp_service_token,
311
+ "FADEN_GATEWAY_SKILLS_SERVICE_TOKEN": skills_service_token,
312
+ "FADEN_BACKEND_SKILLS_SERVICE_TOKEN": skills_service_token,
313
+ "FADEN_SKILLS_SERVICE_TOKEN": skills_service_token,
314
+ "FADEN_BACKEND_RAG_SERVICE_TOKEN": rag_service_token,
315
+ "FADEN_RAG_SERVICE_TOKEN": rag_service_token,
316
+ "FADEN_BACKEND_INTERNAL_TOKEN": internal_token,
317
+ "FADEN_PII_BACKEND_TOKEN": internal_token,
318
+ # Encrypts the credentials MCP stores. Without it the compose file
319
+ # falls back to a fixed dev key, the same for every install.
320
+ "FADEN_MCP_ENCRYPTION_KEY": _random_secret(),
321
+ # Asked for by the backend before it creates the first administrator;
322
+ # deploy sends it. Without it bootstrap is refused.
323
+ BOOTSTRAP_TOKEN_KEY: _random_secret(),
324
+ # Langfuse initial admin seeding (populated during bootstrap)
325
+ "LANGFUSE_INIT_USER_EMAIL": "",
326
+ "LANGFUSE_INIT_USER_PASSWORD": "",
327
+ "LANGFUSE_INIT_USER_NAME": "Admin",
328
+ "LANGFUSE_INIT_ORG_ID": "faden-org",
329
+ "LANGFUSE_INIT_ORG_NAME": "fadenstack",
330
+ "LANGFUSE_INIT_PROJECT_ID": "faden-project",
331
+ "LANGFUSE_INIT_PROJECT_NAME": "fadenstack",
332
+ "LANGFUSE_INIT_PROJECT_PUBLIC_KEY": "",
333
+ "LANGFUSE_INIT_PROJECT_SECRET_KEY": "",
334
+ }
335
+
336
+ # Worker counts and pools are not set here: deploy, upgrade and
337
+ # ``up --gen-env`` size them with ``faden tune`` (core/tune.py).
338
+
339
+ # Module activation
340
+ profs = profiles or []
341
+ for prof in profs:
342
+ env.update(module_env_vars(prof))
343
+
344
+ return env
345
+
346
+
347
+ # ── Template rendering ────────────────────────────────────────────
348
+
349
+
350
+ def render_env_file(
351
+ template_dir: Path,
352
+ *,
353
+ template_name: str = ".env.j2",
354
+ variables: dict[str, str] | None = None,
355
+ ) -> str:
356
+ """Render the ``.env.j2`` template with the given variables."""
357
+ env = Environment(
358
+ loader=FileSystemLoader(str(template_dir)),
359
+ autoescape=select_autoescape(),
360
+ keep_trailing_newline=True,
361
+ )
362
+ tmpl = env.get_template(template_name)
363
+ return tmpl.render(**(variables or {}))
364
+
365
+
366
+ def write_env_file(
367
+ output_path: Path,
368
+ variables: dict[str, str],
369
+ *,
370
+ preserve_secrets: bool = False,
371
+ ) -> Path:
372
+ """Write a ``.env`` file from a variable dict (no template needed).
373
+
374
+ This is the simpler path: just write ``KEY=VALUE`` lines directly.
375
+ Suitable when we don't need Jinja2 logic in the env file.
376
+
377
+ If *preserve_secrets* is True and *output_path* already exists,
378
+ any keys listed in :data:`SECRET_KEYS` that are present in the
379
+ existing file will be kept instead of being replaced.
380
+ """
381
+ if preserve_secrets:
382
+ existing = read_env_file(output_path)
383
+ for key in SECRET_KEYS:
384
+ if key in existing and key in variables:
385
+ variables[key] = existing[key]
386
+ # Also fix derived values that embed a secret
387
+ if "POSTGRES_PASSWORD" in existing and "LANGFUSE_DATABASE_URL" in variables:
388
+ pw = existing["POSTGRES_PASSWORD"]
389
+ user = variables.get("POSTGRES_USER", "postgres")
390
+ db = variables.get("POSTGRES_DB", "langfuse")
391
+ variables["LANGFUSE_DATABASE_URL"] = (
392
+ f"postgresql://{user}:{pw}@postgres:5432/{db}"
393
+ )
394
+ lines: list[str] = []
395
+ lines.append("# Generated by faden init — do not edit manually unless you know what you're doing.")
396
+ lines.append(f"# Generated at: {_timestamp()}")
397
+ lines.append("")
398
+
399
+ # Group by prefix for readability
400
+ groups: dict[str, list[tuple[str, str]]] = {"core": []}
401
+ for key, value in variables.items():
402
+ if key.startswith("FADEN_"):
403
+ group = "faden"
404
+ elif key.startswith("LANGFUSE_"):
405
+ group = "langfuse"
406
+ elif key.startswith("CLICKHOUSE_"):
407
+ group = "clickhouse"
408
+ else:
409
+ group = "core"
410
+ groups.setdefault(group, []).append((key, value))
411
+
412
+ group_labels = {
413
+ "core": "Core Infrastructure",
414
+ "langfuse": "Langfuse",
415
+ "clickhouse": "ClickHouse",
416
+ "faden": "Fadenstack Services",
417
+ }
418
+
419
+ for group_key in ["core", "langfuse", "clickhouse", "faden"]:
420
+ items = groups.get(group_key, [])
421
+ if not items:
422
+ continue
423
+ lines.append(f"# ── {group_labels.get(group_key, group_key)} ──")
424
+ for k, v in items:
425
+ lines.append(f"{k}={v}")
426
+ lines.append("")
427
+
428
+ # Always write LF line endings — .env is consumed by Linux containers.
429
+ # Readable by its owner only: it holds every password and key of the install.
430
+ write_private(output_path, "\n".join(lines).encode("utf-8"))
431
+ return output_path
432
+
433
+
434
+ def ensure_bootstrap_token(env_path: Path) -> bool:
435
+ """Give an install's ``.env`` the bootstrap token when it has none; whether it was added.
436
+
437
+ An ``.env`` written before the token existed keeps its other values. Deploy
438
+ never rewrites an existing ``.env``, and the backend refuses bootstrap without
439
+ the token, so an install whose first deploy stopped before the administrator
440
+ was made would otherwise never get one.
441
+ """
442
+ env = read_env_file(env_path)
443
+ if env.get(BOOTSTRAP_TOKEN_KEY):
444
+ return False
445
+ env[BOOTSTRAP_TOKEN_KEY] = _random_secret()
446
+ write_env_file(env_path, env)
447
+ return True
448
+
449
+
450
+ def fill_required_env(env_path: Path, *, profiles: list[str] | tuple[str, ...] = ()) -> list[str]:
451
+ """Give an install's ``.env`` every key the compose file requires that it lacks; notes for the operator.
452
+
453
+ Everything else in the file stays as it is. A missing token gets the value the rest of its group holds,
454
+ or a new random one; a missing password a random one (deploy and upgrade then set Postgres to it, see
455
+ ``_sync_postgres_password``). A missing key that seals stored data means the install ran on the
456
+ compose file's published fallback, and its data is readable only with that: the published value is
457
+ written, :data:`ALLOW_PUBLISHED_KEYS` lets the services start with it, and a note says to change it.
458
+ Not for MCP's key when MCP is not switched on here: it stored nothing.
459
+ """
460
+ current = read_env_file(env_path)
461
+ defaults = default_env_vars(profiles=list(profiles))
462
+ added: dict[str, str] = {}
463
+ for group in _TOKEN_GROUPS:
464
+ value = next((current[key] for key in group if current.get(key)), None) or defaults[group[0]]
465
+ added.update({key: value for key in group if not current.get(key)})
466
+ kept: list[str] = []
467
+ for key in REQUIRED_KEYS:
468
+ if current.get(key) or key in added:
469
+ continue
470
+ if key in PUBLISHED_DATA_KEYS and (key != "FADEN_MCP_ENCRYPTION_KEY" or "mcp" in profiles):
471
+ added[key] = PUBLISHED_DATA_KEYS[key]
472
+ kept.append(key)
473
+ else:
474
+ added[key] = defaults[key]
475
+ if "POSTGRES_PASSWORD" in added and current.get("LANGFUSE_DATABASE_URL"):
476
+ user = current.get("POSTGRES_USER") or "postgres"
477
+ added["LANGFUSE_DATABASE_URL"] = f"postgresql://{user}:{added['POSTGRES_PASSWORD']}@postgres:5432/langfuse"
478
+ if any(key in kept for key in _REFUSED_DATA_KEYS) and not current.get(ALLOW_PUBLISHED_KEYS):
479
+ added[ALLOW_PUBLISHED_KEYS] = "true"
480
+ if added:
481
+ _put_env(env_path, added, header="Required by the compose file (faden upgrade)")
482
+ return [
483
+ f"{key} was not in .env: this install ran on the published development value, which is now written "
484
+ "there so the data stored with it stays readable. Change it as Help describes (Installing, Secrets in .env)."
485
+ for key in kept
486
+ ]
487
+
488
+
489
+ def fill_dev_env(env_path: Path) -> list[str]:
490
+ """Give a development ``.env`` the keys the compose file requires that it lacks, with development values.
491
+
492
+ A development stack's own ``.env`` predates them: the compose file supplied fixed values. Returns the
493
+ keys it added, never their values.
494
+ """
495
+ current = read_env_file(env_path)
496
+ dev = dev_env_vars()
497
+ added = {key: dev[key] for key in REQUIRED_KEYS if not current.get(key)}
498
+ if added:
499
+ _put_env(env_path, added, header="Required by the compose file (faden dev up)")
500
+ return list(added)
501
+
502
+
503
+ def _put_env(env_path: Path, values: dict[str, str], *, header: str) -> None:
504
+ """Set *values* in the ``.env`` at *env_path*: a key it has is rewritten in place, the rest appended."""
505
+ lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
506
+ where = _key_lines(lines)
507
+ for key, value in values.items():
508
+ if key in where:
509
+ lines[where[key]] = f"{key}={value}"
510
+ appended = [f"{key}={value}" for key, value in values.items() if key not in where]
511
+ if appended:
512
+ if lines and lines[-1].strip():
513
+ lines.append("")
514
+ lines += [f"# ── {header} ──", *appended]
515
+ # LF and owner-only, like write_env_file.
516
+ write_private(env_path, ("\n".join(lines) + "\n").encode("utf-8"))
517
+
518
+
519
+ def set_module_env(
520
+ env_path: Path,
521
+ *,
522
+ enable: list[str] | tuple[str, ...] = (),
523
+ disable: list[str] | tuple[str, ...] = (),
524
+ ) -> list[str]:
525
+ """Switch modules on or off in an install's ``.env``, changing nothing else.
526
+
527
+ Enabling writes the module's settings (its ``*_ENABLED`` switches and its
528
+ URLs) and gives every secret it needs a random value when the file has
529
+ none; a token group missing only some of its keys takes the value the
530
+ others hold. Disabling sets its switches to false and keeps its secrets:
531
+ data may already be encrypted with them. Returns the keys it set, never
532
+ their values.
533
+
534
+ ``faden module enable`` used to record the module in faden.yaml
535
+ only. Its containers started, but the gateway and the backend kept the
536
+ compose file's ``*_ENABLED=false`` and answered "MCP module is not
537
+ enabled.", and MCP encrypted with the compose file's fixed dev key.
538
+ """
539
+ lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
540
+ where = _key_lines(lines)
541
+ current = {key: lines[number].partition("=")[2].strip() for key, number in where.items()}
542
+ changes = {**_enabling(current, enable), **_disabling(current, disable)}
543
+ if not changes:
544
+ return []
545
+ appended = [f"{key}={value}" for key, value in changes.items() if key not in where]
546
+ for key, value in changes.items():
547
+ if key in where:
548
+ lines[where[key]] = f"{key}={value}"
549
+ if appended:
550
+ if lines and lines[-1].strip():
551
+ lines.append("")
552
+ lines += ["# ── Modules (faden module enable) ──", *appended]
553
+ # LF, like write_env_file: the file is read by Linux containers. Owner-only, like write_env_file.
554
+ write_private(env_path, ("\n".join(lines) + "\n").encode("utf-8"))
555
+ return list(changes)
556
+
557
+
558
+ def _key_lines(lines: list[str]) -> dict[str, int]:
559
+ """Which line of an ``.env`` sets each key."""
560
+ where: dict[str, int] = {}
561
+ for number, line in enumerate(lines):
562
+ text = line.strip()
563
+ if text and not text.startswith("#") and "=" in text:
564
+ where[text.partition("=")[0].strip()] = number
565
+ return where
566
+
567
+
568
+ def _enabling(current: dict[str, str], names: list[str] | tuple[str, ...]) -> dict[str, str]:
569
+ """The settings and missing secrets that switch *names* on."""
570
+ from faden_cli.core.registry import MODULES # noqa: PLC0415
571
+
572
+ changes: dict[str, str] = {}
573
+ for name in names:
574
+ module = MODULES[name]
575
+ changes.update({key: value for key, value in module.env_vars if current.get(key) != value})
576
+ for group in module.secret_groups:
577
+ value = next((current[key] for key in group if current.get(key)), None) or _random_secret()
578
+ changes.update({key: value for key in group if not current.get(key)})
579
+ return changes
580
+
581
+
582
+ def _disabling(current: dict[str, str], names: list[str] | tuple[str, ...]) -> dict[str, str]:
583
+ """The switches that turn *names* off; their secrets stay."""
584
+ from faden_cli.core.registry import MODULES # noqa: PLC0415
585
+
586
+ return {
587
+ key: "false"
588
+ for name in names
589
+ for key, _value in MODULES[name].env_vars
590
+ if key.endswith("_ENABLED") and current.get(key) != "false"
591
+ }
592
+
593
+
594
+ def _timestamp() -> str:
595
+ """Return an ISO timestamp string."""
596
+ from datetime import datetime, timezone
597
+
598
+ return datetime.now(tz=timezone.utc).isoformat(timespec="seconds")