mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 13:07:49 +01:00
Two levels of pure taxonomy carrying no information, and a 2-directory
cap leaves no room for them. Categories now sit directly under
linux_configuration/: features, fixes, utils, misc, fresh-install,
installers, diagnostics, gaming, boot_recovery, periodic_background, lib,
meta, tests. A script's own lib/ and tests/ are exempt from the cap, so
those stay put.
Depth violations 248 -> 139; roughly 40 of the remainder are inside
digital_wellbeing and hosts, deferred until the guard-lib repoint.
The moves are the easy half. What breaks is every path that counted
levels, and three classes of it had to be found by resolving paths rather
than reading diffs:
- 52 relative `source` lines for lib/common.sh and lib/android.sh. The
correct prefix differs per file: 40 were ../../ and 12 were ../../../,
so a blanket replace would have fixed one set and broken the other.
- REPO_ROOT in 7 fixes/lib/tests harnesses and setup_syncyomi_guard.sh,
each walking up four levels from a directory now two levels shallower.
This caused a real regression -- the fixes suite went red with
"log_warn: command not found" -- caught by running it and comparing
against the pre-move baseline worktree.
- conftest.py's sys.path entries, which took the pytest gate down with
14 collection errors, and the `# shellcheck source=` directives, which
took shellcheck down with SC1091.
16 symlinks in $HOME broke, including the entire i3blocks bar. All
repointed, and cpu_monitor/memory/disk/time/volume were run to confirm
they emit real output rather than merely resolving.
Verified: 1798 tests pass at 100% coverage, 8 of 9 lib/tests suites pass
(the ninth needs the coverage jail, identically at baseline), pre-commit
is clean, and the two untracked secret configs are still ignored after
their gitignore paths changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
757 lines
23 KiB
Bash
Executable File
757 lines
23 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# ============================================================================
|
|
# boot-repair — offline repair for a stale/unwritten EFI System Partition
|
|
# ============================================================================
|
|
#
|
|
# The failure this fixes (seen 2026-07-30):
|
|
# A kernel upgrade replaced /usr/lib/modules/<old> with <new>, but the new
|
|
# kernel image never reached the ESP. The firmware kept booting the OLD
|
|
# vmlinuz, whose module tree no longer existed — so nothing modular could
|
|
# load after pivot-root. In particular `vfat` was gone, /boot itself could
|
|
# not be mounted, local-fs.target failed and systemd dropped to emergency
|
|
# mode. The upgrade was reported as successful.
|
|
#
|
|
# Root cause on this machine: pacman-git with ParallelDownloads > 1 makes
|
|
# every forked alpm hook child segfault, silently skipping the
|
|
# 90-mkinitcpio-install and depmod hooks.
|
|
#
|
|
# Design constraints (deliberate):
|
|
# * SELF-CONTAINED — no sourcing of repo libs. It must run from the systemd
|
|
# emergency shell where only the root filesystem is mounted.
|
|
# * FULLY OFFLINE — never touches the network. Missing kernel modules are
|
|
# recovered from /var/cache/pacman/pkg, nothing else.
|
|
# * Repairs by default. Use --dry-run to inspect without changing anything.
|
|
#
|
|
# Safety invariants:
|
|
# * Files under /boot are ONLY deleted while /boot is not a mountpoint AND
|
|
# contains no EFI/ or loader/ directory AND the system booted via UEFI.
|
|
# Deleting them after mounting, or on a BIOS box that has no ESP at all,
|
|
# would destroy the real kernel and leave the machine unbootable.
|
|
# * EFI/ and loader/ on the ESP are never written or removed.
|
|
# * The running kernel's module tree is never deleted.
|
|
# ============================================================================
|
|
|
|
set -euo pipefail
|
|
|
|
# Emergency shells can have a threadbare PATH.
|
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
export PATH
|
|
|
|
readonly SCRIPT_NAME="${0##*/}"
|
|
readonly ESP_MOUNTPOINT="/boot"
|
|
|
|
# Defaults
|
|
ROOT="/"
|
|
MODE="fix" # fix | dry-run | preflight | auto
|
|
ESP_DEV_OVERRIDE=""
|
|
PROBLEMS=0
|
|
REPAIRS=0
|
|
QUIET=0
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Output helpers
|
|
# ----------------------------------------------------------------------------
|
|
|
|
msg() { [[ $QUIET -eq 1 ]] || printf '%s\n' "$*"; }
|
|
ok() { [[ $QUIET -eq 1 ]] || printf ' [ OK ] %s\n' "$*"; }
|
|
info() { [[ $QUIET -eq 1 ]] || printf ' [INFO] %s\n' "$*"; }
|
|
|
|
# A problem that was found. Counted so the exit status can reflect reality.
|
|
problem() {
|
|
PROBLEMS=$((PROBLEMS + 1))
|
|
printf ' [FAIL] %s\n' "$*" >&2
|
|
}
|
|
|
|
warn() { printf ' [WARN] %s\n' "$*" >&2; }
|
|
|
|
# A repair that was actually carried out.
|
|
repaired() {
|
|
REPAIRS=$((REPAIRS + 1))
|
|
printf ' [FIXED] %s\n' "$*"
|
|
}
|
|
|
|
die() {
|
|
printf '%s: error: %s\n' "$SCRIPT_NAME" "$*" >&2
|
|
exit 2
|
|
}
|
|
|
|
# True when we are allowed to modify the system.
|
|
acting() { [[ $MODE == "fix" || $MODE == "auto" ]]; }
|
|
|
|
# Describe an action that dry-run would skip.
|
|
would() { msg " [DRY ] would $*"; }
|
|
|
|
usage() {
|
|
cat << EOF
|
|
Usage: $SCRIPT_NAME [OPTIONS]
|
|
|
|
Repairs a stale EFI System Partition after a kernel upgrade failed to write
|
|
it. Repairs by default; runs fully offline.
|
|
|
|
Options:
|
|
--dry-run Report problems, change nothing.
|
|
--preflight Exit non-zero unless the ESP is mounted. For a pacman
|
|
PreTransaction hook that gates kernel upgrades.
|
|
--auto Quiet repair for a pacman PostTransaction hook. Prints
|
|
only when it acts or fails.
|
|
--root DIR Operate on an alternate root (testing, or an Arch ISO
|
|
chroot). Live-only steps are skipped.
|
|
--esp DEVICE Use DEVICE as the ESP instead of auto-detecting it. Also
|
|
overrides the BIOS guard: on a system with no
|
|
/sys/firmware/efi, shadow-file cleanup is refused unless
|
|
the ESP device is named explicitly here.
|
|
-h, --help Show this help.
|
|
|
|
Exit status:
|
|
0 system consistent (or fully repaired)
|
|
1 problems remain / preflight gate failed
|
|
2 usage or environment error
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Path helpers — everything is expressed relative to \$ROOT so the logic can be
|
|
# exercised against a fixture tree without touching the real machine.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
# Join $ROOT with an absolute path, avoiding a doubled leading slash.
|
|
rpath() {
|
|
local p="$1"
|
|
if [[ $ROOT == "/" ]]; then
|
|
printf '%s' "$p"
|
|
else
|
|
printf '%s%s' "${ROOT%/}" "$p"
|
|
fi
|
|
}
|
|
|
|
# True when operating on the live system (so mount/modprobe are meaningful).
|
|
is_live() { [[ $ROOT == "/" ]]; }
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Kernel discovery
|
|
# ----------------------------------------------------------------------------
|
|
|
|
# Read the version banner out of a kernel image. Works on a bare file with no
|
|
# tooling beyond grep, which matters in a degraded emergency shell.
|
|
# Usage: kernel_image_version <path> -> prints e.g. 7.1.5-arch1-2, or nothing
|
|
kernel_image_version() {
|
|
local img="$1"
|
|
[[ -r $img ]] || return 1
|
|
grep -aoE '[0-9]+\.[0-9]+\.[0-9]+-arch[0-9]+-[0-9]+' "$img" 2> /dev/null | head -1
|
|
}
|
|
|
|
# A module tree is "complete" only if it has both the kernel image and actual
|
|
# modules. The failure mode leaves behind a directory holding just the
|
|
# modules.* metadata files, which must not be mistaken for a usable kernel.
|
|
kernel_tree_complete() {
|
|
local dir="$1"
|
|
[[ -f $dir/vmlinuz ]] || return 1
|
|
[[ -d $dir/kernel ]] || return 1
|
|
return 0
|
|
}
|
|
|
|
# Newest complete module tree under $ROOT/usr/lib/modules, by version sort.
|
|
newest_complete_kernel() {
|
|
local moddir kver best=""
|
|
moddir="$(rpath /usr/lib/modules)"
|
|
[[ -d $moddir ]] || return 1
|
|
while IFS= read -r kver; do
|
|
[[ -n $kver ]] || continue
|
|
kernel_tree_complete "$moddir/$kver" || continue
|
|
best="$kver"
|
|
done < <(find "$moddir" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2> /dev/null | sort -V)
|
|
[[ -n $best ]] || return 1
|
|
printf '%s' "$best"
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# ESP discovery
|
|
# ----------------------------------------------------------------------------
|
|
|
|
# The fstab spec for /boot (e.g. "UUID=8A14-F8A7"), if present.
|
|
fstab_boot_spec() {
|
|
local fstab spec mnt
|
|
fstab="$(rpath /etc/fstab)"
|
|
[[ -r $fstab ]] || return 1
|
|
while read -r spec mnt _; do
|
|
[[ $spec == \#* ]] && continue
|
|
[[ -z ${spec:-} ]] && continue
|
|
if [[ $mnt == "$ESP_MOUNTPOINT" ]]; then
|
|
printf '%s' "$spec"
|
|
return 0
|
|
fi
|
|
done < "$fstab"
|
|
return 1
|
|
}
|
|
|
|
# The mount options field for /boot in fstab.
|
|
fstab_boot_options() {
|
|
local fstab spec mnt opts
|
|
fstab="$(rpath /etc/fstab)"
|
|
[[ -r $fstab ]] || return 1
|
|
while read -r spec mnt _ opts _; do
|
|
[[ $spec == \#* ]] && continue
|
|
[[ -z ${spec:-} ]] && continue
|
|
if [[ $mnt == "$ESP_MOUNTPOINT" ]]; then
|
|
printf '%s' "$opts"
|
|
return 0
|
|
fi
|
|
done < "$fstab"
|
|
return 1
|
|
}
|
|
|
|
# Resolve the fstab spec to a block device. Prefers /dev/disk/by-uuid so it
|
|
# works without blkid, which may be unavailable in a minimal shell.
|
|
resolve_esp_device() {
|
|
local spec uuid dev
|
|
if [[ -n $ESP_DEV_OVERRIDE ]]; then
|
|
printf '%s' "$ESP_DEV_OVERRIDE"
|
|
return 0
|
|
fi
|
|
spec="$(fstab_boot_spec)" || return 1
|
|
case "$spec" in
|
|
UUID=*)
|
|
uuid="${spec#UUID=}"
|
|
if [[ -e /dev/disk/by-uuid/$uuid ]]; then
|
|
dev="$(readlink -f "/dev/disk/by-uuid/$uuid")"
|
|
printf '%s' "$dev"
|
|
return 0
|
|
fi
|
|
# Fall back to blkid when the by-uuid symlink is absent.
|
|
if command -v blkid > /dev/null 2>&1; then
|
|
dev="$(blkid -U "$uuid" 2> /dev/null)" && [[ -n $dev ]] && {
|
|
printf '%s' "$dev"
|
|
return 0
|
|
}
|
|
fi
|
|
return 1
|
|
;;
|
|
/dev/*)
|
|
printf '%s' "$spec"
|
|
return 0
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Mount state
|
|
# ----------------------------------------------------------------------------
|
|
|
|
boot_is_mounted() {
|
|
is_live || return 1
|
|
findmnt -n "$ESP_MOUNTPOINT" > /dev/null 2>&1
|
|
}
|
|
|
|
# True when the system booted via UEFI, i.e. an ESP can exist at all. The
|
|
# kernel only creates /sys/firmware/efi when it was loaded by EFI firmware.
|
|
#
|
|
# Expressed via rpath so a fixture tree can declare its own firmware type;
|
|
# on the live machine this resolves to the real /sys path and is exact.
|
|
system_is_uefi() { [[ -d "$(rpath /sys/firmware/efi)" ]]; }
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Repair steps
|
|
# ----------------------------------------------------------------------------
|
|
|
|
# The root filesystem is sometimes left read-only after a failed boot; every
|
|
# later step would fail confusingly without this.
|
|
ensure_root_writable() {
|
|
is_live || return 0
|
|
local testfile
|
|
testfile="$(rpath /.boot-repair-write-test)"
|
|
if (: > "$testfile") 2> /dev/null; then
|
|
rm -f "$testfile"
|
|
return 0
|
|
fi
|
|
problem "root filesystem is read-only"
|
|
if acting; then
|
|
mount -o remount,rw / || die "could not remount / read-write"
|
|
repaired "remounted / read-write"
|
|
else
|
|
would "remount / read-write"
|
|
fi
|
|
}
|
|
|
|
# Restore just enough of the RUNNING kernel's module tree to mount a vfat ESP.
|
|
# Without this the repair cannot even reach the ESP: mounting it needs vfat,
|
|
# and vfat lives in the module tree the upgrade deleted.
|
|
ensure_vfat() {
|
|
is_live || return 0
|
|
if modprobe -n vfat > /dev/null 2>&1 && modprobe vfat > /dev/null 2>&1; then
|
|
ok "vfat available"
|
|
return 0
|
|
fi
|
|
|
|
problem "vfat cannot be loaded for the running kernel ($(uname -r))"
|
|
if ! acting; then
|
|
would "restore fat/nls modules from the pacman cache"
|
|
return 1
|
|
fi
|
|
|
|
local kver pkgver cache pkg
|
|
kver="$(uname -r)"
|
|
# uname 7.0.5-arch1-1 -> package 7.0.5.arch1-1
|
|
pkgver="${kver/-arch/.arch}"
|
|
cache="$(rpath /var/cache/pacman/pkg)"
|
|
|
|
pkg=""
|
|
if [[ -d $cache ]]; then
|
|
# Newest matching cached kernel package for the running version.
|
|
pkg="$(find "$cache" -maxdepth 1 -name "linux*-${pkgver}-*.pkg.tar.zst" 2> /dev/null | sort -V | tail -1)"
|
|
fi
|
|
if [[ -z $pkg ]]; then
|
|
problem "no cached package for the running kernel in $cache"
|
|
msg ""
|
|
msg " Cannot restore vfat offline. Recover with an Arch ISO:"
|
|
msg " mount /dev/<root> /mnt -o subvol=@ && arch-chroot /mnt"
|
|
msg " pacman -S linux && $SCRIPT_NAME"
|
|
return 1
|
|
fi
|
|
|
|
info "restoring fat/nls modules from ${pkg##*/}"
|
|
local tmp
|
|
tmp="$(mktemp -d)" || die "mktemp failed"
|
|
# shellcheck disable=SC2064 # expand $tmp now, on purpose
|
|
trap "rm -rf '$tmp'" RETURN
|
|
|
|
# Extract only the filesystem modules needed to read a FAT32 ESP.
|
|
tar --zstd -xf "$pkg" -C "$tmp" --wildcards \
|
|
"usr/lib/modules/${kver}/kernel/fs/fat/*" \
|
|
"usr/lib/modules/${kver}/kernel/fs/nls/*" 2> /dev/null ||
|
|
{
|
|
problem "could not extract fat/nls modules from ${pkg##*/}"
|
|
return 1
|
|
}
|
|
|
|
local src dest
|
|
src="$tmp/usr/lib/modules/${kver}/kernel"
|
|
dest="$(rpath "/usr/lib/modules/${kver}")"
|
|
[[ -d $src ]] || {
|
|
problem "cached package did not contain fat/nls modules"
|
|
return 1
|
|
}
|
|
mkdir -p "$dest"
|
|
cp -a "$src" "$dest/"
|
|
depmod "$kver" 2> /dev/null || true
|
|
if modprobe vfat > /dev/null 2>&1; then
|
|
repaired "restored vfat for the running kernel"
|
|
return 0
|
|
fi
|
|
problem "vfat still will not load after restore"
|
|
return 1
|
|
}
|
|
|
|
# fstab must GATE on the ESP. `nofail` turns this failure into a silent boot of
|
|
# a stale kernel, which is strictly worse: the machine looks fine until the
|
|
# module tree disappears, one upgrade later.
|
|
check_fstab_options() {
|
|
local opts fstab
|
|
fstab="$(rpath /etc/fstab)"
|
|
opts="$(fstab_boot_options)" || {
|
|
problem "no /boot entry in $fstab"
|
|
return 1
|
|
}
|
|
if [[ $opts != *noauto* && $opts != *nofail* ]]; then
|
|
ok "fstab gates on /boot ($opts)"
|
|
return 0
|
|
fi
|
|
problem "fstab has noauto/nofail on /boot ($opts) — failures would be silent"
|
|
if ! acting; then
|
|
would "rewrite the /boot options to 'defaults'"
|
|
return 1
|
|
fi
|
|
cp -a "$fstab" "${fstab}.bak-$(date +%Y%m%d-%H%M%S)"
|
|
# Rewrite only the options field of the /boot line.
|
|
awk -v mp="$ESP_MOUNTPOINT" '
|
|
/^[[:space:]]*#/ { print; next }
|
|
NF >= 4 && $2 == mp { $4 = "defaults"; print; next }
|
|
{ print }
|
|
' OFS='\t' "$fstab" > "${fstab}.new"
|
|
mv "${fstab}.new" "$fstab"
|
|
repaired "fstab /boot options reset to 'defaults'"
|
|
if is_live; then
|
|
systemctl daemon-reload 2> /dev/null || true
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Orphaned kernel files written into the ROOT filesystem's /boot directory
|
|
# while the ESP was unmounted. They shadow the real ESP and waste ~250 MB.
|
|
#
|
|
# This is the single most dangerous step in the script: doing it after mounting
|
|
# would delete the real kernel. Hence the three hard guards below.
|
|
clean_shadow_files() {
|
|
local bootdir
|
|
bootdir="$(rpath $ESP_MOUNTPOINT)"
|
|
[[ -d $bootdir ]] || return 0
|
|
|
|
# GUARD 1: never touch a mounted /boot.
|
|
if boot_is_mounted; then
|
|
return 0
|
|
fi
|
|
# GUARD 2: a real ESP always has EFI/ or loader/. If either is present in
|
|
# something we believe is unmounted, our mount detection is wrong — stop.
|
|
if [[ -d $bootdir/EFI || -d $bootdir/loader ]]; then
|
|
warn "unmounted $ESP_MOUNTPOINT contains EFI/ or loader/ — refusing to delete anything"
|
|
return 1
|
|
fi
|
|
# GUARD 3: on a BIOS/GRUB system there is no ESP at all, so an unmounted
|
|
# /boot is not a shadow — it is the only kernel the machine has. Deleting it
|
|
# leaves the machine unbootable (measured 2026-08-22: a BIOS guest never
|
|
# booted again). --esp is the deliberate override: naming the device by hand
|
|
# asserts the firmware assumption that /sys/firmware/efi otherwise proves.
|
|
if ! system_is_uefi && [[ -z $ESP_DEV_OVERRIDE ]]; then
|
|
warn "no /sys/firmware/efi — this system booted via BIOS and has no ESP;"
|
|
warn " $ESP_MOUNTPOINT holds the real kernel. Refusing to delete anything."
|
|
warn " If that is wrong, name the ESP explicitly: $SCRIPT_NAME --esp DEVICE"
|
|
return 1
|
|
fi
|
|
|
|
local -a shadows=()
|
|
local f
|
|
while IFS= read -r f; do
|
|
[[ -n $f ]] && shadows+=("$f")
|
|
done < <(find "$bootdir" -maxdepth 1 -type f \
|
|
\( -name 'vmlinuz-*' -o -name 'initramfs-*.img' -o -name '*-ucode.img' \) 2> /dev/null)
|
|
|
|
[[ ${#shadows[@]} -gt 0 ]] || return 0
|
|
|
|
problem "${#shadows[@]} orphaned kernel file(s) in the unmounted $ESP_MOUNTPOINT (shadowing the ESP)"
|
|
if ! acting; then
|
|
would "delete: ${shadows[*]##*/}"
|
|
return 1
|
|
fi
|
|
rm -f "${shadows[@]}"
|
|
repaired "removed ${#shadows[@]} shadow file(s) from the root filesystem"
|
|
return 0
|
|
}
|
|
|
|
mount_esp() {
|
|
is_live || return 0
|
|
if boot_is_mounted; then
|
|
ok "$ESP_MOUNTPOINT mounted ($(findmnt -n -o SOURCE "$ESP_MOUNTPOINT"))"
|
|
return 0
|
|
fi
|
|
problem "$ESP_MOUNTPOINT is not mounted"
|
|
if ! acting; then
|
|
would "mount the ESP at $ESP_MOUNTPOINT"
|
|
return 1
|
|
fi
|
|
local dev
|
|
dev="$(resolve_esp_device)" || {
|
|
problem "could not determine the ESP device"
|
|
return 1
|
|
}
|
|
mkdir -p "$ESP_MOUNTPOINT"
|
|
mount -t vfat "$dev" "$ESP_MOUNTPOINT" || {
|
|
problem "mounting $dev at $ESP_MOUNTPOINT failed"
|
|
return 1
|
|
}
|
|
repaired "mounted $dev at $ESP_MOUNTPOINT"
|
|
return 0
|
|
}
|
|
|
|
# depmod output is what modprobe consults; the same skipped-hook bug that
|
|
# leaves the ESP stale also leaves modules.dep missing for the new kernel.
|
|
ensure_depmod() {
|
|
local kver dep
|
|
kver="$1"
|
|
dep="$(rpath "/usr/lib/modules/${kver}/modules.dep")"
|
|
if [[ -f $dep ]]; then
|
|
ok "modules.dep present for $kver"
|
|
return 0
|
|
fi
|
|
problem "modules.dep missing for $kver"
|
|
if ! acting; then
|
|
would "run depmod $kver"
|
|
return 1
|
|
fi
|
|
if is_live; then
|
|
depmod "$kver" || {
|
|
problem "depmod $kver failed"
|
|
return 1
|
|
}
|
|
else
|
|
depmod -b "${ROOT%/}" "$kver" || {
|
|
problem "depmod failed"
|
|
return 1
|
|
}
|
|
fi
|
|
repaired "generated modules.dep for $kver"
|
|
return 0
|
|
}
|
|
|
|
# The core repair: make the ESP's kernel image match the installed module tree.
|
|
sync_kernel_image() {
|
|
local kver src dest have
|
|
kver="$1"
|
|
src="$(rpath "/usr/lib/modules/${kver}/vmlinuz")"
|
|
dest="$(rpath "${ESP_MOUNTPOINT}/vmlinuz-linux")"
|
|
|
|
[[ -f $src ]] || {
|
|
problem "no kernel image at $src"
|
|
return 1
|
|
}
|
|
|
|
if [[ -f $dest ]]; then
|
|
have="$(kernel_image_version "$dest" || true)"
|
|
if [[ $have == "$kver" ]]; then
|
|
ok "ESP kernel matches installed tree ($kver)"
|
|
return 0
|
|
fi
|
|
problem "ESP kernel is ${have:-unreadable} but installed tree is $kver"
|
|
else
|
|
problem "no kernel image on the ESP"
|
|
fi
|
|
|
|
if ! acting; then
|
|
would "install $kver to $dest"
|
|
return 1
|
|
fi
|
|
install -Dm644 "$src" "$dest"
|
|
repaired "installed kernel $kver to the ESP"
|
|
return 0
|
|
}
|
|
|
|
# Which kernel an initramfs was built for. Prefers lsinitcpio, but falls back
|
|
# to scanning the image directly — lsinitcpio is not guaranteed to be present
|
|
# in a degraded emergency shell.
|
|
initramfs_version() {
|
|
local img="$1" v=""
|
|
[[ -r $img ]] || return 1
|
|
if command -v lsinitcpio > /dev/null 2>&1; then
|
|
v="$(lsinitcpio "$img" 2> /dev/null |
|
|
grep -oE 'modules/[0-9]+\.[0-9]+\.[0-9]+-arch[0-9]+-[0-9]+' |
|
|
head -1 | cut -d/ -f2 || true)"
|
|
fi
|
|
if [[ -z $v ]]; then
|
|
v="$(grep -aoE 'modules/[0-9]+\.[0-9]+\.[0-9]+-arch[0-9]+-[0-9]+' "$img" 2> /dev/null |
|
|
head -1 | cut -d/ -f2 || true)"
|
|
fi
|
|
[[ -n $v ]] || return 1
|
|
printf '%s' "$v"
|
|
}
|
|
|
|
# Rebuild every preset so the initramfs matches the kernel we just installed.
|
|
rebuild_initramfs() {
|
|
local kver img have
|
|
kver="$1"
|
|
img="$(rpath "${ESP_MOUNTPOINT}/initramfs-linux.img")"
|
|
|
|
have="$(initramfs_version "$img" || true)"
|
|
|
|
if [[ -n $have && $have == "$kver" ]]; then
|
|
ok "initramfs matches $kver"
|
|
return 0
|
|
fi
|
|
|
|
problem "initramfs is ${have:-missing/unknown}, expected $kver"
|
|
if ! acting; then
|
|
would "run mkinitcpio -P"
|
|
return 1
|
|
fi
|
|
is_live || {
|
|
warn "skipping mkinitcpio (--root given)"
|
|
return 1
|
|
}
|
|
command -v mkinitcpio > /dev/null 2>&1 || {
|
|
problem "mkinitcpio is not installed"
|
|
return 1
|
|
}
|
|
mkinitcpio -P || {
|
|
problem "mkinitcpio -P failed"
|
|
return 1
|
|
}
|
|
repaired "rebuilt initramfs for $kver"
|
|
return 0
|
|
}
|
|
|
|
# The actual root cause on this machine. With ParallelDownloads > 1, pacman-git
|
|
# segfaults every forked hook child, so mkinitcpio and depmod never run and the
|
|
# transaction still reports success.
|
|
check_parallel_downloads() {
|
|
local conf value
|
|
conf="$(rpath /etc/pacman.conf)"
|
|
[[ -r $conf ]] || return 0
|
|
value="$(grep -E '^[[:space:]]*ParallelDownloads[[:space:]]*=' "$conf" 2> /dev/null |
|
|
tail -1 | sed 's/.*=[[:space:]]*//' | tr -d '[:space:]')"
|
|
[[ -n $value ]] || {
|
|
ok "ParallelDownloads not set (pacman default is serial)"
|
|
return 0
|
|
}
|
|
if [[ $value == "1" ]]; then
|
|
ok "ParallelDownloads = 1"
|
|
return 0
|
|
fi
|
|
problem "ParallelDownloads = $value — forked alpm hooks segfault, silently skipping mkinitcpio"
|
|
if ! acting; then
|
|
would "set ParallelDownloads = 1 in $conf"
|
|
return 1
|
|
fi
|
|
cp -a "$conf" "${conf}.bak-$(date +%Y%m%d-%H%M%S)"
|
|
sed -i -E 's/^([[:space:]]*ParallelDownloads[[:space:]]*=[[:space:]]*).*/\11/' "$conf"
|
|
repaired "set ParallelDownloads = 1"
|
|
return 0
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Modes
|
|
# ----------------------------------------------------------------------------
|
|
|
|
# PreTransaction gate: refuse a kernel upgrade when the ESP is not mounted,
|
|
# because that upgrade would write the kernel into the root filesystem instead.
|
|
run_preflight() {
|
|
if boot_is_mounted; then
|
|
printf 'boot-repair: ESP mounted at %s — proceeding.\n' "$ESP_MOUNTPOINT"
|
|
exit 0
|
|
fi
|
|
cat >&2 << EOF
|
|
|
|
==> boot-repair: ABORTING TRANSACTION
|
|
|
|
$ESP_MOUNTPOINT is not mounted. Installing a kernel now would write it into
|
|
the root filesystem, leaving the firmware booting a stale image whose
|
|
modules this upgrade is about to delete.
|
|
|
|
Fix it, then retry:
|
|
sudo mount $ESP_MOUNTPOINT
|
|
sudo $SCRIPT_NAME
|
|
|
|
EOF
|
|
exit 1
|
|
}
|
|
|
|
run_checks() {
|
|
local kver
|
|
|
|
ensure_root_writable
|
|
|
|
msg "Kernel / ESP consistency"
|
|
check_fstab_options || true
|
|
clean_shadow_files || true
|
|
ensure_vfat || true
|
|
mount_esp || true
|
|
|
|
if ! kver="$(newest_complete_kernel)"; then
|
|
problem "no complete kernel module tree found under $(rpath /usr/lib/modules)"
|
|
msg ""
|
|
msg " Recover the kernel package first (offline, from cache):"
|
|
msg " pacman -U /var/cache/pacman/pkg/linux-*.pkg.tar.zst"
|
|
return 1
|
|
fi
|
|
info "target kernel: $kver"
|
|
|
|
ensure_depmod "$kver" || true
|
|
|
|
# Only meaningful once the ESP is actually mounted; otherwise these would
|
|
# write into the root filesystem — the very bug being repaired.
|
|
if boot_is_mounted || ! is_live; then
|
|
sync_kernel_image "$kver" || true
|
|
rebuild_initramfs "$kver" || true
|
|
else
|
|
problem "ESP still unmounted — cannot sync the kernel image"
|
|
fi
|
|
|
|
msg ""
|
|
msg "Upgrade safety"
|
|
check_parallel_downloads || true
|
|
return 0
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Argument parsing
|
|
# ----------------------------------------------------------------------------
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--dry-run)
|
|
MODE="dry-run"
|
|
shift
|
|
;;
|
|
--preflight)
|
|
MODE="preflight"
|
|
shift
|
|
;;
|
|
--auto)
|
|
MODE="auto"
|
|
QUIET=1
|
|
shift
|
|
;;
|
|
--root)
|
|
[[ $# -ge 2 ]] || die "--root requires a directory"
|
|
ROOT="$2"
|
|
shift 2
|
|
;;
|
|
--esp)
|
|
[[ $# -ge 2 ]] || die "--esp requires a device"
|
|
ESP_DEV_OVERRIDE="$2"
|
|
shift 2
|
|
;;
|
|
-h | --help) usage ;;
|
|
*) die "unknown option: $1 (try --help)" ;;
|
|
esac
|
|
done
|
|
|
|
[[ -d $ROOT ]] || die "root directory does not exist: $ROOT"
|
|
|
|
if [[ $MODE == "preflight" ]]; then
|
|
run_preflight
|
|
fi
|
|
|
|
# Root is required to modify the live system, but not to repair a tree handed
|
|
# to us via --root (a fixture, or a chroot where we are already root).
|
|
if acting && is_live && [[ $EUID -ne 0 ]]; then
|
|
die "must run as root (try: sudo $SCRIPT_NAME)"
|
|
fi
|
|
|
|
[[ $QUIET -eq 1 ]] || {
|
|
msg "=== boot-repair (${MODE}${ROOT:+, root=$ROOT}) ==="
|
|
msg ""
|
|
}
|
|
|
|
run_checks || true
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Report
|
|
# ----------------------------------------------------------------------------
|
|
|
|
# In --auto (post-transaction hook) stay silent unless something happened.
|
|
if [[ $MODE == "auto" ]]; then
|
|
if [[ $REPAIRS -gt 0 ]]; then
|
|
printf 'boot-repair: repaired %d boot inconsistency/ies after this transaction.\n' "$REPAIRS"
|
|
fi
|
|
if [[ $PROBLEMS -gt $REPAIRS ]]; then
|
|
printf 'boot-repair: UNRESOLVED boot problems remain — do NOT reboot. Run: sudo %s\n' "$SCRIPT_NAME" >&2
|
|
exit 1
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
msg ""
|
|
if [[ $PROBLEMS -eq 0 ]]; then
|
|
msg "=== consistent: nothing to repair ==="
|
|
exit 0
|
|
fi
|
|
|
|
if [[ $MODE == "dry-run" ]]; then
|
|
msg "=== $PROBLEMS problem(s) found — rerun without --dry-run to repair ==="
|
|
exit 1
|
|
fi
|
|
|
|
if [[ $REPAIRS -ge $PROBLEMS ]]; then
|
|
msg "=== repaired $REPAIRS problem(s) ==="
|
|
msg ""
|
|
msg "Verify before rebooting:"
|
|
msg " findmnt $ESP_MOUNTPOINT"
|
|
msg " sudo $SCRIPT_NAME --dry-run"
|
|
exit 0
|
|
fi
|
|
|
|
msg "=== repaired $REPAIRS of $PROBLEMS problem(s) — UNRESOLVED issues remain ==="
|
|
exit 1
|