PVE-mods/src/Scripts/apply-patches.sh
2026-06-11 22:20:28 +02:00

222 lines
7.8 KiB
Bash

#!/usr/bin/env bash
# /usr/lib/pve-mod/apply-patches.sh
#
# Generic patch applier for pve-mod.
#
# Reads /etc/pve-mod/pve-mod.conf [modules] to learn which mods are enabled,
# then applies each enabled mod's patches from /usr/lib/pve-mod/patches/<mod>/.
#
# Patch convention: every .patch uses a/<path> b/<path> headers and is applied
# with `patch -p1 -F0 -d /` (zero fuzz: line offsets tolerated, fuzzy context
# matching disabled). Each mod folder contains a 'patches.list' manifest and
# optional 'post-apply.sh' / 'post-revert.sh' hooks.
#
# Per-mod atomicity: before applying, every active patch of a mod is dry-run.
# If any one cannot be applied (or is already partially/broken-applied), the
# whole mod is reverted to a clean state and reported as failed - a mod is
# never left half-applied.
#
# Idempotent: a patch that is already applied is detected (reverse dry-run) and
# skipped, so this script is safe to run repeatedly (e.g. from the dpkg trigger
# after a pve-manager upgrade).
#
# Hook exit-code convention: a hook returns 0 (no change), 100 (made a change,
# triggers a pveproxy restart), or any other code (error).
set -u
# Root paths (overridable via environment, mainly for testing).
PVE_MOD_ROOT="${PVE_MOD_ROOT:-/}"
MAIN_CONF="${PVE_MOD_MAIN_CONF:-/etc/pve-mod/pve-mod.conf}"
CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}"
PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}"
# Storage for non-patch replaced files (e.g. nag-screen's minified proxmoxlib).
# Patched text files need no backups - `patch -R` reverts them.
STASH_DIR="${PVE_MOD_STASH_DIR:-/var/lib/pve-mod/backup}"
info() { echo "[pve-mod] $*"; }
warn() { echo "[pve-mod] WARNING: $*" >&2; }
# read_conf <file> <section> <key> [default]
# Prints one value from an INI file, or the default if absent.
read_conf() {
local file="$1" section="$2" key="$3" default="${4:-0}"
if [[ ! -f "$file" ]]; then
echo "$default"
return
fi
local val
val=$(awk -F= -v sec="[$section]" -v k="$key" '
/^\[/ { in_sec = ($0 == sec) }
in_sec && /^[^#=]+=/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
if ($1 == k) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
print $2; exit
}
}
' "$file")
echo "${val:-$default}"
}
# List the keys of the [modules] section in the main config, one per line.
list_modules() {
[[ -f "$MAIN_CONF" ]] || return 0
awk -F= '
/^\[/ { in_sec = ($0 == "[modules]") }
in_sec && /^[^#=]+=/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
print $1
}
' "$MAIN_CONF"
}
# Patch-state helpers (all use zero fuzz for deterministic detection).
_dry_forward() { patch -p1 -F0 -f --dry-run -s -d "$PVE_MOD_ROOT" < "$1" >/dev/null 2>&1; }
_dry_reverse() { patch -R -p1 -F0 -f --dry-run -s -d "$PVE_MOD_ROOT" < "$1" >/dev/null 2>&1; }
is_applied() { _dry_reverse "$1"; }
can_apply() { _dry_forward "$1"; }
do_apply() { patch -p1 -F0 -f -s -d "$PVE_MOD_ROOT" < "$1"; }
do_revert() { patch -R -p1 -F0 -f -s -d "$PVE_MOD_ROOT" < "$1"; }
CHANGED=false
FAILED=false
# Run a mod hook honouring the exit-code convention. Sets CHANGED / FAILED.
run_hook() {
local hook="$1" mod_conf="$2"
[[ -x "$hook" ]] || return 0
STASH_DIR="$STASH_DIR" MOD_CONF="$mod_conf" CONFD_DIR="$CONFD_DIR" "$hook"
local rc=$?
case "$rc" in
0) ;;
100) CHANGED=true ;;
*) warn " hook $(basename "$hook") reported an error (exit $rc)"; FAILED=true; return 1 ;;
esac
return 0
}
# ── main ──────────────────────────────────────────────────────────────────────
if ! command -v patch >/dev/null 2>&1; then
warn "'patch' command not found; cannot apply mods. Install the 'patch' package."
exit 1
fi
for mod in $(list_modules); do
[[ "$(read_conf "$MAIN_CONF" modules "$mod" 0)" == "1" ]] || continue
mod_dir="$PATCHES_DIR/$mod"
manifest="$mod_dir/patches.list"
mod_conf="$CONFD_DIR/$mod.conf"
if [[ ! -f "$manifest" ]]; then
warn "No patch manifest for enabled mod '$mod' ($manifest); skipping."
FAILED=true
continue
fi
info "Checking mod: $mod"
# Build the list of active patch files (those whose condition is met).
active=()
preflight_ok=true
while IFS= read -r line; do
# Strip comments and surrounding whitespace; skip blanks.
line="${line%%#*}"
line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')"
[[ -z "$line" ]] && continue
# Format: <patch-file> [section.key=value]
patch_name="${line%%[[:space:]]*}"
condition=""
if [[ "$line" == *[[:space:]]* ]]; then
condition="$(echo "${line#"$patch_name"}" | sed -E 's/^[[:space:]]+//')"
fi
# Evaluate optional condition against the mod's conf.d file.
if [[ -n "$condition" ]]; then
local_key="${condition%%=*}"
want="${condition#*=}"
sect="${local_key%%.*}"
ckey="${local_key#*.}"
if [[ "$(read_conf "$mod_conf" "$sect" "$ckey" 0)" != "$want" ]]; then
info " skip $patch_name (condition $condition not met)"
continue
fi
fi
patch_file="$mod_dir/$patch_name"
if [[ ! -f "$patch_file" ]]; then
warn " patch file missing: $patch_file"
preflight_ok=false
continue
fi
active+=("$patch_file")
done < "$manifest"
# Preflight dry-run: every active patch must be already applied or cleanly
# appliable. Otherwise the mod cannot be installed atomically.
to_apply=()
if [[ "$preflight_ok" == "true" ]]; then
for pf in "${active[@]}"; do
if is_applied "$pf"; then
continue
elif can_apply "$pf"; then
to_apply+=("$pf")
else
warn " $(basename "$pf") does not apply cleanly"
patch -p1 -F0 -f --dry-run --verbose -d "$PVE_MOD_ROOT" < "$pf" >&2 || true
preflight_ok=false
break
fi
done
fi
# If preflight failed, revert the whole mod back to a clean state so it is
# never left half-applied.
if [[ "$preflight_ok" != "true" ]]; then
warn "Mod '$mod': preflight failed - reverting mod to clean state."
for (( i=${#active[@]}-1 ; i>=0 ; i-- )); do
pf="${active[$i]}"
if is_applied "$pf"; then
do_revert "$pf" && { info " reverted $(basename "$pf")"; CHANGED=true; }
fi
done
run_hook "$mod_dir/post-revert.sh" "$mod_conf"
FAILED=true
continue
fi
# Apply the outstanding patches.
if [[ ${#to_apply[@]} -gt 0 ]]; then
for pf in "${to_apply[@]}"; do
do_apply "$pf" && { info " applied $(basename "$pf")"; CHANGED=true; }
done
else
info " already up to date"
fi
# Run the post-apply hook. If it errors, roll the mod back so it is never
# left half-applied (matches the preflight atomicity contract).
if ! run_hook "$mod_dir/post-apply.sh" "$mod_conf"; then
warn "Mod '$mod': post-apply hook failed - reverting mod to clean state."
run_hook "$mod_dir/post-revert.sh" "$mod_conf"
for (( i=${#active[@]}-1 ; i>=0 ; i-- )); do
pf="${active[$i]}"
if is_applied "$pf"; then
do_revert "$pf" && { info " reverted $(basename "$pf")"; CHANGED=true; }
fi
done
FAILED=true
continue
fi
done
if [[ "$CHANGED" == "true" ]]; then
info "Restarting pveproxy..."
systemctl restart pveproxy 2>/dev/null || true
fi
[[ "$FAILED" == "true" ]] && exit 1
exit 0