@wipcomputer/wip-ldm-os 0.4.86 → 0.4.87-alpha.2
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.
- package/bin/ldm.js +374 -4
- package/docs/backup/README.md +83 -80
- package/docs/backup/TECHNICAL.md +107 -90
- package/package.json +16 -3
- package/scripts/ldm-backup-engine.py +1176 -0
- package/scripts/ldm-backup-policy.json +1420 -0
- package/scripts/ldm-backup.sh +15 -273
- package/scripts/test-backup-engine.py +609 -0
- package/scripts/test-doctor-commit-deployed.mjs +300 -0
- package/scripts/test-ldm-install-preserves-foreign-bin.mjs +5 -0
- package/shared/docs/README.md.tmpl +1 -1
- package/shared/docs/directory-map.md.tmpl +1 -1
- package/shared/docs/how-backup-works.md.tmpl +50 -83
- package/shared/docs/system-directories.md.tmpl +1 -1
- package/shared/docs/what-is-dotldm.md.tmpl +1 -1
|
@@ -0,0 +1,1176 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LDM OS backup engine.
|
|
3
|
+
|
|
4
|
+
All capture, estimation, manifest, and exclusion decisions come from one JSON
|
|
5
|
+
policy. The test suite injects every path beneath a disposable fixture root.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import errno
|
|
13
|
+
import fnmatch
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sqlite3
|
|
19
|
+
import stat
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
TOOL_VERSION = "2.0.0"
|
|
28
|
+
SCHEDULE_ID = "ai.openclaw.ldm-backup"
|
|
29
|
+
VALID_EVIDENCE_STATUS = {"complete", "deferred-after-write-failure"}
|
|
30
|
+
JSON_SEPARATORS = (",", ":")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BackupError(RuntimeError):
|
|
34
|
+
def __init__(self, code: str, message: str):
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.code = code
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SimulatedDiskFull(BackupError):
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
super().__init__("disk-full-during-capture", "simulated disk full during capture")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Roots:
|
|
46
|
+
home: Path
|
|
47
|
+
ldm: Path
|
|
48
|
+
openclaw: Path
|
|
49
|
+
claude: Path
|
|
50
|
+
codex: Path
|
|
51
|
+
workspace: Path
|
|
52
|
+
backup: Path
|
|
53
|
+
state: Path
|
|
54
|
+
icloud: Path
|
|
55
|
+
lock: Path
|
|
56
|
+
|
|
57
|
+
def mapping(self) -> dict[str, Path]:
|
|
58
|
+
return {
|
|
59
|
+
"HOME": self.home,
|
|
60
|
+
"LDM_HOME": self.ldm,
|
|
61
|
+
"OPENCLAW_HOME": self.openclaw,
|
|
62
|
+
"CLAUDE_HOME": self.claude,
|
|
63
|
+
"CODEX_HOME": self.codex,
|
|
64
|
+
"WORKSPACE": self.workspace,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def utc_now() -> str:
|
|
69
|
+
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def snapshot_id() -> str:
|
|
73
|
+
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d--%H-%M-%SZ")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def json_bytes(value: Any) -> bytes:
|
|
77
|
+
return (json.dumps(value, sort_keys=True, separators=JSON_SEPARATORS) + "\n").encode("utf-8")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sha256_file(path: Path) -> str:
|
|
81
|
+
digest = hashlib.sha256()
|
|
82
|
+
with path.open("rb") as handle:
|
|
83
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
84
|
+
digest.update(chunk)
|
|
85
|
+
return digest.hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def sha256_text(value: str) -> str:
|
|
89
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def write_atomic(path: Path, value: Any) -> None:
|
|
93
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
95
|
+
try:
|
|
96
|
+
with os.fdopen(fd, "wb") as handle:
|
|
97
|
+
handle.write(json_bytes(value))
|
|
98
|
+
handle.flush()
|
|
99
|
+
os.fsync(handle.fileno())
|
|
100
|
+
os.replace(temp_name, path)
|
|
101
|
+
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
102
|
+
try:
|
|
103
|
+
os.fsync(directory_fd)
|
|
104
|
+
finally:
|
|
105
|
+
os.close(directory_fd)
|
|
106
|
+
finally:
|
|
107
|
+
try:
|
|
108
|
+
os.unlink(temp_name)
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def nearest_existing(path: Path) -> Path:
|
|
114
|
+
current = path.resolve(strict=False)
|
|
115
|
+
while not current.exists() and current != current.parent:
|
|
116
|
+
current = current.parent
|
|
117
|
+
return current
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def path_within(path: Path, root: Path) -> bool:
|
|
121
|
+
try:
|
|
122
|
+
path.resolve(strict=False).relative_to(root.resolve(strict=True))
|
|
123
|
+
return True
|
|
124
|
+
except (ValueError, FileNotFoundError):
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def load_config(ldm_home: Path) -> dict[str, Any]:
|
|
129
|
+
config_path = ldm_home / "config.json"
|
|
130
|
+
try:
|
|
131
|
+
value = json.loads(config_path.read_text(encoding="utf-8"))
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
return {}
|
|
134
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
135
|
+
raise BackupError("config-invalid", f"cannot load {config_path}: {exc}") from exc
|
|
136
|
+
if not isinstance(value, dict):
|
|
137
|
+
raise BackupError("config-invalid", "config.json must contain a JSON object")
|
|
138
|
+
for key in ("paths", "backup"):
|
|
139
|
+
if key in value and not isinstance(value[key], dict):
|
|
140
|
+
raise BackupError("config-invalid", f"config.json field {key} must be an object")
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_roots() -> Roots:
|
|
145
|
+
home = Path(os.environ.get("LDM_BACKUP_HOME", os.environ.get("HOME", "~"))).expanduser()
|
|
146
|
+
ldm = Path(os.environ.get("LDM_BACKUP_LDM_HOME", home / ".ldm")).expanduser()
|
|
147
|
+
config = load_config(ldm)
|
|
148
|
+
config_workspace = config.get("workspace") or config.get("paths", {}).get("workspace") or home / "wipcomputerinc"
|
|
149
|
+
config_icloud = config.get("paths", {}).get("icloudBackup") or home / "Library/Mobile Documents/com~apple~CloudDocs/ldm-backups"
|
|
150
|
+
openclaw = Path(os.environ.get("LDM_BACKUP_OPENCLAW_HOME", home / ".openclaw")).expanduser()
|
|
151
|
+
claude = Path(os.environ.get("LDM_BACKUP_CLAUDE_HOME", home / ".claude")).expanduser()
|
|
152
|
+
codex = Path(os.environ.get("LDM_BACKUP_CODEX_HOME", home / ".codex")).expanduser()
|
|
153
|
+
workspace = Path(os.environ.get("LDM_BACKUP_WORKSPACE", config_workspace)).expanduser()
|
|
154
|
+
backup = Path(os.environ.get("LDM_BACKUP_DESTINATION", ldm / "backups/v1/internal")).expanduser()
|
|
155
|
+
state = Path(os.environ.get("LDM_BACKUP_STATE_DIR", ldm / "state/backup/v1")).expanduser()
|
|
156
|
+
icloud = Path(os.environ.get("LDM_BACKUP_ICLOUD_SUBSTITUTE", config_icloud)).expanduser()
|
|
157
|
+
lock = Path(os.environ.get("LDM_BACKUP_LOCK_PATH", state / "backup.lock")).expanduser()
|
|
158
|
+
return Roots(home, ldm, openclaw, claude, codex, workspace, backup, state, icloud, lock)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def prove_fixture_isolation(roots: Roots) -> Path | None:
|
|
162
|
+
required = os.environ.get("LDM_BACKUP_REQUIRE_FIXTURE") == "1"
|
|
163
|
+
fixture_text = os.environ.get("LDM_BACKUP_FIXTURE_ROOT", "")
|
|
164
|
+
if not required and not fixture_text:
|
|
165
|
+
return None
|
|
166
|
+
if not fixture_text:
|
|
167
|
+
raise BackupError("fixture-root-missing", "fixture mode requires LDM_BACKUP_FIXTURE_ROOT")
|
|
168
|
+
fixture = Path(fixture_text).resolve(strict=True)
|
|
169
|
+
if not fixture.name.startswith("ldm-backup-fixture-"):
|
|
170
|
+
raise BackupError("fixture-root-unsafe", "fixture root must use the ldm-backup-fixture- prefix")
|
|
171
|
+
checked = {
|
|
172
|
+
"home": roots.home,
|
|
173
|
+
"ldm": roots.ldm,
|
|
174
|
+
"openclaw": roots.openclaw,
|
|
175
|
+
"claude": roots.claude,
|
|
176
|
+
"codex": roots.codex,
|
|
177
|
+
"workspace": roots.workspace,
|
|
178
|
+
"backup": roots.backup,
|
|
179
|
+
"state": roots.state,
|
|
180
|
+
"icloud": roots.icloud,
|
|
181
|
+
"lock": roots.lock,
|
|
182
|
+
}
|
|
183
|
+
unsafe = [name for name, value in checked.items() if not path_within(value, fixture)]
|
|
184
|
+
if unsafe:
|
|
185
|
+
raise BackupError("fixture-path-escape", f"fixture paths escape root: {', '.join(sorted(unsafe))}")
|
|
186
|
+
return fixture
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def load_policy(path: Path) -> dict[str, Any]:
|
|
190
|
+
try:
|
|
191
|
+
policy = json.loads(path.read_text(encoding="utf-8"))
|
|
192
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError) as exc:
|
|
193
|
+
raise BackupError("policy-invalid", f"cannot load policy: {exc}") from exc
|
|
194
|
+
if policy.get("schema_version") != 1 or not isinstance(policy.get("sources"), list):
|
|
195
|
+
raise BackupError("policy-invalid", "policy schema_version must be 1 and sources must be a list")
|
|
196
|
+
evidence_values = set(policy.get("evidence_status_values", []))
|
|
197
|
+
if evidence_values != VALID_EVIDENCE_STATUS:
|
|
198
|
+
raise BackupError("policy-enum-invalid", "evidence_status enum must contain only complete and deferred-after-write-failure")
|
|
199
|
+
if "complete-or-deferred-after-write-failure" in evidence_values:
|
|
200
|
+
raise BackupError("policy-enum-invalid", "placeholder evidence_status is forbidden")
|
|
201
|
+
ids: set[str] = set()
|
|
202
|
+
for source in policy["sources"]:
|
|
203
|
+
source_id = source.get("id")
|
|
204
|
+
if not source_id or source_id in ids:
|
|
205
|
+
raise BackupError("policy-invalid", "every source requires a unique id")
|
|
206
|
+
ids.add(source_id)
|
|
207
|
+
if source.get("action") not in {"capture", "blocked", "manifest", "exclude"}:
|
|
208
|
+
raise BackupError("policy-invalid", f"invalid action for {source_id}")
|
|
209
|
+
if source.get("type") not in {"file", "tree", "sqlite"}:
|
|
210
|
+
raise BackupError("policy-invalid", f"invalid type for {source_id}")
|
|
211
|
+
destination = source.get("destination")
|
|
212
|
+
if destination is not None:
|
|
213
|
+
destination_path = Path(destination)
|
|
214
|
+
if destination_path.is_absolute() or ".." in destination_path.parts:
|
|
215
|
+
raise BackupError("policy-invalid", f"destination must stay relative for {source_id}")
|
|
216
|
+
return policy
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def prove_policy_fixture_isolation(policy: dict[str, Any], roots: Roots, fixture: Path | None) -> None:
|
|
220
|
+
if fixture is None:
|
|
221
|
+
return
|
|
222
|
+
for source in policy["sources"]:
|
|
223
|
+
resolved = source_path(source, roots)
|
|
224
|
+
if not path_within(resolved, fixture):
|
|
225
|
+
raise BackupError("fixture-source-escape", f"source path escapes fixture root: {source['id']}")
|
|
226
|
+
destination = source.get("destination")
|
|
227
|
+
if destination and not path_within(roots.backup / destination, fixture):
|
|
228
|
+
raise BackupError("fixture-destination-escape", f"destination path escapes fixture root: {source['id']}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def is_excluded(relative: str, rules: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
232
|
+
value = relative.replace(os.sep, "/")
|
|
233
|
+
for rule in rules:
|
|
234
|
+
pattern = rule["pattern"]
|
|
235
|
+
if fnmatch.fnmatch(value, pattern) or (pattern.startswith("**/") and fnmatch.fnmatch(value, pattern[3:])):
|
|
236
|
+
return rule
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def tree_members(
|
|
241
|
+
root: Path,
|
|
242
|
+
rules: list[dict[str, Any]],
|
|
243
|
+
*,
|
|
244
|
+
hash_content: bool = True,
|
|
245
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
246
|
+
root_info = root.lstat()
|
|
247
|
+
members: list[dict[str, Any]] = [{"relative": "", "kind": "directory", "size": 0, "mtime_ns": root_info.st_mtime_ns, "checksum": sha256_text("")}]
|
|
248
|
+
exclusions: dict[str, dict[str, Any]] = {}
|
|
249
|
+
for base, dirs, files in os.walk(root, topdown=True, followlinks=False):
|
|
250
|
+
base_path = Path(base)
|
|
251
|
+
kept_dirs: list[str] = []
|
|
252
|
+
for name in sorted(dirs):
|
|
253
|
+
directory_path = base_path / name
|
|
254
|
+
relative = str(directory_path.relative_to(root))
|
|
255
|
+
rule = is_excluded(relative + "/", rules)
|
|
256
|
+
if rule:
|
|
257
|
+
exclusions.setdefault(rule["pattern"], {**rule, "matched_count": 0})["matched_count"] += 1
|
|
258
|
+
elif directory_path.is_symlink():
|
|
259
|
+
target = os.readlink(directory_path)
|
|
260
|
+
info = directory_path.lstat()
|
|
261
|
+
members.append({"relative": relative, "kind": "symlink", "size": len(target.encode()), "mtime_ns": info.st_mtime_ns, "checksum": sha256_text(target)})
|
|
262
|
+
else:
|
|
263
|
+
kept_dirs.append(name)
|
|
264
|
+
info = directory_path.lstat()
|
|
265
|
+
members.append({"relative": relative, "kind": "directory", "size": 0, "mtime_ns": info.st_mtime_ns, "checksum": sha256_text("")})
|
|
266
|
+
dirs[:] = kept_dirs
|
|
267
|
+
for name in sorted(files):
|
|
268
|
+
source = base_path / name
|
|
269
|
+
relative = str(source.relative_to(root))
|
|
270
|
+
rule = is_excluded(relative, rules)
|
|
271
|
+
if rule:
|
|
272
|
+
exclusions.setdefault(rule["pattern"], {**rule, "matched_count": 0})["matched_count"] += 1
|
|
273
|
+
continue
|
|
274
|
+
info = source.lstat()
|
|
275
|
+
if stat.S_ISLNK(info.st_mode):
|
|
276
|
+
target = os.readlink(source)
|
|
277
|
+
members.append({"relative": relative, "kind": "symlink", "size": len(target.encode()), "mtime_ns": info.st_mtime_ns, "checksum": sha256_text(target)})
|
|
278
|
+
elif stat.S_ISREG(info.st_mode):
|
|
279
|
+
checksum = sha256_file(source) if hash_content else "0" * 64
|
|
280
|
+
members.append({"relative": relative, "kind": "file", "size": info.st_size, "mtime_ns": info.st_mtime_ns, "checksum": checksum})
|
|
281
|
+
else:
|
|
282
|
+
raise BackupError("unsupported-source-type", f"unsupported file type in source {root.name}")
|
|
283
|
+
for rule in rules:
|
|
284
|
+
exclusions.setdefault(rule["pattern"], {**rule, "matched_count": 0})
|
|
285
|
+
return members, list(exclusions.values())
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def sqlite_planned_size(path: Path) -> int:
|
|
289
|
+
try:
|
|
290
|
+
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
291
|
+
try:
|
|
292
|
+
page_count = int(connection.execute("PRAGMA page_count").fetchone()[0])
|
|
293
|
+
page_size = int(connection.execute("PRAGMA page_size").fetchone()[0])
|
|
294
|
+
return page_count * page_size
|
|
295
|
+
finally:
|
|
296
|
+
connection.close()
|
|
297
|
+
except sqlite3.Error as exc:
|
|
298
|
+
raise BackupError("sqlite-source-invalid", f"SQLite preflight failed for source id: {path.name}") from exc
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def source_path(source: dict[str, Any], roots: Roots) -> Path:
|
|
302
|
+
root_name = source.get("root")
|
|
303
|
+
if root_name not in roots.mapping():
|
|
304
|
+
raise BackupError("policy-invalid", f"unknown root {root_name}")
|
|
305
|
+
return (roots.mapping()[root_name] / source.get("path", ".")).resolve(strict=False)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def make_source_plan(
|
|
309
|
+
source: dict[str, Any],
|
|
310
|
+
roots: Roots,
|
|
311
|
+
*,
|
|
312
|
+
hash_content: bool = True,
|
|
313
|
+
) -> dict[str, Any]:
|
|
314
|
+
path = source_path(source, roots)
|
|
315
|
+
classification = list(source.get("classification", []))
|
|
316
|
+
base = {
|
|
317
|
+
"id": source["id"],
|
|
318
|
+
"display_path": source.get("display_path", f"${source['root']}/{source.get('path', '')}"),
|
|
319
|
+
"classification": classification,
|
|
320
|
+
"required": bool(source.get("required")),
|
|
321
|
+
"action": source["action"],
|
|
322
|
+
"type": source["type"],
|
|
323
|
+
"destination": source.get("destination"),
|
|
324
|
+
"estimated_bytes": 0,
|
|
325
|
+
"status": "planned",
|
|
326
|
+
"members": [],
|
|
327
|
+
"exclusions": [],
|
|
328
|
+
"source_path": str(path),
|
|
329
|
+
"source_definition": source,
|
|
330
|
+
}
|
|
331
|
+
if source["action"] == "blocked":
|
|
332
|
+
base["status"] = "blocked"
|
|
333
|
+
base["block_reason"] = source.get("block_reason", "policy gate is unresolved")
|
|
334
|
+
return base
|
|
335
|
+
if source["action"] == "manifest" and source.get("manifest_generator") != "path-inventory":
|
|
336
|
+
base["status"] = "blocked"
|
|
337
|
+
base["block_reason"] = "manifest generator is not implemented"
|
|
338
|
+
return base
|
|
339
|
+
if source["action"] == "exclude":
|
|
340
|
+
base["status"] = "exclude"
|
|
341
|
+
base["exclusions"] = source.get("exclusions", [])
|
|
342
|
+
return base
|
|
343
|
+
if not path.exists():
|
|
344
|
+
base["status"] = "missing"
|
|
345
|
+
return base
|
|
346
|
+
info = path.lstat()
|
|
347
|
+
if source["type"] == "tree":
|
|
348
|
+
if not path.is_dir():
|
|
349
|
+
base["status"] = "type-mismatch"
|
|
350
|
+
return base
|
|
351
|
+
members, exclusions = tree_members(
|
|
352
|
+
path,
|
|
353
|
+
source.get("exclusions", []),
|
|
354
|
+
hash_content=hash_content,
|
|
355
|
+
)
|
|
356
|
+
base["members"] = members
|
|
357
|
+
base["exclusions"] = exclusions
|
|
358
|
+
base["estimated_bytes"] = sum(item["size"] for item in members)
|
|
359
|
+
elif source["type"] == "sqlite":
|
|
360
|
+
if not path.is_file():
|
|
361
|
+
base["status"] = "type-mismatch"
|
|
362
|
+
return base
|
|
363
|
+
size = sqlite_planned_size(path)
|
|
364
|
+
base["members"] = [{"relative": "", "kind": "sqlite", "size": size, "mtime_ns": info.st_mtime_ns, "checksum": None}]
|
|
365
|
+
base["estimated_bytes"] = size
|
|
366
|
+
else:
|
|
367
|
+
if not path.is_file():
|
|
368
|
+
base["status"] = "type-mismatch"
|
|
369
|
+
return base
|
|
370
|
+
if stat.S_ISLNK(info.st_mode):
|
|
371
|
+
target = os.readlink(path)
|
|
372
|
+
member = {"relative": "", "kind": "symlink", "size": len(target.encode()), "mtime_ns": info.st_mtime_ns, "checksum": sha256_text(target)}
|
|
373
|
+
else:
|
|
374
|
+
checksum = sha256_file(path) if hash_content else "0" * 64
|
|
375
|
+
member = {"relative": "", "kind": "file", "size": info.st_size, "mtime_ns": info.st_mtime_ns, "checksum": checksum}
|
|
376
|
+
base["members"] = [member]
|
|
377
|
+
base["estimated_bytes"] = member["size"]
|
|
378
|
+
if source["action"] == "manifest":
|
|
379
|
+
base["estimated_bytes"] = 0
|
|
380
|
+
return base
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def disk_values(path: Path) -> tuple[int, int]:
|
|
384
|
+
test_total = os.environ.get("LDM_BACKUP_TEST_TOTAL_BYTES")
|
|
385
|
+
test_available = os.environ.get("LDM_BACKUP_TEST_AVAILABLE_BYTES")
|
|
386
|
+
if test_total is not None or test_available is not None:
|
|
387
|
+
if test_total is None or test_available is None:
|
|
388
|
+
raise BackupError("test-disk-values-incomplete", "both test disk values are required")
|
|
389
|
+
return int(test_total), int(test_available)
|
|
390
|
+
usage = shutil.disk_usage(nearest_existing(path))
|
|
391
|
+
return usage.total, usage.free
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def planned_manifest(run_id: str, started_at: str, policy: dict[str, Any], sources: list[dict[str, Any]]) -> dict[str, Any]:
|
|
395
|
+
records = []
|
|
396
|
+
observations = []
|
|
397
|
+
evidence = []
|
|
398
|
+
exclusions = []
|
|
399
|
+
for item in sources:
|
|
400
|
+
if item["action"] == "capture" and item["status"] == "planned":
|
|
401
|
+
observations.append({
|
|
402
|
+
"source_id": item["id"],
|
|
403
|
+
"consistency_contract": (
|
|
404
|
+
"sqlite-backup-api-transaction-snapshot"
|
|
405
|
+
if item["type"] == "sqlite"
|
|
406
|
+
else "capture-time-stable"
|
|
407
|
+
),
|
|
408
|
+
"planned_member_count": len(item["members"]),
|
|
409
|
+
"planned_payload_bytes": sum(member["size"] for member in item["members"]),
|
|
410
|
+
"planned_inventory_sha256": members_fingerprint(item["members"]),
|
|
411
|
+
"capture_member_count": len(item["members"]),
|
|
412
|
+
"capture_payload_bytes": sum(member["size"] for member in item["members"]),
|
|
413
|
+
"capture_inventory_sha256": members_fingerprint(item["members"]),
|
|
414
|
+
"changed_since_plan": None if item["type"] == "sqlite" else False,
|
|
415
|
+
"metadata_changed_since_plan": False,
|
|
416
|
+
})
|
|
417
|
+
for member in item["members"]:
|
|
418
|
+
relative_destination = item["destination"]
|
|
419
|
+
if member["relative"]:
|
|
420
|
+
relative_destination = f"{relative_destination}/{member['relative']}"
|
|
421
|
+
records.append({
|
|
422
|
+
"source_id": item["id"],
|
|
423
|
+
"source_path": item["source_path"],
|
|
424
|
+
"destination": relative_destination,
|
|
425
|
+
"type": member["kind"],
|
|
426
|
+
"size": member["size"],
|
|
427
|
+
"mtime_ns": member["mtime_ns"],
|
|
428
|
+
"checksum_sha256": "0" * 64 if member["kind"] == "sqlite" else member["checksum"],
|
|
429
|
+
"planned_size": member["size"],
|
|
430
|
+
"planned_mtime_ns": member["mtime_ns"],
|
|
431
|
+
"planned_checksum_sha256": member["checksum"],
|
|
432
|
+
"changed_since_plan": None if member["kind"] == "sqlite" else False,
|
|
433
|
+
"consistency_contract": (
|
|
434
|
+
"sqlite-backup-api-transaction-snapshot"
|
|
435
|
+
if member["kind"] == "sqlite"
|
|
436
|
+
else "capture-time-stable"
|
|
437
|
+
),
|
|
438
|
+
})
|
|
439
|
+
if item["action"] == "manifest" and item["status"] == "planned":
|
|
440
|
+
evidence.append(manifest_evidence(item))
|
|
441
|
+
for exclusion in item.get("exclusions", []):
|
|
442
|
+
exclusions.append({
|
|
443
|
+
"source_id": item["id"],
|
|
444
|
+
"path": exclusion.get("pattern", item["display_path"]),
|
|
445
|
+
"reason": exclusion.get("reason", item.get("block_reason", item["status"])),
|
|
446
|
+
"reconstruction_source": exclusion.get("reconstruction_source", "policy gate required"),
|
|
447
|
+
"rule_version": exclusion.get("rule_version", policy["rule_version"]),
|
|
448
|
+
"matched_count": exclusion.get("matched_count", 0),
|
|
449
|
+
})
|
|
450
|
+
if item["status"] in {"blocked", "missing", "type-mismatch"}:
|
|
451
|
+
exclusions.append({
|
|
452
|
+
"source_id": item["id"],
|
|
453
|
+
"path": item["display_path"],
|
|
454
|
+
"reason": item.get("block_reason", item["status"]),
|
|
455
|
+
"reconstruction_source": "unresolved required-source gate",
|
|
456
|
+
"rule_version": policy["rule_version"],
|
|
457
|
+
"matched_count": 1,
|
|
458
|
+
})
|
|
459
|
+
return {
|
|
460
|
+
"schema_version": 1,
|
|
461
|
+
"snapshot_id": run_id,
|
|
462
|
+
"tool_version": TOOL_VERSION,
|
|
463
|
+
"policy_rule_version": policy["rule_version"],
|
|
464
|
+
"started_at": started_at,
|
|
465
|
+
"completed_at": "0000-00-00T00:00:00Z",
|
|
466
|
+
"terminal_result": "success",
|
|
467
|
+
"sources": records,
|
|
468
|
+
"source_observations": observations,
|
|
469
|
+
"manifest_evidence": evidence,
|
|
470
|
+
"exclusions": exclusions,
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def completion_value(run_id: str) -> dict[str, Any]:
|
|
475
|
+
return {
|
|
476
|
+
"schema_version": 1,
|
|
477
|
+
"snapshot_id": run_id,
|
|
478
|
+
"status": "complete",
|
|
479
|
+
"manifest_sha256": "0" * 64,
|
|
480
|
+
"completed_at": "0000-00-00T00:00:00Z",
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def build_plan(policy: dict[str, Any], roots: Roots, run_id: str, started_at: str) -> dict[str, Any]:
|
|
485
|
+
sources = [make_source_plan(source, roots) for source in policy["sources"]]
|
|
486
|
+
blockers = [item for item in sources if item["required"] and item["status"] != "planned" and item["action"] == "capture"]
|
|
487
|
+
blockers.extend(item for item in sources if item["required"] and item["status"] != "planned" and item["action"] == "manifest")
|
|
488
|
+
blockers.extend(item for item in sources if item["required"] and item["status"] == "blocked")
|
|
489
|
+
payload_bytes = sum(item["estimated_bytes"] for item in sources if item["status"] == "planned")
|
|
490
|
+
manifest = planned_manifest(run_id, started_at, policy, sources)
|
|
491
|
+
manifest["planned_payload_bytes"] = payload_bytes
|
|
492
|
+
manifest["captured_payload_bytes"] = payload_bytes
|
|
493
|
+
metadata_bytes = len(json_bytes(manifest)) + len(json_bytes(completion_value(run_id)))
|
|
494
|
+
total, available = disk_values(roots.backup)
|
|
495
|
+
reserve_percent = int(policy["disk_floors"]["start_reserve_percent"])
|
|
496
|
+
required_reserve = (total * reserve_percent + 99) // 100
|
|
497
|
+
estimated_new = payload_bytes + metadata_bytes
|
|
498
|
+
reserve_after = available - estimated_new
|
|
499
|
+
destination_parent = nearest_existing(roots.backup)
|
|
500
|
+
destination_ok = destination_parent.is_dir() and os.access(destination_parent, os.W_OK)
|
|
501
|
+
allowed = not blockers and destination_ok and reserve_after >= required_reserve
|
|
502
|
+
reasons = []
|
|
503
|
+
if blockers:
|
|
504
|
+
reasons.append("required-source-gate")
|
|
505
|
+
if not destination_ok:
|
|
506
|
+
reasons.append("destination-unavailable")
|
|
507
|
+
if reserve_after < required_reserve:
|
|
508
|
+
reasons.append("reserve-gate")
|
|
509
|
+
return {
|
|
510
|
+
"schema_version": 1,
|
|
511
|
+
"dry_run": False,
|
|
512
|
+
"snapshot_id": run_id,
|
|
513
|
+
"tool_version": TOOL_VERSION,
|
|
514
|
+
"policy_rule_version": policy["rule_version"],
|
|
515
|
+
"planned_staging_path": str(roots.backup / f".in-progress-{run_id}"),
|
|
516
|
+
"planned_destination_path": str(roots.backup / run_id),
|
|
517
|
+
"icloud_tier": {"path": str(roots.icloud), "status": "not-in-slice-4", "write": False},
|
|
518
|
+
"vault_tier": {"path": None, "status": "not-in-slice-3", "write": False},
|
|
519
|
+
"sources": sources,
|
|
520
|
+
"initial_estimated_payload_bytes": payload_bytes,
|
|
521
|
+
"estimated_payload_bytes": payload_bytes,
|
|
522
|
+
"initial_estimated_metadata_bytes": metadata_bytes,
|
|
523
|
+
"estimated_metadata_bytes": metadata_bytes,
|
|
524
|
+
"estimated_new_bytes": estimated_new,
|
|
525
|
+
"disk_total_bytes": total,
|
|
526
|
+
"disk_available_bytes": available,
|
|
527
|
+
"required_reserve_bytes": required_reserve,
|
|
528
|
+
"reserve_after_bytes": reserve_after,
|
|
529
|
+
"preflight_allowed": allowed,
|
|
530
|
+
"blocking_reasons": reasons,
|
|
531
|
+
"manifest_template": manifest,
|
|
532
|
+
"policy": policy,
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def public_plan(plan: dict[str, Any]) -> dict[str, Any]:
|
|
537
|
+
safe_sources = []
|
|
538
|
+
for item in plan["sources"]:
|
|
539
|
+
safe_sources.append({key: item[key] for key in (
|
|
540
|
+
"id", "display_path", "classification", "required", "action", "type",
|
|
541
|
+
"destination", "estimated_bytes", "status",
|
|
542
|
+
)} | ({"block_reason": item["block_reason"]} if "block_reason" in item else {}) | {
|
|
543
|
+
"exclusions": [
|
|
544
|
+
{key: value for key, value in exclusion.items() if key in {"pattern", "reason", "reconstruction_source", "rule_version", "matched_count"}}
|
|
545
|
+
for exclusion in item.get("exclusions", [])
|
|
546
|
+
]
|
|
547
|
+
})
|
|
548
|
+
return {key: value for key, value in plan.items() if key not in {"sources", "manifest_template", "policy"}} | {"sources": safe_sources}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def print_plan(plan: dict[str, Any], as_json: bool) -> None:
|
|
552
|
+
safe = public_plan(plan)
|
|
553
|
+
if as_json:
|
|
554
|
+
print(json.dumps(safe, indent=2, sort_keys=True))
|
|
555
|
+
return
|
|
556
|
+
print("=== LDM Backup dry run ===")
|
|
557
|
+
print(f"Policy: {safe['policy_rule_version']}")
|
|
558
|
+
for item in safe["sources"]:
|
|
559
|
+
classes = ", ".join(item["classification"])
|
|
560
|
+
print(f"- {item['id']}: {item['display_path']}")
|
|
561
|
+
print(f" class={classes} action={item['action']} status={item['status']} estimated_bytes={item['estimated_bytes']}")
|
|
562
|
+
if item.get("block_reason"):
|
|
563
|
+
print(f" blocked: {item['block_reason']}")
|
|
564
|
+
for exclusion in item.get("exclusions", []):
|
|
565
|
+
print(f" exclude {exclusion.get('pattern', 'policy')}: {exclusion.get('reason', 'policy rule')}")
|
|
566
|
+
print(f"Total estimated new bytes: {safe['estimated_new_bytes']}")
|
|
567
|
+
print(f"Disk capacity bytes: {safe['disk_total_bytes']}")
|
|
568
|
+
print(f"Disk available bytes: {safe['disk_available_bytes']}")
|
|
569
|
+
print(f"Required reserve bytes: {safe['required_reserve_bytes']}")
|
|
570
|
+
print(f"Reserve after proposed backup: {safe['reserve_after_bytes']}")
|
|
571
|
+
print(f"Planned staging path: {safe['planned_staging_path']}")
|
|
572
|
+
print(f"Planned destination path: {safe['planned_destination_path']}")
|
|
573
|
+
print(f"Preflight: {'PASS' if safe['preflight_allowed'] else 'FAIL'}")
|
|
574
|
+
if safe["blocking_reasons"]:
|
|
575
|
+
print(f"Blocking reasons: {', '.join(safe['blocking_reasons'])}")
|
|
576
|
+
print("[DRY RUN] No files modified.")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def pid_alive(pid: int) -> bool:
|
|
580
|
+
try:
|
|
581
|
+
os.kill(pid, 0)
|
|
582
|
+
return True
|
|
583
|
+
except ProcessLookupError:
|
|
584
|
+
return False
|
|
585
|
+
except PermissionError:
|
|
586
|
+
return True
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def acquire_lock(path: Path, run_id: str) -> int:
|
|
590
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
591
|
+
try:
|
|
592
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
593
|
+
except FileExistsError as exc:
|
|
594
|
+
try:
|
|
595
|
+
lock = json.loads(path.read_text(encoding="utf-8"))
|
|
596
|
+
pid = int(lock.get("pid", 0))
|
|
597
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
598
|
+
raise BackupError("lock-invalid", "existing lock is unreadable and was not replaced") from exc
|
|
599
|
+
if pid and pid_alive(pid):
|
|
600
|
+
raise BackupError("concurrent-run", f"backup already running with pid {pid}") from exc
|
|
601
|
+
raise BackupError("stale-lock", f"stale lock detected for dead pid {pid}; lock was not stolen") from exc
|
|
602
|
+
payload = json_bytes({"schema_version": 1, "pid": os.getpid(), "snapshot_id": run_id, "created_at": utc_now()})
|
|
603
|
+
if os.write(fd, payload) != len(payload):
|
|
604
|
+
os.close(fd)
|
|
605
|
+
raise BackupError("lock-write-failed", "lock payload write was incomplete")
|
|
606
|
+
os.fsync(fd)
|
|
607
|
+
return fd
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def release_lock(path: Path, fd: int | None) -> None:
|
|
611
|
+
if fd is None:
|
|
612
|
+
return
|
|
613
|
+
owned = os.fstat(fd)
|
|
614
|
+
try:
|
|
615
|
+
current = path.lstat()
|
|
616
|
+
if current.st_dev == owned.st_dev and current.st_ino == owned.st_ino:
|
|
617
|
+
path.unlink()
|
|
618
|
+
except FileNotFoundError:
|
|
619
|
+
pass
|
|
620
|
+
finally:
|
|
621
|
+
os.close(fd)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def copy_member(source_root: Path, destination_root: Path, member: dict[str, Any]) -> int:
|
|
625
|
+
relative = Path(member["relative"]) if member["relative"] else Path()
|
|
626
|
+
source = source_root / relative
|
|
627
|
+
destination = destination_root / relative
|
|
628
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
629
|
+
if member["kind"] == "directory":
|
|
630
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
631
|
+
shutil.copystat(source, destination, follow_symlinks=False)
|
|
632
|
+
return 0
|
|
633
|
+
if member["kind"] == "symlink":
|
|
634
|
+
os.symlink(os.readlink(source), destination)
|
|
635
|
+
return member["size"]
|
|
636
|
+
shutil.copy2(source, destination, follow_symlinks=False)
|
|
637
|
+
return destination.stat().st_size
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def member_key(member: dict[str, Any]) -> tuple[str, str]:
|
|
641
|
+
return member["relative"], member["kind"]
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def member_map(members: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
|
|
645
|
+
return {member_key(member): member for member in members}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def members_changed(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> bool:
|
|
649
|
+
return member_map(before) != member_map(after)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def members_fingerprint(members: list[dict[str, Any]]) -> str:
|
|
653
|
+
return hashlib.sha256(json_bytes(members)).hexdigest()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def manifest_evidence(item: dict[str, Any]) -> dict[str, Any]:
|
|
657
|
+
members = item["members"]
|
|
658
|
+
return {
|
|
659
|
+
"source_id": item["id"],
|
|
660
|
+
"generator": item["source_definition"].get("manifest_generator"),
|
|
661
|
+
"source_path": item["source_path"],
|
|
662
|
+
"source_type": item["type"],
|
|
663
|
+
"member_count": len(members),
|
|
664
|
+
"logical_bytes": sum(member["size"] for member in members),
|
|
665
|
+
"inventory_sha256": members_fingerprint(members),
|
|
666
|
+
"members": members,
|
|
667
|
+
"generated_at": "0000-00-00T00:00:00Z",
|
|
668
|
+
"status": "valid",
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def apply_test_source_mutation(item: dict[str, Any], phase: str) -> None:
|
|
673
|
+
if os.environ.get("LDM_BACKUP_TEST_MUTATE_SOURCE_ID") != item["id"]:
|
|
674
|
+
return
|
|
675
|
+
if os.environ.get("LDM_BACKUP_TEST_MUTATE_PHASE", "before-capture") != phase:
|
|
676
|
+
return
|
|
677
|
+
mode = os.environ.get("LDM_BACKUP_TEST_MUTATE_MODE", "same-size")
|
|
678
|
+
source_root = Path(item["source_path"])
|
|
679
|
+
member = next((value for value in item["members"] if value["kind"] == "file"), None)
|
|
680
|
+
if member is None:
|
|
681
|
+
raise BackupError("test-mutation-invalid", "test mutation requires a regular file member")
|
|
682
|
+
target = source_root / member["relative"] if member["relative"] else source_root
|
|
683
|
+
original = target.read_bytes()
|
|
684
|
+
if mode == "same-size":
|
|
685
|
+
replacement = (b"X" if original[:1] != b"X" else b"Y") + original[1:]
|
|
686
|
+
elif mode == "size":
|
|
687
|
+
replacement = original + b"-changed"
|
|
688
|
+
else:
|
|
689
|
+
raise BackupError("test-mutation-invalid", f"unknown mutation mode: {mode}")
|
|
690
|
+
target.write_bytes(replacement)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def capture_sqlite(source: Path, destination: Path, source_id: str) -> tuple[int, str]:
|
|
694
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
695
|
+
source_db = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
|
|
696
|
+
destination_db = sqlite3.connect(destination)
|
|
697
|
+
try:
|
|
698
|
+
source_db.backup(destination_db)
|
|
699
|
+
finally:
|
|
700
|
+
destination_db.close()
|
|
701
|
+
source_db.close()
|
|
702
|
+
if os.environ.get("LDM_BACKUP_TEST_SQLITE_FAIL_ID") == source_id:
|
|
703
|
+
destination.write_bytes(b"not-a-database")
|
|
704
|
+
try:
|
|
705
|
+
check_db = sqlite3.connect(f"file:{destination}?mode=ro", uri=True)
|
|
706
|
+
try:
|
|
707
|
+
result = check_db.execute("PRAGMA quick_check").fetchone()[0]
|
|
708
|
+
finally:
|
|
709
|
+
check_db.close()
|
|
710
|
+
except sqlite3.Error as exc:
|
|
711
|
+
raise BackupError("sqlite-validation-failed", f"quick_check failed for source id {source_id}") from exc
|
|
712
|
+
if result != "ok":
|
|
713
|
+
raise BackupError("sqlite-validation-failed", f"quick_check failed for source id {source_id}")
|
|
714
|
+
return destination.stat().st_size, sha256_file(destination)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def source_metadata_projection_bytes(item: dict[str, Any], plan: dict[str, Any]) -> int:
|
|
718
|
+
fragment = planned_manifest(
|
|
719
|
+
plan["snapshot_id"],
|
|
720
|
+
plan["manifest_template"]["started_at"],
|
|
721
|
+
plan["policy"],
|
|
722
|
+
[item],
|
|
723
|
+
)
|
|
724
|
+
empty = planned_manifest(
|
|
725
|
+
plan["snapshot_id"],
|
|
726
|
+
plan["manifest_template"]["started_at"],
|
|
727
|
+
plan["policy"],
|
|
728
|
+
[],
|
|
729
|
+
)
|
|
730
|
+
return len(json_bytes(fragment)) - len(json_bytes(empty))
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def projected_metadata_bytes(
|
|
734
|
+
plan: dict[str, Any],
|
|
735
|
+
refreshed: dict[str, dict[str, Any]],
|
|
736
|
+
generated_evidence: list[dict[str, Any]],
|
|
737
|
+
) -> int:
|
|
738
|
+
metadata_bytes = plan["initial_estimated_metadata_bytes"]
|
|
739
|
+
original = {item["id"]: item for item in plan["sources"]}
|
|
740
|
+
for source_id, observation in refreshed.items():
|
|
741
|
+
before = source_metadata_projection_bytes(original[source_id], plan)
|
|
742
|
+
after = source_metadata_projection_bytes(observation, plan)
|
|
743
|
+
metadata_bytes += max(0, after - before)
|
|
744
|
+
planned_evidence = {
|
|
745
|
+
item["source_id"]: item
|
|
746
|
+
for item in plan["manifest_template"]["manifest_evidence"]
|
|
747
|
+
}
|
|
748
|
+
for item in generated_evidence:
|
|
749
|
+
before = planned_evidence.get(item["source_id"])
|
|
750
|
+
before_bytes = len(json_bytes(before)) if before is not None else 0
|
|
751
|
+
metadata_bytes += max(0, len(json_bytes(item)) - before_bytes)
|
|
752
|
+
return metadata_bytes
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def refresh_remaining_capture_sources(
|
|
756
|
+
plan: dict[str, Any],
|
|
757
|
+
start_index: int,
|
|
758
|
+
roots: Roots,
|
|
759
|
+
) -> dict[str, dict[str, Any]]:
|
|
760
|
+
refreshed: dict[str, dict[str, Any]] = {}
|
|
761
|
+
for item in plan["sources"][start_index:]:
|
|
762
|
+
if item["action"] != "capture" or item["status"] != "planned":
|
|
763
|
+
continue
|
|
764
|
+
observation = make_source_plan(
|
|
765
|
+
item["source_definition"],
|
|
766
|
+
roots,
|
|
767
|
+
hash_content=False,
|
|
768
|
+
)
|
|
769
|
+
if observation["status"] != "planned":
|
|
770
|
+
raise BackupError("source-changed-during-run", f"source became unavailable before capture: {item['id']}")
|
|
771
|
+
refreshed[item["id"]] = observation
|
|
772
|
+
return refreshed
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def revalidate_capture_reserve(
|
|
776
|
+
plan: dict[str, Any],
|
|
777
|
+
roots: Roots,
|
|
778
|
+
written: int,
|
|
779
|
+
refreshed: dict[str, dict[str, Any]],
|
|
780
|
+
generated_evidence: list[dict[str, Any]],
|
|
781
|
+
) -> None:
|
|
782
|
+
remaining_payload = sum(item["estimated_bytes"] for item in refreshed.values())
|
|
783
|
+
metadata_bytes = projected_metadata_bytes(plan, refreshed, generated_evidence)
|
|
784
|
+
_, current_available = disk_values(roots.backup)
|
|
785
|
+
available_after_staged = min(current_available, plan["disk_available_bytes"] - written)
|
|
786
|
+
reserve_after = available_after_staged - remaining_payload - metadata_bytes
|
|
787
|
+
updated_payload = written + remaining_payload
|
|
788
|
+
plan["estimated_payload_bytes"] = updated_payload
|
|
789
|
+
plan["estimated_metadata_bytes"] = metadata_bytes
|
|
790
|
+
plan["estimated_new_bytes"] = updated_payload + metadata_bytes
|
|
791
|
+
plan["reserve_after_bytes"] = reserve_after
|
|
792
|
+
plan["capture_time_available_bytes"] = available_after_staged
|
|
793
|
+
if reserve_after < plan["required_reserve_bytes"]:
|
|
794
|
+
plan["preflight_allowed"] = False
|
|
795
|
+
if "capture-reserve-gate" not in plan["blocking_reasons"]:
|
|
796
|
+
plan["blocking_reasons"].append("capture-reserve-gate")
|
|
797
|
+
raise BackupError(
|
|
798
|
+
"capture-reserve-gate",
|
|
799
|
+
"updated capture estimate would cross the required disk reserve",
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def revalidate_final_metadata_reserve(
|
|
804
|
+
plan: dict[str, Any],
|
|
805
|
+
roots: Roots,
|
|
806
|
+
payload_written: int,
|
|
807
|
+
manifest: dict[str, Any],
|
|
808
|
+
) -> None:
|
|
809
|
+
metadata_bytes = len(json_bytes(manifest)) + len(json_bytes(completion_value(plan["snapshot_id"])))
|
|
810
|
+
_, current_available = disk_values(roots.backup)
|
|
811
|
+
available_after_staged = min(current_available, plan["disk_available_bytes"] - payload_written)
|
|
812
|
+
reserve_after = available_after_staged - metadata_bytes
|
|
813
|
+
plan["estimated_payload_bytes"] = payload_written
|
|
814
|
+
plan["estimated_metadata_bytes"] = metadata_bytes
|
|
815
|
+
plan["estimated_new_bytes"] = payload_written + metadata_bytes
|
|
816
|
+
plan["reserve_after_bytes"] = reserve_after
|
|
817
|
+
plan["capture_time_available_bytes"] = available_after_staged
|
|
818
|
+
if reserve_after < plan["required_reserve_bytes"]:
|
|
819
|
+
plan["preflight_allowed"] = False
|
|
820
|
+
if "capture-reserve-gate" not in plan["blocking_reasons"]:
|
|
821
|
+
plan["blocking_reasons"].append("capture-reserve-gate")
|
|
822
|
+
raise BackupError(
|
|
823
|
+
"capture-reserve-gate",
|
|
824
|
+
"final manifest metadata would cross the required disk reserve",
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def capture(
|
|
829
|
+
plan: dict[str, Any],
|
|
830
|
+
staging: Path,
|
|
831
|
+
roots: Roots,
|
|
832
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], int]:
|
|
833
|
+
records: list[dict[str, Any]] = []
|
|
834
|
+
observations: list[dict[str, Any]] = []
|
|
835
|
+
evidence: list[dict[str, Any]] = []
|
|
836
|
+
written = 0
|
|
837
|
+
fail_after = int(os.environ.get("LDM_BACKUP_TEST_FAIL_AFTER_BYTES", "-1"))
|
|
838
|
+
crash_after = int(os.environ.get("LDM_BACKUP_TEST_CRASH_AFTER_FILES", "-1"))
|
|
839
|
+
captured_files = 0
|
|
840
|
+
for index, item in enumerate(plan["sources"]):
|
|
841
|
+
if item["action"] == "manifest" and item["status"] == "planned":
|
|
842
|
+
if os.environ.get("LDM_BACKUP_TEST_MANIFEST_FAIL_ID") == item["id"]:
|
|
843
|
+
raise BackupError("manifest-evidence-failed", f"manifest generator failed: {item['id']}")
|
|
844
|
+
observation = make_source_plan(item["source_definition"], roots)
|
|
845
|
+
if observation["status"] != "planned":
|
|
846
|
+
raise BackupError("manifest-evidence-failed", f"manifest evidence unavailable: {item['id']}")
|
|
847
|
+
record = manifest_evidence(observation)
|
|
848
|
+
record["generated_at"] = utc_now()
|
|
849
|
+
evidence.append(record)
|
|
850
|
+
refreshed = refresh_remaining_capture_sources(plan, index + 1, roots)
|
|
851
|
+
revalidate_capture_reserve(plan, roots, written, refreshed, evidence)
|
|
852
|
+
continue
|
|
853
|
+
if item["action"] != "capture" or item["status"] != "planned":
|
|
854
|
+
continue
|
|
855
|
+
apply_test_source_mutation(item, "before-capture")
|
|
856
|
+
refreshed = refresh_remaining_capture_sources(plan, index, roots)
|
|
857
|
+
capture_observation = make_source_plan(item["source_definition"], roots)
|
|
858
|
+
if capture_observation["status"] != "planned":
|
|
859
|
+
raise BackupError("source-changed-during-run", f"source became unavailable before capture: {item['id']}")
|
|
860
|
+
refreshed[item["id"]] = capture_observation
|
|
861
|
+
revalidate_capture_reserve(plan, roots, written, refreshed, evidence)
|
|
862
|
+
plan_changed = members_changed(item["members"], capture_observation["members"])
|
|
863
|
+
observations.append({
|
|
864
|
+
"source_id": item["id"],
|
|
865
|
+
"consistency_contract": (
|
|
866
|
+
"sqlite-backup-api-transaction-snapshot"
|
|
867
|
+
if item["type"] == "sqlite"
|
|
868
|
+
else "capture-time-stable"
|
|
869
|
+
),
|
|
870
|
+
"planned_member_count": len(item["members"]),
|
|
871
|
+
"planned_payload_bytes": sum(member["size"] for member in item["members"]),
|
|
872
|
+
"planned_inventory_sha256": members_fingerprint(item["members"]),
|
|
873
|
+
"capture_member_count": len(capture_observation["members"]),
|
|
874
|
+
"capture_payload_bytes": sum(member["size"] for member in capture_observation["members"]),
|
|
875
|
+
"capture_inventory_sha256": members_fingerprint(capture_observation["members"]),
|
|
876
|
+
"changed_since_plan": None if item["type"] == "sqlite" else plan_changed,
|
|
877
|
+
"metadata_changed_since_plan": plan_changed,
|
|
878
|
+
})
|
|
879
|
+
source_root = Path(item["source_path"])
|
|
880
|
+
destination_root = staging / item["destination"]
|
|
881
|
+
if item["type"] == "sqlite":
|
|
882
|
+
capture_info = source_root.lstat()
|
|
883
|
+
size, checksum = capture_sqlite(source_root, destination_root, item["id"])
|
|
884
|
+
member = item["members"][0]
|
|
885
|
+
records.append({
|
|
886
|
+
"source_id": item["id"], "source_path": item["source_path"],
|
|
887
|
+
"destination": item["destination"], "type": "sqlite", "size": size,
|
|
888
|
+
"mtime_ns": capture_info.st_mtime_ns, "checksum_sha256": checksum,
|
|
889
|
+
"planned_size": member["size"], "planned_mtime_ns": member["mtime_ns"],
|
|
890
|
+
"planned_checksum_sha256": None, "changed_since_plan": None,
|
|
891
|
+
"consistency_contract": "sqlite-backup-api-transaction-snapshot",
|
|
892
|
+
})
|
|
893
|
+
written += size
|
|
894
|
+
captured_files += 1
|
|
895
|
+
else:
|
|
896
|
+
for member in capture_observation["members"]:
|
|
897
|
+
destination = destination_root
|
|
898
|
+
if member["relative"]:
|
|
899
|
+
destination = destination_root / member["relative"]
|
|
900
|
+
size = copy_member(source_root, destination_root, member)
|
|
901
|
+
if member["kind"] == "directory":
|
|
902
|
+
checksum = sha256_text("")
|
|
903
|
+
elif member["kind"] == "symlink":
|
|
904
|
+
checksum = sha256_text(os.readlink(destination))
|
|
905
|
+
else:
|
|
906
|
+
checksum = sha256_file(destination)
|
|
907
|
+
if size != member["size"] or checksum != member["checksum"]:
|
|
908
|
+
raise BackupError("source-capture-mismatch", f"captured bytes do not match source observation: {item['id']}")
|
|
909
|
+
relative_destination = item["destination"]
|
|
910
|
+
if member["relative"]:
|
|
911
|
+
relative_destination = f"{relative_destination}/{member['relative']}"
|
|
912
|
+
records.append({
|
|
913
|
+
"source_id": item["id"], "source_path": item["source_path"],
|
|
914
|
+
"destination": relative_destination, "type": member["kind"], "size": size,
|
|
915
|
+
"mtime_ns": member["mtime_ns"], "checksum_sha256": checksum,
|
|
916
|
+
"planned_size": member_map(item["members"]).get(member_key(member), {}).get("size"),
|
|
917
|
+
"planned_mtime_ns": member_map(item["members"]).get(member_key(member), {}).get("mtime_ns"),
|
|
918
|
+
"planned_checksum_sha256": member_map(item["members"]).get(member_key(member), {}).get("checksum"),
|
|
919
|
+
"changed_since_plan": plan_changed,
|
|
920
|
+
"consistency_contract": "capture-time-stable",
|
|
921
|
+
})
|
|
922
|
+
written += size
|
|
923
|
+
captured_files += 1
|
|
924
|
+
if fail_after >= 0 and written >= fail_after:
|
|
925
|
+
raise SimulatedDiskFull()
|
|
926
|
+
if crash_after >= 0 and captured_files >= crash_after:
|
|
927
|
+
os._exit(88)
|
|
928
|
+
apply_test_source_mutation(capture_observation, "during-capture")
|
|
929
|
+
final_observation = make_source_plan(item["source_definition"], roots)
|
|
930
|
+
if final_observation["status"] != "planned" or members_changed(capture_observation["members"], final_observation["members"]):
|
|
931
|
+
raise BackupError("source-changed-during-capture", f"source changed during capture: {item['id']}")
|
|
932
|
+
return records, observations, evidence, written
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def read_evidence_status(state_dir: Path) -> str:
|
|
936
|
+
marker = state_dir / "containment.json"
|
|
937
|
+
try:
|
|
938
|
+
value = json.loads(marker.read_text(encoding="utf-8"))
|
|
939
|
+
except FileNotFoundError:
|
|
940
|
+
return "unknown"
|
|
941
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
942
|
+
raise BackupError("containment-marker-invalid", f"cannot read containment marker: {exc}") from exc
|
|
943
|
+
evidence_status = value.get("evidence_status")
|
|
944
|
+
if evidence_status not in VALID_EVIDENCE_STATUS:
|
|
945
|
+
raise BackupError("state-enum-invalid", "containment marker has invalid evidence_status")
|
|
946
|
+
return evidence_status
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def validate_manifest(manifest: dict[str, Any], staging: Path, plan: dict[str, Any]) -> None:
|
|
950
|
+
if manifest.get("terminal_result") != "success":
|
|
951
|
+
raise BackupError("manifest-invalid", "manifest result is not success")
|
|
952
|
+
expected_ids = {item["id"] for item in plan["sources"] if item["required"] and item["action"] == "capture"}
|
|
953
|
+
actual_ids = {item["source_id"] for item in manifest["sources"]}
|
|
954
|
+
if expected_ids - actual_ids:
|
|
955
|
+
raise BackupError("manifest-required-source-missing", "manifest is missing a required source")
|
|
956
|
+
expected_evidence_ids = {
|
|
957
|
+
item["id"]
|
|
958
|
+
for item in plan["sources"]
|
|
959
|
+
if item["required"] and item["action"] == "manifest"
|
|
960
|
+
}
|
|
961
|
+
evidence = manifest.get("manifest_evidence")
|
|
962
|
+
if not isinstance(evidence, list):
|
|
963
|
+
raise BackupError("manifest-evidence-invalid", "manifest evidence must be a list")
|
|
964
|
+
actual_evidence_ids = {item.get("source_id") for item in evidence}
|
|
965
|
+
if expected_evidence_ids - actual_evidence_ids:
|
|
966
|
+
raise BackupError("manifest-required-evidence-missing", "manifest is missing required evidence")
|
|
967
|
+
for item in evidence:
|
|
968
|
+
members = item.get("members")
|
|
969
|
+
if (
|
|
970
|
+
item.get("status") != "valid"
|
|
971
|
+
or item.get("generator") != "path-inventory"
|
|
972
|
+
or not isinstance(members, list)
|
|
973
|
+
or item.get("member_count") != len(members)
|
|
974
|
+
or item.get("logical_bytes") != sum(member.get("size", -1) for member in members)
|
|
975
|
+
or item.get("inventory_sha256") != members_fingerprint(members)
|
|
976
|
+
):
|
|
977
|
+
raise BackupError("manifest-evidence-invalid", f"invalid manifest evidence: {item.get('source_id')}")
|
|
978
|
+
for record in manifest["sources"]:
|
|
979
|
+
destination = staging / record["destination"]
|
|
980
|
+
if not destination.exists() and not destination.is_symlink():
|
|
981
|
+
raise BackupError("manifest-file-missing", "manifest destination is missing")
|
|
982
|
+
if record["type"] == "directory":
|
|
983
|
+
if not destination.is_dir():
|
|
984
|
+
raise BackupError("manifest-file-missing", "manifest directory is missing")
|
|
985
|
+
checksum = sha256_text("")
|
|
986
|
+
elif destination.is_symlink():
|
|
987
|
+
checksum = sha256_text(os.readlink(destination))
|
|
988
|
+
else:
|
|
989
|
+
checksum = sha256_file(destination)
|
|
990
|
+
if checksum != record["checksum_sha256"]:
|
|
991
|
+
raise BackupError("manifest-checksum-mismatch", "manifest checksum mismatch")
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def verified_snapshot(path: Path) -> bool:
|
|
995
|
+
marker = path / "completion.json"
|
|
996
|
+
manifest = path / "manifest.json"
|
|
997
|
+
if not path.is_dir() or not marker.is_file() or not manifest.is_file():
|
|
998
|
+
return False
|
|
999
|
+
try:
|
|
1000
|
+
marker_value = json.loads(marker.read_text(encoding="utf-8"))
|
|
1001
|
+
manifest_value = json.loads(manifest.read_text(encoding="utf-8"))
|
|
1002
|
+
except (OSError, json.JSONDecodeError):
|
|
1003
|
+
return False
|
|
1004
|
+
return marker_value.get("status") == "complete" and manifest_value.get("terminal_result") == "success" and marker_value.get("manifest_sha256") == sha256_file(manifest)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def rotate(root: Path, keep: int) -> list[str]:
|
|
1008
|
+
eligible = sorted(
|
|
1009
|
+
path for path in root.iterdir()
|
|
1010
|
+
if path.is_dir() and not path.name.startswith(".in-progress-") and verified_snapshot(path) and not (path / ".pinned").exists()
|
|
1011
|
+
) if root.exists() else []
|
|
1012
|
+
remove_count = max(0, len(eligible) - keep)
|
|
1013
|
+
removed = []
|
|
1014
|
+
for path in eligible[:remove_count]:
|
|
1015
|
+
shutil.rmtree(path)
|
|
1016
|
+
removed.append(path.name)
|
|
1017
|
+
return removed
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def result_value(run_id: str, status: str, code: str | None, message: str | None, plan: dict[str, Any] | None) -> dict[str, Any]:
|
|
1021
|
+
return {
|
|
1022
|
+
"schema_version": 1,
|
|
1023
|
+
"snapshot_id": run_id,
|
|
1024
|
+
"status": status,
|
|
1025
|
+
"failure_code": code,
|
|
1026
|
+
"message": message,
|
|
1027
|
+
"finished_at": utc_now(),
|
|
1028
|
+
"estimated_new_bytes": plan.get("estimated_new_bytes") if plan else None,
|
|
1029
|
+
"estimated_payload_bytes": plan.get("estimated_payload_bytes") if plan else None,
|
|
1030
|
+
"estimated_metadata_bytes": plan.get("estimated_metadata_bytes") if plan else None,
|
|
1031
|
+
"required_reserve_bytes": plan.get("required_reserve_bytes") if plan else None,
|
|
1032
|
+
"reserve_after_bytes": plan.get("reserve_after_bytes") if plan else None,
|
|
1033
|
+
"preflight_allowed": plan.get("preflight_allowed") if plan else False,
|
|
1034
|
+
"tier_results": {"internal": status, "vault": "not-in-slice", "icloud": "not-in-slice"},
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def run_backup(policy: dict[str, Any], roots: Roots, keep: int) -> int:
|
|
1039
|
+
run_id = snapshot_id()
|
|
1040
|
+
started_at = utc_now()
|
|
1041
|
+
lock_fd: int | None = None
|
|
1042
|
+
plan: dict[str, Any] | None = None
|
|
1043
|
+
staging = roots.backup / f".in-progress-{run_id}"
|
|
1044
|
+
final = roots.backup / run_id
|
|
1045
|
+
try:
|
|
1046
|
+
lock_fd = acquire_lock(roots.lock, run_id)
|
|
1047
|
+
plan = build_plan(policy, roots, run_id, started_at)
|
|
1048
|
+
schedule = {
|
|
1049
|
+
"schema_version": 1,
|
|
1050
|
+
"schedule_id": SCHEDULE_ID,
|
|
1051
|
+
"timezone": "America/Los_Angeles",
|
|
1052
|
+
"expected_slot": "03:00",
|
|
1053
|
+
"containment_status": os.environ.get("LDM_BACKUP_CONTAINMENT_STATUS", "unknown"),
|
|
1054
|
+
"evidence_status": read_evidence_status(roots.state),
|
|
1055
|
+
"next_due_time": None,
|
|
1056
|
+
}
|
|
1057
|
+
write_atomic(roots.state / "schedule.json", schedule)
|
|
1058
|
+
write_atomic(roots.state / "attempt.json", {
|
|
1059
|
+
"schema_version": 1, "snapshot_id": run_id, "slot_id": run_id,
|
|
1060
|
+
"start_time": started_at, "pid": os.getpid(), "tool_version": TOOL_VERSION,
|
|
1061
|
+
"preflight": {key: plan[key] for key in (
|
|
1062
|
+
"estimated_new_bytes", "disk_total_bytes", "disk_available_bytes",
|
|
1063
|
+
"required_reserve_bytes", "reserve_after_bytes", "preflight_allowed",
|
|
1064
|
+
)},
|
|
1065
|
+
})
|
|
1066
|
+
if not plan["preflight_allowed"]:
|
|
1067
|
+
raise BackupError("preflight-failed", ",".join(plan["blocking_reasons"]) or "preflight failed")
|
|
1068
|
+
if staging.exists() or final.exists():
|
|
1069
|
+
raise BackupError("snapshot-collision", "snapshot path already exists")
|
|
1070
|
+
roots.backup.mkdir(parents=True, exist_ok=True)
|
|
1071
|
+
staging.mkdir(mode=0o700)
|
|
1072
|
+
records, observations, evidence, payload_written = capture(plan, staging, roots)
|
|
1073
|
+
drop_evidence_id = os.environ.get("LDM_BACKUP_TEST_DROP_MANIFEST_EVIDENCE_ID")
|
|
1074
|
+
if drop_evidence_id:
|
|
1075
|
+
evidence = [item for item in evidence if item["source_id"] != drop_evidence_id]
|
|
1076
|
+
manifest = plan["manifest_template"]
|
|
1077
|
+
manifest["sources"] = records
|
|
1078
|
+
manifest["source_observations"] = observations
|
|
1079
|
+
manifest["manifest_evidence"] = evidence
|
|
1080
|
+
manifest["planned_payload_bytes"] = plan["initial_estimated_payload_bytes"]
|
|
1081
|
+
manifest["captured_payload_bytes"] = payload_written
|
|
1082
|
+
manifest["completed_at"] = utc_now()
|
|
1083
|
+
revalidate_final_metadata_reserve(plan, roots, payload_written, manifest)
|
|
1084
|
+
validate_manifest(manifest, staging, plan)
|
|
1085
|
+
write_atomic(staging / "manifest.json", manifest)
|
|
1086
|
+
if os.environ.get("LDM_BACKUP_TEST_FAIL_BEFORE_COMPLETION") == "1":
|
|
1087
|
+
raise BackupError("completion-gate-failed", "simulated failure before completion marker")
|
|
1088
|
+
marker = completion_value(run_id)
|
|
1089
|
+
marker["manifest_sha256"] = sha256_file(staging / "manifest.json")
|
|
1090
|
+
marker["completed_at"] = utc_now()
|
|
1091
|
+
write_atomic(staging / "completion.json", marker)
|
|
1092
|
+
if not verified_snapshot(staging):
|
|
1093
|
+
raise BackupError("completion-validation-failed", "completion proof failed before promotion")
|
|
1094
|
+
os.replace(staging, final)
|
|
1095
|
+
success = result_value(run_id, "success", None, None, plan)
|
|
1096
|
+
success["snapshot_path"] = str(final)
|
|
1097
|
+
success["manifest_sha256"] = marker["manifest_sha256"]
|
|
1098
|
+
write_atomic(roots.state / "result.json", success)
|
|
1099
|
+
write_atomic(roots.state / "success-local.json", success)
|
|
1100
|
+
removed = rotate(roots.backup, keep)
|
|
1101
|
+
print("=== Backup complete ===")
|
|
1102
|
+
print(f"Location: {final}")
|
|
1103
|
+
print(f"Estimated and captured payload bytes: {payload_written}")
|
|
1104
|
+
if removed:
|
|
1105
|
+
print(f"Rotated verified snapshots: {', '.join(removed)}")
|
|
1106
|
+
return 0
|
|
1107
|
+
except BackupError as exc:
|
|
1108
|
+
if lock_fd is not None:
|
|
1109
|
+
try:
|
|
1110
|
+
write_atomic(roots.state / "result.json", result_value(run_id, "failure", exc.code, str(exc), plan))
|
|
1111
|
+
except OSError:
|
|
1112
|
+
pass
|
|
1113
|
+
print(f"Backup failed [{exc.code}]: {exc}", file=sys.stderr)
|
|
1114
|
+
return 1
|
|
1115
|
+
except OSError as exc:
|
|
1116
|
+
code = "disk-full-during-capture" if exc.errno == errno.ENOSPC else "io-failure"
|
|
1117
|
+
try:
|
|
1118
|
+
write_atomic(roots.state / "result.json", result_value(run_id, "failure", code, str(exc), plan))
|
|
1119
|
+
except OSError:
|
|
1120
|
+
pass
|
|
1121
|
+
print(f"Backup failed [{code}]: {exc}", file=sys.stderr)
|
|
1122
|
+
return 1
|
|
1123
|
+
finally:
|
|
1124
|
+
release_lock(roots.lock, lock_fd)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def verify_snapshot(path: Path) -> int:
|
|
1128
|
+
if verified_snapshot(path):
|
|
1129
|
+
print("Snapshot verified complete")
|
|
1130
|
+
return 0
|
|
1131
|
+
print("Snapshot is missing valid completion proof", file=sys.stderr)
|
|
1132
|
+
return 1
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
1136
|
+
parser = argparse.ArgumentParser(description="LDM OS backup engine")
|
|
1137
|
+
parser.add_argument("--policy", type=Path)
|
|
1138
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
1139
|
+
parser.add_argument("--json", action="store_true")
|
|
1140
|
+
parser.add_argument("--keep", type=int, default=None)
|
|
1141
|
+
parser.add_argument("--include-secrets", action="store_true")
|
|
1142
|
+
parser.add_argument("--verify-snapshot", type=Path)
|
|
1143
|
+
return parser.parse_args(argv)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1147
|
+
args = parse_args(argv or sys.argv[1:])
|
|
1148
|
+
if args.verify_snapshot:
|
|
1149
|
+
return verify_snapshot(args.verify_snapshot)
|
|
1150
|
+
try:
|
|
1151
|
+
roots = resolve_roots()
|
|
1152
|
+
fixture = prove_fixture_isolation(roots)
|
|
1153
|
+
policy_path = args.policy or Path(__file__).with_name("ldm-backup-policy.json")
|
|
1154
|
+
policy = load_policy(policy_path)
|
|
1155
|
+
prove_policy_fixture_isolation(policy, roots, fixture)
|
|
1156
|
+
if args.include_secrets:
|
|
1157
|
+
raise BackupError("encryption-not-implemented", "--include-secrets is blocked until artifact encryption ships")
|
|
1158
|
+
if args.dry_run:
|
|
1159
|
+
run_id = snapshot_id()
|
|
1160
|
+
started_at = utc_now()
|
|
1161
|
+
plan = build_plan(policy, roots, run_id, started_at)
|
|
1162
|
+
plan["dry_run"] = True
|
|
1163
|
+
print_plan(plan, args.json)
|
|
1164
|
+
return 0 if plan["preflight_allowed"] else 1
|
|
1165
|
+
config = load_config(roots.ldm)
|
|
1166
|
+
keep = args.keep if args.keep is not None else int(config.get("backup", {}).get("keep", 7))
|
|
1167
|
+
if keep < 1:
|
|
1168
|
+
raise BackupError("keep-invalid", "keep must be one or greater")
|
|
1169
|
+
return run_backup(policy, roots, keep)
|
|
1170
|
+
except BackupError as exc:
|
|
1171
|
+
print(f"Backup failed [{exc.code}]: {exc}", file=sys.stderr)
|
|
1172
|
+
return 1
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
if __name__ == "__main__":
|
|
1176
|
+
raise SystemExit(main())
|