Implement a restore command (#13)
* Implement restore command Add a restore subcommand alongside the existing backup command: - curateipsum/restore.py: restore_snapshot() and verify_restored(), reusing fs.scantree/copy_direntry/rm_direntry so metadata handling (mode, ownership, timestamps, symlinks) matches backup exactly. - backup.find_backup(): resolve a snapshot by name or latest. - cli.py: argparse subcommands (backup/restore), with backup implied when no subcommand is given so existing invocations keep working. Restore supports dry-run (creates nothing), partial restore by snapshot-relative path, an overwrite policy (never/always), a destination-inside-snapshot boundary check, and manifest-checksum verification of restored content. It takes the same backups_lock a backup does, since retention could otherwise delete the snapshot being restored out from under it. Closes P1 'Provide a safe restoration workflow' in IMPROVEMENTS.md. * Address Copilot review: symlink-escape boundary check, docstring fix - restore_snapshot() and verify_restored() now canonicalize each destination path and refuse to operate on it if it resolves outside dest_dir - a symlink planted under dest_dir (by another process, or left over from a prior run) could otherwise redirect a write, delete, or read outside the intended destination. - Fixed restore_snapshot()'s docstring: the skipped-paths list reports what would be skipped on dry-run too, it isn't always empty. * Address Copilot suppressed findings: stream plan_restore, restore-specific lock message - plan_restore() is now a generator instead of building a full list in memory, matching fs.scantree()'s streaming style everywhere else in the codebase. - restore CLI logs a restore-specific message on lock-acquire failure, since set_backups_lock()'s own log line talks about a 'previous backup', which read wrong for a restore. * Fix PATH docs: snapshot-relative means source-basename-prefixed README and CLI help examples for restore's PATH argument omitted the source directory's basename, which backup.initiate_backup() always prefixes onto snapshot-relative paths - the given examples would have matched nothing in an actual snapshot. --------- Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
This commit is contained in:
1 parent
248204fe41
commit
33dcbe6c58
7 files changed
+799
-41
No files matched your search
@@ -338,6 +338,29 @@ def _date_from_backup(backup_entry: os.DirEntry) -> datetime:
|
||||
return datetime.strptime(backup_entry.name, BACKUP_ENT_FMT)
|
||||
|
||||
|
||||
def find_backup(backups_dir: str,
|
||||
snapshot_name: Optional[str] = None) -> os.DirEntry:
|
||||
"""
|
||||
Return a specific snapshot by name, or the latest one if
|
||||
snapshot_name is None.
|
||||
|
||||
:raises BackupFailedError: if snapshot_name doesn't match any backup
|
||||
in backups_dir, or no backups exist at all.
|
||||
"""
|
||||
if snapshot_name is None:
|
||||
latest = _get_latest_backup(backups_dir)
|
||||
if latest is None:
|
||||
raise BackupFailedError("No backups found in %s" % backups_dir)
|
||||
return latest
|
||||
|
||||
for entry in _iterate_backups(backups_dir):
|
||||
if entry.name == snapshot_name:
|
||||
return entry
|
||||
raise BackupFailedError(
|
||||
"No such backup %r in %s" % (snapshot_name, backups_dir)
|
||||
)
|
||||
|
||||
|
||||
# Open lock file descriptors currently held by this process, keyed by the
|
||||
# absolute path of the backups directory they lock. The kernel associates
|
||||
# an flock() with this exact open file description, so the descriptor must
|
||||
|
||||
Reference in new issue
Block a user