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
@@ -37,6 +37,11 @@ pip install .
|
||||
|
||||
## Usage
|
||||
|
||||
cura-te-ipsum has two subcommands, `backup` and `restore`. For backwards
|
||||
compatibility, `backup` is implied when the first argument isn't a
|
||||
recognized subcommand - so `cura-te-ipsum -b DIR SOURCE` and
|
||||
`cura-te-ipsum backup -b DIR SOURCE` are equivalent.
|
||||
|
||||
### Basic Backup
|
||||
|
||||
```bash
|
||||
@@ -85,6 +90,53 @@ cura-te-ipsum -b /backups /home/user/data --verbose
|
||||
cura-te-ipsum -b /backups /home/user/data --external-rsync
|
||||
```
|
||||
|
||||
### Restoring a Backup
|
||||
|
||||
```
|
||||
cura-te-ipsum restore -b BACKUPS_DIR DEST [PATH ...]
|
||||
|
||||
Required Arguments:
|
||||
-b BACKUPS_DIR Directory where backups are stored
|
||||
DEST Directory to restore into (created if missing)
|
||||
|
||||
Optional Arguments:
|
||||
PATH Restore only these snapshot-relative paths - each
|
||||
starts with the source directory's basename (e.g.
|
||||
"data/documents/notes.txt" for a source backed up
|
||||
from /home/user/data); default is everything
|
||||
--snapshot NAME Snapshot to restore, e.g. 20260101_120000
|
||||
(default: the latest snapshot)
|
||||
--overwrite {never,always}
|
||||
What to do when a destination path already exists
|
||||
(default: never - existing paths are left alone
|
||||
and reported as skipped)
|
||||
--verify Verify restored content against the snapshot's
|
||||
manifest checksums afterwards
|
||||
-n, --dry-run Show what would be restored without copying
|
||||
anything or creating DEST
|
||||
-f, --force Wait for a running backup/restore instead of
|
||||
exiting immediately
|
||||
-v, --verbose Enable debug logging
|
||||
```
|
||||
|
||||
Restoring the latest snapshot in full:
|
||||
```bash
|
||||
cura-te-ipsum restore -b /backups /tmp/recovered
|
||||
```
|
||||
|
||||
Restoring one file from a specific snapshot, verifying it afterwards
|
||||
(here the source was backed up from `/home/user/data`, so the path
|
||||
starts with `data/`):
|
||||
```bash
|
||||
cura-te-ipsum restore -b /backups --snapshot 20260101_120000 --verify \
|
||||
/tmp/recovered data/documents/notes.txt
|
||||
```
|
||||
|
||||
A restore takes the same `.backups_lock` a backup does, so retention
|
||||
cleanup can never delete a snapshot out from under a restore in progress.
|
||||
Restored files keep the source's mode, ownership (when running as root),
|
||||
and timestamps; symlinks are recreated as symlinks, never followed.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Hardlink-Based Snapshots
|
||||
@@ -139,10 +191,10 @@ backups/
|
||||
truncating a file in one snapshot changes that same file's content
|
||||
in every other snapshot sharing its inode. Treat every snapshot
|
||||
directory as read-only; copy files out before editing them.
|
||||
- **No dedicated restore command.** Recovering data means manually
|
||||
copying files out of a snapshot directory - there is no restore
|
||||
workflow with dry-run, partial restore, or overwrite-policy support
|
||||
yet.
|
||||
- **Restore preserves data content and standard metadata only.** The
|
||||
`restore` command copies file content, mode, ownership, timestamps,
|
||||
and symlinks; it does not preserve ACLs, extended attributes, or
|
||||
filesystem-specific flags.
|
||||
- **Special filesystem entries (FIFOs, sockets, device nodes) are
|
||||
excluded, not backed up** - but only with the default Python
|
||||
implementation, which records them in the snapshot manifest as
|
||||
|
||||
Reference in new issue
Block a user