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