Files
Varaverk/Deployment/conf_upgrade.sh
T
Gmer4Lfe 7669cd75b8 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.
2026-08-01 22:44:23 -04:00

320 lines
16 KiB
Bash
Executable File

#!/bin/bash
# ==============================================================================================
# ============================= conf_upgrade.sh ================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Merges a new conf template into an existing user conf while preserving every
# value the user has already set. Run manually when the conf schema changes
# between versions — adds new keys, removes deprecated ones, and keeps the
# structure of the new template exactly.
#
# Keys in template only → ADDED (placeholder/default — user fills in once)
# Keys in target only → REMOVED (deprecated in new version)
# Keys in both → KEPT (target's value always wins, template ignored)
# Comments / blank lines → always from template (structure follows new version)
#
# Supports all conf variable patterns: simple scalars, indexed arrays, and
# 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.
#
# Backup Option
# --backup writes a .bak copy of the target before overwriting. Use when
# applying to a conf that has never been upgraded before.
#
# File Existence Guards
# Both --template and --target are validated before any parsing begins.
# Missing files abort immediately with a clear error.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# No conf vars. All inputs are CLI flags.
#
# --template <file> New version conf file (source of structure and defaults)
# --target <file> Existing user conf (source of real values — always preserved)
# --dry-run Show what would change without writing
# --backup Write a .bak copy of target before modifying
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# conf_upgrade.sh --template Deployment/master.conf.template --target Configurations/master.conf --dry-run
# Preview what would be added, removed, and kept — no changes written.
#
# conf_upgrade.sh --template Deployment/master.conf.template --target Configurations/master.conf --backup
# Apply the upgrade, writing a .bak first.
#
# conf_upgrade.sh --template Deployment/master.conf.template --target Configurations/master.conf
# Apply the upgrade in-place with no backup.
#
# ==============================================================================================
set -uo pipefail
# ── Arguments ────────────────────────────────────────────────────────────────────────────────
TEMPLATE=""
TARGET=""
DRY_RUN=false
BACKUP=false
while [[ $# -gt 0 ]]; do
case "$1" in
--template) TEMPLATE="$2"; shift 2 ;;
--target) TARGET="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
--backup) BACKUP=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
[[ -z "$TEMPLATE" ]] && { echo "Error: --template required" >&2; exit 1; }
[[ -z "$TARGET" ]] && { echo "Error: --target required" >&2; exit 1; }
[[ -f "$TEMPLATE" ]] || { echo "Error: template not found: $TEMPLATE" >&2; exit 1; }
[[ -f "$TARGET" ]] || { echo "Error: target not found: $TARGET" >&2; exit 1; }
# ── Parse target → KEY → full definition block ───────────────────────────────────────────────
declare -A HOST_MAP # KEY → complete definition line(s) from user's conf
_parse_target() {
local in_block=false cur_key="" cur_block="" line
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$in_block" == true ]]; then
cur_block+="$line"$'\n'
# Closing ) — optional trailing whitespace and comment
if [[ "$line" =~ ^[[:space:]]*\)[[:space:]]*(#.*)?$ ]]; then
HOST_MAP["$cur_key"]="$cur_block"
in_block=false; cur_key=""; cur_block=""
fi
else
# declare -A KEY=(
if [[ "$line" =~ ^[[:space:]]*declare[[:space:]]+-[a-zA-Z]+[[:space:]]+([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true; cur_block="$line"$'\n'
# KEY=(
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true; cur_block="$line"$'\n'
# KEY=value (simple scalar)
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*= ]]; then
HOST_MAP["${BASH_REMATCH[1]}"]="$line"$'\n'
fi
fi
done < "$TARGET"
}
# ── Walk template — collect stats (must run in current shell so arrays persist) ──────────────
declare -a ADDED=() KEPT=() REMOVED=()
declare -A TMPL_SEEN=()
_collect_stats() {
local in_block=false cur_key="" line
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$in_block" == true ]]; then
if [[ "$line" =~ ^[[:space:]]*\)[[:space:]]*(#.*)?$ ]]; then
in_block=false
TMPL_SEEN["$cur_key"]=1
if [[ -n "${HOST_MAP[$cur_key]+_}" ]]; then KEPT+=("$cur_key")
else ADDED+=("$cur_key"); fi
cur_key=""
fi
else
if [[ "$line" =~ ^[[:space:]]*declare[[:space:]]+-[a-zA-Z]+[[:space:]]+([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*= ]]; then
local k="${BASH_REMATCH[1]}"
TMPL_SEEN["$k"]=1
if [[ -n "${HOST_MAP[$k]+_}" ]]; then KEPT+=("$k")
else ADDED+=("$k"); fi
fi
fi
done < "$TEMPLATE"
for key in "${!HOST_MAP[@]}"; do
[[ -z "${TMPL_SEEN[$key]+_}" ]] && REMOVED+=("$key")
done
}
# ── Walk template — write merged output ──────────────────────────────────────────────────────
# Runs in a subshell (stdout redirected) — array mutations are intentionally discarded here.
_write_merged() {
local in_block=false cur_key="" cur_block="" line
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$in_block" == true ]]; then
cur_block+="$line"$'\n'
if [[ "$line" =~ ^[[:space:]]*\)[[:space:]]*(#.*)?$ ]]; then
in_block=false
if [[ -n "${HOST_MAP[$cur_key]+_}" ]]; then printf '%s' "${HOST_MAP[$cur_key]}"
else printf '%s' "$cur_block"; fi
cur_key=""; cur_block=""
fi
else
if [[ "$line" =~ ^[[:space:]]*declare[[:space:]]+-[a-zA-Z]+[[:space:]]+([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true; cur_block="$line"$'\n'
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*=\( ]]; then
cur_key="${BASH_REMATCH[1]}"; in_block=true; cur_block="$line"$'\n'
elif [[ "$line" =~ ^[[:space:]]*([A-Z0-9_]+)[[:space:]]*= ]]; then
local k="${BASH_REMATCH[1]}"
if [[ -n "${HOST_MAP[$k]+_}" ]]; then printf '%s' "${HOST_MAP[$k]}"
else printf '%s\n' "$line"; fi
else
printf '%s\n' "$line"
fi
fi
done < "$TEMPLATE"
}
# ── Run ───────────────────────────────────────────────────────────────────────────────────────
_parse_target
_collect_stats
# ── Report ────────────────────────────────────────────────────────────────────────────────────
TARGET_NAME="$(basename "$TARGET")"
echo ""
echo "── conf_upgrade: $TARGET_NAME ──────────────────────────────────────────"
if [[ ${#ADDED[@]} -gt 0 ]]; then
echo " ADDED (new — fill in your values where needed):"
for k in "${ADDED[@]}"; do echo " + $k"; done
fi
if [[ ${#REMOVED[@]} -gt 0 ]]; then
echo " REMOVED (deprecated — no longer in this version):"
for k in "${REMOVED[@]}"; do echo " - $k"; done
fi
echo " KEPT ${#KEPT[@]} existing vars — your values preserved"
if [[ ${#ADDED[@]} -eq 0 && ${#REMOVED[@]} -eq 0 ]]; then
echo " Already up to date — no changes needed."
echo "────────────────────────────────────────────────────────────────────────"
echo ""
exit 0
fi
echo "────────────────────────────────────────────────────────────────────────"
echo ""
# ── Apply ─────────────────────────────────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == true ]]; then
echo "(dry-run — no changes written)"
exit 0
fi
# 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 -a "$TARGET" "${TARGET}.bak"
echo "Backup: ${TARGET}.bak"
fi
# 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"