Make dry-run genuinely non-mutating (#23)
* Make dry-run genuinely non-mutating fs.rsync() now takes a real dry_run code path: every mutating call (copy, delete, chmod, chown, utime) is skipped, and a missing dst_dir is never created - the same comparison logic still runs so the yielded actions match what a real run would do. backup.initiate_backup() diffs each source directly against the latest completed snapshot on dry-run, instead of building and discarding a throwaway staging directory with real hardlinks. cleanup_old_backups() no longer creates the repo-id file as a side effect of a dry-run. cli.py skips lock acquisition entirely on dry-run (set_backups_lock() writes PID/timestamp metadata, itself a mutation) and exposes the retention policy via --keep-all/--keep-daily/--keep-weekly/ --keep-monthly/--keep-yearly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Address Copilot review: guard restore lock release, reject negative retention values - Guard restore --dry-run's finally block the same way backup's is guarded, for symmetry (release_backups_lock() was already a safe no-op when unheld, but this makes the dry-run contract explicit rather than relying on that implementation detail). - Reject negative --keep-* values via a dedicated argparse type: a negative threshold shifts retention comparisons into the future and can make cleanup treat every existing backup as deletable. - Add a restore --dry-run lock-skip regression test, mirroring the existing backup one. - Fix _snapshot_all_stats() in the new dry-run non-mutation test to also record the backups_dir root's own stat, not just its children. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
8 files changed
+478
-51
No files matched your search
+64
-12
@@ -637,14 +637,20 @@ def cleanup_old_backups(backups_dir: str,
|
||||
)
|
||||
|
||||
backups_dir_abs = _canonical(backups_dir)
|
||||
repo_id = _ensure_repo_id(backups_dir)
|
||||
|
||||
if dry_run:
|
||||
# _ensure_repo_id() would create the repo-id file on first use -
|
||||
# a mutation dry-run must never perform - and it isn't needed
|
||||
# for anything below, since nothing is actually deleted here.
|
||||
for backup in all_backups:
|
||||
if backup not in keep:
|
||||
_lg.info("Would remove old backup %s", backup.name)
|
||||
return
|
||||
|
||||
repo_id = _ensure_repo_id(backups_dir)
|
||||
for backup in all_backups:
|
||||
if backup in keep:
|
||||
continue
|
||||
if dry_run:
|
||||
_lg.info("Would remove old backup %s", backup.name)
|
||||
continue
|
||||
_quarantine_and_delete(backup, backups_dir_abs, repo_id)
|
||||
|
||||
|
||||
@@ -750,6 +756,50 @@ def process_backed_entry(backup_dir: str,
|
||||
dst_dir=os.path.join(backup_dir, DELTA_DIR))
|
||||
|
||||
|
||||
def _plan_backup(sources, backups_dir: str,
|
||||
latest_backup: Optional[os.DirEntry],
|
||||
rsync_func) -> None:
|
||||
"""
|
||||
Print the operations a real run would perform, without creating,
|
||||
deleting, or modifying anything: no staging directory, no lock,
|
||||
no hardlinks, no metadata updates. Each source is diffed directly
|
||||
against its counterpart in the latest completed snapshot (or
|
||||
reported as entirely new, if there is no latest snapshot, or this
|
||||
source wasn't part of it) - never against a throwaway copy.
|
||||
"""
|
||||
# A placeholder path that is guaranteed not to exist, used as the
|
||||
# comparison target for a source with nothing to diff against yet.
|
||||
# rsync_func(..., dry_run=True) treats a missing dst as "everything
|
||||
# in src is new" and never creates it.
|
||||
no_prior_copy = os.path.join(
|
||||
backups_dir, "%sdry-run-%s" % (STAGING_PREFIX, uuid.uuid4().hex)
|
||||
)
|
||||
|
||||
for src in sources:
|
||||
src_abs = os.path.abspath(src)
|
||||
src_name = os.path.basename(src_abs)
|
||||
if latest_backup is not None:
|
||||
dst_abs = os.path.join(latest_backup.path, src_name)
|
||||
else:
|
||||
dst_abs = os.path.join(no_prior_copy, src_name)
|
||||
|
||||
_lg.info("Dry-run: comparing %s against %s", src_abs, dst_abs)
|
||||
for entry_relpath, action, msg in rsync_func(
|
||||
src_abs, dst_abs, dry_run=True
|
||||
):
|
||||
full_relpath = os.path.join(src_name, entry_relpath)
|
||||
if action == fs.Actions.ERROR:
|
||||
_lg.error("Would fail to back up %s: %s",
|
||||
full_relpath, msg)
|
||||
continue
|
||||
if action == fs.Actions.EXCLUDE:
|
||||
_lg.info("Would exclude %s (%s)", full_relpath, msg)
|
||||
continue
|
||||
if action == fs.Actions.NOTHING:
|
||||
continue
|
||||
_lg.info("Would %s: %s", action.name.lower(), full_relpath)
|
||||
|
||||
|
||||
def initiate_backup(sources,
|
||||
backups_dir: str,
|
||||
dry_run: bool = False,
|
||||
@@ -765,6 +815,10 @@ def initiate_backup(sources,
|
||||
never be mistaken for a successful one. On success, the staging
|
||||
directory is fsync'd and atomically renamed into its final snapshot
|
||||
name.
|
||||
|
||||
With dry_run=True, this only plans and prints proposed operations -
|
||||
see _plan_backup(). No staging directory, hardlink, or file is ever
|
||||
created, and no existing file is touched.
|
||||
:param sources: list of directories to backup (relative paths ok)
|
||||
:param backups_dir: directory where all backups are stored
|
||||
:param dry_run: if True, no actual changes will be made
|
||||
@@ -774,6 +828,11 @@ def initiate_backup(sources,
|
||||
snapshot was created (dry-run or no changes since last backup).
|
||||
:raises BackupFailedError: if the backup could not be completed.
|
||||
"""
|
||||
if dry_run:
|
||||
_plan_backup(sources, backups_dir, _get_latest_backup(backups_dir),
|
||||
fs.rsync_ext if external_rsync else fs.rsync)
|
||||
return None
|
||||
|
||||
cleanup_stale_staging_dirs(backups_dir)
|
||||
|
||||
snapshot_name = _reserve_snapshot_name(backups_dir)
|
||||
@@ -840,9 +899,7 @@ def initiate_backup(sources,
|
||||
dst_abs = os.path.join(staging.path, src_name)
|
||||
_lg.info("Backing up directory %s to staging for %s",
|
||||
src_abs, snapshot_name)
|
||||
for entry_relpath, action, msg in rsync_func(
|
||||
src_abs, dst_abs, dry_run=dry_run
|
||||
):
|
||||
for entry_relpath, action, msg in rsync_func(src_abs, dst_abs):
|
||||
if action == fs.Actions.ERROR:
|
||||
raise BackupFailedError(
|
||||
"Failed to copy %s: %s" % (entry_relpath, msg)
|
||||
@@ -889,11 +946,6 @@ def initiate_backup(sources,
|
||||
shutil.rmtree(staging.path, ignore_errors=True)
|
||||
raise
|
||||
|
||||
# do not create backup on dry-run
|
||||
if dry_run:
|
||||
_lg.info("Dry-run, removing staging directory: %s", snapshot_name)
|
||||
shutil.rmtree(staging.path, ignore_errors=True)
|
||||
return None
|
||||
# do not create backup if no change from previous one - but a change
|
||||
# in *which* entries are excluded is itself a change worth keeping,
|
||||
# or the new exclusions would never make it into a written manifest.
|
||||
|
||||
+71
-6
@@ -29,6 +29,20 @@ def _normalize_argv(argv):
|
||||
return ["backup", *argv]
|
||||
|
||||
|
||||
def _nonneg_int(value: str) -> int:
|
||||
"""
|
||||
argparse type for --keep-* options: a negative threshold shifts
|
||||
retention comparisons into the future, which can make cleanup treat
|
||||
every existing backup as eligible for deletion.
|
||||
"""
|
||||
parsed = int(value)
|
||||
if parsed < 0:
|
||||
raise argparse.ArgumentTypeError(
|
||||
"must be zero or a positive integer, got %r" % value
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="cura-te-ipsum",
|
||||
@@ -72,6 +86,40 @@ def _build_parser():
|
||||
default=False,
|
||||
help="Use cp command for creating hardlink "
|
||||
"copies")
|
||||
backup_parser.add_argument("--keep-all",
|
||||
type=_nonneg_int,
|
||||
default=7,
|
||||
metavar="DAYS",
|
||||
help="keep every backup up to this many days "
|
||||
"in the past (default: %(default)s)")
|
||||
backup_parser.add_argument("--keep-daily",
|
||||
type=_nonneg_int,
|
||||
default=30,
|
||||
metavar="DAYS",
|
||||
help="keep one backup per day up to this "
|
||||
"many days in the past "
|
||||
"(default: %(default)s)")
|
||||
backup_parser.add_argument("--keep-weekly",
|
||||
type=_nonneg_int,
|
||||
default=52,
|
||||
metavar="WEEKS",
|
||||
help="keep one backup per week up to this "
|
||||
"many weeks in the past "
|
||||
"(default: %(default)s)")
|
||||
backup_parser.add_argument("--keep-monthly",
|
||||
type=_nonneg_int,
|
||||
default=12,
|
||||
metavar="MONTHS",
|
||||
help="keep one backup per month up to this "
|
||||
"many months in the past "
|
||||
"(default: %(default)s)")
|
||||
backup_parser.add_argument("--keep-yearly",
|
||||
type=_nonneg_int,
|
||||
default=5,
|
||||
metavar="YEARS",
|
||||
help="keep one backup per year up to this "
|
||||
"many years in the past "
|
||||
"(default: %(default)s)")
|
||||
backup_parser.add_argument("sources",
|
||||
nargs="+",
|
||||
metavar="SOURCE",
|
||||
@@ -143,7 +191,11 @@ def _run_backup(args) -> int:
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
if not backup.set_backups_lock(backups_dir_abs, args.force):
|
||||
# A dry-run never writes anything, including lock metadata, so it
|
||||
# doesn't need to acquire the lock at all - only a real run has to
|
||||
# serialize against other backups/restores.
|
||||
if not args.dry_run and not backup.set_backups_lock(
|
||||
backups_dir_abs, args.force):
|
||||
return 1
|
||||
|
||||
exit_code = 0
|
||||
@@ -160,13 +212,21 @@ def _run_backup(args) -> int:
|
||||
external_rsync=args.external_rsync,
|
||||
external_hardlink=args.external_hardlink,
|
||||
)
|
||||
backup.cleanup_old_backups(backups_dir=backups_dir_abs,
|
||||
dry_run=args.dry_run)
|
||||
backup.cleanup_old_backups(
|
||||
backups_dir=backups_dir_abs,
|
||||
dry_run=args.dry_run,
|
||||
keep_all=args.keep_all,
|
||||
keep_daily=args.keep_daily,
|
||||
keep_weekly=args.keep_weekly,
|
||||
keep_monthly=args.keep_monthly,
|
||||
keep_yearly=args.keep_yearly,
|
||||
)
|
||||
except backup.BackupFailedError as err:
|
||||
_lg.error("Backup failed: %s", err)
|
||||
exit_code = 1
|
||||
finally:
|
||||
backup.release_backups_lock(backups_dir_abs)
|
||||
if not args.dry_run:
|
||||
backup.release_backups_lock(backups_dir_abs)
|
||||
|
||||
end_time = time.time()
|
||||
spent_time = end_time - start_time
|
||||
@@ -189,7 +249,11 @@ def _run_restore(args) -> int:
|
||||
# while it's still in progress. set_backups_lock() already logs why
|
||||
# (e.g. "Previous backup is still in progress") - add restore-specific
|
||||
# context here so a restore failure doesn't read like a backup one.
|
||||
if not backup.set_backups_lock(backups_dir_abs, args.force):
|
||||
# A dry-run never writes to dest_dir, so it doesn't need this
|
||||
# protection - nothing it reads can be invalidated by a concurrent
|
||||
# writer in a way that produces anything worse than a stale preview.
|
||||
if not args.dry_run and not backup.set_backups_lock(
|
||||
backups_dir_abs, args.force):
|
||||
_lg.error("Could not acquire the backups lock, restore aborted "
|
||||
"(use --force to wait for it instead)")
|
||||
return 1
|
||||
@@ -243,7 +307,8 @@ def _run_restore(args) -> int:
|
||||
else:
|
||||
_lg.info("Verified: restored content matches the manifest")
|
||||
finally:
|
||||
backup.release_backups_lock(backups_dir_abs)
|
||||
if not args.dry_run:
|
||||
backup.release_backups_lock(backups_dir_abs)
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
+64
-21
@@ -439,6 +439,18 @@ def update_direntry(src_entry: os.DirEntry,
|
||||
return copy_direntry(src_entry, dst_entry.path)
|
||||
|
||||
|
||||
def _maybe_update(src_entry: os.DirEntry, dst_entry: os.DirEntry,
|
||||
dry_run: bool) -> Optional[str]:
|
||||
"""
|
||||
update_direntry(), unless dry_run - in which case dst_entry is left
|
||||
untouched and no digest is available (nothing was actually copied to
|
||||
hash).
|
||||
"""
|
||||
if dry_run:
|
||||
return None
|
||||
return update_direntry(src_entry, dst_entry)
|
||||
|
||||
|
||||
def rsync(src_dir,
|
||||
dst_dir,
|
||||
dry_run=False) -> Iterable[Tuple[str, Actions, str]]:
|
||||
@@ -447,6 +459,14 @@ def rsync(src_dir,
|
||||
Yield (path, action, error message) tuples.
|
||||
Entries in dst_dir will be removed if not present in src_dir.
|
||||
Analog of 'rsync --delete -irltpog'.
|
||||
|
||||
With dry_run=True, dst_dir is never created, written to, or mutated
|
||||
in any way (no copy, delete, chmod, chown, or utime call runs) - the
|
||||
same comparison logic that decides each action still runs, so the
|
||||
yielded (path, action, message) tuples describe exactly what a real
|
||||
run would do, but purely by reading src_dir and dst_dir. This lets a
|
||||
dry run diff straight against the previous, immutable snapshot
|
||||
instead of a throwaway hardlinked copy.
|
||||
"""
|
||||
|
||||
_lg.debug("Rsync: %s -> %s", src_dir, dst_dir)
|
||||
@@ -457,13 +477,18 @@ def rsync(src_dir,
|
||||
raise BackupCreationError(
|
||||
"Error during reading source directory: %s" % src_root_abs
|
||||
)
|
||||
if os.path.exists(dst_root_abs):
|
||||
if not os.path.isdir(dst_root_abs):
|
||||
raise BackupCreationError(
|
||||
"Destination path is not a directory: %s" % dst_root_abs
|
||||
)
|
||||
else:
|
||||
os.mkdir(dst_root_abs)
|
||||
dst_exists = os.path.exists(dst_root_abs)
|
||||
if dst_exists and not os.path.isdir(dst_root_abs):
|
||||
raise BackupCreationError(
|
||||
"Destination path is not a directory: %s" % dst_root_abs
|
||||
)
|
||||
if not dst_exists:
|
||||
if dry_run:
|
||||
# Nothing to compare against and nothing to create - every
|
||||
# source entry below is naturally reported as a CREATE.
|
||||
dst_root_abs = None
|
||||
else:
|
||||
os.mkdir(dst_root_abs)
|
||||
|
||||
# Create source map {rel_path: dir_entry}
|
||||
src_files_map = {
|
||||
@@ -472,7 +497,8 @@ def rsync(src_dir,
|
||||
}
|
||||
|
||||
# process dst tree
|
||||
for dst_entry in scantree(dst_root_abs, dir_first=False):
|
||||
dst_iter = scantree(dst_root_abs, dir_first=False) if dst_root_abs else ()
|
||||
for dst_entry in dst_iter:
|
||||
rel_path = dst_entry.path[len(dst_root_abs) + 1:]
|
||||
|
||||
src_entry = src_files_map.get(rel_path)
|
||||
@@ -480,6 +506,9 @@ def rsync(src_dir,
|
||||
# remove dst entries not existing in source
|
||||
if src_entry is None:
|
||||
_lg.debug("Rsync, deleting: %s", rel_path)
|
||||
if dry_run:
|
||||
yield rel_path, Actions.DELETE, ""
|
||||
continue
|
||||
try:
|
||||
rm_direntry(dst_entry)
|
||||
yield rel_path, Actions.DELETE, ""
|
||||
@@ -513,7 +542,7 @@ def rsync(src_dir,
|
||||
" (src is a file, dst is not a file): %s",
|
||||
rel_path)
|
||||
try:
|
||||
digest = update_direntry(src_entry, dst_entry)
|
||||
digest = _maybe_update(src_entry, dst_entry, dry_run)
|
||||
yield rel_path, Actions.REWRITE, digest or ""
|
||||
except (OSError, BackupCreationError) as exc:
|
||||
yield rel_path, Actions.ERROR, str(exc)
|
||||
@@ -525,7 +554,7 @@ def rsync(src_dir,
|
||||
" (src is a dir, dst is not a dir): %s",
|
||||
rel_path)
|
||||
try:
|
||||
update_direntry(src_entry, dst_entry)
|
||||
_maybe_update(src_entry, dst_entry, dry_run)
|
||||
yield rel_path, Actions.REWRITE, ""
|
||||
except (OSError, BackupCreationError) as exc:
|
||||
yield rel_path, Actions.ERROR, str(exc)
|
||||
@@ -537,7 +566,7 @@ def rsync(src_dir,
|
||||
" (src is a symlink, dst is not a symlink): %s",
|
||||
rel_path)
|
||||
try:
|
||||
update_direntry(src_entry, dst_entry)
|
||||
_maybe_update(src_entry, dst_entry, dry_run)
|
||||
yield rel_path, Actions.REWRITE, ""
|
||||
except (OSError, BackupCreationError) as exc:
|
||||
yield rel_path, Actions.ERROR, str(exc)
|
||||
@@ -547,7 +576,7 @@ def rsync(src_dir,
|
||||
if src_entry.inode() == dst_entry.inode():
|
||||
_lg.debug("Rsync, rewriting (shared inode with src): %s", rel_path)
|
||||
try:
|
||||
digest = update_direntry(src_entry, dst_entry)
|
||||
digest = _maybe_update(src_entry, dst_entry, dry_run)
|
||||
yield rel_path, Actions.REWRITE, digest or ""
|
||||
except (OSError, BackupCreationError) as exc:
|
||||
yield rel_path, Actions.ERROR, str(exc)
|
||||
@@ -565,7 +594,7 @@ def rsync(src_dir,
|
||||
_lg.debug("Rsync, rewriting (different %s): %s",
|
||||
reason, rel_path)
|
||||
try:
|
||||
digest = update_direntry(src_entry, dst_entry)
|
||||
digest = _maybe_update(src_entry, dst_entry, dry_run)
|
||||
yield rel_path, Actions.REWRITE, digest or ""
|
||||
except (OSError, BackupCreationError) as exc:
|
||||
yield rel_path, Actions.ERROR, str(exc)
|
||||
@@ -577,7 +606,7 @@ def rsync(src_dir,
|
||||
_lg.debug("Rsync, rewriting (different symlink target): %s",
|
||||
rel_path)
|
||||
try:
|
||||
update_direntry(src_entry, dst_entry)
|
||||
_maybe_update(src_entry, dst_entry, dry_run)
|
||||
yield rel_path, Actions.REWRITE, ""
|
||||
except (OSError, BackupCreationError) as exc:
|
||||
yield rel_path, Actions.ERROR, str(exc)
|
||||
@@ -598,25 +627,30 @@ def rsync(src_dir,
|
||||
# before mutating permissions/ownership in place, or the change
|
||||
# would silently apply to every older snapshot sharing the inode.
|
||||
if src_entry.is_file(follow_symlinks=False):
|
||||
if src_stat.st_mode != dst_stat.st_mode or (
|
||||
if not dry_run and (src_stat.st_mode != dst_stat.st_mode or (
|
||||
src_stat.st_uid != dst_stat.st_uid
|
||||
or src_stat.st_gid != dst_stat.st_gid):
|
||||
or src_stat.st_gid != dst_stat.st_gid)):
|
||||
_break_shared_hardlink(dst_entry.path)
|
||||
|
||||
# update permissions and ownership
|
||||
if src_stat.st_mode != dst_stat.st_mode:
|
||||
_lg.debug("Rsync, updating permissions: %s", rel_path)
|
||||
os.chmod(dst_entry.path, src_stat.st_mode)
|
||||
if not dry_run:
|
||||
os.chmod(dst_entry.path, src_stat.st_mode)
|
||||
yield rel_path, Actions.UPDATE_PERM, ""
|
||||
|
||||
if (src_stat.st_uid != dst_stat.st_uid
|
||||
or src_stat.st_gid != dst_stat.st_gid):
|
||||
_lg.debug("Rsync, updating owners: %s", rel_path)
|
||||
try:
|
||||
os.chown(dst_entry.path, src_stat.st_uid, src_stat.st_gid)
|
||||
if dry_run:
|
||||
yield rel_path, Actions.UPDATE_OWNER, ""
|
||||
except PermissionError:
|
||||
_lg.debug("Cannot change ownership (not root): %s", rel_path)
|
||||
else:
|
||||
try:
|
||||
os.chown(dst_entry.path, src_stat.st_uid, src_stat.st_gid)
|
||||
yield rel_path, Actions.UPDATE_OWNER, ""
|
||||
except PermissionError:
|
||||
_lg.debug("Cannot change ownership (not root): %s",
|
||||
rel_path)
|
||||
|
||||
# process remained source entries (new files/dirs/symlinks)
|
||||
for rel_path, src_entry in src_files_map.items():
|
||||
@@ -627,6 +661,10 @@ def rsync(src_dir,
|
||||
yield rel_path, Actions.EXCLUDE, src_kind
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
yield rel_path, Actions.CREATE, ""
|
||||
continue
|
||||
|
||||
dst_path = os.path.join(dst_root_abs, rel_path)
|
||||
_lg.debug("Rsync, creating: %s", rel_path)
|
||||
try:
|
||||
@@ -635,6 +673,11 @@ def rsync(src_dir,
|
||||
except (OSError, BackupCreationError) as exc:
|
||||
yield rel_path, Actions.ERROR, str(exc)
|
||||
|
||||
if dry_run:
|
||||
# Nothing below this point does anything but restore directory
|
||||
# mtimes disturbed by the mutations above - none of which ran.
|
||||
return
|
||||
|
||||
# restore dir mtimes in dst, updated by updating files
|
||||
for src_entry in scantree(src_root_abs, dir_first=True,
|
||||
one_filesystem=True):
|
||||
|
||||
Reference in new issue
Block a user