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,342 @@
1
+ """The release feed: which Fadenstack releases exist, checked and compared with an install.
2
+
3
+ The feed is a static file on fadenstack.com (``index.json``) with a signature beside it
4
+ (``index.json.sig``). Reading it sends nothing about the install: it is fetched like any
5
+ file, and compared here, locally.
6
+
7
+ This file is in the CLI (``faden_cli/core/release_feed.py``) and in the backend
8
+ (``faden_backend/services/updates/release_feed.py``), byte for byte the same -- a test
9
+ keeps them so. It does no I/O: the caller fetches the two files and hands over their bytes.
10
+
11
+ Readers in the field read this feed for years, so it only ever grows: unknown fields are
12
+ ignored, and a change that old readers would misread goes to ``/v2/``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import binascii
19
+ import json
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, date, datetime
22
+ from typing import Any
23
+
24
+ from cryptography.exceptions import InvalidSignature
25
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
26
+ from packaging.version import InvalidVersion, Version
27
+
28
+ SCHEMA = 1
29
+ PRODUCT = "fadenstack"
30
+ FEED_URL = "https://fadenstack.com/updates/v1/index.json"
31
+
32
+ #: Keys a feed may be signed with: key id -> raw Ed25519 public key, base64. A new key
33
+ #: ships here in a release before anything is signed with it.
34
+ TRUSTED_KEYS: dict[str, str] = {
35
+ "fadenstack-2026-10": "F87bn6AH1s5Lrw0bUKebHzxWO23vv8A6Hh9/+cxHjyo=",
36
+ }
37
+
38
+ #: Least to most urgent. "none": nothing newer.
39
+ SEVERITIES = ("none", "normal", "security", "critical")
40
+ CHANNELS = ("stable", "preview")
41
+
42
+ #: Where the standalone CLI is downloaded from, by tag.
43
+ RELEASE_PAGE = "https://github.com/fadenstack/fadenstack/releases/tag/v{version}"
44
+
45
+
46
+ class FeedError(ValueError):
47
+ """The feed cannot be used; the message says why."""
48
+
49
+
50
+ def signature_url(feed_url: str) -> str:
51
+ """Where the signature of the feed at *feed_url* is."""
52
+ return f"{feed_url}.sig"
53
+
54
+
55
+ def parse_version(text: str | None) -> Version | None:
56
+ """*text* as a release version; None for anything else (``dev``, ``main``, a commit)."""
57
+ if not text:
58
+ return None
59
+ try:
60
+ return Version(text.strip().removeprefix("v"))
61
+ except InvalidVersion:
62
+ return None
63
+
64
+
65
+ def is_newer(a: str | None, b: str | None) -> bool:
66
+ """Whether release *a* comes after release *b* (False when either is no release version)."""
67
+ va, vb = parse_version(a), parse_version(b)
68
+ return va is not None and vb is not None and va > vb
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Fix:
73
+ """A security problem a release fixes."""
74
+
75
+ id: str
76
+ severity: str
77
+ summary: str
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Release:
82
+ """One release in the feed."""
83
+
84
+ version: str
85
+ channel: str
86
+ date: str
87
+ severity: str
88
+ summary: str
89
+ notes_url: str
90
+ #: The oldest release that can upgrade straight to this one; None: any.
91
+ upgrade_from: str | None
92
+ fixes: tuple[Fix, ...]
93
+ #: Versions of the parts released with it: ``cli``, ``agent``.
94
+ components: dict[str, str]
95
+
96
+ @property
97
+ def parsed(self) -> Version:
98
+ """The version, for comparing."""
99
+ found = parse_version(self.version)
100
+ if found is None: # parse() keeps only releases with a version; one made by hand may not be
101
+ raise FeedError(f"{self.version!r} is not a release version.")
102
+ return found
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class Feed:
107
+ """A verified feed."""
108
+
109
+ generated_at: datetime
110
+ #: Newest first.
111
+ releases: tuple[Release, ...]
112
+ #: Series ("0.4") -> the last day it gets fixes.
113
+ supported_until: dict[str, date]
114
+
115
+ def release(self, version: str) -> Release | None:
116
+ """The entry for *version*, if the feed has one."""
117
+ wanted = parse_version(version)
118
+ return next((r for r in self.releases if r.parsed == wanted), None)
119
+
120
+
121
+ def verify(index: bytes, signature: bytes, keys: dict[str, str] | None = None) -> Feed:
122
+ """The feed in *index*, once *signature* proves it was signed with a trusted key."""
123
+ trusted = TRUSTED_KEYS if keys is None else keys
124
+ try:
125
+ sig = json.loads(signature)
126
+ key_id = str(sig["key_id"])
127
+ raw_sig = base64.b64decode(sig["signature"], validate=True)
128
+ except (ValueError, KeyError, TypeError, binascii.Error) as exc:
129
+ raise FeedError("The feed's signature file is not readable.") from exc
130
+ key = trusted.get(key_id)
131
+ if key is None:
132
+ raise FeedError(f"The feed is signed with a key this version does not know ({key_id}).")
133
+ try:
134
+ Ed25519PublicKey.from_public_bytes(base64.b64decode(key)).verify(raw_sig, index)
135
+ except InvalidSignature as exc:
136
+ raise FeedError("The feed's signature does not match: it was changed or is not ours.") from exc
137
+ try:
138
+ data = json.loads(index)
139
+ except ValueError as exc:
140
+ raise FeedError("The feed is not valid JSON.") from exc
141
+ return parse(data)
142
+
143
+
144
+ def parse(data: Any) -> Feed:
145
+ """The feed in *data* (already verified). Entries this version cannot read are left out."""
146
+ if not isinstance(data, dict):
147
+ raise FeedError("The feed is not a JSON object.")
148
+ if data.get("schema") != SCHEMA or data.get("product") != PRODUCT:
149
+ raise FeedError(f"Not a Fadenstack release feed of schema {SCHEMA}.")
150
+ try:
151
+ generated_at = datetime.fromisoformat(str(data["generated_at"]).replace("Z", "+00:00"))
152
+ except (KeyError, ValueError) as exc:
153
+ raise FeedError("The feed has no valid generated_at.") from exc
154
+ if generated_at.tzinfo is None:
155
+ generated_at = generated_at.replace(tzinfo=UTC)
156
+ releases = [r for r in (_release(raw) for raw in data.get("releases") or []) if r is not None]
157
+ releases.sort(key=lambda r: r.parsed, reverse=True)
158
+ supported: dict[str, date] = {}
159
+ for raw in data.get("series") or []:
160
+ try:
161
+ supported[str(raw["series"])] = date.fromisoformat(str(raw["supported_until"]))
162
+ except (KeyError, TypeError, ValueError):
163
+ continue
164
+ return Feed(generated_at=generated_at, releases=tuple(releases), supported_until=supported)
165
+
166
+
167
+ def _release(raw: Any) -> Release | None:
168
+ if not isinstance(raw, dict) or parse_version(str(raw.get("version", ""))) is None:
169
+ return None
170
+ channel = str(raw.get("channel", "stable"))
171
+ if channel not in CHANNELS:
172
+ return None # a channel added later: not this version's to offer
173
+ severity = str(raw.get("severity", "normal"))
174
+ if severity not in SEVERITIES[1:]:
175
+ severity = "normal"
176
+ upgrade_from = raw.get("upgrade_from")
177
+ fixes = tuple(
178
+ Fix(str(f.get("id", "")), str(f.get("severity", "")), str(f.get("summary", "")))
179
+ for f in raw.get("fixes") or []
180
+ if isinstance(f, dict)
181
+ )
182
+ components = raw.get("components") or {}
183
+ return Release(
184
+ version=str(raw["version"]),
185
+ channel=channel,
186
+ date=str(raw.get("date", "")),
187
+ severity=severity,
188
+ summary=str(raw.get("summary", "")),
189
+ notes_url=str(raw.get("notes_url", "")),
190
+ upgrade_from=str(upgrade_from) if parse_version(str(upgrade_from or "")) else None,
191
+ fixes=fixes,
192
+ components={str(k): str(v) for k, v in components.items()} if isinstance(components, dict) else {},
193
+ )
194
+
195
+
196
+ def not_older(feed: Feed, seen: datetime | None) -> None:
197
+ """Refuse a feed older than one already accepted: replaying an old feed must not hide a release."""
198
+ if seen is not None and feed.generated_at < seen:
199
+ raise FeedError(
200
+ f"The feed is older ({feed.generated_at.isoformat()}) than one already seen ({seen.isoformat()})."
201
+ )
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class Assessment:
206
+ """An install's release, measured against the feed."""
207
+
208
+ current: str
209
+ channel: str
210
+ #: The newest release on the channel; None when the feed offers none.
211
+ latest: Release | None
212
+ #: Releases on the channel newer than the install's, newest first.
213
+ newer: tuple[Release, ...]
214
+ #: The most urgent of them: none | normal | security | critical.
215
+ severity: str
216
+ #: Releases to install, in order, to reach ``latest`` (a release that cannot be skipped comes first).
217
+ path: tuple[str, ...]
218
+ #: The last day the install's series gets fixes, when the feed says.
219
+ supported_until: date | None
220
+ #: That day has passed.
221
+ unsupported: bool
222
+
223
+ @property
224
+ def update_available(self) -> bool:
225
+ """A newer release is out."""
226
+ return bool(self.newer)
227
+
228
+ @property
229
+ def target(self) -> Release | None:
230
+ """The first release to install: the next step on the path."""
231
+ return next((r for r in self.newer if self.path and r.version == self.path[0]), None)
232
+
233
+
234
+ def offered(feed: Feed, channel: str) -> list[Release]:
235
+ """Releases an install on *channel* is offered: stable ones, and on preview the previews too."""
236
+ return [
237
+ r
238
+ for r in feed.releases
239
+ if channel == "preview" or (r.channel == "stable" and not r.parsed.is_prerelease)
240
+ ]
241
+
242
+
243
+ def assess(feed: Feed, current: str, channel: str = "stable", today: date | None = None) -> Assessment:
244
+ """Where *current* stands: newer releases, how urgent, the path to the newest, support."""
245
+ channel = channel if channel in CHANNELS else "stable"
246
+ releases = offered(feed, channel)
247
+ latest = releases[0] if releases else None
248
+ cur = parse_version(current)
249
+ if cur is None:
250
+ return Assessment(current, channel, latest, (), "none", (), None, False)
251
+ newer = tuple(r for r in releases if r.parsed > cur)
252
+ severity = max((r.severity for r in newer), key=SEVERITIES.index, default="none")
253
+ path = upgrade_path(releases, cur, latest.parsed) if newer and latest else ()
254
+ until = feed.supported_until.get(f"{cur.major}.{cur.minor}")
255
+ unsupported = until is not None and (today or datetime.now(UTC).date()) > until
256
+ return Assessment(current, channel, latest, newer, severity, path, until, unsupported)
257
+
258
+
259
+ def upgrade_path(releases: list[Release], current: Version, target: Version) -> tuple[str, ...]:
260
+ """The releases to install one after the other to go from *current* to *target*.
261
+
262
+ Each step is the newest release reachable straight from where the install is: one whose
263
+ ``upgrade_from`` is no newer than that.
264
+ """
265
+ path: list[str] = []
266
+ at = current
267
+ while at < target:
268
+ reachable = [
269
+ r
270
+ for r in releases
271
+ if at < r.parsed <= target and (r.upgrade_from is None or Version(r.upgrade_from) <= at)
272
+ ]
273
+ if not reachable:
274
+ path.append(str(target)) # the feed names no way there: say where, not how
275
+ break
276
+ step = max(reachable, key=lambda r: r.parsed)
277
+ path.append(step.version)
278
+ at = step.parsed
279
+ return tuple(path)
280
+
281
+
282
+ def required_first(feed: Feed, installed: str, target: str) -> str | None:
283
+ """The release to install before going from *installed* straight to *target*; None: nothing is skipped."""
284
+ release = feed.release(target)
285
+ cur = parse_version(installed)
286
+ if release is None or cur is None or release.upgrade_from is None or Version(release.upgrade_from) <= cur:
287
+ return None
288
+ path = upgrade_path(list(feed.releases), cur, release.parsed)
289
+ return path[0] if path else release.upgrade_from
290
+
291
+
292
+ #: How the CLI was installed, as `faden deploy` records it (FADEN_CLI_INSTALLER).
293
+ INSTALLERS = ("pipx", "uv", "pip", "binary", "source")
294
+
295
+
296
+ def cli_install_command(installer: str | None, version: str, *, latest: bool) -> str:
297
+ """The command that puts CLI *version* on this server, for how the CLI was installed.
298
+
299
+ *latest*: the version is the newest stable release, so the plain upgrade command gets it.
300
+ """
301
+ parsed = parse_version(version)
302
+ # A step on the way, or a preview (which plain upgrades skip), is named exactly.
303
+ pin = not latest or (parsed is not None and parsed.is_prerelease)
304
+ match installer:
305
+ case "uv":
306
+ return f"uv tool install --force fadenstack=={version}" if pin else "uv tool upgrade fadenstack"
307
+ case "pip":
308
+ return f"pip install fadenstack=={version}" if pin else "pip install -U fadenstack"
309
+ case "binary":
310
+ return f"download faden for this system from {RELEASE_PAGE.format(version=version)}"
311
+ case "source":
312
+ return "git pull"
313
+ case _:
314
+ return f"pipx install --force fadenstack=={version}" if pin else "pipx upgrade fadenstack"
315
+
316
+
317
+ def upgrade_commands(installer: str | None, assessment: Assessment) -> list[str]:
318
+ """The commands an operator runs on the server to take the next step on the path."""
319
+ target = assessment.target
320
+ if target is None:
321
+ return []
322
+ latest = assessment.latest is not None and target.version == assessment.latest.version
323
+ return [
324
+ cli_install_command(installer, target.version, latest=latest),
325
+ "faden upgrade --dry-run",
326
+ "faden upgrade -y",
327
+ ]
328
+
329
+
330
+ def as_dict(release: Release) -> dict[str, Any]:
331
+ """*release* as JSON-ready data."""
332
+ return {
333
+ "version": release.version,
334
+ "channel": release.channel,
335
+ "date": release.date,
336
+ "severity": release.severity,
337
+ "summary": release.summary,
338
+ "notes_url": release.notes_url,
339
+ "upgrade_from": release.upgrade_from,
340
+ "fixes": [{"id": f.id, "severity": f.severity, "summary": f.summary} for f in release.fixes],
341
+ "components": dict(release.components),
342
+ }
faden_cli/core/rmq.py ADDED
@@ -0,0 +1,135 @@
1
+ """RabbitMQ definitions.json generator.
2
+
3
+ Generates a ``definitions.json`` file that RabbitMQ loads on startup
4
+ (via ``load_definitions``). This creates per-service users with
5
+ separate credentials so that the admin/management password is fully
6
+ decoupled from the AMQP service accounts.
7
+
8
+ User layout:
9
+ - ``admin`` — management console, tagged ``administrator``
10
+ - ``faden-backend`` — backend + worker AMQP access
11
+ - ``faden-gateway`` — API gateway AMQP access
12
+ - ``faden-pii`` — PII module AMQP access (when enabled)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import hashlib
19
+ import json
20
+ import os
21
+ from pathlib import Path
22
+
23
+ # Service user names — hardcoded, not configurable.
24
+ SVC_USER_BACKEND = "faden-backend"
25
+ SVC_USER_API = "faden-gateway"
26
+ SVC_USER_PII = "faden-pii"
27
+
28
+
29
+ def rabbitmq_password_hash(password: str) -> str:
30
+ """Compute RabbitMQ-compatible SHA-256 password hash.
31
+
32
+ Format: ``base64(salt(4) || SHA256(salt || UTF-8(password)))``
33
+ """
34
+ salt = os.urandom(4)
35
+ digest = hashlib.sha256(salt + password.encode("utf-8")).digest()
36
+ return base64.b64encode(salt + digest).decode("ascii")
37
+
38
+
39
+ def _user_entry(name: str, password: str, *, tags: str = "") -> dict:
40
+ return {
41
+ "name": name,
42
+ "password_hash": rabbitmq_password_hash(password),
43
+ "hashing_algorithm": "rabbit_password_hashing_sha256",
44
+ "tags": tags,
45
+ }
46
+
47
+
48
+ def _permission_entry(user: str, vhost: str = "/") -> dict:
49
+ return {
50
+ "user": user,
51
+ "vhost": vhost,
52
+ "configure": ".*",
53
+ "write": ".*",
54
+ "read": ".*",
55
+ }
56
+
57
+
58
+ def generate_definitions(
59
+ *,
60
+ admin_user: str = "admin",
61
+ admin_pass: str,
62
+ backend_pass: str,
63
+ api_pass: str,
64
+ pii_pass: str | None = None,
65
+ ) -> dict:
66
+ """Build a RabbitMQ definitions dict with per-service users."""
67
+ users = [
68
+ _user_entry(admin_user, admin_pass, tags="administrator"),
69
+ _user_entry(SVC_USER_BACKEND, backend_pass),
70
+ _user_entry(SVC_USER_API, api_pass),
71
+ ]
72
+ permissions = [
73
+ _permission_entry(admin_user),
74
+ _permission_entry(SVC_USER_BACKEND),
75
+ _permission_entry(SVC_USER_API),
76
+ ]
77
+ if pii_pass is not None:
78
+ users.append(_user_entry(SVC_USER_PII, pii_pass))
79
+ permissions.append(_permission_entry(SVC_USER_PII))
80
+
81
+ return {
82
+ "users": users,
83
+ "vhosts": [{"name": "/"}],
84
+ "permissions": permissions,
85
+ }
86
+
87
+
88
+ #: How long a task may hold its message unacknowledged (RabbitMQ's
89
+ #: ``consumer_timeout``; its default is 30 minutes). A task is acknowledged
90
+ #: when it finishes, and downloading a large model takes hours: at 30 minutes
91
+ #: RabbitMQ closed the worker's channel and handed every message on it out
92
+ #: again -- the download started a second time, and whatever else the channel
93
+ #: held ran twice. Two days; a download delivered twice finds the first
94
+ #: still running and leaves it be (backend ``services/llm/tasks.py``).
95
+ CONSUMER_TIMEOUT_MS = 2 * 24 * 3600 * 1000
96
+
97
+
98
+ def write_definitions(
99
+ output_dir: Path,
100
+ *,
101
+ admin_user: str = "admin",
102
+ admin_pass: str,
103
+ backend_pass: str,
104
+ api_pass: str,
105
+ pii_pass: str | None = None,
106
+ ) -> Path:
107
+ """Write ``definitions.json`` into *output_dir*/rabbitmq/.
108
+
109
+ Also writes ``10-faden.conf`` so RabbitMQ loads the definitions
110
+ on startup. Returns the path to ``definitions.json``.
111
+ """
112
+ rmq_dir = output_dir / "rabbitmq"
113
+ rmq_dir.mkdir(parents=True, exist_ok=True)
114
+
115
+ defs = generate_definitions(
116
+ admin_user=admin_user,
117
+ admin_pass=admin_pass,
118
+ backend_pass=backend_pass,
119
+ api_pass=api_pass,
120
+ pii_pass=pii_pass,
121
+ )
122
+
123
+ defs_path = rmq_dir / "definitions.json"
124
+ defs_path.write_text(json.dumps(defs, indent=2) + "\n", encoding="utf-8")
125
+
126
+ # conf.d snippet that tells RabbitMQ to load our definitions.
127
+ conf_path = rmq_dir / "10-faden.conf"
128
+ conf_path.write_text(
129
+ "# Auto-generated by faden deploy — do not edit.\n"
130
+ "load_definitions = /etc/rabbitmq/definitions.json\n"
131
+ f"consumer_timeout = {CONSUMER_TIMEOUT_MS}\n",
132
+ encoding="utf-8",
133
+ )
134
+
135
+ return defs_path