Install upgraded confs by atomic rename

A copy truncates the live conf and writes into it, so anything sourcing load_config.sh
during that window reads a partial file with empty path variables.
This commit is contained in:
Gmer4Lfe
2026-08-01 22:44:23 -04:00
parent 3813884287
commit 7669cd75b8
+87 -7
View File
@@ -19,9 +19,73 @@
# associative arrays (declare -A).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. Validate both --template and --target exist
# 2. Parse each into keys, handling scalars, indexed arrays and declare -A
# 3. Classify every key: ADDED (template only) / REMOVED (target only) / KEPT (both)
# 4. Emit the merged result — template structure, target values — to a temp file
# staged in the target's own directory
# 5. --dry-run stops here and prints the report
# 6. --backup copies the current target to .bak
# 7. Install by atomic rename over the target
#
# Called automatically by git_pull_execute.sh after every pull, for master.conf and this
# host's own host*.conf. The host template is HOSTN_-prefixed and the caller substitutes
# the real MY_ID before merging.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# The User's Value Always Wins
# For any key present in both files, the target's value is kept and the template's is
# discarded. The template supplies structure and new keys, never settings. This is what
# makes the upgrade safe to run unattended after every single pull.
#
# Structure Follows the Template
# Comments, ordering and blank lines come from the template, so an upgraded conf reads
# like the current version rather than accumulating layers of old formatting.
#
# Standalone by Design — Do Not Add load_config.sh
# This script sources nothing. It is the tool that repairs the conf that load_config.sh
# depends on, so it has to work when that conf is broken, partial, or missing keys.
# Sourcing load_config.sh here would make the repair tool fail in exactly the situation
# it exists for. That is also why it uses plain echo instead of log()/error(), and why
# there is no acquire_lock — common.sh is not available to it.
#
# Atomic Install, Never In-Place
# The merged conf is renamed over the target, not copied into it. Every watchdog sources
# load_config.sh on every run; a cp would truncate master.conf and write into it, and
# anything reading during that window gets a partial conf with empty path variables.
#
# Concurrency Handled by Atomicity, Not a Lock
# Two concurrent runs against the same target cannot corrupt it — each stages its own
# temp file and the rename is atomic, so the last writer simply wins. Since the merge is
# idempotent, that outcome is identical to running once.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required to Write
# Installing over the target requires root. --dry-run deliberately does not, so the
# change report can be previewed by anyone.
#
# Atomic Rename
# The temp file is created in the target's own directory so the rename stays within one
# filesystem. On Unraid /tmp is rootfs while the confs live on flash, and a cross-device
# mv degrades to copy-then-unlink — precisely the torn write this avoids.
#
# Permissions Preserved
# mktemp creates 0600; the target's existing mode and owner are copied onto the temp file
# before it is installed, so a conf does not come back with different permissions.
#
# Temp File Cleanup
# An EXIT trap removes the staged file on any early exit, and is cleared once the rename
# has succeeded so the trap cannot delete the installed conf.
#
# Dry-run Mode
# --dry-run prints the full change report (ADDED / REMOVED / KEPT) then exits
# without writing anything. Always preview before applying to production confs.
@@ -34,10 +98,6 @@
# Both --template and --target are validated before any parsing begins.
# Missing files abort immediately with a clear error.
#
# Atomic Write
# Merged output is written to a tempfile first, then copied to the target.
# A partial write cannot corrupt the original.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
@@ -225,15 +285,35 @@ if [[ "$DRY_RUN" == true ]]; then
exit 0
fi
TMPOUT="$(mktemp)"
# Checked here rather than at the top: --dry-run is a read-only report and is useful to
# anyone, but installing over a conf under /boot needs root. Plain echo because this script
# deliberately does not source common.sh — see the header.
if [[ "$EUID" -ne 0 ]]; then
echo "ERROR: writing '$TARGET' requires root (use --dry-run to preview as any user)" >&2
exit 1
fi
# Staged beside the target, not in /tmp. mv is only atomic within one filesystem, and on
# Unraid /tmp is rootfs while the confs live on flash — a cross-device mv silently degrades
# to copy-then-unlink, which is exactly the torn write this is meant to prevent.
TMPOUT="$(mktemp "${TARGET}.XXXXXX")"
trap 'rm -f "$TMPOUT"' EXIT
# mktemp creates 0600; carry the target's existing mode/owner across so the installed conf
# does not come back with different permissions than it went in with.
chmod --reference="$TARGET" "$TMPOUT" 2>/dev/null || true
chown --reference="$TARGET" "$TMPOUT" 2>/dev/null || true
_write_merged > "$TMPOUT"
if [[ "$BACKUP" == true ]]; then
cp "$TARGET" "${TARGET}.bak"
cp -a "$TARGET" "${TARGET}.bak"
echo "Backup: ${TARGET}.bak"
fi
cp "$TMPOUT" "$TARGET"
# Atomic install. cp would truncate the live conf and write into it, leaving a window where
# anything sourcing load_config.sh reads a half-written master.conf — every watchdog does
# that constantly. A rename swaps the inode: readers get the old file or the new one.
mv -f "$TMPOUT" "$TARGET"
trap - EXIT
echo "Updated: $TARGET"