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,717 @@
1
+ """Backup and restore helpers for Fadenstack databases and volumes.
2
+
3
+ Shared by ``faden backup``, ``faden restore``, and
4
+ ``faden upgrade``. All database operations run via
5
+ ``docker exec`` against the running ``faden-postgres`` container —
6
+ no direct network connection is needed.
7
+
8
+ A backup holds every database and every secret of the install, so it is
9
+ readable by the user who made it only: its directory 0700, its files 0600,
10
+ the volume archives too (the CLI writes them from the helper container's
11
+ output, rather than the container writing them as root).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import shutil
19
+ import subprocess
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ from faden_cli import __version__
25
+ from faden_cli.core.compose import ComposeContext, up as compose_up
26
+ from faden_cli.core.console import console, error, info, success, warning
27
+ from faden_cli.core.private_files import PRIVATE_DIR, open_private, private_dir, write_private
28
+ from faden_cli.core.registry import DATABASES, POSTGRES_CONTAINER
29
+
30
+ # Named Docker volumes that can be included in a backup, by their name in the compose file. Not among them:
31
+ # Postgres's data volume, because the dumps are the databases' backup, a copy of a running data directory is
32
+ # not consistent, and writing one back under the Postgres a restore runs would break it; and the models,
33
+ # which can be downloaded again and run to hundreds of gigabytes.
34
+ BACKUP_VOLUMES: list[str] = [
35
+ "faden_rag_lite",
36
+ "minio_data",
37
+ "ch_data",
38
+ "ch_logs",
39
+ "loki_data",
40
+ "grafana_data",
41
+ ]
42
+
43
+ #: Never written back by a restore, whatever an older backup holds.
44
+ _NEVER_RESTORED = frozenset({"pg_data"})
45
+
46
+ # Alembic migrator containers (service name → database).
47
+ _MIGRATOR_SERVICES: dict[str, str] = {
48
+ "faden-backend-migrator": "faden_backend",
49
+ "faden-gateway-migrator": "faden_gateway",
50
+ "faden-mcp-migrator": "faden_mcp",
51
+ "faden-skills-migrator": "faden_skills",
52
+ "faden-pii-migrator": "pii",
53
+ }
54
+
55
+
56
+ # ── Result types ──────────────────────────────────────────────────
57
+
58
+
59
+ @dataclass
60
+ class BackupResult:
61
+ """Outcome of a backup operation."""
62
+
63
+ backup_dir: Path
64
+ databases: dict[str, str] = field(default_factory=dict) # db → dump file
65
+ env_snapshot: str = ""
66
+ volumes: list[str] = field(default_factory=list)
67
+ tls_material: list[str] = field(default_factory=list)
68
+ manifest_path: Path | None = None
69
+ ok: bool = True
70
+ errors: list[str] = field(default_factory=list)
71
+
72
+
73
+ @dataclass
74
+ class RestoreResult:
75
+ """Outcome of a restore operation."""
76
+
77
+ databases_restored: list[str] = field(default_factory=list)
78
+ env_restored: bool = False
79
+ volumes_restored: list[str] = field(default_factory=list)
80
+ tls_restored: list[str] = field(default_factory=list)
81
+ ok: bool = True
82
+ errors: list[str] = field(default_factory=list)
83
+
84
+
85
+ # ── Checksums ─────────────────────────────────────────────────────
86
+
87
+
88
+ def _sha256(path: Path) -> str:
89
+ """Return the hex SHA-256 digest of a file."""
90
+ h = hashlib.sha256()
91
+ with path.open("rb") as fh:
92
+ for chunk in iter(lambda: fh.read(1 << 16), b""):
93
+ h.update(chunk)
94
+ return h.hexdigest()
95
+
96
+
97
+ # ── Database dump / restore ───────────────────────────────────────
98
+
99
+
100
+ def _docker_bin() -> str:
101
+ docker = shutil.which("docker")
102
+ if not docker:
103
+ raise RuntimeError("docker not found on PATH")
104
+ return docker
105
+
106
+
107
+ def existing_databases(docker: str) -> list[str] | None:
108
+ """The databases the Postgres server holds, or ``None`` when it cannot say.
109
+
110
+ A backup takes these rather than a fixed list: the list named ``pii``,
111
+ absent where the PII module was never on (a failed dump, a warning), and
112
+ missed ``rag``, which an install had -- so it was never backed up.
113
+ """
114
+ proc = subprocess.run( # noqa: S603
115
+ [
116
+ docker, "exec", POSTGRES_CONTAINER, "psql", "-U", "postgres", "-tAc",
117
+ "SELECT datname FROM pg_database WHERE NOT datistemplate AND datname <> 'postgres' ORDER BY 1",
118
+ ],
119
+ capture_output=True,
120
+ check=False,
121
+ )
122
+ if proc.returncode != 0:
123
+ return None
124
+ names = [line.strip() for line in proc.stdout.decode("utf-8", errors="replace").splitlines() if line.strip()]
125
+ return names or None
126
+
127
+
128
+ def dump_databases(
129
+ backup_dir: Path,
130
+ *,
131
+ databases: list[str] | None = None,
132
+ ) -> dict[str, str]:
133
+ """Dump each database with ``pg_dump -Fc`` via docker exec.
134
+
135
+ Returns a mapping of *database name* → *dump filename* for
136
+ databases that were successfully dumped.
137
+ """
138
+ docker = _docker_bin()
139
+ targets = databases or existing_databases(docker) or list(DATABASES)
140
+ results: dict[str, str] = {}
141
+
142
+ for db in targets:
143
+ dump_file = f"{db}.dump"
144
+ dump_path = backup_dir / dump_file
145
+
146
+ info(f" Dumping database: {db}")
147
+ proc = subprocess.run( # noqa: S603
148
+ [
149
+ docker, "exec", POSTGRES_CONTAINER,
150
+ "pg_dump", "-U", "postgres", "-Fc", db,
151
+ ],
152
+ capture_output=True,
153
+ check=False,
154
+ )
155
+ if proc.returncode != 0:
156
+ stderr = proc.stderr.decode("utf-8", errors="replace").strip()
157
+ warning(f" pg_dump failed for {db}: {stderr}")
158
+ continue
159
+
160
+ write_private(dump_path, proc.stdout)
161
+ results[db] = dump_file
162
+
163
+ return results
164
+
165
+
166
+ def restore_databases(
167
+ backup_dir: Path,
168
+ manifest: dict,
169
+ ) -> list[str]:
170
+ """Restore databases from custom-format dumps.
171
+
172
+ Returns the list of database names that were successfully restored.
173
+ """
174
+ docker = _docker_bin()
175
+ restored: list[str] = []
176
+ db_section = manifest.get("databases", {})
177
+
178
+ for db, meta in db_section.items():
179
+ dump_file = meta.get("file", f"{db}.dump")
180
+ dump_path = backup_dir / dump_file
181
+ if not dump_path.exists():
182
+ warning(f" Dump file missing for {db}: {dump_file}")
183
+ continue
184
+
185
+ info(f" Restoring database: {db}")
186
+
187
+ # Read the dump file and pipe it into pg_restore via stdin.
188
+ dump_bytes = dump_path.read_bytes()
189
+ proc = subprocess.run( # noqa: S603
190
+ [
191
+ docker, "exec", "-i", POSTGRES_CONTAINER,
192
+ "pg_restore", "-U", "postgres",
193
+ "--clean", "--if-exists", "-d", db,
194
+ ],
195
+ input=dump_bytes,
196
+ capture_output=True,
197
+ check=False,
198
+ )
199
+ # pg_restore may return non-zero for harmless warnings
200
+ # (e.g. "role does not exist" during --clean).
201
+ stderr = proc.stderr.decode("utf-8", errors="replace").strip()
202
+ if proc.returncode != 0 and "ERROR" in stderr.upper():
203
+ warning(f" pg_restore warnings for {db}: {stderr[:200]}")
204
+ restored.append(db)
205
+
206
+ return restored
207
+
208
+
209
+ # ── Migration heads ───────────────────────────────────────────────
210
+
211
+
212
+ def get_migration_heads(ctx: ComposeContext) -> dict[str, str]:
213
+ """Collect current Alembic revision heads from migrator containers.
214
+
215
+ Runs ``alembic current`` inside each migrator service. Returns a
216
+ mapping of *database name* → *revision hash* (or empty string if
217
+ the head could not be determined).
218
+ """
219
+ docker = _docker_bin()
220
+ heads: dict[str, str] = {}
221
+
222
+ for service, db in _MIGRATOR_SERVICES.items():
223
+ proc = subprocess.run( # noqa: S603
224
+ [
225
+ docker, "compose",
226
+ *_compose_flags(ctx),
227
+ "run", "--rm", "--no-deps", service,
228
+ "alembic", "current",
229
+ ],
230
+ capture_output=True,
231
+ text=True,
232
+ check=False,
233
+ )
234
+ head = ""
235
+ if proc.returncode == 0:
236
+ # Output looks like: "a1b2c3d4e5f6 (head)"
237
+ for line in proc.stdout.strip().splitlines():
238
+ if "(head)" in line:
239
+ head = line.split()[0]
240
+ break
241
+ heads[db] = head
242
+
243
+ return heads
244
+
245
+
246
+ def _compose_flags(ctx: ComposeContext) -> list[str]:
247
+ """Build raw compose flags from a ComposeContext (without 'docker compose')."""
248
+ flags: list[str] = []
249
+ for f in ctx.compose_files:
250
+ flags.extend(["-f", str(f)])
251
+ if ctx.env_file and Path(str(ctx.env_file)).exists():
252
+ flags.extend(["--env-file", str(ctx.env_file)])
253
+ if ctx.project_dir:
254
+ flags.extend(["--project-directory", str(ctx.project_dir)])
255
+ for p in ctx.profiles:
256
+ flags.extend(["--profile", p])
257
+ return flags
258
+
259
+
260
+ # ── .env snapshot ─────────────────────────────────────────────────
261
+
262
+
263
+ def snapshot_env(env_path: Path, backup_dir: Path) -> str:
264
+ """Copy the .env file into the backup directory.
265
+
266
+ Returns the filename inside backup_dir, or empty string on failure.
267
+ """
268
+ if not env_path.exists():
269
+ warning(" No .env file found — skipping env snapshot.")
270
+ return ""
271
+ dest = backup_dir / ".env.bak"
272
+ write_private(dest, env_path.read_bytes())
273
+ return ".env.bak"
274
+
275
+
276
+ def restore_env(backup_dir: Path, env_path: Path) -> bool:
277
+ """Restore the .env snapshot from a backup, readable by its owner only."""
278
+ src = backup_dir / ".env.bak"
279
+ if not src.exists():
280
+ warning(" No .env snapshot in backup.")
281
+ return False
282
+ write_private(env_path, src.read_bytes())
283
+ return True
284
+
285
+
286
+ # ── Volume snapshots ──────────────────────────────────────────────
287
+
288
+
289
+ def _compose_project(docker: str) -> str:
290
+ """The compose project the running install belongs to, read from its Postgres container."""
291
+ proc = subprocess.run( # noqa: S603
292
+ [
293
+ docker, "inspect", "-f", '{{ index .Config.Labels "com.docker.compose.project" }}',
294
+ POSTGRES_CONTAINER,
295
+ ],
296
+ capture_output=True,
297
+ check=False,
298
+ )
299
+ return proc.stdout.decode("utf-8", errors="replace").strip() if proc.returncode == 0 else ""
300
+
301
+
302
+ def _compose_volume(docker: str, project: str, name: str) -> str | None:
303
+ """The Docker volume behind the compose volume *name*, or ``None`` when the install has none.
304
+
305
+ Compose names a volume after its project (``fadenstack_minio_data``). The bare name is another volume,
306
+ one ``docker run -v`` creates empty on the spot: backups tarred that and restores filled it.
307
+ """
308
+ if not project:
309
+ return None
310
+ proc = subprocess.run( # noqa: S603
311
+ [
312
+ docker, "volume", "ls", "-q",
313
+ "--filter", f"label=com.docker.compose.project={project}",
314
+ "--filter", f"label=com.docker.compose.volume={name}",
315
+ ],
316
+ capture_output=True,
317
+ check=False,
318
+ )
319
+ lines = proc.stdout.decode("utf-8", errors="replace").splitlines()
320
+ found = [line.strip() for line in lines if line.strip()]
321
+ return found[0] if proc.returncode == 0 and len(found) == 1 else None
322
+
323
+
324
+ def snapshot_volumes(backup_dir: Path, *, volumes: list[str] | None = None) -> list[str]:
325
+ """Tar each named Docker volume into the backup directory.
326
+
327
+ Uses a temporary ``alpine`` container to read from the volume mount. The archive carries the compose
328
+ name (``minio_data.tar.gz``), so a restore finds the volume on whichever install it runs.
329
+ Returns list of created archive filenames.
330
+
331
+ The container writes the archive to its output and the CLI into the backup, readable by its owner
332
+ only. Written by the container into a mounted backup directory, it was root's, 0644, for every
333
+ local user to read.
334
+ """
335
+ docker = _docker_bin()
336
+ project = _compose_project(docker)
337
+ targets = volumes or list(BACKUP_VOLUMES)
338
+ created: list[str] = []
339
+
340
+ for vol in targets:
341
+ source = _compose_volume(docker, project, vol)
342
+ if source is None:
343
+ warning(f" No volume {vol} in this install; skipped")
344
+ continue
345
+ archive = f"{vol}.tar.gz"
346
+ archive_path = backup_dir / archive
347
+ info(f" Snapshotting volume: {vol}")
348
+ with open_private(archive_path) as out:
349
+ proc = subprocess.run( # noqa: S603
350
+ [
351
+ docker, "run", "--rm",
352
+ "-v", f"{source}:/data:ro",
353
+ "alpine",
354
+ "tar", "czf", "-", "-C", "/data", ".",
355
+ ],
356
+ stdout=out,
357
+ stderr=subprocess.PIPE,
358
+ check=False,
359
+ )
360
+ if proc.returncode != 0:
361
+ stderr = proc.stderr.decode("utf-8", errors="replace").strip()
362
+ warning(f" Volume snapshot failed for {vol}: {stderr[:200]}")
363
+ archive_path.unlink(missing_ok=True)
364
+ continue
365
+ created.append(archive)
366
+
367
+ return created
368
+
369
+
370
+ def restore_volumes(backup_dir: Path, manifest: dict) -> list[str]:
371
+ """Restore volume archives from a backup.
372
+
373
+ Returns list of volume names that were restored.
374
+ """
375
+ docker = _docker_bin()
376
+ project = _compose_project(docker)
377
+ archives = manifest.get("volumes", [])
378
+ restored: list[str] = []
379
+
380
+ for archive in archives:
381
+ archive_path = backup_dir / archive
382
+ if not archive_path.exists():
383
+ warning(f" Volume archive missing: {archive}")
384
+ continue
385
+ # Derive volume name from archive filename (e.g. "minio_data.tar.gz" → "minio_data")
386
+ vol = archive.removesuffix(".tar.gz")
387
+ if vol in _NEVER_RESTORED:
388
+ warning(f" {vol} skipped: the databases come back from their dumps")
389
+ continue
390
+ target = _compose_volume(docker, project, vol)
391
+ if target is None:
392
+ warning(f" No volume {vol} in this install; {archive} not restored")
393
+ continue
394
+ info(f" Restoring volume: {vol}")
395
+ # The archive goes in on the container's input: the backup is its owner's only, which a container's
396
+ # root may not be (user namespaces), and a mount of it is not needed.
397
+ with archive_path.open("rb") as archive_in:
398
+ proc = subprocess.run( # noqa: S603
399
+ [
400
+ docker, "run", "--rm", "-i",
401
+ "-v", f"{target}:/data",
402
+ "alpine",
403
+ "sh", "-c", "find /data -mindepth 1 -delete && tar xzf - -C /data",
404
+ ],
405
+ stdin=archive_in,
406
+ capture_output=True,
407
+ check=False,
408
+ )
409
+ if proc.returncode != 0:
410
+ stderr = proc.stderr.decode("utf-8", errors="replace").strip()
411
+ warning(f" Volume restore failed for {vol}: {stderr[:200]}")
412
+ continue
413
+ restored.append(vol)
414
+
415
+ return restored
416
+
417
+
418
+ # ── Manifest ──────────────────────────────────────────────────────
419
+
420
+
421
+ # ── Certificate material ──────────────────────────────────────────
422
+
423
+ #: What HTTPS and the machines' trust rest on, relative to the install:
424
+ #: the certificate served and its key, and the install's own authority with
425
+ #: its key. Without the authority, a server rebuilt from a backup makes a new
426
+ #: one, and every machine that followed it to HTTPS refuses to connect.
427
+ TLS_MATERIAL = (
428
+ "tls/edge.crt",
429
+ "tls/edge.key",
430
+ "tls/edge-chain.pem",
431
+ "tls/ca.crt",
432
+ "tls-ca/ca.crt",
433
+ "tls-ca/ca.key",
434
+ "tls-ca/ca-previous.crt",
435
+ "tls-ca/roots.pem",
436
+ "tls-ca/source.json",
437
+ )
438
+ _TLS_BACKUP_DIR = "tls-material"
439
+
440
+
441
+ def snapshot_tls(install_dir: Path, backup_dir: Path) -> list[str]:
442
+ """Copy the certificate material into the backup; the files copied, relative to the install."""
443
+ copied: list[str] = []
444
+ for rel in TLS_MATERIAL:
445
+ src = install_dir / rel
446
+ if not src.is_file():
447
+ continue
448
+ dest = backup_dir / _TLS_BACKUP_DIR / rel
449
+ private_dir(dest.parent)
450
+ write_private(dest, src.read_bytes())
451
+ copied.append(rel)
452
+ return copied
453
+
454
+
455
+ def _tls_mode(rel: str) -> int:
456
+ """The mode a certificate file has in an install: keys (and where they came from) owner-only."""
457
+ return 0o600 if rel.endswith((".key", "source.json")) else 0o644
458
+
459
+
460
+ def restore_tls(backup_dir: Path, install_dir: Path, files: list[str]) -> list[str]:
461
+ """Put the certificate material back, keys readable by their owner only.
462
+
463
+ Each file gets the mode it has in an install, not the backup's (where every file is its owner's only):
464
+ the certificates are read by nginx and handed to the machines.
465
+ """
466
+ restored: list[str] = []
467
+ for rel in files:
468
+ if rel not in TLS_MATERIAL:
469
+ continue # only the files this CLI backs up, never a path out of the install
470
+ src = backup_dir / _TLS_BACKUP_DIR / rel
471
+ if not src.is_file():
472
+ continue
473
+ dest = install_dir / rel
474
+ dest.parent.mkdir(parents=True, exist_ok=True)
475
+ write_private(dest, src.read_bytes(), mode=_tls_mode(rel))
476
+ restored.append(rel)
477
+ if any(rel.startswith("tls-ca/") for rel in restored):
478
+ (install_dir / "tls-ca").chmod(PRIVATE_DIR)
479
+ return restored
480
+
481
+
482
+ def write_manifest(
483
+ backup_dir: Path,
484
+ *,
485
+ databases: dict[str, str],
486
+ env_snapshot: str,
487
+ volumes: list[str],
488
+ migration_heads: dict[str, str],
489
+ tls_material: list[str] | None = None,
490
+ ) -> Path:
491
+ """Write ``manifest.json`` with file checksums and metadata."""
492
+ db_entries: dict[str, dict] = {}
493
+ for db, dump_file in databases.items():
494
+ dump_path = backup_dir / dump_file
495
+ db_entries[db] = {
496
+ "file": dump_file,
497
+ "sha256": _sha256(dump_path) if dump_path.exists() else "",
498
+ "migration_head": migration_heads.get(db, ""),
499
+ }
500
+
501
+ manifest = {
502
+ "version": 1,
503
+ "created_at": datetime.now(tz=timezone.utc).isoformat(timespec="seconds"),
504
+ "faden_version": __version__,
505
+ "databases": db_entries,
506
+ "env_snapshot": env_snapshot,
507
+ "volumes": volumes,
508
+ "tls_material": {
509
+ rel: _sha256(backup_dir / _TLS_BACKUP_DIR / rel) for rel in (tls_material or [])
510
+ },
511
+ }
512
+
513
+ manifest_path = backup_dir / "manifest.json"
514
+ write_private(manifest_path, (json.dumps(manifest, indent=2) + "\n").encode("utf-8"))
515
+ return manifest_path
516
+
517
+
518
+ def read_manifest(backup_dir: Path) -> dict | None:
519
+ """Read and parse ``manifest.json`` from a backup directory."""
520
+ manifest_path = backup_dir / "manifest.json"
521
+ if not manifest_path.exists():
522
+ return None
523
+ return json.loads(manifest_path.read_text(encoding="utf-8"))
524
+
525
+
526
+ def verify_backup(backup_dir: Path, manifest: dict) -> list[str]:
527
+ """Verify database dump checksums against the manifest.
528
+
529
+ Returns a list of error messages (empty if all OK).
530
+ """
531
+ errors: list[str] = []
532
+ for db, meta in manifest.get("databases", {}).items():
533
+ dump_file = meta.get("file", "")
534
+ expected = meta.get("sha256", "")
535
+ if not dump_file or not expected:
536
+ continue
537
+ dump_path = backup_dir / dump_file
538
+ if not dump_path.exists():
539
+ errors.append(f"Missing dump file: {dump_file}")
540
+ continue
541
+ actual = _sha256(dump_path)
542
+ if actual != expected:
543
+ errors.append(f"Checksum mismatch for {dump_file}: expected {expected[:12]}… got {actual[:12]}…")
544
+ for rel, expected in (manifest.get("tls_material") or {}).items():
545
+ path = backup_dir / _TLS_BACKUP_DIR / rel
546
+ if not path.is_file():
547
+ errors.append(f"Missing certificate file: {rel}")
548
+ elif _sha256(path) != expected:
549
+ errors.append(f"Checksum mismatch for {rel}")
550
+ return errors
551
+
552
+
553
+ # ── Retention ─────────────────────────────────────────────────────
554
+
555
+
556
+ def rotate_backups(backup_root: Path, *, retain: int = 5) -> int:
557
+ """Remove oldest backup directories beyond the retention count.
558
+
559
+ Assumes backup directories are named with ISO timestamps so
560
+ lexicographic sort == chronological sort. Returns the number
561
+ of directories removed.
562
+ """
563
+ if retain <= 0:
564
+ return 0
565
+
566
+ dirs = sorted(
567
+ [d for d in backup_root.iterdir() if d.is_dir() and (d / "manifest.json").exists()],
568
+ key=lambda d: d.name,
569
+ )
570
+ to_remove = dirs[:-retain] if len(dirs) > retain else []
571
+ for d in to_remove:
572
+ shutil.rmtree(d)
573
+ return len(to_remove)
574
+
575
+
576
+ # ── Orchestrators ─────────────────────────────────────────────────
577
+
578
+
579
+ def create_backup(
580
+ ctx: ComposeContext,
581
+ *,
582
+ output_dir: Path,
583
+ include_volumes: bool = False,
584
+ db_only: bool = False,
585
+ retain: int = 5,
586
+ ) -> BackupResult:
587
+ """Full backup orchestration.
588
+
589
+ 1. Ensure postgres is running
590
+ 2. Create timestamped directory
591
+ 3. Dump databases
592
+ 4. Snapshot .env
593
+ 5. Optionally snapshot volumes
594
+ 6. Collect migration heads
595
+ 7. Write manifest
596
+ 8. Rotate old backups
597
+ """
598
+ # Owner-only, the directory and each backup in it: a backup holds every database and secret. A directory
599
+ # of the operator's (--output-dir) that already exists keeps its mode; the backups in it are private.
600
+ installs_own = ctx.project_dir is not None and (
601
+ output_dir.resolve() == (Path(str(ctx.project_dir)) / "backups").resolve()
602
+ )
603
+ if installs_own or not output_dir.exists():
604
+ private_dir(output_dir)
605
+ timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ")
606
+ backup_dir = private_dir(output_dir / timestamp)
607
+
608
+ result = BackupResult(backup_dir=backup_dir)
609
+
610
+ # 1. Ensure postgres is running
611
+ info("Ensuring PostgreSQL is running…")
612
+ compose_up(ctx, services=["postgres"], detach=True, wait=True, timeout=60)
613
+
614
+ # 2. Dump databases
615
+ console.print("\n[bold cyan]Dumping databases…[/bold cyan]")
616
+ result.databases = dump_databases(backup_dir)
617
+ if not result.databases:
618
+ result.ok = False
619
+ result.errors.append("No databases were dumped successfully.")
620
+ return result
621
+
622
+ # 3. Snapshot .env
623
+ if not db_only and ctx.env_file:
624
+ console.print("\n[bold cyan]Snapshotting .env…[/bold cyan]")
625
+ result.env_snapshot = snapshot_env(Path(str(ctx.env_file)), backup_dir)
626
+
627
+ # 3b. The certificate material: HTTPS and the machines' trust rest on it
628
+ if not db_only and ctx.project_dir:
629
+ result.tls_material = snapshot_tls(Path(str(ctx.project_dir)), backup_dir)
630
+ if result.tls_material:
631
+ count = len(result.tls_material)
632
+ console.print(f"\n[bold cyan]Certificate material:[/bold cyan] {count} file(s)")
633
+
634
+ # 4. Snapshot volumes
635
+ if include_volumes and not db_only:
636
+ console.print("\n[bold cyan]Snapshotting Docker volumes…[/bold cyan]")
637
+ result.volumes = snapshot_volumes(backup_dir)
638
+
639
+ # 5. Collect migration heads
640
+ console.print("\n[bold cyan]Collecting migration heads…[/bold cyan]")
641
+ heads = get_migration_heads(ctx)
642
+
643
+ # 6. Write manifest
644
+ console.print("\n[bold cyan]Writing manifest…[/bold cyan]")
645
+ result.manifest_path = write_manifest(
646
+ backup_dir,
647
+ databases=result.databases,
648
+ env_snapshot=result.env_snapshot,
649
+ volumes=result.volumes,
650
+ migration_heads=heads,
651
+ tls_material=result.tls_material,
652
+ )
653
+
654
+ # 7. Rotate
655
+ removed = rotate_backups(output_dir, retain=retain)
656
+ if removed:
657
+ info(f"Rotated {removed} old backup(s).")
658
+
659
+ return result
660
+
661
+
662
+ def restore_backup(
663
+ ctx: ComposeContext,
664
+ *,
665
+ backup_dir: Path,
666
+ db_only: bool = False,
667
+ skip_env: bool = False,
668
+ ) -> RestoreResult:
669
+ """Full restore orchestration.
670
+
671
+ 1. Read and verify manifest
672
+ 2. Ensure postgres is running
673
+ 3. Restore databases
674
+ 4. Restore .env
675
+ 5. Restore volumes
676
+ """
677
+ result = RestoreResult()
678
+
679
+ manifest = read_manifest(backup_dir)
680
+ if manifest is None:
681
+ result.ok = False
682
+ result.errors.append("No manifest.json found in backup directory.")
683
+ return result
684
+
685
+ # Verify checksums
686
+ check_errors = verify_backup(backup_dir, manifest)
687
+ if check_errors:
688
+ result.ok = False
689
+ result.errors.extend(check_errors)
690
+ return result
691
+
692
+ # 1. Ensure postgres is running
693
+ info("Ensuring PostgreSQL is running…")
694
+ compose_up(ctx, services=["postgres"], detach=True, wait=True, timeout=60)
695
+
696
+ # 2. Restore databases
697
+ console.print("\n[bold cyan]Restoring databases…[/bold cyan]")
698
+ result.databases_restored = restore_databases(backup_dir, manifest)
699
+
700
+ # 3. Restore .env
701
+ if not db_only and not skip_env and manifest.get("env_snapshot") and ctx.env_file:
702
+ console.print("\n[bold cyan]Restoring .env…[/bold cyan]")
703
+ result.env_restored = restore_env(backup_dir, Path(str(ctx.env_file)))
704
+
705
+ # 4. Restore volumes
706
+ if not db_only and manifest.get("volumes"):
707
+ console.print("\n[bold cyan]Restoring Docker volumes…[/bold cyan]")
708
+ result.volumes_restored = restore_volumes(backup_dir, manifest)
709
+
710
+ # 5. The certificate material, so the machines still trust this server
711
+ if not db_only and manifest.get("tls_material") and ctx.project_dir:
712
+ console.print("\n[bold cyan]Restoring the certificate material…[/bold cyan]")
713
+ result.tls_restored = restore_tls(
714
+ backup_dir, Path(str(ctx.project_dir)), list(manifest["tls_material"])
715
+ )
716
+
717
+ return result