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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user