Only apply --delete on merge-run when the pull pass completed
A capped or failed pull leaves the remote holding content the local never received, so deleting against it destroys the only copy.
This commit is contained in:
+106
-6
@@ -41,9 +41,59 @@
|
||||
# using version-stable field names.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# DESIGN PRINCIPLES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Deletion Is Opt-In, Never Inherited
|
||||
# DEFAULT_RSYNC_OPTS deliberately omits --delete. Media shares only ever gain files
|
||||
# here; the arr cleanup scripts own deletion and are the only things that understand
|
||||
# whether a file is genuinely orphaned. --delete appears in exactly two places: a
|
||||
# profile that sets it explicitly, and the push pass of --merge-run.
|
||||
#
|
||||
# Profiles Replace, Not Extend
|
||||
# PROFILE_RSYNC_OPTS does not inherit DEFAULT_RSYNC_OPTS. A profile states its full
|
||||
# flag set, so reading one profile tells you exactly what will run — no tracing
|
||||
# through a base list to discover an inherited --delete.
|
||||
#
|
||||
# Local Is Authoritative
|
||||
# In --merge-run the remote may contribute content the local lacks, but never a
|
||||
# competing version. Pass 1 pulls only with --ignore-existing, so the push in pass 2
|
||||
# can safely be authoritative. Reversing that order would let the remote overwrite
|
||||
# local files before the delete pass.
|
||||
#
|
||||
# Pre-flight Before Payload
|
||||
# Connectivity, version parity, remote rootfs, remote share, remote disks and drive
|
||||
# temperature are all checked before a byte moves. A transfer aborted halfway is more
|
||||
# expensive to reason about than one that never started.
|
||||
#
|
||||
# Bounded, Resumable Transfers
|
||||
# No single attempt may exceed RSYNC_MAX_RUNTIME_HOURS. This is safe only because
|
||||
# --partial is in the default opts: a terminated transfer resumes rather than
|
||||
# restarting, so bounding it costs nothing and prevents one huge or stuck transfer
|
||||
# from holding its lock indefinitely and starving every other profile of a turn.
|
||||
#
|
||||
# Silent on Success
|
||||
# Only failures produce visible output. A quiet run is a successful one.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL SAFEGUARDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Root Enforcement
|
||||
# rsync over SSH as root and container stop/start both require it.
|
||||
#
|
||||
# Docker Presence Check
|
||||
# Verified before any profile container operations.
|
||||
#
|
||||
# Source Path Depth Guard
|
||||
# The source must be an absolute path at least three levels deep. It is pushed to
|
||||
# root@remote at the same absolute path and --merge-run adds --delete, so a truncated
|
||||
# argument is a remote-side hazard: /mnt/user would sync every share at once.
|
||||
#
|
||||
# Source Existence Check
|
||||
# The local directory must exist. Without this, --merge-run's pull pass would create
|
||||
# a mistyped directory, populate it from the remote, then push it back with --delete.
|
||||
#
|
||||
# Global Rsync Gate
|
||||
# check_rsync_enabled() — RSYNC_ENABLED=false exits cleanly before any operation.
|
||||
#
|
||||
@@ -72,8 +122,23 @@
|
||||
# acquire_rsync_lock() — per-profile lock prevents parallel runs of the same profile.
|
||||
# Global concurrent limit prevents too many simultaneous rsync processes.
|
||||
#
|
||||
# Notification Validated
|
||||
# platform_require_cmd confirms the notify script is present before use.
|
||||
# Runtime Ceiling
|
||||
# RSYNC_MAX_RUNTIME_HOURS terminates a single attempt that overruns, releasing the
|
||||
# per-profile lock for the next scheduled run. --partial in the default opts means the
|
||||
# paused transfer resumes rather than restarting from scratch.
|
||||
#
|
||||
# Merge-Run Ordering
|
||||
# Pass 1 pulls remote-unique content with --ignore-existing before pass 2 pushes with
|
||||
# --delete. Content the remote had and the local did not is preserved locally before
|
||||
# anything is deleted remotely.
|
||||
#
|
||||
# Merge-Run Delete Interlock
|
||||
# --delete is applied only when pass 1 completed. Its entire justification is that the
|
||||
# local is now the authoritative superset, and a capped or failed pull means it is not:
|
||||
# the remote still holds content the local never received. On an incomplete pull the
|
||||
# push proceeds without --delete and notifies, so local content still propagates while
|
||||
# nothing remote-unique is destroyed. The delete happens on a later run whose pull
|
||||
# succeeded.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# CONFIGURATION
|
||||
@@ -176,6 +241,25 @@ parse_args "${RAW_ARGS[@]}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Source path guards ────────────────────────────────────────────────────────────────────────
|
||||
# This script pushes to root@remote at the same absolute path, and --merge-run adds --delete on
|
||||
# the push pass. A truncated or mistyped source is therefore a remote-side data hazard, not just
|
||||
# a local no-op: /mnt/user would sync every share at once, and / would target the filesystem
|
||||
# root. Every real job path (see HOST*_*_SYNC_SHARES) is at least three levels deep.
|
||||
_rsync_depth="${DIRECTORY//[^\/]/}"
|
||||
if [[ "$DIRECTORY" != /* || "${#_rsync_depth}" -lt 3 ]]; then
|
||||
error "Refusing unsafe source path: '$DIRECTORY' — expected an absolute path at least 3 levels deep"
|
||||
exit 1
|
||||
fi
|
||||
unset _rsync_depth
|
||||
|
||||
# Without this, --merge-run's pull pass would create a mistyped local directory, fill it with
|
||||
# remote content, then push it back with --delete.
|
||||
if [[ ! -d "$DIRECTORY" ]]; then
|
||||
error "Source directory does not exist locally: $DIRECTORY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
@@ -380,23 +464,39 @@ if [[ "$MERGE_RUN" == true ]]; then
|
||||
_pull_exit=$?
|
||||
_pull_bytes=$(echo "$_pull_output" | \
|
||||
awk '/Total transferred file size:/{gsub(/,/,"",$NF); gsub(/[^0-9]/,"",$NF); print $NF+0}')
|
||||
_pull_complete=false
|
||||
if [[ "$_pull_exit" -eq 0 ]]; then
|
||||
echo "$ICON_DONE Merge pass 1 complete — ${_pull_bytes:-0} bytes pulled"
|
||||
_pull_complete=true
|
||||
elif [[ "$_pull_exit" -eq 124 ]]; then
|
||||
# Same cap as the main push — never let one direction of the merge run unbounded.
|
||||
# Files already completed before the timeout stay pulled; next scheduled run
|
||||
# picks up whatever's still remote-unique (no --partial here, so an in-flight
|
||||
# file at the moment of the kill is discarded, not corrupted).
|
||||
warn "Merge pass 1 exceeded ${RSYNC_MAX_RUNTIME_HOURS}h cap — continuing with push, will resume pull next scheduled run"
|
||||
warn "Merge pass 1 exceeded ${RSYNC_MAX_RUNTIME_HOURS}h cap — pull incomplete"
|
||||
else
|
||||
warn "Merge pass 1 failed (exit $_pull_exit) — continuing with push"
|
||||
warn "Merge pass 1 failed (exit $_pull_exit)"
|
||||
echo "$_pull_output" | grep -iE "error|rsync:|permission denied" | while read -r _line; do
|
||||
warn " $_line"
|
||||
done
|
||||
fi
|
||||
unset _pull_opts _pull_output _pull_exit _pull_bytes
|
||||
# Pass 2: push with --delete — local is now the authoritative superset
|
||||
|
||||
# Pass 2: push with --delete — but ONLY if pass 1 actually finished.
|
||||
# --delete is justified solely by "local is now the authoritative superset", and that
|
||||
# premise holds only on a complete pull. If pass 1 was capped or failed, the remote still
|
||||
# holds content the local never received; deleting it here would destroy it permanently
|
||||
# and make the "resume the pull next run" promise impossible to keep. Push without
|
||||
# --delete instead — local content still propagates, nothing remote-unique is lost, and
|
||||
# the delete happens on a later run whose pull completed.
|
||||
if [[ "$_pull_complete" == true ]]; then
|
||||
RSYNC_OPTS+=(--delete)
|
||||
else
|
||||
warn "Skipping --delete this run — pass 1 did not complete, remote may hold content the local lacks"
|
||||
warn "Local content still pushes; the delete pass runs once a full pull succeeds"
|
||||
notify "Merge run on $(hostname) pushed without --delete — pass 1 incomplete for $PROFILE_NAME" \
|
||||
"Rsync" "warning"
|
||||
fi
|
||||
unset _pull_opts _pull_output _pull_exit _pull_bytes _pull_complete
|
||||
fi
|
||||
|
||||
START=$(date +%s)
|
||||
|
||||
Reference in New Issue
Block a user