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,309 @@
|
|
|
1
|
+
"""Runtime images: the containers models run in, fetched onto the server.
|
|
2
|
+
|
|
3
|
+
A machine never pulls its runtime image from the internet. The Fadenstack
|
|
4
|
+
server exports the pinned image from its own Docker and the machine downloads
|
|
5
|
+
it from the server, or from a cluster peer that already has it. So the server
|
|
6
|
+
has to hold every runtime image a machine may ask for -- and nothing put it
|
|
7
|
+
there: a fresh install could take over a cluster that already had its image,
|
|
8
|
+
and could not give a new machine one.
|
|
9
|
+
|
|
10
|
+
``deploy`` and ``upgrade`` now fetch them from the registry the release
|
|
11
|
+
publishes to (``ghcr.io/fadenstack/ray-runtime-*``). Which images is not decided
|
|
12
|
+
here: they are read out of this release's backend image, from the same
|
|
13
|
+
runtime manifests the backend builds its catalogue from, so the CLI can never
|
|
14
|
+
fetch a build the backend would then refuse.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from faden_cli.core.env_gen import read_env_file
|
|
26
|
+
|
|
27
|
+
#: Architectures as machines report them (``uname -m``), with the names
|
|
28
|
+
#: registries and Docker use for the same thing.
|
|
29
|
+
ARCHITECTURES = ("x86_64", "aarch64")
|
|
30
|
+
_ARCH_ALIASES = {"x86_64": "x86_64", "amd64": "x86_64", "aarch64": "aarch64", "arm64": "aarch64"}
|
|
31
|
+
_PLATFORMS = {"x86_64": "linux/amd64", "aarch64": "linux/arm64"}
|
|
32
|
+
|
|
33
|
+
#: Run inside the backend image, whose working directory holds the manifests.
|
|
34
|
+
_READ_MANIFESTS = (
|
|
35
|
+
"import glob, json\n"
|
|
36
|
+
"print(json.dumps({p.rsplit('/', 1)[-1]: json.load(open(p))"
|
|
37
|
+
" for p in sorted(glob.glob('runtime-image/runtime-manifest*.json'))}))\n"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
DEFAULT_REGISTRY = "ghcr.io/fadenstack"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class RuntimeImage:
|
|
45
|
+
"""One runtime image a release pins."""
|
|
46
|
+
|
|
47
|
+
manifest: str
|
|
48
|
+
ref: str
|
|
49
|
+
arch: str
|
|
50
|
+
rootfs_layers: tuple[str, ...]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def platform(self) -> str | None:
|
|
54
|
+
"""Docker's name for the architecture (``linux/arm64``), or None when unknown."""
|
|
55
|
+
return _PLATFORMS.get(self.arch)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def published(self) -> bool:
|
|
59
|
+
"""Whether the reference names a registry to pull it from.
|
|
60
|
+
|
|
61
|
+
Until a runtime release is certified, a manifest can still name a
|
|
62
|
+
build that only ever existed on the machines it was made on
|
|
63
|
+
(``faden/ray-vllm-gb10:…``); pulling that would ask Docker Hub for
|
|
64
|
+
an image nobody published.
|
|
65
|
+
"""
|
|
66
|
+
return is_registry_ref(self.ref)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Outcome:
|
|
71
|
+
"""What became of one image."""
|
|
72
|
+
|
|
73
|
+
image: RuntimeImage
|
|
74
|
+
status: str # present | pulled | skipped | not_published | failed | mismatch
|
|
75
|
+
detail: str = ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Report:
|
|
80
|
+
"""Everything ``fetch`` did, for the caller to print."""
|
|
81
|
+
|
|
82
|
+
backend_image: str
|
|
83
|
+
outcomes: list[Outcome] = field(default_factory=list)
|
|
84
|
+
error: str = ""
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def ok(self) -> bool:
|
|
88
|
+
"""Whether everything that was wanted is on the server."""
|
|
89
|
+
return not self.error and not any(o.status in {"failed", "mismatch"} for o in self.outcomes)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_registry_ref(ref: str) -> bool:
|
|
93
|
+
"""``ghcr.io/org/name:tag`` yes; ``org/name:tag`` or ``name:tag`` no (they mean Docker Hub)."""
|
|
94
|
+
first, sep, _ = ref.partition("/")
|
|
95
|
+
return bool(sep) and ("." in first or ":" in first or first == "localhost")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def normalise_arch(value: str) -> str:
|
|
99
|
+
"""``amd64`` -> ``x86_64``, ``arm64`` -> ``aarch64``; empty for anything else."""
|
|
100
|
+
return _ARCH_ALIASES.get((value or "").strip().lower(), "")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_selection(value: str | None) -> set[str]:
|
|
104
|
+
"""``all`` / ``none`` / ``x86_64,aarch64`` -> the architectures to fetch.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ValueError: an architecture nobody builds a runtime for.
|
|
108
|
+
"""
|
|
109
|
+
text = (value or "all").strip().lower()
|
|
110
|
+
if text in {"", "all"}:
|
|
111
|
+
return set(ARCHITECTURES)
|
|
112
|
+
if text == "none":
|
|
113
|
+
return set()
|
|
114
|
+
chosen: set[str] = set()
|
|
115
|
+
for part in text.split(","):
|
|
116
|
+
arch = normalise_arch(part)
|
|
117
|
+
if not arch:
|
|
118
|
+
raise ValueError(f"unknown architecture {part.strip()!r}; use all, none, x86_64 or aarch64")
|
|
119
|
+
chosen.add(arch)
|
|
120
|
+
return chosen
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def backend_image(env_path: Path) -> str:
|
|
124
|
+
"""The backend image this install runs, as the compose file names it."""
|
|
125
|
+
env = read_env_file(env_path) if env_path.exists() else {}
|
|
126
|
+
registry = (env.get("REGISTRY") or DEFAULT_REGISTRY).rstrip("/")
|
|
127
|
+
version = env.get("VERSION") or "latest"
|
|
128
|
+
return f"{registry}/backend:{version}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _arch_of(manifest: dict) -> str:
|
|
132
|
+
for value in (
|
|
133
|
+
manifest.get("machine"),
|
|
134
|
+
(manifest.get("target_hardware") or {}).get("architecture"),
|
|
135
|
+
(manifest.get("base_image") or {}).get("architecture"),
|
|
136
|
+
):
|
|
137
|
+
arch = normalise_arch(str(value or ""))
|
|
138
|
+
if arch:
|
|
139
|
+
return arch
|
|
140
|
+
return ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _docker() -> str:
|
|
144
|
+
docker = shutil.which("docker")
|
|
145
|
+
if not docker:
|
|
146
|
+
raise RuntimeError("docker is not on PATH")
|
|
147
|
+
return docker
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
151
|
+
return subprocess.run(cmd, check=False, text=True, **kwargs) # noqa: S603
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def pinned(image: str) -> list[RuntimeImage]:
|
|
155
|
+
"""The runtime images *image* (a backend image) pins, read out of it.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
RuntimeError: the backend image cannot be run or holds no manifests.
|
|
159
|
+
"""
|
|
160
|
+
proc = _run(
|
|
161
|
+
[_docker(), "run", "--rm", "--entrypoint", "python", image, "-c", _READ_MANIFESTS],
|
|
162
|
+
capture_output=True,
|
|
163
|
+
)
|
|
164
|
+
if proc.returncode != 0:
|
|
165
|
+
detail = (proc.stderr or proc.stdout or "").strip().splitlines()[-1:] or [""]
|
|
166
|
+
raise RuntimeError(f"could not read the runtime manifests from {image}: {detail[0]}")
|
|
167
|
+
manifests: dict[str, dict] = {}
|
|
168
|
+
for line in reversed((proc.stdout or "").splitlines()):
|
|
169
|
+
if line.strip().startswith("{"):
|
|
170
|
+
manifests = json.loads(line)
|
|
171
|
+
break
|
|
172
|
+
images = []
|
|
173
|
+
for name, manifest in manifests.items():
|
|
174
|
+
ref = str(manifest.get("release_tag") or manifest.get("target_image") or "")
|
|
175
|
+
if not ref:
|
|
176
|
+
continue
|
|
177
|
+
images.append(
|
|
178
|
+
RuntimeImage(
|
|
179
|
+
manifest=name,
|
|
180
|
+
ref=ref,
|
|
181
|
+
arch=_arch_of(manifest),
|
|
182
|
+
rootfs_layers=tuple(manifest.get("rootfs_layers") or ()),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return images
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def local_layers(ref: str) -> tuple[str, ...] | None:
|
|
189
|
+
"""The layers of *ref* in this server's Docker, or None when it is not here."""
|
|
190
|
+
proc = _run(
|
|
191
|
+
[_docker(), "image", "inspect", "--format", "{{json .RootFS.Layers}}", ref],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
)
|
|
194
|
+
if proc.returncode != 0:
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
return tuple(json.loads(proc.stdout.strip() or "[]"))
|
|
198
|
+
except ValueError:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def pull(image: RuntimeImage) -> int:
|
|
203
|
+
"""``docker pull`` it, for its own platform, showing Docker's progress."""
|
|
204
|
+
cmd = [_docker(), "pull"]
|
|
205
|
+
if image.platform:
|
|
206
|
+
# The GB10 image exists only for arm64. An x86_64 server that hands it
|
|
207
|
+
# to DGX machines has to be told which platform it wants, or Docker
|
|
208
|
+
# finds "no matching manifest" for its own.
|
|
209
|
+
cmd += ["--platform", image.platform]
|
|
210
|
+
cmd.append(image.ref)
|
|
211
|
+
return _run(cmd).returncode
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def fetch(image: str, *, architectures: set[str]) -> Report:
|
|
215
|
+
"""Put every runtime image *image* pins, for the chosen architectures, on this server."""
|
|
216
|
+
report = Report(backend_image=image)
|
|
217
|
+
try:
|
|
218
|
+
images = pinned(image)
|
|
219
|
+
except RuntimeError as exc:
|
|
220
|
+
report.error = str(exc)
|
|
221
|
+
return report
|
|
222
|
+
if not images:
|
|
223
|
+
report.error = f"{image} names no runtime images"
|
|
224
|
+
return report
|
|
225
|
+
|
|
226
|
+
for runtime in images:
|
|
227
|
+
if runtime.arch and runtime.arch not in architectures:
|
|
228
|
+
report.outcomes.append(Outcome(runtime, "skipped", f"{runtime.arch} not chosen"))
|
|
229
|
+
continue
|
|
230
|
+
# Identity is the content, as the backend checks it: the layer list.
|
|
231
|
+
have = local_layers(runtime.ref)
|
|
232
|
+
if have is not None and have == runtime.rootfs_layers:
|
|
233
|
+
report.outcomes.append(Outcome(runtime, "present"))
|
|
234
|
+
continue
|
|
235
|
+
if not runtime.published:
|
|
236
|
+
report.outcomes.append(
|
|
237
|
+
Outcome(runtime, "not_published", "names no registry; load it with docker load"),
|
|
238
|
+
)
|
|
239
|
+
continue
|
|
240
|
+
if pull(runtime) != 0:
|
|
241
|
+
report.outcomes.append(Outcome(runtime, "failed", "docker pull failed"))
|
|
242
|
+
continue
|
|
243
|
+
have = local_layers(runtime.ref)
|
|
244
|
+
if runtime.rootfs_layers and have != runtime.rootfs_layers:
|
|
245
|
+
# The tag was moved, or the registry serves another build: the
|
|
246
|
+
# backend would refuse to hand this to a machine.
|
|
247
|
+
report.outcomes.append(
|
|
248
|
+
Outcome(runtime, "mismatch", "the registry's image is not the build this release pins"),
|
|
249
|
+
)
|
|
250
|
+
continue
|
|
251
|
+
report.outcomes.append(Outcome(runtime, "pulled"))
|
|
252
|
+
return report
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── the machines' model-TLS proxy ──────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
#: nginx in front of the models on every machine (the node agent's
|
|
258
|
+
#: ``model_tls.py`` pins the same build): one single-platform manifest per
|
|
259
|
+
#: architecture, and the content identity the agent checks (sha256 over the
|
|
260
|
+
#: ordered layer diff IDs). An air-gapped machine takes it from this server.
|
|
261
|
+
MODEL_TLS_NGINX = {
|
|
262
|
+
"x86_64": (
|
|
263
|
+
"nginx@sha256:0530961ff0592b58c10f767535cc0abdfccf9e389ff7cc90f87320c1bc7e8506",
|
|
264
|
+
"sha256:268c8b4b4baa285e4082ade32c9f2f69a013617ea0b7d0ff25f711bdb10750e7",
|
|
265
|
+
),
|
|
266
|
+
"aarch64": (
|
|
267
|
+
"nginx@sha256:84dd96a0337ae0f15be81afa8c6bd99d06a514a2ab71a1503aba91ce4392ecc2",
|
|
268
|
+
"sha256:2f89fd28b672e74bd0d11deb485485881ca606ccc04a6a14b05cca1272f0459a",
|
|
269
|
+
),
|
|
270
|
+
}
|
|
271
|
+
MODEL_TLS_TAG = "faden/model-tls-nginx:1.31.6-alpine-{arch}"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def rootfs_digest(layers: tuple[str, ...] | list[str]) -> str:
|
|
275
|
+
"""What the agent and the backend compare: sha256 over the layer IDs, one per line."""
|
|
276
|
+
import hashlib # noqa: PLC0415
|
|
277
|
+
|
|
278
|
+
return "sha256:" + hashlib.sha256(chr(10).join(layers).encode()).hexdigest()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def fetch_model_tls(architectures: set[str]) -> list[tuple[str, str, str]]:
|
|
282
|
+
"""Hold the model-TLS proxy image for each chosen architecture: ``(tag, arch, outcome)``.
|
|
283
|
+
|
|
284
|
+
Pulled by its single-platform manifest (so either Docker image store keeps
|
|
285
|
+
exactly that platform under the tag) and checked against the pin before
|
|
286
|
+
it is tagged where the agents ask for it.
|
|
287
|
+
"""
|
|
288
|
+
out: list[tuple[str, str, str]] = []
|
|
289
|
+
for arch, (ref, pin) in MODEL_TLS_NGINX.items():
|
|
290
|
+
tag = MODEL_TLS_TAG.format(arch=arch)
|
|
291
|
+
if arch not in architectures:
|
|
292
|
+
out.append((tag, arch, "skipped"))
|
|
293
|
+
continue
|
|
294
|
+
have = local_layers(tag)
|
|
295
|
+
if have and rootfs_digest(have) == pin:
|
|
296
|
+
out.append((tag, arch, "present"))
|
|
297
|
+
continue
|
|
298
|
+
if _run([_docker(), "pull", "--platform", _PLATFORMS[arch], ref]).returncode != 0:
|
|
299
|
+
out.append((tag, arch, "failed"))
|
|
300
|
+
continue
|
|
301
|
+
pulled = local_layers(ref)
|
|
302
|
+
if not pulled or rootfs_digest(pulled) != pin:
|
|
303
|
+
out.append((tag, arch, "mismatch"))
|
|
304
|
+
continue
|
|
305
|
+
if _run([_docker(), "tag", ref, tag], capture_output=True).returncode != 0:
|
|
306
|
+
out.append((tag, arch, "failed"))
|
|
307
|
+
continue
|
|
308
|
+
out.append((tag, arch, "pulled"))
|
|
309
|
+
return out
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Daily jobs -- the backup, the certificate renewal -- for the user who runs Fadenstack; no root.
|
|
2
|
+
|
|
3
|
+
Two ways, whichever the machine has:
|
|
4
|
+
|
|
5
|
+
* a **systemd user timer** (``faden-backup.timer``), preferred: every
|
|
6
|
+
systemd distribution has one, and ``Persistent=true`` runs a job missed
|
|
7
|
+
while the server was off. It runs without anyone logged in only with
|
|
8
|
+
*lingering* on, which is switched on for the user (as the node agent's
|
|
9
|
+
installer does).
|
|
10
|
+
* a **crontab line** marked with the job's name, where there is cron but no
|
|
11
|
+
usable systemd user manager.
|
|
12
|
+
|
|
13
|
+
Ubuntu 26.04 server ships no cron, which is why cron is not the only way.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import shlex
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
#: Marks the crontab line of the backup.
|
|
27
|
+
MARK = "# faden-backup"
|
|
28
|
+
#: The systemd user units of the backup.
|
|
29
|
+
UNIT = "faden-backup"
|
|
30
|
+
#: The systemd user units (and crontab mark) of the daily ``faden tls renew``.
|
|
31
|
+
TLS_UNIT = "faden-tls-renew"
|
|
32
|
+
|
|
33
|
+
#: Scheduled jobs run with a bare PATH; docker is usually in one of these.
|
|
34
|
+
_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
38
|
+
return subprocess.run(cmd, capture_output=True, text=True, check=False, **kwargs) # noqa: S603
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── which way ─────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _systemd_user() -> bool:
|
|
45
|
+
if sys.platform != "linux" or shutil.which("systemctl") is None:
|
|
46
|
+
return False
|
|
47
|
+
state = _run(["systemctl", "--user", "is-system-running"]).stdout.strip()
|
|
48
|
+
# "degraded" still runs timers; "offline"/empty means no user manager.
|
|
49
|
+
return state in {"running", "degraded", "starting", "initializing"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cron() -> bool:
|
|
53
|
+
return sys.platform != "win32" and shutil.which("crontab") is not None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def available() -> bool:
|
|
57
|
+
"""Whether jobs can be scheduled on this machine at all."""
|
|
58
|
+
return _systemd_user() or _cron()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── what runs ─────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cli() -> list[str]:
|
|
65
|
+
"""How a scheduled job should run this CLI: by absolute path, not a PATH lookup."""
|
|
66
|
+
found = shutil.which("faden")
|
|
67
|
+
if found:
|
|
68
|
+
return [str(Path(found).resolve())]
|
|
69
|
+
return [sys.executable, "-m", "faden_cli"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _config() -> Path | None:
|
|
73
|
+
value = os.environ.get("FADEN_CONFIG")
|
|
74
|
+
return Path(value).expanduser().resolve() if value else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Job:
|
|
79
|
+
"""A command of this CLI run every day at *hour*:*minute*, appending to *log*."""
|
|
80
|
+
|
|
81
|
+
unit: str
|
|
82
|
+
description: str
|
|
83
|
+
args: tuple[str, ...]
|
|
84
|
+
hour: int
|
|
85
|
+
minute: int
|
|
86
|
+
log: Path
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def mark(self) -> str:
|
|
90
|
+
"""What ends its crontab line."""
|
|
91
|
+
return f"# {self.unit}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _command(job: Job) -> str:
|
|
95
|
+
return " ".join(shlex.quote(part) for part in [*_cli(), *job.args])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── cron ──────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read() -> list[str]:
|
|
102
|
+
out = _run(["crontab", "-l"])
|
|
103
|
+
# "no crontab for <user>" is exit 1 with nothing to keep.
|
|
104
|
+
return out.stdout.splitlines() if out.returncode == 0 else []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _write(lines: list[str]) -> None:
|
|
108
|
+
text = "\n".join(lines).rstrip("\n") + "\n" if lines else ""
|
|
109
|
+
subprocess.run(["crontab", "-"], input=text, text=True, check=True) # noqa: S607
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def job_entry(job: Job, config: Path | None) -> str:
|
|
113
|
+
"""The crontab line for *job*."""
|
|
114
|
+
env = f"PATH={_PATH}"
|
|
115
|
+
if config is not None:
|
|
116
|
+
env += f" FADEN_CONFIG={shlex.quote(str(config))}"
|
|
117
|
+
log = shlex.quote(str(job.log))
|
|
118
|
+
return f"{job.minute} {job.hour} * * * {env} {_command(job)} >> {log} 2>&1 {job.mark}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── systemd ───────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _unit_dir() -> Path:
|
|
125
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
126
|
+
return Path(base) / "systemd" / "user"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def job_units(job: Job, config: Path | None) -> dict[str, str]:
|
|
130
|
+
"""The service and timer unit files for *job*."""
|
|
131
|
+
env = [f"Environment=PATH={_PATH}"]
|
|
132
|
+
if config is not None:
|
|
133
|
+
env.append(f'Environment="FADEN_CONFIG={config}"')
|
|
134
|
+
service = "\n".join([
|
|
135
|
+
"[Unit]",
|
|
136
|
+
f"Description={job.description}",
|
|
137
|
+
"",
|
|
138
|
+
"[Service]",
|
|
139
|
+
"Type=oneshot",
|
|
140
|
+
*env,
|
|
141
|
+
f"ExecStart={_command(job)}",
|
|
142
|
+
f"StandardOutput=append:{job.log}",
|
|
143
|
+
f"StandardError=append:{job.log}",
|
|
144
|
+
"",
|
|
145
|
+
])
|
|
146
|
+
timer = "\n".join([
|
|
147
|
+
"[Unit]",
|
|
148
|
+
f"Description={job.description}",
|
|
149
|
+
"",
|
|
150
|
+
"[Timer]",
|
|
151
|
+
f"OnCalendar=*-*-* {job.hour:02d}:{job.minute:02d}:00",
|
|
152
|
+
# A day the server was off is caught up when it next starts.
|
|
153
|
+
"Persistent=true",
|
|
154
|
+
"",
|
|
155
|
+
"[Install]",
|
|
156
|
+
"WantedBy=timers.target",
|
|
157
|
+
"",
|
|
158
|
+
])
|
|
159
|
+
return {f"{job.unit}.service": service, f"{job.unit}.timer": timer}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _linger() -> bool:
|
|
163
|
+
"""Lingering on for this user (so the timer runs with nobody logged in); whether it is."""
|
|
164
|
+
user = os.environ.get("USER") or ""
|
|
165
|
+
shown = _run(["loginctl", "show-user", user, "-p", "Linger"]).stdout.strip()
|
|
166
|
+
if shown == "Linger=yes":
|
|
167
|
+
return True
|
|
168
|
+
_run(["loginctl", "enable-linger", user])
|
|
169
|
+
return _run(["loginctl", "show-user", user, "-p", "Linger"]).stdout.strip() == "Linger=yes"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── any job ───────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def job_current(unit: str) -> str | None:
|
|
176
|
+
"""How *unit* is scheduled, if it is: the timer's time or the crontab line."""
|
|
177
|
+
enabled = ""
|
|
178
|
+
if _systemd_user():
|
|
179
|
+
enabled = _run(["systemctl", "--user", "is-enabled", f"{unit}.timer"]).stdout.strip()
|
|
180
|
+
if enabled == "enabled":
|
|
181
|
+
lines = (_unit_dir() / f"{unit}.timer").read_text().splitlines()
|
|
182
|
+
calendar = next((line for line in lines if line.startswith("OnCalendar=")), "")
|
|
183
|
+
return f"systemd timer {unit}.timer ({calendar.removeprefix('OnCalendar=')})"
|
|
184
|
+
if _cron():
|
|
185
|
+
return next((line for line in _read() if line.endswith(f"# {unit}")), None)
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def install_job(job: Job) -> tuple[str, list[str]]:
|
|
190
|
+
"""Schedule *job*, replacing an earlier schedule of it. Returns how, and warnings for the operator."""
|
|
191
|
+
config = _config()
|
|
192
|
+
warnings: list[str] = []
|
|
193
|
+
remove_job(job.unit)
|
|
194
|
+
job.log.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
if _systemd_user():
|
|
196
|
+
directory = _unit_dir()
|
|
197
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
for name, text in job_units(job, config).items():
|
|
199
|
+
(directory / name).write_text(text, encoding="utf-8")
|
|
200
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
201
|
+
enabled = _run(["systemctl", "--user", "enable", "--now", f"{job.unit}.timer"])
|
|
202
|
+
if enabled.returncode != 0:
|
|
203
|
+
msg = f"systemctl --user enable {job.unit}.timer failed: {enabled.stderr.strip()}"
|
|
204
|
+
raise RuntimeError(msg)
|
|
205
|
+
if not _linger():
|
|
206
|
+
warnings.append(
|
|
207
|
+
f"Lingering is off for this user, so {job.unit} only runs while someone is logged in. "
|
|
208
|
+
f"Turn it on once with: sudo loginctl enable-linger {os.environ.get('USER', '<user>')}"
|
|
209
|
+
)
|
|
210
|
+
return f"systemd timer {job.unit}.timer", warnings
|
|
211
|
+
if _cron():
|
|
212
|
+
_write([*_read(), job_entry(job, config)])
|
|
213
|
+
return "crontab", warnings
|
|
214
|
+
msg = "Neither a systemd user manager nor cron is available."
|
|
215
|
+
raise RuntimeError(msg)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def remove_job(unit: str) -> bool:
|
|
219
|
+
"""Remove *unit*'s schedule, whichever way it was scheduled; whether there was one."""
|
|
220
|
+
removed = False
|
|
221
|
+
if _systemd_user():
|
|
222
|
+
timer = _unit_dir() / f"{unit}.timer"
|
|
223
|
+
if timer.exists():
|
|
224
|
+
_run(["systemctl", "--user", "disable", "--now", f"{unit}.timer"])
|
|
225
|
+
for name in (f"{unit}.timer", f"{unit}.service"):
|
|
226
|
+
(_unit_dir() / name).unlink(missing_ok=True)
|
|
227
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
228
|
+
removed = True
|
|
229
|
+
if _cron():
|
|
230
|
+
lines = _read()
|
|
231
|
+
kept = [line for line in lines if not line.endswith(f"# {unit}")]
|
|
232
|
+
if len(kept) != len(lines):
|
|
233
|
+
_write(kept)
|
|
234
|
+
removed = True
|
|
235
|
+
return removed
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── the backup ────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _backup(*, hour: int, minute: int, retain: int, log: Path) -> Job:
|
|
242
|
+
return Job(UNIT, "Fadenstack nightly backup", ("backup", "-y", "--retain", str(retain)), hour, minute, log)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def entry(*, hour: int, minute: int, retain: int, log: Path, config: Path | None) -> str:
|
|
246
|
+
"""The crontab line for a backup every day at *hour*:*minute*."""
|
|
247
|
+
return job_entry(_backup(hour=hour, minute=minute, retain=retain, log=log), config)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def units(*, hour: int, minute: int, retain: int, log: Path, config: Path | None) -> dict[str, str]:
|
|
251
|
+
"""The service and timer unit files for a backup every day at *hour*:*minute*."""
|
|
252
|
+
return job_units(_backup(hour=hour, minute=minute, retain=retain, log=log), config)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def current() -> str | None:
|
|
256
|
+
"""How the nightly backup is scheduled, if it is: the timer's time or the crontab line."""
|
|
257
|
+
return job_current(UNIT)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def install(*, hour: int, minute: int, retain: int, log: Path) -> tuple[str, list[str]]:
|
|
261
|
+
"""Schedule the backup, replacing an earlier schedule.
|
|
262
|
+
|
|
263
|
+
Returns how it is scheduled, and warnings for the operator.
|
|
264
|
+
"""
|
|
265
|
+
return install_job(_backup(hour=hour, minute=minute, retain=retain, log=log))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def remove() -> bool:
|
|
269
|
+
"""Remove the scheduled backup, whichever way it was scheduled; whether there was one."""
|
|
270
|
+
return remove_job(UNIT)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ── the certificate renewal ───────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def tls_renewal(log: Path) -> Job:
|
|
277
|
+
"""``faden tls renew`` every morning; it changes nothing until something is due."""
|
|
278
|
+
return Job(TLS_UNIT, "Fadenstack certificate renewal", ("tls", "renew"), 4, 40, log)
|