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
faden_cli/core/backup.py
ADDED
|
@@ -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
|