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