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).
|
# 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
|
# 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 Mode
|
||||||
# --dry-run prints the full change report (ADDED / REMOVED / KEPT) then exits
|
# --dry-run prints the full change report (ADDED / REMOVED / KEPT) then exits
|
||||||
# without writing anything. Always preview before applying to production confs.
|
# without writing anything. Always preview before applying to production confs.
|
||||||
@@ -34,10 +98,6 @@
|
|||||||
# Both --template and --target are validated before any parsing begins.
|
# Both --template and --target are validated before any parsing begins.
|
||||||
# Missing files abort immediately with a clear error.
|
# 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
|
# CONFIGURATION
|
||||||
# ==============================================================================================
|
# ==============================================================================================
|
||||||
@@ -225,15 +285,35 @@ if [[ "$DRY_RUN" == true ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
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"
|
_write_merged > "$TMPOUT"
|
||||||
|
|
||||||
if [[ "$BACKUP" == true ]]; then
|
if [[ "$BACKUP" == true ]]; then
|
||||||
cp "$TARGET" "${TARGET}.bak"
|
cp -a "$TARGET" "${TARGET}.bak"
|
||||||
echo "Backup: ${TARGET}.bak"
|
echo "Backup: ${TARGET}.bak"
|
||||||
fi
|
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"
|
echo "Updated: $TARGET"
|
||||||
|
|||||||
Reference in New Issue
Block a user