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
@@ -586,3 +586,108 @@ class TestBackupExecution:
|
||||
assert any("Finished" in record.message for record in caplog.records)
|
||||
assert any("time spent" in record.message
|
||||
for record in caplog.records)
|
||||
|
||||
|
||||
class TestRestoreSubcommand:
|
||||
"""End-to-end tests for `cura-te-ipsum restore`, against a real
|
||||
snapshot on disk - restore's file operations are cheap enough that
|
||||
mocking them out would just be testing the mocks."""
|
||||
|
||||
def _make_backup(self, backups_dir, source):
|
||||
from curateipsum import backup as bk
|
||||
(source / "file1.txt").write_text("content1")
|
||||
entry = bk.initiate_backup(sources=[str(source)],
|
||||
backups_dir=str(backups_dir))
|
||||
assert entry is not None
|
||||
return entry
|
||||
|
||||
def test_restore_copies_snapshot_to_dest(self, tmp_path):
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
source = tmp_path / "src"
|
||||
source.mkdir()
|
||||
self._make_backup(backups_dir, source)
|
||||
|
||||
dest = tmp_path / "dest"
|
||||
with mock.patch('sys.argv', ['cura-te-ipsum', 'restore', '-b',
|
||||
str(backups_dir), str(dest)]):
|
||||
result = cli.main()
|
||||
|
||||
assert result == 0
|
||||
restored = dest / "src" / "file1.txt"
|
||||
assert restored.read_text() == "content1"
|
||||
|
||||
def test_restore_unknown_backups_dir_fails(self, tmp_path):
|
||||
dest = tmp_path / "dest"
|
||||
with mock.patch('sys.argv', ['cura-te-ipsum', 'restore', '-b',
|
||||
str(tmp_path / "nope"), str(dest)]):
|
||||
result = cli.main()
|
||||
|
||||
assert result == 1
|
||||
|
||||
def test_restore_unknown_snapshot_fails(self, tmp_path):
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
source = tmp_path / "src"
|
||||
source.mkdir()
|
||||
self._make_backup(backups_dir, source)
|
||||
|
||||
dest = tmp_path / "dest"
|
||||
with mock.patch('sys.argv', [
|
||||
'cura-te-ipsum', 'restore', '-b', str(backups_dir),
|
||||
'--snapshot', '20000101_000000', str(dest)]):
|
||||
result = cli.main()
|
||||
|
||||
assert result == 1
|
||||
|
||||
def test_restore_dry_run_creates_nothing(self, tmp_path):
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
source = tmp_path / "src"
|
||||
source.mkdir()
|
||||
self._make_backup(backups_dir, source)
|
||||
|
||||
dest = tmp_path / "dest"
|
||||
with mock.patch('sys.argv', ['cura-te-ipsum', 'restore', '-b',
|
||||
str(backups_dir), '-n', str(dest)]):
|
||||
result = cli.main()
|
||||
|
||||
assert result == 0
|
||||
assert not dest.exists()
|
||||
|
||||
def test_restore_verify_passes_after_clean_restore(self, tmp_path):
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
source = tmp_path / "src"
|
||||
source.mkdir()
|
||||
self._make_backup(backups_dir, source)
|
||||
|
||||
dest = tmp_path / "dest"
|
||||
with mock.patch('sys.argv', [
|
||||
'cura-te-ipsum', 'restore', '-b', str(backups_dir),
|
||||
'--verify', str(dest)]):
|
||||
result = cli.main()
|
||||
|
||||
assert result == 0
|
||||
|
||||
def test_backup_subcommand_still_explicit(self, tmp_path):
|
||||
"""`cura-te-ipsum backup -b ... SRC` works the same as the
|
||||
implicit form."""
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
source = tmp_path / "src"
|
||||
source.mkdir()
|
||||
|
||||
with mock.patch('sys.argv', ['cura-te-ipsum', 'backup', '-b',
|
||||
str(backups_dir), str(source)]):
|
||||
with mock.patch('curateipsum.backup.set_backups_lock',
|
||||
return_value=True):
|
||||
with mock.patch('curateipsum.backup.cleanup_old_backups'):
|
||||
with mock.patch(
|
||||
'curateipsum.backup.initiate_backup') as m_init:
|
||||
with mock.patch(
|
||||
'curateipsum.backup.release_backups_lock'):
|
||||
result = cli.main()
|
||||
|
||||
assert result == 0
|
||||
assert m_init.call_args.kwargs['sources'] == [str(source)]
|
||||
Reference in new issue
Block a user