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.
|
# 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
|
# 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
|
# Global Rsync Gate
|
||||||
# check_rsync_enabled() — RSYNC_ENABLED=false exits cleanly before any operation.
|
# 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.
|
# acquire_rsync_lock() — per-profile lock prevents parallel runs of the same profile.
|
||||||
# Global concurrent limit prevents too many simultaneous rsync processes.
|
# Global concurrent limit prevents too many simultaneous rsync processes.
|
||||||
#
|
#
|
||||||
# Notification Validated
|
# Runtime Ceiling
|
||||||
# platform_require_cmd confirms the notify script is present before use.
|
# 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
|
# CONFIGURATION
|
||||||
@@ -176,6 +241,25 @@ parse_args "${RAW_ARGS[@]}"
|
|||||||
exit 1
|
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 ━━━
|
# ━━━ Setup ━━━
|
||||||
# ==============================================================================================
|
# ==============================================================================================
|
||||||
@@ -380,23 +464,39 @@ if [[ "$MERGE_RUN" == true ]]; then
|
|||||||
_pull_exit=$?
|
_pull_exit=$?
|
||||||
_pull_bytes=$(echo "$_pull_output" | \
|
_pull_bytes=$(echo "$_pull_output" | \
|
||||||
awk '/Total transferred file size:/{gsub(/,/,"",$NF); gsub(/[^0-9]/,"",$NF); print $NF+0}')
|
awk '/Total transferred file size:/{gsub(/,/,"",$NF); gsub(/[^0-9]/,"",$NF); print $NF+0}')
|
||||||
|
_pull_complete=false
|
||||||
if [[ "$_pull_exit" -eq 0 ]]; then
|
if [[ "$_pull_exit" -eq 0 ]]; then
|
||||||
echo "$ICON_DONE Merge pass 1 complete — ${_pull_bytes:-0} bytes pulled"
|
echo "$ICON_DONE Merge pass 1 complete — ${_pull_bytes:-0} bytes pulled"
|
||||||
|
_pull_complete=true
|
||||||
elif [[ "$_pull_exit" -eq 124 ]]; then
|
elif [[ "$_pull_exit" -eq 124 ]]; then
|
||||||
# Same cap as the main push — never let one direction of the merge run unbounded.
|
# 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
|
# 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
|
# 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).
|
# 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
|
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
|
echo "$_pull_output" | grep -iE "error|rsync:|permission denied" | while read -r _line; do
|
||||||
warn " $_line"
|
warn " $_line"
|
||||||
done
|
done
|
||||||
fi
|
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)
|
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
|
fi
|
||||||
|
|
||||||
START=$(date +%s)
|
START=$(date +%s)
|
||||||
|
|||||||
Reference in New Issue
Block a user