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:
Gmer4Lfe
2026-08-01 20:37:58 -04:00
parent ce580d9935
commit 3f5ad22632
+107 -7
View File
@@ -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
RSYNC_OPTS+=(--delete)
# 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)