backupproof 0.2.0__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.
- backupproof/__init__.py +23 -0
- backupproof/__main__.py +5 -0
- backupproof/cli.py +420 -0
- backupproof/config.py +121 -0
- backupproof/discovery.py +185 -0
- backupproof/errors.py +55 -0
- backupproof/models.py +85 -0
- backupproof/runners.py +436 -0
- backupproof/smoke.py +202 -0
- backupproof/verdict.py +111 -0
- backupproof-0.2.0.dist-info/METADATA +334 -0
- backupproof-0.2.0.dist-info/RECORD +16 -0
- backupproof-0.2.0.dist-info/WHEEL +5 -0
- backupproof-0.2.0.dist-info/entry_points.txt +2 -0
- backupproof-0.2.0.dist-info/licenses/LICENSE +21 -0
- backupproof-0.2.0.dist-info/top_level.txt +1 -0
backupproof/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""backupproof — backup restore-testing CLI.
|
|
2
|
+
|
|
3
|
+
Pick the latest restic/borg/Proxmox backup, restore it into a
|
|
4
|
+
throwaway target, verify it, and report PASS or FAIL with proof.
|
|
5
|
+
|
|
6
|
+
What "verify" means depends on the source, and the report says
|
|
7
|
+
which: restic/borg backups are file-level — the restored files are
|
|
8
|
+
content-checked (web pack served from the restored tree, DB and
|
|
9
|
+
file checks against the restored files); there is no disk image to
|
|
10
|
+
boot, and the report says so. Proxmox backups restore into a
|
|
11
|
+
throwaway VMID on the Proxmox host and are probed there (web pack
|
|
12
|
+
via a probe URL; db/files packs cannot verify a VM from the host
|
|
13
|
+
and are refused, not silently dropped). Fixture sources are
|
|
14
|
+
labelled simulated in every output.
|
|
15
|
+
|
|
16
|
+
Exit codes: 0 = PASS, 1 = FAIL (a check failed), 2 = ERROR
|
|
17
|
+
(no backup found, restore failure, boot timeout, bad config,
|
|
18
|
+
missing tool, stale backup with --fail-on-stale, unsupported pack
|
|
19
|
+
for Proxmox). A PASS is only ever issued when every check was
|
|
20
|
+
actually run and passed; unverified checks never pass silently.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__version__ = "0.2.0"
|
backupproof/__main__.py
ADDED
backupproof/cli.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""backupproof CLI.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
run the verdict: latest backup -> restore -> boot -> smoke -> PASS/FAIL
|
|
5
|
+
list list backups for a source (newest first)
|
|
6
|
+
validate validate a config file (no restore, no boot)
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 PASS, 1 FAIL, 2 ERROR (no backup, restore/boot
|
|
9
|
+
failure, bad config, missing tool). --json on all commands.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from dataclasses import replace
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from . import config as configmod
|
|
24
|
+
from . import discovery, smoke, verdict as verdictmod
|
|
25
|
+
from .errors import (BackupProofError, StaleBackupError,
|
|
26
|
+
UnsupportedPackError)
|
|
27
|
+
from .runners import FixtureRunner, RealRunner
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _backup_datetime(backup):
|
|
31
|
+
"""Backup timestamp as an aware datetime, or None when missing/
|
|
32
|
+
unparseable."""
|
|
33
|
+
ts = (backup.timestamp or "").strip()
|
|
34
|
+
if not ts:
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
38
|
+
except ValueError:
|
|
39
|
+
return None
|
|
40
|
+
if dt.tzinfo is None:
|
|
41
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
42
|
+
return dt
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _backup_age_days(backup):
|
|
46
|
+
"""Age of a backup in days, or None when the timestamp is
|
|
47
|
+
missing/unparseable (staleness then warns, never fails)."""
|
|
48
|
+
dt = _backup_datetime(backup)
|
|
49
|
+
if dt is None:
|
|
50
|
+
return None
|
|
51
|
+
return (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _verified_against(source, mode, boot_kind, probe_url):
|
|
55
|
+
"""What the packs actually verified, in one honest line. Web
|
|
56
|
+
checks hit an HTTP target; db/files checks always hit restored
|
|
57
|
+
files — the report must never imply otherwise."""
|
|
58
|
+
if mode == "simulated":
|
|
59
|
+
return ("restored fixture tree (web pack served over real HTTP "
|
|
60
|
+
"from the restored webroot)")
|
|
61
|
+
if boot_kind == "file-level":
|
|
62
|
+
return ("restored files (web pack served from serve_dir where "
|
|
63
|
+
"configured, else explicit URLs)")
|
|
64
|
+
if probe_url:
|
|
65
|
+
return ("booted VM via probe URL (web pack only; db/files not "
|
|
66
|
+
"verifiable from host)")
|
|
67
|
+
return ("booted VM, unprobed (web pack only — no probe URL "
|
|
68
|
+
"configured)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _runner_for(source: dict, include: list[str] | None = None):
|
|
72
|
+
if source["type"] == "fixture":
|
|
73
|
+
return FixtureRunner(source["dir"]), "simulated"
|
|
74
|
+
return RealRunner(source, include), "real"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _source_from_args(args) -> dict:
|
|
78
|
+
if getattr(args, "config", None):
|
|
79
|
+
return configmod.load_config(args.config)["source"]
|
|
80
|
+
stype = args.source_type
|
|
81
|
+
src: dict = {"type": stype}
|
|
82
|
+
if stype in ("restic", "borg"):
|
|
83
|
+
src["repo"] = args.repo
|
|
84
|
+
elif stype == "proxmox":
|
|
85
|
+
src["dump_dir"] = args.dump_dir
|
|
86
|
+
elif stype == "fixture":
|
|
87
|
+
src["dir"] = args.fixture_dir
|
|
88
|
+
if stype in ("restic", "borg") and not src.get("repo"):
|
|
89
|
+
raise configmod.ConfigError(f"{stype} source requires --repo")
|
|
90
|
+
if stype == "proxmox" and not src.get("dump_dir"):
|
|
91
|
+
raise configmod.ConfigError("proxmox source requires --dump-dir")
|
|
92
|
+
if stype == "fixture" and not src.get("dir"):
|
|
93
|
+
raise configmod.ConfigError("fixture source requires --fixture-dir")
|
|
94
|
+
return src
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_list(args) -> int:
|
|
98
|
+
try:
|
|
99
|
+
source = _source_from_args(args)
|
|
100
|
+
runner, _mode = _runner_for(source)
|
|
101
|
+
backups = runner.list_backups()
|
|
102
|
+
latest = discovery.latest(backups)
|
|
103
|
+
except BackupProofError as e:
|
|
104
|
+
if args.json:
|
|
105
|
+
print(json.dumps({"ok": False, "error": e.code,
|
|
106
|
+
"detail": str(e)}, indent=2))
|
|
107
|
+
else:
|
|
108
|
+
print(f"error: {e.code}: {e}")
|
|
109
|
+
return 2
|
|
110
|
+
if args.json:
|
|
111
|
+
print(json.dumps({"ok": True, "latest": latest.to_dict(),
|
|
112
|
+
"backups": [b.to_dict() for b in backups]}, indent=2))
|
|
113
|
+
else:
|
|
114
|
+
if not backups:
|
|
115
|
+
print("no backups found")
|
|
116
|
+
for b in backups:
|
|
117
|
+
mark = " (latest)" if b is latest or b.snapshot_id == latest.snapshot_id else ""
|
|
118
|
+
print(f" {b.source_type} {b.snapshot_id} {b.timestamp}{mark}")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_validate(args) -> int:
|
|
123
|
+
try:
|
|
124
|
+
cfg = configmod.load_config(args.config)
|
|
125
|
+
except BackupProofError as e:
|
|
126
|
+
if args.json:
|
|
127
|
+
print(json.dumps({"ok": False, "error": e.code,
|
|
128
|
+
"detail": str(e)}, indent=2))
|
|
129
|
+
else:
|
|
130
|
+
print(f"error: {e.code}: {e}")
|
|
131
|
+
return 2
|
|
132
|
+
n = sum(len(v) for v in cfg["checks"].values())
|
|
133
|
+
if args.json:
|
|
134
|
+
print(json.dumps({"ok": True, "source": cfg["source"],
|
|
135
|
+
"packs": sorted(cfg["checks"]),
|
|
136
|
+
"check_count": n}, indent=2))
|
|
137
|
+
else:
|
|
138
|
+
print(f"config valid: source={cfg['source']['type']}, "
|
|
139
|
+
f"{n} check(s) across packs {sorted(cfg['checks'])}")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
PING_TIMEOUT_SECONDS = 10
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_run(args) -> int:
|
|
147
|
+
code = _run_verdict(args)
|
|
148
|
+
if getattr(args, "ping_url", None):
|
|
149
|
+
_ping(args.ping_url, code)
|
|
150
|
+
return code
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _ping(url: str, code: int) -> None:
|
|
154
|
+
"""healthchecks.io convention: GET URL on PASS, URL/fail on FAIL
|
|
155
|
+
or ERROR. A ping failure warns and never changes the exit code."""
|
|
156
|
+
import urllib.request
|
|
157
|
+
|
|
158
|
+
if code != 0:
|
|
159
|
+
url = url.rstrip("/") + "/fail"
|
|
160
|
+
try:
|
|
161
|
+
urllib.request.urlopen(url, timeout=PING_TIMEOUT_SECONDS).close()
|
|
162
|
+
except Exception as e:
|
|
163
|
+
print(f"warning: ping {url} failed: {type(e).__name__}: {e}",
|
|
164
|
+
file=sys.stderr)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run_verdict(args) -> int:
|
|
168
|
+
try:
|
|
169
|
+
cfg = configmod.load_config(args.config)
|
|
170
|
+
except BackupProofError as e:
|
|
171
|
+
return _emit_error(None, "simulated", e, args)
|
|
172
|
+
source = cfg["source"]
|
|
173
|
+
try:
|
|
174
|
+
include = (configmod.checked_paths(cfg)
|
|
175
|
+
if source.get("restore") == "checked-paths" else None)
|
|
176
|
+
runner, mode = _runner_for(source, include)
|
|
177
|
+
backups = runner.list_backups()
|
|
178
|
+
backup = discovery.latest(backups)
|
|
179
|
+
except BackupProofError as e:
|
|
180
|
+
return _emit_error(None, "simulated" if source["type"] == "fixture" else "real",
|
|
181
|
+
e, args)
|
|
182
|
+
# Staleness gate: a PASS on an ancient "latest" backup proves an
|
|
183
|
+
# old backup, not a current one. Warn by default; --fail-on-stale
|
|
184
|
+
# refuses the run outright (before touching anything).
|
|
185
|
+
warnings: list[str] = []
|
|
186
|
+
max_age = getattr(args, "max_age_days", None)
|
|
187
|
+
if max_age is None:
|
|
188
|
+
max_age = cfg.get("max_age_days")
|
|
189
|
+
if max_age is not None:
|
|
190
|
+
age = _backup_age_days(backup)
|
|
191
|
+
if age is None:
|
|
192
|
+
warnings.append(
|
|
193
|
+
f"could not determine backup age (timestamp "
|
|
194
|
+
f"{backup.timestamp!r} unparseable); staleness not checked")
|
|
195
|
+
elif age > float(max_age):
|
|
196
|
+
msg = (f"latest backup is {age:.1f} days old "
|
|
197
|
+
f"(max {max_age})")
|
|
198
|
+
if getattr(args, "fail_on_stale", False):
|
|
199
|
+
return _emit_error(backup, mode,
|
|
200
|
+
StaleBackupError(msg), args)
|
|
201
|
+
warnings.append(msg)
|
|
202
|
+
# Proxmox pack guard: a host cannot verify a VM's filesystem
|
|
203
|
+
# without guest access, so db/files packs can never verify a
|
|
204
|
+
# Proxmox VM from here. Fail fast instead of running a partial
|
|
205
|
+
# verdict that silently drops packs.
|
|
206
|
+
if source["type"] == "proxmox" and mode == "real":
|
|
207
|
+
unsupported = [p for p in ("db", "files") if cfg["checks"].get(p)]
|
|
208
|
+
if unsupported:
|
|
209
|
+
return _emit_error(
|
|
210
|
+
backup, mode,
|
|
211
|
+
UnsupportedPackError(
|
|
212
|
+
f"packs {', '.join(unsupported)} cannot verify a "
|
|
213
|
+
f"Proxmox VM's filesystem from the host — use web "
|
|
214
|
+
f"checks with --probe-url, or drop those packs"),
|
|
215
|
+
args)
|
|
216
|
+
integrity = None
|
|
217
|
+
if getattr(args, "integrity", False):
|
|
218
|
+
try:
|
|
219
|
+
if not isinstance(runner, RealRunner):
|
|
220
|
+
raise configmod.ConfigError(
|
|
221
|
+
"--integrity supports restic/borg sources only")
|
|
222
|
+
integrity = runner.check_integrity(args.read_data_subset,
|
|
223
|
+
args.verify_data)
|
|
224
|
+
except BackupProofError as e:
|
|
225
|
+
return _emit_error(backup, mode, e, args)
|
|
226
|
+
tmp = tempfile.TemporaryDirectory(prefix="backupproof-")
|
|
227
|
+
try:
|
|
228
|
+
try:
|
|
229
|
+
target = runner.restore_and_boot(
|
|
230
|
+
backup, Path(tmp.name), boot_timeout=args.boot_timeout,
|
|
231
|
+
probe_url=getattr(args, "probe_url", None))
|
|
232
|
+
except BackupProofError as e:
|
|
233
|
+
return _emit_error(backup, mode, e, args)
|
|
234
|
+
try:
|
|
235
|
+
smoke_target = smoke.Target(target.root, target.base_url,
|
|
236
|
+
_backup_datetime(backup))
|
|
237
|
+
results = smoke.run_smoke(cfg["checks"], smoke_target)
|
|
238
|
+
v = verdictmod.build_verdict(
|
|
239
|
+
backup, mode, results,
|
|
240
|
+
target.boot_evidence,
|
|
241
|
+
_restore_evidence(runner, target),
|
|
242
|
+
boot_kind=target.boot_kind,
|
|
243
|
+
boot_probed=target.boot_probed,
|
|
244
|
+
verified_against=_verified_against(
|
|
245
|
+
source, mode, target.boot_kind,
|
|
246
|
+
getattr(args, "probe_url", None)),
|
|
247
|
+
warnings=warnings)
|
|
248
|
+
v = replace(v, restore_partial=len(include or []),
|
|
249
|
+
integrity=integrity,
|
|
250
|
+
restore_seconds=target.restore_seconds)
|
|
251
|
+
finally:
|
|
252
|
+
target.close()
|
|
253
|
+
finally:
|
|
254
|
+
tmp.cleanup()
|
|
255
|
+
if args.report:
|
|
256
|
+
Path(args.report).write_text(verdictmod.render_json(v) + "\n")
|
|
257
|
+
print(verdictmod.render_json(v) if args.json else verdictmod.render_text(v))
|
|
258
|
+
return v.exit_code
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _restore_evidence(runner, target) -> str:
|
|
262
|
+
text = _restore_evidence_base(runner, target)
|
|
263
|
+
if target.restore_seconds is not None:
|
|
264
|
+
text += f" in {target.restore_seconds:.1f}s"
|
|
265
|
+
if isinstance(runner, FixtureRunner):
|
|
266
|
+
text += " (simulated)"
|
|
267
|
+
return text
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _restore_evidence_base(runner, target) -> str:
|
|
271
|
+
n_files = sum(1 for _ in target.root.rglob("*") if _.is_file())
|
|
272
|
+
if getattr(runner, "include", None):
|
|
273
|
+
return (f"partial ({len(runner.include)} paths) — only checked "
|
|
274
|
+
f"paths restored; the rest of the backup was not "
|
|
275
|
+
f"restore-tested; restored into {target.root} "
|
|
276
|
+
f"({n_files} files)")
|
|
277
|
+
if isinstance(runner, FixtureRunner):
|
|
278
|
+
return (f"SIMULATED restore into {target.root} ({n_files} files "
|
|
279
|
+
f"copied from fixture)")
|
|
280
|
+
return f"restored into {target.root} ({n_files} files)"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _emit_error(backup, mode, err: BackupProofError, args) -> int:
|
|
284
|
+
v = verdictmod.error_verdict(backup, mode, err.code, str(err))
|
|
285
|
+
if getattr(args, "report", None):
|
|
286
|
+
Path(args.report).write_text(verdictmod.render_json(v) + "\n")
|
|
287
|
+
print(verdictmod.render_json(v) if args.json else verdictmod.render_text(v))
|
|
288
|
+
return 2
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
292
|
+
p = argparse.ArgumentParser(
|
|
293
|
+
prog="backupproof",
|
|
294
|
+
description="Backup restore testing: restore the latest backup "
|
|
295
|
+
"into a throwaway target, boot it, run smoke checks, "
|
|
296
|
+
"report PASS or FAIL with proof.")
|
|
297
|
+
p.add_argument("--version", action="version",
|
|
298
|
+
version=f"backupproof {__version__}")
|
|
299
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
300
|
+
|
|
301
|
+
def json_arg(x):
|
|
302
|
+
x.add_argument("--json", action="store_true",
|
|
303
|
+
help="machine-readable JSON output")
|
|
304
|
+
|
|
305
|
+
p_run = sub.add_parser("run", help="run the restore/boot/smoke verdict")
|
|
306
|
+
p_run.add_argument("--config", required=True, help="config JSON file")
|
|
307
|
+
p_run.add_argument("--boot-timeout", type=int, default=300,
|
|
308
|
+
help="seconds to wait for boot (default: 300)")
|
|
309
|
+
p_run.add_argument("--probe-url",
|
|
310
|
+
help="HTTP URL to poll for boot readiness (real runner)")
|
|
311
|
+
p_run.add_argument("--max-age-days", type=float, default=None,
|
|
312
|
+
help="warn when the latest backup is older than "
|
|
313
|
+
"this (overrides config max_age_days)")
|
|
314
|
+
p_run.add_argument("--fail-on-stale", action="store_true",
|
|
315
|
+
help="refuse the run when the latest backup "
|
|
316
|
+
"exceeds --max-age-days (default: warn only)")
|
|
317
|
+
p_run.add_argument("--integrity", action="store_true",
|
|
318
|
+
help="run restic check / borg check "
|
|
319
|
+
"--repository-only before restoring")
|
|
320
|
+
p_run.add_argument("--read-data-subset", metavar="PCT",
|
|
321
|
+
help="with --integrity on restic: also read this "
|
|
322
|
+
"subset of pack data (e.g. 5%%)")
|
|
323
|
+
p_run.add_argument("--verify-data", action="store_true",
|
|
324
|
+
help="with --integrity on borg: borg check "
|
|
325
|
+
"--verify-data instead of --repository-only")
|
|
326
|
+
p_run.add_argument("--ping-url",
|
|
327
|
+
help="after the verdict, GET this URL on PASS or "
|
|
328
|
+
"URL/fail on FAIL/ERROR (healthchecks.io "
|
|
329
|
+
"convention); off unless given")
|
|
330
|
+
p_run.add_argument("--report", help="write the proof report JSON to this file")
|
|
331
|
+
json_arg(p_run)
|
|
332
|
+
p_run.set_defaults(func=cmd_run)
|
|
333
|
+
|
|
334
|
+
p_list = sub.add_parser("list", help="list backups, newest first")
|
|
335
|
+
p_list.add_argument("--config", help="config JSON file (source is read from it)")
|
|
336
|
+
p_list.add_argument("--source-type", choices=list(discovery.SUPPORTED),
|
|
337
|
+
default="fixture")
|
|
338
|
+
p_list.add_argument("--repo", help="restic/borg repository path")
|
|
339
|
+
p_list.add_argument("--dump-dir", help="proxmox dump directory")
|
|
340
|
+
p_list.add_argument("--fixture-dir", help="fixture directory")
|
|
341
|
+
json_arg(p_list)
|
|
342
|
+
p_list.set_defaults(func=cmd_list)
|
|
343
|
+
|
|
344
|
+
p_val = sub.add_parser("validate", help="validate a config file")
|
|
345
|
+
p_val.add_argument("--config", required=True, help="config JSON file")
|
|
346
|
+
json_arg(p_val)
|
|
347
|
+
p_val.set_defaults(func=cmd_validate)
|
|
348
|
+
|
|
349
|
+
p_init = sub.add_parser("init", help="write example config + fixture "
|
|
350
|
+
"files (try: backupproof run --config "
|
|
351
|
+
"<dir>/config.json)")
|
|
352
|
+
p_init.add_argument("--output", default="backupproof-examples",
|
|
353
|
+
help="directory to write into (default: "
|
|
354
|
+
"backupproof-examples)")
|
|
355
|
+
p_init.set_defaults(func=cmd_init)
|
|
356
|
+
return p
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def cmd_init(args) -> int:
|
|
360
|
+
"""Write a runnable fixture example: config + fixture tree with
|
|
361
|
+
a real SQLite DB and a webroot. The files-check sha256 is
|
|
362
|
+
computed from the written app.conf, so the example PASSES."""
|
|
363
|
+
import hashlib
|
|
364
|
+
import sqlite3
|
|
365
|
+
|
|
366
|
+
out = Path(args.output)
|
|
367
|
+
fixture = out / "fixture"
|
|
368
|
+
restored = fixture / "restored"
|
|
369
|
+
webroot = restored / "webroot"
|
|
370
|
+
webroot.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
app_conf = "port=8080\nmode=production\n"
|
|
372
|
+
# bytes, not text: Windows newline translation would break the sha256
|
|
373
|
+
(restored / "app.conf").write_bytes(app_conf.encode("utf-8"))
|
|
374
|
+
(webroot / "index.html").write_text(
|
|
375
|
+
"<html><body>Welcome to the restored app</body></html>\n",
|
|
376
|
+
encoding="utf-8")
|
|
377
|
+
db_path = restored / "app.db"
|
|
378
|
+
if db_path.exists():
|
|
379
|
+
db_path.unlink()
|
|
380
|
+
conn = sqlite3.connect(str(db_path))
|
|
381
|
+
try:
|
|
382
|
+
conn.execute("CREATE TABLE users (name TEXT)")
|
|
383
|
+
conn.execute("INSERT INTO users VALUES ('alice')")
|
|
384
|
+
conn.commit()
|
|
385
|
+
finally:
|
|
386
|
+
conn.close()
|
|
387
|
+
(fixture / "backups.json").write_text(json.dumps(
|
|
388
|
+
[{"id": "snap-example", "time": "2026-10-28T03:00:00Z",
|
|
389
|
+
"size": 1234}], indent=2) + "\n", encoding="utf-8")
|
|
390
|
+
(fixture / "boot.json").write_text(json.dumps(
|
|
391
|
+
{"ok": True, "seconds": 0, "webroot": "webroot"},
|
|
392
|
+
indent=2) + "\n", encoding="utf-8")
|
|
393
|
+
sha = hashlib.sha256(app_conf.encode("utf-8")).hexdigest()
|
|
394
|
+
config = {
|
|
395
|
+
# Absolute fixture dir so the example runs from any cwd.
|
|
396
|
+
"source": {"type": "fixture", "dir": str(fixture.resolve())},
|
|
397
|
+
"checks": {
|
|
398
|
+
"web": [{"name": "homepage", "path": "/index.html",
|
|
399
|
+
"expect_status": 200, "expect_contains": "Welcome"}],
|
|
400
|
+
"db": [{"name": "users", "db_file": "app.db",
|
|
401
|
+
"query": "SELECT name FROM users",
|
|
402
|
+
"expect_rows_min": 1}],
|
|
403
|
+
"files": [{"name": "config", "path": "app.conf",
|
|
404
|
+
"sha256": sha}],
|
|
405
|
+
},
|
|
406
|
+
}
|
|
407
|
+
(out / "config.json").write_text(json.dumps(config, indent=2) + "\n",
|
|
408
|
+
encoding="utf-8")
|
|
409
|
+
print(f"wrote {out / 'config.json'} + {fixture}/")
|
|
410
|
+
print(f"try: backupproof run --config {out / 'config.json'}")
|
|
411
|
+
return 0
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def main(argv=None) -> int:
|
|
415
|
+
args = build_parser().parse_args(argv)
|
|
416
|
+
return args.func(args)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__":
|
|
420
|
+
sys.exit(main())
|
backupproof/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Config loading and validation.
|
|
2
|
+
|
|
3
|
+
Config is a JSON file (stdlib only, no YAML dependency):
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"source": {"type": "restic", "repo": "/srv/backups"},
|
|
7
|
+
"checks": {
|
|
8
|
+
"web": [{"name": "homepage", "path": "/",
|
|
9
|
+
"expect_status": 200, "expect_contains": "Welcome"}],
|
|
10
|
+
"db": [{"name": "users table", "db_file": "app.db",
|
|
11
|
+
"query": "SELECT count(*) FROM users",
|
|
12
|
+
"expect_rows_min": 1}],
|
|
13
|
+
"files": [{"name": "config", "path": "etc/app.conf",
|
|
14
|
+
"sha256": "<hex>", "size_min": 10}]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
For fixture runs the source is {"type": "fixture", "dir": "<dir>"}.
|
|
19
|
+
Validation is strict: unknown packs, unknown check keys, and empty
|
|
20
|
+
check sets are CONFIG_ERROR, because a run with no checks to run
|
|
21
|
+
must never report PASS.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from .discovery import SUPPORTED
|
|
30
|
+
from .errors import ConfigError
|
|
31
|
+
|
|
32
|
+
PACKS = ("web", "db", "files")
|
|
33
|
+
WEB_KEYS = {"name", "path", "expect_status", "expect_contains", "url"}
|
|
34
|
+
DB_KEYS = {"name", "db_file", "query", "expect_contains", "expect_rows_min",
|
|
35
|
+
"command"}
|
|
36
|
+
FILE_KEYS = {"name", "path", "sha256", "size_min", "size_max", "size_exact",
|
|
37
|
+
"max_age_days", "mode", "owner"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config(path: str | Path) -> dict:
|
|
41
|
+
p = Path(path)
|
|
42
|
+
if not p.is_file():
|
|
43
|
+
raise ConfigError(f"config file not found: {path}")
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(p.read_text())
|
|
46
|
+
except json.JSONDecodeError as e:
|
|
47
|
+
raise ConfigError(f"config is not valid JSON: {e}")
|
|
48
|
+
return validate_config(data)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_config(data: dict) -> dict:
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
raise ConfigError("config must be a JSON object")
|
|
54
|
+
source = data.get("source")
|
|
55
|
+
if not isinstance(source, dict) or source.get("type") not in SUPPORTED:
|
|
56
|
+
raise ConfigError(f"source.type must be one of {list(SUPPORTED)}")
|
|
57
|
+
if source["type"] in ("restic", "borg") and not source.get("repo"):
|
|
58
|
+
raise ConfigError(f"{source['type']} source requires 'repo'")
|
|
59
|
+
if source["type"] == "proxmox" and not source.get("dump_dir"):
|
|
60
|
+
raise ConfigError("proxmox source requires 'dump_dir'")
|
|
61
|
+
if source["type"] == "fixture" and not source.get("dir"):
|
|
62
|
+
raise ConfigError("fixture source requires 'dir'")
|
|
63
|
+
max_age = data.get("max_age_days")
|
|
64
|
+
if max_age is not None:
|
|
65
|
+
if not isinstance(max_age, (int, float)) \
|
|
66
|
+
or isinstance(max_age, bool) or max_age <= 0:
|
|
67
|
+
raise ConfigError("max_age_days must be a positive number")
|
|
68
|
+
checks = data.get("checks")
|
|
69
|
+
if not isinstance(checks, dict) or not checks:
|
|
70
|
+
raise ConfigError("checks must be a non-empty object "
|
|
71
|
+
"(at least one of web/db/files)")
|
|
72
|
+
for pack, items in checks.items():
|
|
73
|
+
if pack not in PACKS:
|
|
74
|
+
raise ConfigError(f"unknown smoke pack: {pack!r} "
|
|
75
|
+
f"(expected one of {list(PACKS)})")
|
|
76
|
+
if not isinstance(items, list) or not items:
|
|
77
|
+
raise ConfigError(f"pack {pack!r} must be a non-empty list")
|
|
78
|
+
allowed = {"web": WEB_KEYS, "db": DB_KEYS, "files": FILE_KEYS}[pack]
|
|
79
|
+
for item in items:
|
|
80
|
+
if not isinstance(item, dict) or not item.get("name"):
|
|
81
|
+
raise ConfigError(f"pack {pack!r}: each check needs a 'name'")
|
|
82
|
+
unknown = set(item) - allowed
|
|
83
|
+
if unknown:
|
|
84
|
+
raise ConfigError(f"pack {pack!r} check {item['name']!r}: "
|
|
85
|
+
f"unknown keys {sorted(unknown)}")
|
|
86
|
+
if pack == "web" and "path" not in item and "url" not in item:
|
|
87
|
+
raise ConfigError(f"web check {item['name']!r} needs 'path' or 'url'")
|
|
88
|
+
if pack == "db" and "query" not in item and "command" not in item:
|
|
89
|
+
raise ConfigError(f"db check {item['name']!r} needs 'query' or 'command'")
|
|
90
|
+
if pack == "db" and "query" in item and "db_file" not in item:
|
|
91
|
+
raise ConfigError(f"db check {item['name']!r} needs 'db_file' with 'query'")
|
|
92
|
+
if pack == "files" and "path" not in item:
|
|
93
|
+
raise ConfigError(f"files check {item['name']!r} needs 'path'")
|
|
94
|
+
if pack == "files" and "mode" in item:
|
|
95
|
+
try:
|
|
96
|
+
int(str(item["mode"]), 8)
|
|
97
|
+
except ValueError:
|
|
98
|
+
raise ConfigError(f"files check {item['name']!r}: mode "
|
|
99
|
+
f"must be an octal string like '0640'")
|
|
100
|
+
restore = source.get("restore", "full")
|
|
101
|
+
if restore not in ("full", "checked-paths"):
|
|
102
|
+
raise ConfigError("source.restore must be 'full' or 'checked-paths'")
|
|
103
|
+
if restore == "checked-paths":
|
|
104
|
+
if source["type"] not in ("restic", "borg"):
|
|
105
|
+
raise ConfigError("restore 'checked-paths' is restic/borg only")
|
|
106
|
+
if not checked_paths(data):
|
|
107
|
+
raise ConfigError("restore 'checked-paths' needs at least one "
|
|
108
|
+
"path-bearing check (files path, db db_file) "
|
|
109
|
+
"or serve_dir")
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def checked_paths(cfg: dict) -> list[str]:
|
|
114
|
+
"""Restored-tree paths the checks read: files paths, db files,
|
|
115
|
+
serve_dir. Web URLs and db commands name no restorable path."""
|
|
116
|
+
checks = cfg["checks"]
|
|
117
|
+
paths = [c["path"] for c in checks.get("files", [])]
|
|
118
|
+
paths += [c["db_file"] for c in checks.get("db", []) if "db_file" in c]
|
|
119
|
+
if cfg["source"].get("serve_dir"):
|
|
120
|
+
paths.append(cfg["source"]["serve_dir"])
|
|
121
|
+
return sorted({p.strip("/") for p in paths})
|