#!/bin/bash
# Proxmox VE 8 -> 9 in-place upgrade (Debian 12 "bookworm" -> Debian 13 "trixie")
# Hardened: nuke/lock Enterprise repo, move backups OUT of APT dir, ensure single no-sub,
# robust pve8to9 detection/installation, switch to trixie, non-interactive upgrade, post-boot check.
set -euo pipefail
LOG="/root/pve-upgrade-$(date +%Y%m%d-%H%M%S).log"
SINK="/etc/apt/disabled-sources" # OUTSIDE of APT scan path
APT_BACKUP_DIR="/root/apt-sources-backup-$(date +%Y%m%d-%H%M%S)"
NO_REBOOT="${NO_REBOOT:-0}"
FORCE_UPGRADE="${FORCE_UPGRADE:-0}"
SKIP_PRECHECK="${SKIP_PRECHECK:-0}"
# Ensure sane PATH for non-interactive shells
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
export DEBIAN_FRONTEND=noninteractive
export APT_LISTCHANGES_FRONTEND=none
export UCF_FORCE_CONFFOLD=1
shopt -s nullglob
msg() { echo "$*" | tee -a "$LOG"; }
die() { echo "ERROR: $*" | tee -a "$LOG"; exit 1; }
ts() { date +%F-%H%M%S; }
sink() { # move file out of sources.list.d safely
local f="$1" base
[[ -e "$f" ]] || return 0
base="$(basename "$f")"
mkdir -p "$SINK"
mv -f "$f" "$SINK/$base.bak.$(ts)"
}
divert_add() {
local p="$1"
if ! dpkg-divert --list | grep -Fq "diversion of $p"; then
dpkg-divert --quiet --local --rename --add "$p" || true
fi
rm -f "$p" || true
}
cleanup_nonlist_sources() {
mkdir -p "$SINK"
find /etc/apt/sources.list.d -maxdepth 1 -type f \
! -name '*.list' ! -name '*.sources' \
-exec mv -f {} "$SINK"/ \; || true
}
# STRICT verify: only fail on ACTIVE enterprise refs
verify_no_enterprise() {
local bad=0
# 1) Active lines in *.list (not commented)
if grep -RIsn '^[[:space:]]*deb[[:space:]].*enterprise\.proxmox\.com' /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null; then
bad=1
fi
# 2) Deb822 .sources that mention enterprise AND are effectively enabled
while IFS= read -r -d '' f; do
if grep -qi 'enterprise\.proxmox\.com' "$f"; then
# treat missing Enabled as enabled, only "Enabled: no" is safe
if ! grep -qi '^[[:space:]]*Enabled:[[:space:]]*no[[:space:]]*$' "$f"; then
echo "[VERIFY] Enabled enterprise in: $f" | tee -a "$LOG"
bad=1
fi
fi
done < <(find /etc/apt/sources.list.d -maxdepth 1 -type f -name '*.sources' -print0 2>/dev/null)
(( bad == 0 )) || die "Enterprise repo still detected. Clean failed."
}
# Disabled deb822 stub WITHOUT enterprise domain (so greps never trip)
write_disabled_enterprise_sources() {
cat > /etc/apt/sources.list.d/pve-enterprise.sources <<'EOF'
Types: deb
URIs: https://example.invalid/proxmox # placeholder to avoid enterprise domain
Suites: trixie
Components: pve-enterprise
Enabled: no
EOF
chmod 0644 /etc/apt/sources.list.d/pve-enterprise.sources
}
# --------- FIXED: robust locator for pve8to9 + debug ----------
locate_pve8to9() {
hash -r 2>/dev/null || true
local tool=""
tool="$(type -P pve8to9 2>/dev/null || true)"
[[ -z "$tool" ]] && tool="$(command -v pve8to9 2>/dev/null || true)"
[[ -z "$tool" && -x /usr/bin/pve8to9 ]] && tool="/usr/bin/pve8to9"
[[ -z "$tool" && -x /usr/sbin/pve8to9 ]] && tool="/usr/sbin/pve8to9"
[[ -z "$tool" ]] && tool="$(/usr/bin/which pve8to9 2>/dev/null || true)"
if [[ -z "$tool" ]] && command -v dpkg >/dev/null 2>&1; then
local cand
cand="$(dpkg -L pve-manager 2>/dev/null | grep -E '/pve8to9$' || true)"
[[ -n "$cand" && -x "$cand" ]] && tool="$cand"
fi
echo "$tool"
}
run_pve8to9_precheck() {
[[ "$SKIP_PRECHECK" == "1" ]] && { msg "SKIP_PRECHECK=1 set — skipping pve8to9."; return 0; }
msg "Running pve8to9 --full precheck..."
msg "PATH=$PATH"
type -a pve8to9 2>&1 | sed 's/^/[type] /' | tee -a "$LOG" || true
[[ -e /usr/bin/pve8to9 ]] && ls -l /usr/bin/pve8to9 | sed 's/^/[ls] /' | tee -a "$LOG" || true
local tool; tool="$(locate_pve8to9)"
if [[ -z "$tool" ]]; then
msg "pve8to9 not found — attempting to ensure 'pve-manager' is installed..."
apt-get update -y >> "$LOG" 2>&1 || true
apt-get install -y pve-manager >> "$LOG" 2>&1 || true
cleanup_nonlist_sources; verify_no_enterprise
hash -r 2>/dev/null || true
tool="$(locate_pve8to9)"
fi
if [[ -z "$tool" ]]; then
msg "pve8to9 still not available on this system. Continuing without precheck."
return 0
fi
msg "pve8to9 found at: $tool"
local tmp rc
tmp="$(mktemp)"
set +e
"$tool" --full | tee -a "$LOG" | tee "$tmp" >/dev/null
rc=${PIPESTATUS[0]}
set -e
if grep -qE 'FAILURES:\s*[1-9]+' "$tmp"; then
rm -f "$tmp"
die "pve8to9 reported FAILURES. Check log above."
fi
rm -f "$tmp"
msg "pve8to9 executed (rc=$rc). No FAILURES."
}
# ---------------------------------------------------------------
# ----- header / live log hint -----
echo "== Proxmox VE 8 -> 9 upgrade started: $(date) ==" | tee -a "$LOG"
echo "$$" > /run/pve8to9.pid
ln -sfn "$LOG" /root/pve-upgrade-latest.log
echo "Live log: tail -f /root/pve-upgrade-latest.log" | tee -a "$LOG"
echo "If started via nohup, also watch its file (example: /root/pve8to9.<ts>.nohup.log)" | tee -a "$LOG"
# 0) PRE-NUKE ENTERPRISE + move backups OUT of APT dir
msg "[PRE-NUKE] Backup APT lists and clean directory..."
mkdir -p "$APT_BACKUP_DIR"
cp -a /etc/apt/sources.list "$APT_BACKUP_DIR"/ 2>/dev/null || true
cp -a /etc/apt/sources.list.d "$APT_BACKUP_DIR"/ 2>/dev/null || true
cleanup_nonlist_sources
# Remove any *.sources referencing Enterprise
for s in /etc/apt/sources.list.d/*.sources; do
[[ -f "$s" ]] || continue
grep -qi 'enterprise\.proxmox\.com' "$s" && { msg " -> removing: $s"; sink "$s"; }
done
# Remove typical filenames
sink /etc/apt/sources.list.d/pve-enterprise.sources
sink /etc/apt/sources.list.d/pve-enterprise.list
# For *.list with Enterprise: comment lines or remove if Enterprise-only
for l in /etc/apt/sources.list.d/*.list; do
[[ -f "$l" ]] || continue
if grep -qi 'enterprise\.proxmox\.com' "$l"; then
if awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*deb/ && $0 !~ /enterprise\.proxmox\.com/ {ok=1} END{exit ok?0:1}' "$l"; then
msg " -> commenting Enterprise lines in: $l"
sed -ri 's|^\s*deb\s+https?://enterprise\.proxmox\.com|#DISABLED-BY-SCRIPT &|I' "$l"
else
msg " -> removing Enterprise-only list: $l"
sink "$l"
fi
fi
done
# Comment Enterprise in main sources.list
[[ -f /etc/apt/sources.list ]] && sed -ri 's|^\s*deb\s+https?://enterprise\.proxmox\.com|#DISABLED-BY-SCRIPT &|I' /etc/apt/sources.list || true
# Diversions + disabled deb822 file (with example.invalid), then re-clean any .distrib/.bak
divert_add /etc/apt/sources.list.d/pve-enterprise.list
divert_add /etc/apt/sources.list.d/pve-enterprise.sources
write_disabled_enterprise_sources
cleanup_nonlist_sources
verify_no_enterprise
# 1) Sanity checks
if [[ $EUID -ne 0 ]]; then die "Run as root."; fi
if ! command -v pveversion >/dev/null 2>&1; then die "Not a Proxmox VE host."; fi
CUR_VER_RAW="$(pveversion || true)"; msg "Current pveversion: $CUR_VER_RAW"
if ! echo "$CUR_VER_RAW" | grep -qE 'pve-manager/8\.'; then
msg "Expected Proxmox 8.x. Detected: $CUR_VER_RAW"
[[ "$FORCE_UPGRADE" == "1" ]] || die "Set FORCE_UPGRADE=1 to override."
fi
# BEFORE snapshot
{
echo; echo "===== BEFORE UPGRADE ====="; date
echo "# uname -a"; uname -a || true
echo; echo "# pveversion"; pveversion || true
echo; echo "# qm list"; qm list || true
echo; echo "# pct list"; pct list || true
} >> "$LOG" 2>&1
# 2) pve8to9 precheck (robust)
run_pve8to9_precheck
# 3) Keyring + initial apt refresh
apt-get update -y >> "$LOG" 2>&1 || true
apt-get install -y proxmox-archive-keyring >> "$LOG" 2>&1 || true
verify_no_enterprise
cleanup_nonlist_sources
# 4) Switch bookworm -> trixie
msg "Switching Debian repositories: bookworm -> trixie ..."
sed -i 's/bookworm/trixie/g' /etc/apt/sources.list || true
find /etc/apt/sources.list.d -maxdepth 1 -type f -exec sed -i 's/bookworm/trixie/g' {} \; || true
# 5) Ensure single no-subscription entry
sed -ri '/download\.proxmox\.com\/debian\/pve.*pve-no-subscription/s/^/#DUPLICATE-BY-SCRIPT /' /etc/apt/sources.list || true
for l in /etc/apt/sources.list.d/*.list; do
[[ -f "$l" ]] || continue
[[ "$l" == "/etc/apt/sources.list.d/pve-no-subscription.list" ]] && continue
sed -ri '/download\.proxmox\.com\/debian\/pve.*pve-no-subscription/s/^/#DUPLICATE-BY-SCRIPT /' "$l" || true
done
cat > /etc/apt/sources.list.d/pve-no-subscription.list <<'EOF'
deb http://download.proxmox.com/debian/pve trixie pve-no-subscription
EOF
chmod 0644 /etc/apt/sources.list.d/pve-no-subscription.list
verify_no_enterprise
cleanup_nonlist_sources
# 6) Full non-interactive upgrade
msg "Running apt-get update + non-interactive upgrade/dist-upgrade..."
TMPU="$(mktemp)"
apt-get update 2>&1 | tee -a "$LOG" | tee "$TMPU" >/dev/null
if grep -q 'enterprise\.proxmox\.com' "$TMPU"; then rm -f "$TMPU"; die "APT tried to reach Enterprise during update."; fi
rm -f "$TMPU"
apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade -y | tee -a "$LOG"
apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade -y | tee -a "$LOG"
# Second cleanup pass
cleanup_nonlist_sources
apt-get autoremove -y | tee -a "$LOG" || true
apt-get update -y >> "$LOG" 2>&1 || true
# 7) One-shot post-boot verification
POST_SH="/root/pve-postcheck.sh"
POST_SVC="/etc/systemd/system/pve-upgrade-postcheck.service"
cat > "$POST_SH" <<'EOS'
#!/bin/bash
set -euo pipefail
LOG_FILE="/root/pve-upgrade-post-$(date +%Y%m%d-%H%M%S).log"
{
echo "===== AFTER UPGRADE (first boot) ====="; date
echo "# uname -a"; uname -a || true
echo; echo "# pveversion"; pveversion || true
echo; echo "# apt policy (trixie check)"; apt-cache policy | sed -n '1,120p' || true
} >> "$LOG_FILE" 2>&1
systemctl disable --now pve-upgrade-postcheck.service >/dev/null 2>&1 || true
rm -f /root/pve-postcheck.sh
EOS
chmod +x "$POST_SH"
cat > "$POST_SVC" <<'EOS'
[Unit]
Description=Proxmox upgrade post-check (one-shot)
After=multi-user.target pvedaemon.service pve-cluster.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/bash /root/pve-postcheck.sh
[Install]
WantedBy=multi-user.target
EOS
systemctl daemon-reload
systemctl enable pve-upgrade-postcheck.service >> "$LOG" 2>&1 || true
# 8) Pre-reboot snapshot
{
echo; echo "===== PRE-REBOOT SNAPSHOT ====="; date
echo "# Installed pve kernels:"; dpkg -l | grep -E '^ii\s+pve-kernel' | sort -V || true
} >> "$LOG" 2>&1
msg "Upgrade phase completed. Log: $LOG"
if [[ "$NO_REBOOT" == "1" ]]; then
msg "NO_REBOOT=1 set — skipping reboot. Please reboot manually."
else
msg "Rebooting now to complete the upgrade..."
sleep 3
reboot
fi