toolframe-backup 0.1.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.
- toolframe_backup/__init__.py +3 -0
- toolframe_backup/archive.py +907 -0
- toolframe_backup/catalog.py +511 -0
- toolframe_backup/cli.py +82 -0
- toolframe_backup/docs/usage.md +88 -0
- toolframe_backup/environments.py +493 -0
- toolframe_backup/page.py +150 -0
- toolframe_backup/plugin.py +220 -0
- toolframe_backup/service.py +105 -0
- toolframe_backup/zipguard.py +178 -0
- toolframe_backup-0.1.0.dist-info/METADATA +148 -0
- toolframe_backup-0.1.0.dist-info/RECORD +16 -0
- toolframe_backup-0.1.0.dist-info/WHEEL +4 -0
- toolframe_backup-0.1.0.dist-info/entry_points.txt +5 -0
- toolframe_backup-0.1.0.dist-info/licenses/LICENSE +202 -0
- toolframe_backup-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Bounded local archive catalog and explicit, verified retention."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
import stat
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .archive import BackupError, verify_backup
|
|
19
|
+
|
|
20
|
+
MAX_CANDIDATES = 1000
|
|
21
|
+
MAX_DIRECTORY_ENTRIES = 10_000
|
|
22
|
+
MAX_SCAN_BYTES = 4 * 1024**3
|
|
23
|
+
PLAN_LIFETIME_SECONDS = 600
|
|
24
|
+
_BACKUP_NAME = re.compile(r"backup-([0-9a-f]{32})\.zip\Z")
|
|
25
|
+
_BACKUP_ID = re.compile(r"[0-9a-f]{32}\Z")
|
|
26
|
+
_PLAN_TOKEN = re.compile(r"[0-9a-f]{64}\Z")
|
|
27
|
+
GUIDANCE = (
|
|
28
|
+
"Stop the application, then use toolframe-backup create with the configured "
|
|
29
|
+
"destination. Restart the application to inspect the archive here."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _now() -> str:
|
|
34
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _error(code: str, message: str) -> BackupError:
|
|
38
|
+
return BackupError(code, message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def bounded_integer(value: Any, name: str, maximum: int) -> int:
|
|
42
|
+
if (
|
|
43
|
+
isinstance(value, bool)
|
|
44
|
+
or not isinstance(value, int)
|
|
45
|
+
or not 1 <= value <= maximum
|
|
46
|
+
):
|
|
47
|
+
raise _error("backup_validation", f"{name} must be between 1 and {maximum}.")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_backup_id(value: Any) -> str:
|
|
52
|
+
if not isinstance(value, str) or not _BACKUP_ID.fullmatch(value):
|
|
53
|
+
raise _error(
|
|
54
|
+
"backup_validation",
|
|
55
|
+
"backup_id must be 32 lowercase hexadecimal characters.",
|
|
56
|
+
)
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _stamp(value: os.stat_result) -> tuple[int, ...]:
|
|
61
|
+
return (
|
|
62
|
+
value.st_dev,
|
|
63
|
+
value.st_ino,
|
|
64
|
+
value.st_mode,
|
|
65
|
+
value.st_uid,
|
|
66
|
+
value.st_size,
|
|
67
|
+
value.st_mtime_ns,
|
|
68
|
+
value.st_ctime_ns,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _protected(value: os.stat_result, *, directory: bool) -> bool:
|
|
73
|
+
correct_kind = (
|
|
74
|
+
stat.S_ISDIR(value.st_mode) if directory else stat.S_ISREG(value.st_mode)
|
|
75
|
+
)
|
|
76
|
+
return (
|
|
77
|
+
correct_kind
|
|
78
|
+
and value.st_uid == os.geteuid()
|
|
79
|
+
and stat.S_IMODE(value.st_mode) == (0o700 if directory else 0o600)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class _Scan:
|
|
85
|
+
backups: list[dict[str, Any]]
|
|
86
|
+
rejected: list[dict[str, str]]
|
|
87
|
+
fingerprint: str
|
|
88
|
+
stamps: dict[str, tuple[int, ...]]
|
|
89
|
+
directory_stamp: tuple[int, int] | None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class _Plan:
|
|
94
|
+
application_id: str
|
|
95
|
+
fingerprint: str
|
|
96
|
+
delete: list[dict[str, Any]]
|
|
97
|
+
expires: float
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BackupCatalog:
|
|
101
|
+
"""The directory is authoritative; caches never authorize deletion."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, destination: str | Path, *, retention_keep: int = 10):
|
|
104
|
+
if not isinstance(destination, (str, Path)) or not str(destination):
|
|
105
|
+
raise _error(
|
|
106
|
+
"backup_configuration",
|
|
107
|
+
"Configure an absolute local destination directory.",
|
|
108
|
+
)
|
|
109
|
+
self.destination = Path(destination)
|
|
110
|
+
if (
|
|
111
|
+
not self.destination.is_absolute()
|
|
112
|
+
or ".." in self.destination.parts
|
|
113
|
+
or "\x00" in str(self.destination)
|
|
114
|
+
):
|
|
115
|
+
raise _error(
|
|
116
|
+
"backup_configuration",
|
|
117
|
+
"Destination must be an absolute local path without traversal.",
|
|
118
|
+
)
|
|
119
|
+
self.retention_keep = bounded_integer(retention_keep, "retention_keep", 1000)
|
|
120
|
+
self._lock = threading.RLock()
|
|
121
|
+
self._plans: dict[str, _Plan] = {}
|
|
122
|
+
self._directory()
|
|
123
|
+
|
|
124
|
+
def _directory(self) -> os.stat_result | None:
|
|
125
|
+
"""Reject symlink components without resolving them into a different directory."""
|
|
126
|
+
current = Path(self.destination.anchor)
|
|
127
|
+
for part in self.destination.parts[1:]:
|
|
128
|
+
current /= part
|
|
129
|
+
try:
|
|
130
|
+
value = current.lstat()
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
return None
|
|
133
|
+
except OSError:
|
|
134
|
+
raise _error(
|
|
135
|
+
"backup_destination", "Backup destination cannot be inspected."
|
|
136
|
+
) from None
|
|
137
|
+
if stat.S_ISLNK(value.st_mode) or not stat.S_ISDIR(value.st_mode):
|
|
138
|
+
raise _error(
|
|
139
|
+
"backup_destination",
|
|
140
|
+
"Destination components must be directories without symlinks.",
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
value = self.destination.lstat()
|
|
144
|
+
except OSError:
|
|
145
|
+
raise _error(
|
|
146
|
+
"backup_destination", "Backup destination changed during inspection."
|
|
147
|
+
) from None
|
|
148
|
+
if not _protected(value, directory=True):
|
|
149
|
+
raise _error(
|
|
150
|
+
"backup_permissions",
|
|
151
|
+
"Backup destination must be operator-owned with permissions 0700.",
|
|
152
|
+
)
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
def _entries(
|
|
156
|
+
self,
|
|
157
|
+
) -> tuple[list[tuple[str, os.stat_result]], tuple[int, int] | None]:
|
|
158
|
+
directory = self._directory()
|
|
159
|
+
if directory is None:
|
|
160
|
+
return [], None
|
|
161
|
+
candidates: list[tuple[str, os.stat_result]] = []
|
|
162
|
+
entries = 0
|
|
163
|
+
total_bytes = 0
|
|
164
|
+
try:
|
|
165
|
+
with os.scandir(self.destination) as iterator:
|
|
166
|
+
for entry in iterator:
|
|
167
|
+
entries += 1
|
|
168
|
+
if entries > MAX_DIRECTORY_ENTRIES:
|
|
169
|
+
raise _error(
|
|
170
|
+
"backup_catalog_limit",
|
|
171
|
+
"Destination has too many directory entries to scan safely.",
|
|
172
|
+
)
|
|
173
|
+
if not _BACKUP_NAME.fullmatch(entry.name):
|
|
174
|
+
continue
|
|
175
|
+
if len(candidates) >= MAX_CANDIDATES:
|
|
176
|
+
raise _error(
|
|
177
|
+
"backup_catalog_limit",
|
|
178
|
+
"Destination has more than 1000 backup candidates.",
|
|
179
|
+
)
|
|
180
|
+
value = entry.stat(follow_symlinks=False)
|
|
181
|
+
if stat.S_ISREG(value.st_mode):
|
|
182
|
+
total_bytes += value.st_size
|
|
183
|
+
if total_bytes > MAX_SCAN_BYTES:
|
|
184
|
+
raise _error(
|
|
185
|
+
"backup_catalog_limit",
|
|
186
|
+
"Backup candidates exceed the 4 GiB scan budget.",
|
|
187
|
+
)
|
|
188
|
+
candidates.append((entry.name, value))
|
|
189
|
+
except OSError:
|
|
190
|
+
raise _error(
|
|
191
|
+
"backup_destination", "Backup destination changed or cannot be scanned."
|
|
192
|
+
) from None
|
|
193
|
+
current = self._directory()
|
|
194
|
+
if current is None or (current.st_dev, current.st_ino) != (
|
|
195
|
+
directory.st_dev,
|
|
196
|
+
directory.st_ino,
|
|
197
|
+
):
|
|
198
|
+
raise _error(
|
|
199
|
+
"backup_catalog_changed", "Backup destination changed. Try again."
|
|
200
|
+
)
|
|
201
|
+
return sorted(candidates), (directory.st_dev, directory.st_ino)
|
|
202
|
+
|
|
203
|
+
def _verified(self, name: str, before: os.stat_result) -> dict[str, Any]:
|
|
204
|
+
if not _protected(before, directory=False):
|
|
205
|
+
raise _error(
|
|
206
|
+
"backup_permissions",
|
|
207
|
+
"Archive must be an operator-owned regular file with permissions 0600.",
|
|
208
|
+
)
|
|
209
|
+
verified = verify_backup(self.destination / name)
|
|
210
|
+
summary = verified.summary()
|
|
211
|
+
if name != f"backup-{summary['id']}.zip":
|
|
212
|
+
raise _error(
|
|
213
|
+
"backup_identity", "Archive name does not match its backup identity."
|
|
214
|
+
)
|
|
215
|
+
try:
|
|
216
|
+
after = (self.destination / name).lstat()
|
|
217
|
+
except OSError:
|
|
218
|
+
raise _error(
|
|
219
|
+
"backup_catalog_changed",
|
|
220
|
+
"Archive changed during verification. Try again.",
|
|
221
|
+
) from None
|
|
222
|
+
if _stamp(after) != _stamp(before):
|
|
223
|
+
raise _error(
|
|
224
|
+
"backup_catalog_changed",
|
|
225
|
+
"Archive changed during verification. Try again.",
|
|
226
|
+
)
|
|
227
|
+
return {**summary, "last_verified_at": _now()}
|
|
228
|
+
|
|
229
|
+
def _scan(self) -> _Scan:
|
|
230
|
+
entries, directory_stamp = self._entries()
|
|
231
|
+
backups: list[dict[str, Any]] = []
|
|
232
|
+
rejected: list[dict[str, str]] = []
|
|
233
|
+
stamps: dict[str, tuple[int, ...]] = {}
|
|
234
|
+
fingerprint: list[Any] = [directory_stamp]
|
|
235
|
+
for name, value in entries:
|
|
236
|
+
backup_id = _BACKUP_NAME.fullmatch(name).group(1)
|
|
237
|
+
stamps[backup_id] = _stamp(value)
|
|
238
|
+
try:
|
|
239
|
+
summary = self._verified(name, value)
|
|
240
|
+
except BackupError as error:
|
|
241
|
+
rejected.append({"id": backup_id, "error_code": error.code})
|
|
242
|
+
fingerprint.append([name, _stamp(value), error.code])
|
|
243
|
+
else:
|
|
244
|
+
backups.append(summary)
|
|
245
|
+
fingerprint.append([name, _stamp(value), summary["archive_sha256"]])
|
|
246
|
+
# A scan is a single directory observation. A concurrently created or
|
|
247
|
+
# replaced candidate invalidates it even when all individual checks pass.
|
|
248
|
+
after_entries, after_directory = self._entries()
|
|
249
|
+
if after_directory != directory_stamp or [
|
|
250
|
+
(name, _stamp(value)) for name, value in after_entries
|
|
251
|
+
] != [(name, _stamp(value)) for name, value in entries]:
|
|
252
|
+
raise _error(
|
|
253
|
+
"backup_catalog_changed",
|
|
254
|
+
"Backup catalog changed during verification. Try again.",
|
|
255
|
+
)
|
|
256
|
+
backups.sort(
|
|
257
|
+
key=lambda item: (datetime.fromisoformat(item["created_at"]), item["id"]),
|
|
258
|
+
reverse=True,
|
|
259
|
+
)
|
|
260
|
+
encoded = json.dumps(fingerprint, separators=(",", ":"), sort_keys=True)
|
|
261
|
+
return _Scan(
|
|
262
|
+
backups,
|
|
263
|
+
rejected,
|
|
264
|
+
hashlib.sha256(encoded.encode()).hexdigest(),
|
|
265
|
+
stamps,
|
|
266
|
+
directory_stamp,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def list_backups(
|
|
270
|
+
self, limit: int = 50, *, application_id: str | None = None
|
|
271
|
+
) -> dict[str, Any]:
|
|
272
|
+
limit = bounded_integer(limit, "limit", 100)
|
|
273
|
+
with self._lock:
|
|
274
|
+
scan = self._scan()
|
|
275
|
+
eligible = [
|
|
276
|
+
item
|
|
277
|
+
for item in scan.backups
|
|
278
|
+
if application_id is None or item["application_id"] == application_id
|
|
279
|
+
]
|
|
280
|
+
return {
|
|
281
|
+
"backups": eligible[:limit],
|
|
282
|
+
"count": len(eligible[:limit]),
|
|
283
|
+
"verified_count": len(eligible),
|
|
284
|
+
"rejected_count": len(scan.rejected),
|
|
285
|
+
"rejected": scan.rejected[:limit],
|
|
286
|
+
"limit": limit,
|
|
287
|
+
"destination": str(self.destination),
|
|
288
|
+
"destination_kind": "local",
|
|
289
|
+
"retention_keep": self.retention_keep,
|
|
290
|
+
"status": "ready"
|
|
291
|
+
if scan.directory_stamp is not None
|
|
292
|
+
else "destination_missing",
|
|
293
|
+
"guidance": GUIDANCE,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
def verify(
|
|
297
|
+
self, backup_id: str, *, application_id: str | None = None
|
|
298
|
+
) -> dict[str, Any]:
|
|
299
|
+
backup_id = validate_backup_id(backup_id)
|
|
300
|
+
with self._lock:
|
|
301
|
+
self._directory()
|
|
302
|
+
name = f"backup-{backup_id}.zip"
|
|
303
|
+
try:
|
|
304
|
+
before = (self.destination / name).lstat()
|
|
305
|
+
except FileNotFoundError:
|
|
306
|
+
raise _error(
|
|
307
|
+
"backup_missing",
|
|
308
|
+
"Backup is absent from the configured destination.",
|
|
309
|
+
) from None
|
|
310
|
+
except OSError:
|
|
311
|
+
raise _error(
|
|
312
|
+
"backup_destination", "Backup cannot be inspected."
|
|
313
|
+
) from None
|
|
314
|
+
summary = self._verified(name, before)
|
|
315
|
+
if (
|
|
316
|
+
application_id is not None
|
|
317
|
+
and summary["application_id"] != application_id
|
|
318
|
+
):
|
|
319
|
+
raise _error(
|
|
320
|
+
"backup_identity", "Backup belongs to a different application."
|
|
321
|
+
)
|
|
322
|
+
return summary
|
|
323
|
+
|
|
324
|
+
def retention_preview(
|
|
325
|
+
self, max_delete: int = 100, *, application_id: str
|
|
326
|
+
) -> dict[str, Any]:
|
|
327
|
+
max_delete = bounded_integer(max_delete, "max_delete", 100)
|
|
328
|
+
if not isinstance(application_id, str) or not application_id:
|
|
329
|
+
raise _error(
|
|
330
|
+
"backup_identity_missing",
|
|
331
|
+
"Retention requires a host-issued application identity.",
|
|
332
|
+
)
|
|
333
|
+
with self._lock:
|
|
334
|
+
scan = self._scan()
|
|
335
|
+
eligible = [
|
|
336
|
+
item
|
|
337
|
+
for item in scan.backups
|
|
338
|
+
if item["application_id"] == application_id
|
|
339
|
+
]
|
|
340
|
+
delete = list(reversed(eligible[self.retention_keep :]))[:max_delete]
|
|
341
|
+
self._plans = {
|
|
342
|
+
key: plan
|
|
343
|
+
for key, plan in self._plans.items()
|
|
344
|
+
if plan.expires > time.monotonic()
|
|
345
|
+
}
|
|
346
|
+
if len(self._plans) >= 10:
|
|
347
|
+
self._plans.pop(next(iter(self._plans)))
|
|
348
|
+
token = secrets.token_hex(32)
|
|
349
|
+
self._plans[token] = _Plan(
|
|
350
|
+
application_id,
|
|
351
|
+
scan.fingerprint,
|
|
352
|
+
delete,
|
|
353
|
+
time.monotonic() + PLAN_LIFETIME_SECONDS,
|
|
354
|
+
)
|
|
355
|
+
return {
|
|
356
|
+
"plan_token": token,
|
|
357
|
+
"expires_at": (
|
|
358
|
+
datetime.now(UTC) + timedelta(seconds=PLAN_LIFETIME_SECONDS)
|
|
359
|
+
)
|
|
360
|
+
.isoformat()
|
|
361
|
+
.replace("+00:00", "Z"),
|
|
362
|
+
"retention_keep": self.retention_keep,
|
|
363
|
+
"eligible_count": len(eligible),
|
|
364
|
+
"delete_count": len(delete),
|
|
365
|
+
"delete": delete,
|
|
366
|
+
"remaining_count": len(eligible) - len(delete),
|
|
367
|
+
"max_delete": max_delete,
|
|
368
|
+
"preserved_rejected_count": len(scan.rejected),
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
def apply_retention(
|
|
372
|
+
self, plan_token: str, *, application_id: str
|
|
373
|
+
) -> dict[str, Any]:
|
|
374
|
+
if not isinstance(plan_token, str) or not _PLAN_TOKEN.fullmatch(plan_token):
|
|
375
|
+
raise _error(
|
|
376
|
+
"backup_validation", "plan_token must be a retention preview token."
|
|
377
|
+
)
|
|
378
|
+
with self._lock:
|
|
379
|
+
plan = self._plans.pop(plan_token, None)
|
|
380
|
+
if plan is None or plan.expires <= time.monotonic():
|
|
381
|
+
raise _error(
|
|
382
|
+
"backup_retention_stale",
|
|
383
|
+
"Retention preview expired or was already used. Preview again.",
|
|
384
|
+
)
|
|
385
|
+
if plan.application_id != application_id:
|
|
386
|
+
raise _error(
|
|
387
|
+
"backup_identity",
|
|
388
|
+
"Retention preview belongs to a different application.",
|
|
389
|
+
)
|
|
390
|
+
scan = self._scan()
|
|
391
|
+
if scan.fingerprint != plan.fingerprint:
|
|
392
|
+
raise _error(
|
|
393
|
+
"backup_retention_stale",
|
|
394
|
+
"Backup catalog changed. Preview retention again.",
|
|
395
|
+
)
|
|
396
|
+
if not plan.delete:
|
|
397
|
+
return {"deleted_ids": [], "deleted_count": 0}
|
|
398
|
+
return self._delete_verified(plan, scan)
|
|
399
|
+
|
|
400
|
+
def _delete_verified(self, plan: _Plan, scan: _Scan) -> dict[str, Any]:
|
|
401
|
+
"""Move candidates into a private staging directory and recheck before unlink."""
|
|
402
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
403
|
+
try:
|
|
404
|
+
directory_fd = os.open(self.destination, flags)
|
|
405
|
+
except OSError:
|
|
406
|
+
raise _error(
|
|
407
|
+
"backup_retention_failed", "Backup destination cannot be opened safely."
|
|
408
|
+
) from None
|
|
409
|
+
staging_name = f".backup-retention-{secrets.token_hex(16)}"
|
|
410
|
+
staging_fd: int | None = None
|
|
411
|
+
moved: list[str] = []
|
|
412
|
+
deleted: list[str] = []
|
|
413
|
+
staged_stamps: dict[str, tuple[int, ...]] = {}
|
|
414
|
+
try:
|
|
415
|
+
opened = os.fstat(directory_fd)
|
|
416
|
+
if (opened.st_dev, opened.st_ino) != scan.directory_stamp:
|
|
417
|
+
raise _error(
|
|
418
|
+
"backup_retention_stale",
|
|
419
|
+
"Backup destination changed. Preview retention again.",
|
|
420
|
+
)
|
|
421
|
+
os.mkdir(staging_name, mode=0o700, dir_fd=directory_fd)
|
|
422
|
+
staging_fd = os.open(staging_name, flags, dir_fd=directory_fd)
|
|
423
|
+
for item in plan.delete:
|
|
424
|
+
name = f"backup-{item['id']}.zip"
|
|
425
|
+
value = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
426
|
+
if _stamp(value) != scan.stamps[item["id"]]:
|
|
427
|
+
raise _error(
|
|
428
|
+
"backup_retention_stale",
|
|
429
|
+
"Backup changed. Preview retention again.",
|
|
430
|
+
)
|
|
431
|
+
os.rename(name, name, src_dir_fd=directory_fd, dst_dir_fd=staging_fd)
|
|
432
|
+
moved.append(name)
|
|
433
|
+
before_verify = os.stat(name, dir_fd=staging_fd, follow_symlinks=False)
|
|
434
|
+
staged = self.destination / staging_name / name
|
|
435
|
+
verified = verify_backup(staged).summary()
|
|
436
|
+
current = os.stat(name, dir_fd=staging_fd, follow_symlinks=False)
|
|
437
|
+
if (
|
|
438
|
+
not _protected(current, directory=False)
|
|
439
|
+
or _stamp(current) != _stamp(before_verify)
|
|
440
|
+
or verified["id"] != item["id"]
|
|
441
|
+
or verified["application_id"] != plan.application_id
|
|
442
|
+
or verified["archive_sha256"] != item["archive_sha256"]
|
|
443
|
+
):
|
|
444
|
+
raise _error(
|
|
445
|
+
"backup_retention_stale",
|
|
446
|
+
"Backup changed. Preview retention again.",
|
|
447
|
+
)
|
|
448
|
+
staged_stamps[name] = _stamp(current)
|
|
449
|
+
# All staged archives passed the same verification as the preview.
|
|
450
|
+
# Unknown entries and rejected archives are never moved or removed.
|
|
451
|
+
for name in moved[:]:
|
|
452
|
+
current = os.stat(name, dir_fd=staging_fd, follow_symlinks=False)
|
|
453
|
+
if _stamp(current) != staged_stamps[name]:
|
|
454
|
+
raise _error(
|
|
455
|
+
"backup_retention_stale",
|
|
456
|
+
"Backup changed after verification. Preview retention again.",
|
|
457
|
+
)
|
|
458
|
+
os.unlink(name, dir_fd=staging_fd)
|
|
459
|
+
moved.remove(name)
|
|
460
|
+
deleted.append(_BACKUP_NAME.fullmatch(name).group(1))
|
|
461
|
+
except (OSError, BackupError) as error:
|
|
462
|
+
recovery_failed = False
|
|
463
|
+
for name in moved[:]:
|
|
464
|
+
try:
|
|
465
|
+
# link is exclusive: an externally created replacement at
|
|
466
|
+
# the original name is preserved rather than overwritten.
|
|
467
|
+
os.link(
|
|
468
|
+
name,
|
|
469
|
+
name,
|
|
470
|
+
src_dir_fd=staging_fd,
|
|
471
|
+
dst_dir_fd=directory_fd,
|
|
472
|
+
follow_symlinks=False,
|
|
473
|
+
)
|
|
474
|
+
os.unlink(name, dir_fd=staging_fd)
|
|
475
|
+
moved.remove(name)
|
|
476
|
+
except OSError:
|
|
477
|
+
recovery_failed = True
|
|
478
|
+
if recovery_failed:
|
|
479
|
+
raise _error(
|
|
480
|
+
"backup_retention_recovery",
|
|
481
|
+
"Retention stopped. Preserved archives remain in a protected .backup-retention directory for operator recovery.",
|
|
482
|
+
) from None
|
|
483
|
+
if isinstance(error, BackupError):
|
|
484
|
+
raise
|
|
485
|
+
raise _error(
|
|
486
|
+
"backup_retention_failed",
|
|
487
|
+
"Retention stopped because the destination changed or could not be updated.",
|
|
488
|
+
) from None
|
|
489
|
+
finally:
|
|
490
|
+
if staging_fd is not None:
|
|
491
|
+
os.close(staging_fd)
|
|
492
|
+
if not moved:
|
|
493
|
+
try:
|
|
494
|
+
os.rmdir(staging_name, dir_fd=directory_fd)
|
|
495
|
+
except OSError:
|
|
496
|
+
# Preserve a changed staging directory rather than
|
|
497
|
+
# recursively deleting an unexpected entry inside it.
|
|
498
|
+
pass
|
|
499
|
+
os.close(directory_fd)
|
|
500
|
+
return {"deleted_ids": deleted, "deleted_count": len(deleted)}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
Catalog = BackupCatalog
|
|
504
|
+
|
|
505
|
+
__all__ = [
|
|
506
|
+
"GUIDANCE",
|
|
507
|
+
"BackupCatalog",
|
|
508
|
+
"Catalog",
|
|
509
|
+
"bounded_integer",
|
|
510
|
+
"validate_backup_id",
|
|
511
|
+
]
|
toolframe_backup/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Operator CLI with nonsecret output and no dependency on a running host."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .archive import BackupError, create_backup, restore_backup, verify_backup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parser() -> argparse.ArgumentParser:
|
|
13
|
+
result = argparse.ArgumentParser(prog="toolframe-backup")
|
|
14
|
+
result.add_argument("--version", action="version", version=__version__)
|
|
15
|
+
commands = result.add_subparsers(dest="command", required=True)
|
|
16
|
+
create = commands.add_parser(
|
|
17
|
+
"create", help="Back up a cleanly stopped local application"
|
|
18
|
+
)
|
|
19
|
+
create.add_argument("--home", type=Path, required=True)
|
|
20
|
+
create.add_argument("--directory", type=Path, required=True)
|
|
21
|
+
verify = commands.add_parser(
|
|
22
|
+
"verify", help="Validate an archive without extracting"
|
|
23
|
+
)
|
|
24
|
+
verify.add_argument("archive", type=Path)
|
|
25
|
+
restore = commands.add_parser(
|
|
26
|
+
"restore", help="Validate and restore into a new application home"
|
|
27
|
+
)
|
|
28
|
+
restore.add_argument("archive", type=Path)
|
|
29
|
+
restore.add_argument("--destination", type=Path, required=True)
|
|
30
|
+
restore.add_argument("--uv-path", default="uv")
|
|
31
|
+
restore.add_argument("--wheelhouse", type=Path)
|
|
32
|
+
restore.add_argument(
|
|
33
|
+
"--offline",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Reconstruct dependencies from available local assets only",
|
|
36
|
+
)
|
|
37
|
+
listing = commands.add_parser(
|
|
38
|
+
"list", help="Scan a protected local backup directory"
|
|
39
|
+
)
|
|
40
|
+
listing.add_argument("--directory", type=Path, required=True)
|
|
41
|
+
listing.add_argument("--limit", type=int, default=50)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv: list[str] | None = None) -> int:
|
|
46
|
+
arguments = parser().parse_args(argv)
|
|
47
|
+
try:
|
|
48
|
+
if arguments.command == "create":
|
|
49
|
+
result = create_backup(arguments.home, arguments.directory)
|
|
50
|
+
elif arguments.command == "verify":
|
|
51
|
+
result = verify_backup(arguments.archive).summary()
|
|
52
|
+
elif arguments.command == "restore":
|
|
53
|
+
result = restore_backup(
|
|
54
|
+
arguments.archive,
|
|
55
|
+
arguments.destination,
|
|
56
|
+
uv_path=arguments.uv_path,
|
|
57
|
+
wheelhouse=arguments.wheelhouse,
|
|
58
|
+
offline=arguments.offline,
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
from .catalog import Catalog
|
|
62
|
+
|
|
63
|
+
result = Catalog(arguments.directory).list_backups(arguments.limit)
|
|
64
|
+
print(json.dumps(result, sort_keys=True))
|
|
65
|
+
return 0
|
|
66
|
+
except BackupError as error:
|
|
67
|
+
print(
|
|
68
|
+
json.dumps({"error": error.code, "message": error.message}), file=sys.stderr
|
|
69
|
+
)
|
|
70
|
+
return 2
|
|
71
|
+
except KeyboardInterrupt:
|
|
72
|
+
print(
|
|
73
|
+
json.dumps(
|
|
74
|
+
{"error": "interrupted", "message": "Backup operation interrupted."}
|
|
75
|
+
),
|
|
76
|
+
file=sys.stderr,
|
|
77
|
+
)
|
|
78
|
+
return 130
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Backup catalog and recovery workflow
|
|
2
|
+
|
|
3
|
+
Backup catalogs local recovery archives created by the `toolframe-backup`
|
|
4
|
+
operator CLI. Its Dashboard and read-only MCP tool inspect archives without
|
|
5
|
+
copying a running application's files. The source application must be cleanly
|
|
6
|
+
stopped while the CLI captures its public maintenance inventory.
|
|
7
|
+
|
|
8
|
+
Install the plugin package in the application's environment and configure a
|
|
9
|
+
fixed, absolute local backup directory through the application's plugin
|
|
10
|
+
configuration:
|
|
11
|
+
|
|
12
|
+
```toml
|
|
13
|
+
[plugins.backup.config]
|
|
14
|
+
destination = "/srv/toolframe-backups"
|
|
15
|
+
retention_keep = 10
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The destination must belong to the operator, have mode `0700`, and have no
|
|
19
|
+
symlink components. Archive files must belong to the operator and have no group
|
|
20
|
+
or other permissions; the CLI creates them with mode `0600`. The plugin permits
|
|
21
|
+
a missing destination so the stopped CLI can create it. It never accepts paths
|
|
22
|
+
or arbitrary filesystem actions from Dashboard, MCP, or RPC clients. Only local
|
|
23
|
+
filesystem destinations are supported; S3 and other external destinations are
|
|
24
|
+
unimplemented.
|
|
25
|
+
|
|
26
|
+
To capture an application, stop it using its ordinary shutdown flow, then run:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
toolframe-backup create --home /srv/toolframe-app --directory /srv/toolframe-backups
|
|
30
|
+
toolframe-backup list --directory /srv/toolframe-backups
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The operator installation needs the `operator` extra for creation. Verification,
|
|
34
|
+
listing, and restoration work without a running host. Restart the source
|
|
35
|
+
application to inspect the catalog. Read the package README for the complete
|
|
36
|
+
creation and restoration support limits.
|
|
37
|
+
|
|
38
|
+
The destination directory is the catalog's source of truth. No catalog file is
|
|
39
|
+
written into the source application by the offline CLI, and the runtime plugin
|
|
40
|
+
does not require a storage, Jobs, or Audit provider. Each scan fully verifies
|
|
41
|
+
the own-format files named `backup-<32 lowercase hexadecimal characters>.zip`
|
|
42
|
+
before showing them. The `last_verified_at` timestamp reports that scan's
|
|
43
|
+
verification, rather than a cached assertion. When the host supplies its
|
|
44
|
+
application identity, catalog results and individual verification are restricted
|
|
45
|
+
to that application. Invalid candidates are reported by ID and error code and
|
|
46
|
+
preserved. Unknown filenames are ignored and preserved.
|
|
47
|
+
|
|
48
|
+
Scans are bounded to 1,000 archive candidates, 10,000 directory entries, and a
|
|
49
|
+
4 GiB total archive scan budget. Each archive also has the library's file-count,
|
|
50
|
+
compressed-size, payload-size, and decompression limits. The read interface shows
|
|
51
|
+
at most 100 verified backups, newest first, with a default limit of 50. Split
|
|
52
|
+
larger catalogs into separate operator-managed directories before scanning.
|
|
53
|
+
|
|
54
|
+
The public `backup.catalog` service is version 1:
|
|
55
|
+
|
|
56
|
+
| Method | Scope | Behavior |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| `list_backups(limit=50)` | `backup.read` | Scan and list at most 100 verified archives; read-only MCP uses this method. |
|
|
59
|
+
| `verify_backup(backup_id)` | `system.plugins.manage` | Recheck one validated ID in the configured destination. |
|
|
60
|
+
| `retention_preview(max_delete=100)` | `system.plugins.manage` | Preview deletion of at most 100 oldest verified archives beyond the configured keep count. |
|
|
61
|
+
| `apply_retention(plan_token)` | `system.plugins.manage` | Reverify and apply the unchanged, unexpired preview once. |
|
|
62
|
+
|
|
63
|
+
`backup.read` is a business scope. Verification and retention require the
|
|
64
|
+
existing **Manage plugins** operator permission, `system.plugins.manage`; an
|
|
65
|
+
all-business grant does not permit these actions. The Dashboard requires
|
|
66
|
+
`backup.read` and hides management controls without `system.plugins.manage`.
|
|
67
|
+
All service methods require scoped calls and are
|
|
68
|
+
excluded from anonymous public API routes. MCP exposes only listing.
|
|
69
|
+
|
|
70
|
+
Retention is explicit, never automatic. `retention_keep` defaults to 10 and is
|
|
71
|
+
bounded to 1–1,000. A preview requires the host-issued application identity,
|
|
72
|
+
keeps the newest matching verified archives, and selects the oldest excess
|
|
73
|
+
archives up to `max_delete` (1–100). Unknown entries, rejected candidates, and
|
|
74
|
+
archives for other applications remain untouched. Preview tokens live in
|
|
75
|
+
process memory for ten minutes and can be used once; restarting the plugin
|
|
76
|
+
invalidates them. Catalog changes invalidate a preview. Confirmation moves
|
|
77
|
+
selected files into a protected temporary directory and verifies them again
|
|
78
|
+
before deleting them. If a destination change prevents safe recovery, preserved
|
|
79
|
+
files may remain under `.backup-retention-*` for operator recovery; these entries
|
|
80
|
+
are never adopted by the catalog or automatically deleted.
|
|
81
|
+
|
|
82
|
+
Archives are **unencrypted** and can contain credentials and retained key
|
|
83
|
+
payloads. Protect the destination and archive permissions. Public catalog
|
|
84
|
+
summaries contain identity, composition revision, counts, timestamps, and
|
|
85
|
+
checksums, never payload contents or dependency credential URLs. SHA-256 checks
|
|
86
|
+
detect corruption; an archive is not signed or authenticated. Restoration
|
|
87
|
+
preserves the original application identity for recovery, so do not concurrently
|
|
88
|
+
run a restored clone with the same external identity.
|