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