diff --git a/Deployment/conf_upgrade.sh b/Deployment/conf_upgrade.sh index 9877b88..8497bab 100755 --- a/Deployment/conf_upgrade.sh +++ b/Deployment/conf_upgrade.sh @@ -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"