Files
bootstrap-public/upgrade-debian12-13.sh
2026-11-04 03:01:27 +08:00

239 lines
12 KiB
Bash

#!/usr/bin/env bash
# Independent manual upgrade. Never invoked by install.sh.
set -euo pipefail
export LC_ALL=C
usage() { echo 'Usage: bash upgrade-debian12-13.sh --check|--upgrade|--resume|--verify'; }
fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; }
# Validate only explicitly supported official source syntax. Unknown layouts stop.
validate_source() {
local file=$1 suite=$2
awk -v suite="$suite" -v deb822="$([[ "$file" == *.sources || "$file" == *.sources.target ]] && echo 1 || echo 0)" '
function reject(){bad=1}
function valid_uri(u){return u ~ /^https?:\/\/(deb\.debian\.org|security\.debian\.org)\/debian(-security)?\/?$/}
function valid_suite(s){if(s==suite)base=1;if(s==suite "-security")security=1;return s==suite || s==suite "-updates" || s==suite "-security"}
function reset(){have=0;types=uri=suites=enabled=components="";delete seen}
function block( n,a,i){
if(!have)return
if(enabled!="" && enabled!="yes" && enabled!="no")reject()
if(enabled=="no"){reset();return}
if(types!="deb" || !valid_uri(uri) || suites=="" || components=="")reject()
n=split(suites,a,/[ \t]+/);for(i=1;i<=n;i++)if(!valid_suite(a[i]))reject()
if(uri ~ /debian-security\/?$/ && suites!=suite "-security")reject()
if(uri !~ /debian-security\/?$/ && suites ~ /-security/)reject()
reset()
}
/^[ \t]*#/ {next}
/^[ \t]*$/ {if(deb822)block();next}
!deb822 {
line=$0;sub(/[ \t]+#.*/,"",line)
if(line !~ /^[ \t]*deb[ \t]+/) {reject();next}
sub(/^[ \t]*deb[ \t]+/,"",line)
if(line ~ /^\[/){
if(line !~ /^\[[^]]+\][ \t]+/){reject();next}
options=line;sub(/\].*$/,"",options);sub(/^\[/,"",options)
n=split(options,a,/[ \t]+/);delete options_seen
for(i=1;i<=n;i++){
if(a[i]!~/^(arch|signed-by)=[A-Za-z0-9_\/.+,-]+$/)reject()
option=a[i];sub(/=.*/,"",option);if(options_seen[option]++)reject()
}
sub(/^\[[^]]+\][ \t]+/,"",line)
}
n=split(line,a,/[ \t]+/)
if(n<3 || !valid_uri(a[1]) || !valid_suite(a[2]))reject()
if((a[1] ~ /debian-security\/?$/)!=(a[2]==suite "-security"))reject()
next
}
deb822 {
if($0 !~ /^[A-Za-z][A-Za-z-]*:/){reject();next}
field=tolower($0);sub(/:.*/,"",field)
if(seen[field]++)reject()
value=$0;sub(/^[^:]+:[ \t]*/,"",value);sub(/[ \t]+$/,"",value)
if(field=="types")types=value
else if(field=="uris")uri=value
else if(field=="suites")suites=value
else if(field=="enabled")enabled=tolower(value)
else if(field=="components")components=value
else if(field!="signed-by" && field!="architectures")reject()
have=1
}
END{if(deb822)block();if(bad)exit 1;if(base)print "base";if(security)print "security"}' "$file"
}
# Change only active suite fields, including every suite in a Deb822 list.
render_source() {
local file=$1
validate_source "$file" bookworm >/dev/null || return 1
awk -v deb822="$([[ "$file" == *.sources || "$file" == *.sources.target ]] && echo 1 || echo 0)" '
function flush( i,disabled){
disabled=0
for(i=1;i<=count;i++)if(tolower(lines[i]) ~ /^enabled:[ \t]*no[ \t]*$/)disabled=1
for(i=1;i<=count;i++){
if(!disabled && tolower(lines[i]) ~ /^suites:/)gsub(/bookworm/,"trixie",lines[i])
print lines[i]
}
count=0;delete lines
}
!deb822 {
line=$0
if(match(line,/^[ \t]*deb[ \t]+(\[[^]]+\][ \t]+)?[^ \t]+[ \t]+/)){
prefix=substr(line,1,RLENGTH);tail=substr(line,RLENGTH+1)
sub(/^bookworm/,"trixie",tail);line=prefix tail
}
print line;next
}
/^[ \t]*$/ {flush();print;next}
{lines[++count]=$0}
END{if(deb822)flush()}' "$file"
}
package_checks() {
local report preference
report=$(dpkg --audit) || { fail 'dpkg audit failed'; return 1; }
[[ -z "$report" ]] || { fail 'Repair the original unfinished dpkg operation first'; return 1; }
report=$(apt-mark showhold) || { fail 'Cannot read held packages'; return 1; }
[[ -z "$report" ]] || { fail 'Review held packages first'; return 1; }
shopt -s nullglob
for preference in /etc/apt/preferences /etc/apt/preferences.d/*; do
[[ ! -f "$preference" ]] || ! grep -Eq '^[[:space:]]*[^#[:space:]]' "$preference" || { fail 'Review APT pinning first'; return 1; }
done
}
check_source_set() {
local state=$1 original=() current=() file
mapfile -t original < "$state/source-files"
[[ ${#original[@]} -gt 0 ]] || { fail 'Empty original source plan'; return 1; }
shopt -s nullglob
for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
[[ ! -L "$file" ]] || { fail 'APT source became a symlink'; return 1; }
[[ ! -e "$file" ]] || current+=("$file")
done
[[ "${original[*]}" == "${current[*]}" ]] || { fail 'APT source file set changed since review'; return 1; }
}
switch_sources() {
local state=$1 file next
while IFS= read -r file; do
[[ -f "$file" && ! -L "$file" ]] && { cmp -s "$file" "$state/sources$file" || cmp -s "$file" "$state/sources$file.target"; } || { fail 'APT source changed after review'; return 1; }
validate_source "$state/sources$file.target" trixie >/dev/null || return 1
done < "$state/source-files"
while IFS= read -r file; do
if cmp -s "$file" "$state/sources$file.target"; then continue; fi
next=$(mktemp "$(dirname "$file")/.platform-source-XXXXXXXX")
if ! cat "$state/sources$file.target" > "$next" || ! chmod --reference="$file" "$next" || ! chown --reference="$file" "$next" || ! mv -fT -- "$next" "$file"; then
rm -f -- "$next"; return 1
fi
done < "$state/source-files"
}
scan_sources() {
local suite=$1 file report all_reports=''
shopt -s nullglob
source_files=()
for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
[[ ! -L "$file" ]] || { fail 'Symlink APT source'; return 1; }
[[ -e "$file" ]] || continue
[[ "$file" != *$'\n'* && ! -L "$file" && -f "$file" ]] || { fail 'APT source is not a regular file'; return 1; }
report=$(validate_source "$file" "$suite") || { fail "Review unknown/mixed/third-party source syntax: $file"; return 1; }
source_files+=("$file")
all_reports+=" $report"
done
[[ "$all_reports" == *base* && "$all_reports" == *security* ]] || { fail 'Explicit Debian base and security sources required'; return 1; }
}
reboot_pending() {
[[ -e /run/reboot-required ]] || { [[ -e /vmlinuz ]] && [[ $(basename "$(readlink -f /vmlinuz)") != "vmlinuz-$(uname -r)" ]]; }
}
save_phase() {
local temporary
temporary=$(mktemp "$state/.phase-XXXXXXXX")
printf '%s\n' "$1" > "$temporary"
mv -fT -- "$temporary" "$state/phase"
}
main() {
local action=${1:---help}
case "$action" in --help|-h) usage; return 0;; --check|--upgrade|--resume|--verify) :;; *) usage; return 1;; esac
[[ $# == 1 && $EUID == 0 && -d /run/systemd/system && ! -e /.dockerenv ]] || { fail 'Run on a native systemd host as root'; return 1; }
grep -Eq '^ID="?debian"?$' /etc/os-release || { fail 'Debian required'; return 1; }
local state=/var/lib/platform-debian-upgrade phase='' boot_id answer file
boot_id=$(cat /proc/sys/kernel/random/boot_id)
[[ ! -L "$state" ]] || { fail 'State must not be a symlink'; return 1; }
if [[ -e "$state" ]]; then
[[ $(stat -c %u "$state") == 0 && $(stat -c %a "$state") == 700 ]] || { fail 'State must be root-owned mode 0700'; return 1; }
[[ ! -L "$state/phase" ]] || { fail 'Invalid phase file'; return 1; }
[[ ! -f "$state/phase" ]] || phase=$(cat "$state/phase")
fi
package_checks
if [[ "$action" != --check ]]; then
[[ "$action" == --upgrade || -d "$state" ]] || { fail 'No original upgrade state'; return 1; }
[[ -r /dev/tty && -w /dev/tty ]] || { fail 'A real controlling terminal is required'; return 1; }
umask 077
install -d -m 0700 "$state"
[[ ! -L "$state/lock" && ( ! -e "$state/lock" || ( -f "$state/lock" && $(stat -c %h "$state/lock") == 1 ) ) ]] || { fail 'Invalid lock'; return 1; }
exec 9>>"$state/lock"; flock -n 9 || { fail 'Another upgrade is running'; return 1; }
phase=''
[[ ! -f "$state/phase" ]] || phase=$(cat "$state/phase")
fi
if [[ "$action" == --verify ]]; then
[[ "$phase" == awaiting_reboot || "$phase" == verified ]] || { fail 'No completed upgrade record'; return 1; }
grep -Eq '^VERSION_ID="?13"?$' /etc/os-release
[[ "$boot_id" != "$(cat "$state/upgrade-boot")" ]] && ! reboot_pending || { fail 'Reboot into the installed target kernel first'; return 1; }
[[ $(uname -r) != "$(cat "$state/bookworm-kernel")" && -s "/boot/vmlinuz-$(uname -r)" ]] || { fail 'Boot the installed target kernel'; return 1; }
if [[ -s "$state/identities.sha256" ]]; then sha256sum --check "$state/identities.sha256"; fi
save_phase verified
echo 'Debian 13 local verification passed. Confirm a fresh SSH connection and existing workloads.'
return 0
fi
if [[ "$action" == --check || -z "$phase" ]]; then
grep -Eq '^VERSION_ID="?12"?$' /etc/os-release || { fail 'Start this entry on Debian 12'; return 1; }
scan_sources bookworm
[[ $(df -Pk / | awk 'NR==2{print $4}') -ge 5242880 && $(df -Pk /boot | awk 'NR==2{print $4}') -ge 262144 ]] || { fail 'Need 5 GiB free on / and 256 MiB on /boot'; return 1; }
if [[ -d /sys/firmware/efi ]]; then mountpoint -q /boot/efi || { fail 'Mount and review the EFI System Partition first'; return 1; }; fi
if [[ "$action" == --check ]]; then echo 'Source/package/storage checks passed; review bootloader and recovery access before --upgrade.'; return 0; fi
fi
[[ -r /dev/tty && -w /dev/tty ]] || { fail 'A real controlling terminal is required'; return 1; }
[[ ( "$action" == --upgrade && -z "$phase" ) || ( "$action" == --resume && -n "$phase" ) ]] || { fail 'Use --upgrade for a new run or --resume for the existing run'; return 1; }
if [[ -z "$phase" ]]; then
printf 'Bootloader, rescue access and required backups reviewed. Type UPGRADE: ' >/dev/tty
read -r answer </dev/tty; [[ "$answer" == UPGRADE ]] || return 1
tar --acls --xattrs -cpf "$state/before.tar" /etc /boot /var/lib/dpkg/status
tar -tf "$state/before.tar" >/dev/null
: > "$state/identities.sha256"
for file in /etc/ssh/ssh_host_*_key.pub /etc/komodo/keys/periphery.pub; do [[ ! -f "$file" ]] || sha256sum "$file" >> "$state/identities.sha256"; done
phase=prepared; save_phase "$phase"
fi
case "$phase" in prepared|bookworm_updated|switching_sources|sources_switched|awaiting_reboot|verified) :;; *) fail 'Unknown recorded phase'; return 1;; esac
if [[ "$phase" == awaiting_reboot || "$phase" == verified ]]; then echo 'Upgrade already finished; reboot manually and use --verify.'; return 0; fi
if [[ "$phase" == prepared ]]; then
scan_sources bookworm
apt-get update; apt-get full-upgrade </dev/tty
package_checks
printf '%s\n' "$boot_id" > "$state/bookworm-boot"
phase=bookworm_updated; save_phase "$phase"
if reboot_pending; then touch "$state/reboot-required"; echo 'Reboot Debian 12 manually, reconnect, then --resume.'; return 3; fi
fi
if [[ "$phase" == bookworm_updated ]]; then
! reboot_pending || { fail 'Reboot before continuing'; return 1; }
[[ ! -e "$state/reboot-required" || "$boot_id" != "$(cat "$state/bookworm-boot")" ]] || return 1
scan_sources bookworm
printf 'Switch the reviewed Debian sources to trixie. Type TRIXIE: ' >/dev/tty
read -r answer </dev/tty; [[ "$answer" == TRIXIE ]] || return 1
uname -r > "$state/bookworm-kernel"
install -d -m 0700 "$state/sources"
printf '%s\n' "${source_files[@]}" > "$state/source-files"
for file in "${source_files[@]}"; do
cp --parents "$file" "$state/sources/"
render_source "$file" > "$state/sources${file}.target"
done
phase=switching_sources; save_phase "$phase"
fi
check_source_set "$state"
if [[ "$phase" == switching_sources ]]; then
switch_sources "$state"
phase=sources_switched; save_phase "$phase"
fi
scan_sources trixie
while IFS= read -r file; do cmp -s "$file" "$state/sources$file.target" || { fail 'Target source changed'; return 1; }; done < "$state/source-files"
apt-get update
apt-get upgrade --without-new-pkgs </dev/tty
apt-get full-upgrade </dev/tty
package_checks
printf '%s\n' "$boot_id" > "$state/upgrade-boot"
save_phase awaiting_reboot
echo 'APT stages completed. Review kernel/bootloader output, reboot manually, reconnect and run --verify.'
}
if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi