#!/bin/bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Thomas Wagner
#
# Update the running Debian and openSUSE Leap VMs of a KVM host through the
# QEMU guest agent. No SSH or password is needed.
#   Debian: apt-get update and apt-get upgrade (like "apt upgrade": new
#           dependencies are installed, nothing is removed)
#   Leap:   zypper refresh and zypper update --no-recommends
#
# Before upgrading, each VM is checked for anything the package manager would
# ask about; the packages are downloaded first. A VM is skipped if
#   Debian: dpkg would ask about a conffile (changed locally and in the new
#           package), or a file managed by ucf is changed locally and its
#           package is upgraded (ucf may ask then). Not detected: debconf
#           questions (they are answered with their defaults).
#   Leap:   the dry run reports a dependency problem, packages to be removed,
#           or a license to agree to. Configuration files never stop zypper:
#           rpm keeps a changed file and puts the package's version next to
#           it (.rpmnew), or saves the changed one (.rpmsave); those files are
#           listed after the upgrade, to merge by hand.
# Skipped VMs are logged with priority err, for a log monitor to report right
# away; upgrade those manually.
#
# Afterwards each VM is checked for a needed reboot (newer or rebuilt kernel,
# /run/reboot-required, zypper needs-rebooting); VMs are never rebooted by
# this script.
#
# Hooks: when a VM is upgraded, the executable files in its
# /etc/kvm-guest-update/pre.d, post.d and cleanup.d are run in name order
# (C locale): pre.d before the upgrade, post.d after a successful upgrade,
# cleanup.d at the end, always. A failing pre hook stops everything except the
# cleanup hooks (no further pre hooks, no upgrade, no post hooks); a failing
# post hook stops the remaining post hooks. The cleanup hooks all run, also
# when one of them fails. Not run in check-only mode, nor when there is nothing
# to upgrade, unless -H is given. Failed hooks are logged with priority err.
#
# Usage: kvm-guest-update [-n | -H] [-o OS[,OS...]] [VM ...]
#        kvm-guest-update -l
#   -n    check only: refresh, download and the check, no upgrade, no notification
#   -H    run the hooks also in VMs with nothing to upgrade
#   -o    only VMs with these operating systems (ID from os-release, see -l)
#   -l    list the running VMs and their operating systems, and exit
#   VM    only these VMs (default: every running VM with a supported operating system)
#
# The upgrade runs in each VM as kvm-guest-update.service, independent of this
# script and of the guest agent: when this script is interrupted, the upgrade
# in the VM still finishes.
#
# Logs: /var/log/kvm-guest-update/<date>/<vm>.log on the host,
#       /var/log/kvm-guest-update.log in each VM (output of its last upgrade)

NAME=kvm-guest-update
TIMEOUT=3600                     # seconds per VM and step
HOOKDIR=/etc/$NAME               # in the VM: pre.d, post.d, cleanup.d
LOGDIR=/var/log/$NAME/$(date +%F_%H%M)

# Supported operating systems: the ID the guest agent reports (from
# os-release) and the package manager the VM is updated with.
declare -A PM=([debian]=apt [opensuse-leap]=zypper)
# per package manager: the scripts run in the VM, and names for the summary
declare -A CHECK UPGRADE REBOOTCHECK
declare -A CHECKNAME=([apt]="apt-get update or download" [zypper]="zypper refresh, dry run or download")
declare -A UPGRADENAME=([apt]="apt-get upgrade" [zypper]="zypper update")

usage() {
    sed -n 's/^# \{0,1\}//; /^Usage:/,/^  VM/p' "$0"
}

CHECKONLY=""
HOOKSALWAYS=""
LIST=""
OSES=()
while getopts nHlo:h opt; do
    case $opt in
        n) CHECKONLY=1 ;;
        H) HOOKSALWAYS=1 ;;
        l) LIST=1 ;;
        o) IFS=, read -ra os <<< "$OPTARG"; OSES+=("${os[@]}") ;;
        h) usage; exit 0 ;;
        *) usage >&2; exit 2 ;;
    esac
done
shift $((OPTIND - 1))
if [ -n "$CHECKONLY" ] && [ -n "$HOOKSALWAYS" ]; then
    echo "$NAME: -n and -H exclude each other: a check does not run hooks" >&2
    exit 2
fi
for os in "${OSES[@]}"; do
    [ -n "$os" ] && [ -n "${PM[$os]}" ] && continue
    echo "$NAME: operating system not supported: $os (supported: ${!PM[*]})" >&2
    exit 2
done

[ "$(id -u)" = 0 ] || { echo "$NAME: must be run as root" >&2; exit 1; }
for c in virsh jq base64 logger; do
    command -v $c > /dev/null || { echo "$NAME: $c not found" >&2; exit 1; }
done

agent() {   # agent <vm> <json>: the "return" value of a guest agent command
    virsh qemu-agent-command "$1" "$2" 2>/dev/null | jq -c '.return'
}

osinfo() {  # osinfo <vm>: the guest agent's guest-get-osinfo, empty without a guest agent
    agent "$1" '{"execute":"guest-get-osinfo"}'
}

pm() {      # pm <os id>: the package manager for this operating system, empty if not supported
    [ -n "$1" ] && echo "${PM[$1]}"
}

### apt (Debian)

# In the VM: update, download the upgrades, look for prompts.
# Exit code 0: no questions expected, 10: questions expected, 1: apt failed.
read -r -d '' 'CHECK[apt]' <<'EOF'
export DEBIAN_FRONTEND=noninteractive
APT="apt-get -q -y -o DPkg::Lock::Timeout=600"
$APT update || exit 1
# "name version" of each package to install or upgrade
SIM=$(apt-get -s --with-new-pkgs upgrade)
mapfile -t PKGS < <(awk '/^Inst /{v=$0; sub(/.*\(/, "", v); split(v, a, " "); print $2, a[1]}' <<< "$SIM")
echo "UPGRADES: ${#PKGS[@]}"
# what is updated: name, installed version -> new version (or "new")
awk '/^Inst /{old = ($3 ~ /^\[/) ? substr($3, 2, length($3) - 2) : "new"; v = $0; sub(/.*\(/, "", v); split(v, a, " "); printf "  %-40s %s -> %s\n", $2, old, a[1]}' <<< "$SIM"
[ ${#PKGS[@]} -eq 0 ] && exit 0
$APT -d --with-new-pkgs upgrade > /dev/null || exit 1

md5() { md5sum | cut -d' ' -f1; }
prompts=0
names=" "
for e in "${PKGS[@]}"; do
    p=${e% *}; v=${e#* }; pn=${p%%:*}; names="$names$pn "
    deb=$(ls /var/cache/apt/archives/"${pn}_${v//:/%3a}"_*.deb 2>/dev/null | head -1)
    if [ -z "$deb" ]; then echo "PROMPT: $p $v: package file not found, cannot check"; prompts=1; continue; fi
    installed=$(dpkg-query -W -f='${Conffiles}\n' "$p" 2>/dev/null)
    while read -r cf; do
        [ -e "$cf" ] || continue
        cur=$(md5 < "$cf")
        new=$(dpkg-deb --fsys-tarfile "$deb" | tar -xO ".$cf" 2>/dev/null | md5)
        [ "$cur" = "$new" ] && continue
        orig=$(awk -v f="$cf" '$1 == f {print $2}' <<< "$installed")
        if [ -z "$orig" ] || [ "$orig" = newconffile ]; then
            echo "PROMPT: $pn: $cf exists and differs from the new package's file"; prompts=1
        elif [ "$cur" != "$orig" ] && [ "$new" != "$orig" ]; then
            echo "PROMPT: $pn: $cf is changed locally and in the new package"; prompts=1
        fi
    done < <(dpkg-deb --ctrl-tarfile "$deb" | tar -xO ./conffiles 2>/dev/null | sed -n 's|^\(remove-on-upgrade \)\{0,1\}\(/.*\)|\2|p')
done

# files managed by ucf: changed locally, and their package is upgraded
if [ -r /var/lib/ucf/registry ] && [ -r /var/lib/ucf/hashfile ]; then
    while read -r up f; do
        case "$names" in *" $up "*) ;; *) continue ;; esac
        [ -e "$f" ] || continue
        h=$(awk -v f="$f" '$2 == f {print $1}' /var/lib/ucf/hashfile)
        [ -n "$h" ] && [ "$(md5 < "$f")" != "$h" ] && { echo "PROMPT: $up: $f (ucf) is changed locally, may be asked about"; prompts=1; }
    done < /var/lib/ucf/registry
fi
[ $prompts = 1 ] && exit 10
exit 0
EOF

# In the VM: the upgrade, sets rc; needrestart only lists, it does not ask.
read -r -d '' 'UPGRADE[apt]' <<'EOF'
export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l
echo "=== $(date '+%F %T') apt-get upgrade"
apt-get -q -y -o DPkg::Lock::Timeout=600 -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold \
    --with-new-pkgs upgrade
rc=$?
EOF

# In the VM: is a reboot needed? A newer kernel is installed than the running
# one, the running kernel's package was rebuilt (same name, other build), or
# /run/reboot-required exists (only created by some packages' hooks on Debian).
read -r -d '' 'REBOOTCHECK[apt]' <<'EOF'
r=""
running=$(uname -r)
newest=$(ls /boot/vmlinuz-* 2>/dev/null | sed 's|^/boot/vmlinuz-||' | sort -V | tail -1)
if [ -n "$newest" ] && [ "$newest" != "$running" ]; then
    r="kernel $running -> $newest"
else
    inst=$(dpkg-query -W -f='${Version}' "linux-image-$running" 2>/dev/null)
    [ -n "$inst" ] && ! uname -v | grep -qF " $inst " && r="kernel $running rebuilt: $inst installed"
fi
if [ -e /run/reboot-required ]; then
    p=$(tr '\n' ' ' < /run/reboot-required.pkgs 2>/dev/null | sed 's/ *$//')
    r="${r:+$r, }${p:-/run/reboot-required}"
fi
[ -n "$r" ] && echo "REBOOT REQUIRED: $r"
exit 0
EOF

### zypper (openSUSE Leap)

# In the VM: refresh, look for questions in a dry run, download the upgrades.
# Exit code 0: no questions expected, 10: questions expected, 1: zypper failed.
read -r -d '' 'CHECK[zypper]' <<'EOF'
export LC_ALL=C ZYPP_LOCK_TIMEOUT=600
ZYPPER="zypper -n --no-refresh"
# a repository that cannot be refreshed is skipped, and reported
if ! REF=$(zypper -n refresh 2>&1); then
    bad=$(sed -nE "s/^Repository '(.*)' is invalid\.$/\1/p" <<< "$REF" | sort -u | paste -sd, | sed 's/,/, /g')
    echo "REPOSITORY PROBLEM: ${bad:-$(tail -1 <<< "$REF")}"
fi
SIM=$($ZYPPER update --dry-run --no-recommends 2>&1)
rc=$?
# number of packages: "176 packages to upgrade, 5 new." or "Nothing to do."
n=$(grep -E '^[0-9]+ .*(to upgrade|to install|new)' <<< "$SIM" | head -1 | grep -oE '[0-9]+' | awk '{s += $1} END {print s + 0}')
echo "UPGRADES: ${n:-0}"
# what is updated: name, installed version -> new version
$ZYPPER list-updates 2>/dev/null | awk -F'|' '$1 ~ /^v / {gsub(/ /, "", $3); gsub(/ /, "", $4); gsub(/ /, "", $5); printf "  %-40s %s -> %s\n", $3, $4, $5}'
new=$(sed -En '/NEW packages? (is|are) going to be installed/,/^$/p' <<< "$SIM" | tail -n +2 | tr -s ' \n' ' ' | sed 's/^ *//; s/ *$//')
[ -n "$new" ] && echo "  new: $new"
prompts=0
grep -E '^Problem:' <<< "$SIM" | sed 's/^/PROMPT: dependencies: /' | grep . && prompts=1
if grep -q 'going to be REMOVED' <<< "$SIM"; then
    echo "PROMPT: removals: $(sed -n '/going to be REMOVED/,/^$/p' <<< "$SIM" | tail -n +2 | tr -s ' \n' ' ')"; prompts=1
fi
grep -qiE 'agree.*licen[cs]e|licen[cs]e.*(agree|confirm)' <<< "$SIM" && { echo "PROMPT: license: a package needs a license confirmation"; prompts=1; }
[ $prompts = 1 ] && exit 10
case $rc in 0|100|101|102|103) ;; *) echo "$SIM" | tail -5; exit 1 ;; esac
[ "${n:-0}" = 0 ] && exit 0
$ZYPPER update --no-recommends --download-only > /dev/null || exit 1
exit 0
EOF

# In the VM: the upgrade, sets rc; changed configuration files are listed afterwards.
read -r -d '' 'UPGRADE[zypper]' <<'EOF'
export LC_ALL=C ZYPP_LOCK_TIMEOUT=600
start=$(mktemp /run/kvm-guest-update.XXXXXX)
echo "=== $(date '+%F %T') zypper update"
for try in 1 2; do
    zypper -n --no-refresh update --no-recommends
    rc=$?
    # 103: zypper updated itself, and has to run again for the rest
    [ $rc = 103 ] && [ $try = 1 ] && { echo "=== zypper updated itself, running it again"; continue; }
    break
done
# 100-103 are informational (updates, reboot or restart needed)
case $rc in 100|101|102|103) rc=0 ;; esac
cf=$(find /etc -xdev \( -name '*.rpmnew' -o -name '*.rpmsave' \) -newer "$start" 2>/dev/null | tr '\n' ' ')
[ -n "$cf" ] && echo "CONFIG FILES TO MERGE: $cf"
rm -f "$start"
EOF

# In the VM: is a reboot needed? A newer kernel is installed than the running
# one, or zypper needs-rebooting says so (core libraries or services updated).
read -r -d '' 'REBOOTCHECK[zypper]' <<'EOF'
r=""
running=$(uname -r)
newest=$(ls /boot/vmlinuz-* 2>/dev/null | sed 's|^/boot/vmlinuz-||' | grep -v '\.old$' | sort -V | tail -1)
[ -n "$newest" ] && [ "$newest" != "$running" ] && r="kernel $running -> $newest"
zypper -n needs-rebooting > /dev/null 2>&1
[ $? = 102 ] && r="${r:+$r, }zypper needs-rebooting"
[ -n "$r" ] && echo "REBOOT REQUIRED: $r"
exit 0
EOF

### hooks

# In the VM: hooks <stage> [all]: run the executable files in $HOOKDIR/<stage>.d
# in name order (C locale), with KVM_GUEST_UPDATE_STAGE and
# KVM_GUEST_UPDATE_STATUS (ok, pre-failed, upgrade-failed, post-failed) in their
# environment. Stops at the first failing hook unless "all" is given; returns 1
# if a hook failed. Leftovers of editors and package managers are not run. A
# hook not owned by the user running it (root) or writable by group or others
# is not run and counts as failed.
read -r -d '' HOOKS <<'EOF'
hooks() {
    local stage=$1 all=$2 f name rc failed=0
    local -a files
    mapfile -t files < <(printf '%s\n' "$HOOKDIR/$stage.d"/* | LC_ALL=C sort)
    for f in "${files[@]}"; do
        name=${f##*/}
        [ -f "$f" ] && [ -x "$f" ] || continue
        case $name in *~|*.bak|*.dpkg-*|*.rpmnew|*.rpmsave|*.rpmorig) continue ;; esac
        echo "=== $(date '+%F %T') $stage hook $name"
        if [ "$(stat -L -c %u "$f")" != "$(id -u)" ] || (( 8#$(stat -L -c %a "$f") & 8#022 )); then
            echo "HOOK FAILED: $stage/$name: not owned by $(id -un), or writable by group or others"
            rc=1
        else
            KVM_GUEST_UPDATE_STAGE=$stage KVM_GUEST_UPDATE_STATUS=$status "$f" < /dev/null
            rc=$?
            [ $rc = 0 ] || echo "HOOK FAILED: $stage/$name: exit code $rc"
        fi
        [ $rc = 0 ] && continue
        failed=1
        [ -n "$all" ] || break
    done
    return $failed
}
EOF

# In the VM, with -H when there is nothing to upgrade: the upgrade between the hooks.
read -r -d '' NOUPGRADE <<'EOF'
echo "=== $(date '+%F %T') nothing to upgrade, running the hooks only"
rc=0
EOF

# In the VM, around the upgrade (pm_upgrade, which sets rc): pre hooks, the
# upgrade, post hooks, cleanup hooks. Reports the upgrade's exit code, or
# "pre-hook" if a pre hook failed and nothing was upgraded.
read -r -d '' HOOKMAIN <<'EOF'
status=ok
if hooks pre; then
    pm_upgrade
    if [ "$rc" = 0 ]; then
        hooks post || status=post-failed
    else
        status=upgrade-failed
    fi
else
    status=pre-failed rc=pre-hook
fi
hooks cleanup all
echo "EXIT: $rc"
EOF

questions() {   # questions <pm> <log>: the short list of expected questions for the summary
    case $1 in
        apt)    sed -n 's/^PROMPT: \([^:]*\): \([^ ]*\).*/ \1 (\2)/p' "$2" ;;
        zypper) sed -n 's/^PROMPT: \([^:]*\):.*/ \1/p' "$2" ;;
    esac | sort -u | tr -d '\n'
}

### running the upgrade

# In the VM, the upgrade runs as a systemd unit of its own
# (kvm-guest-update.service), not as a child of the guest agent: an upgrade of
# qemu-guest-agent stops or restarts the agent, which would kill the upgrade
# with it. So the upgrade also finishes when this script is interrupted
# (Ctrl+C, SSH disconnect).
UPGRADE_SCRIPT=/root/$NAME-upgrade.sh
VMLOG=/var/log/$NAME.log                     # in the VM: the output of the last upgrade
read -r -d '' START <<EOF
if systemctl is-active --quiet $NAME; then echo "an upgrade is already running in this VM, see $VMLOG"; exit 6; fi
: > $VMLOG
systemd-run --unit=$NAME --collect --quiet -p StandardOutput=append:$VMLOG -p StandardError=append:$VMLOG \
    /bin/bash $UPGRADE_SCRIPT
EOF

run() {     # run <vm> <script> <log>: run <script> in the VM, show its output and append it to <log>; returns its exit code
            # (the guest agent returns the output when the script has finished)
    local vm=$1 script=$2 log=$3 pid status i
    pid=$(agent "$vm" "$(jq -nc --arg c "$script" \
        '{execute:"guest-exec",arguments:{path:"/bin/bash",arg:["-c",$c],"capture-output":true}}')" | jq -r '.pid // empty')
    [ -n "$pid" ] || { echo "guest-exec failed" | tee -a "$log"; return 3; }
    for ((i = 0; i < TIMEOUT; i += 5)); do
        status=$(agent "$vm" "{\"execute\":\"guest-exec-status\",\"arguments\":{\"pid\":$pid}}")
        [ "$(jq -r '.exited' <<< "$status")" = true ] && break
        sleep 5
    done
    if [ "$(jq -r '.exited' <<< "$status")" != true ]; then
        echo "timeout after $TIMEOUT s, still running in the VM (pid $pid)" | tee -a "$log"
        return 4
    fi
    { jq -r '."out-data" // empty' <<< "$status" | base64 -d
      jq -r '."err-data" // empty' <<< "$status" | base64 -d; } | tee -a "$log"
    return "$(jq -r '.exitcode // 5' <<< "$status")"
}

put() {     # put <vm> <file> <content>: write <content> to <file> in the VM
    local h
    h=$(agent "$1" "$(jq -nc --arg p "$2" '{execute:"guest-file-open",arguments:{path:$p,mode:"w"}}')")
    [[ $h =~ ^[0-9]+$ ]] || return 1
    agent "$1" "$(jq -nc --argjson h "$h" --arg b "$(printf '%s\n' "$3" | base64 -w0)" \
        '{execute:"guest-file-write",arguments:{handle:$h,"buf-b64":$b}}')" > /dev/null
    agent "$1" "{\"execute\":\"guest-file-close\",\"arguments\":{\"handle\":$h}}" > /dev/null
}

upgrade() { # upgrade <vm> <pm> <log> [hooks-only]: start the upgrade unit in the VM, wait for it, show
            # its output and append it to <log>; with hooks-only, the unit runs only the hooks. Returns 0,
            # 1 if the package manager failed (its exit code is in PMRC), 7 if a pre hook failed, or 3-6
            # (see the summary)
    local vm=$1 pm=$2 log=$3 body=${UPGRADE[$2]} state i out rc
    [ "$4" = hooks-only ] && body=$NOUPGRADE
    put "$vm" "$UPGRADE_SCRIPT" "HOOKDIR=$HOOKDIR
$HOOKS
pm_upgrade() {
$body
}
$HOOKMAIN" || { echo "cannot write $UPGRADE_SCRIPT" | tee -a "$log"; return 3; }
    run "$vm" "$START" "$log" || return $?
    for ((i = 0; i < TIMEOUT; i += 10)); do
        sleep 10
        # empty while the guest agent restarts (when its own package is upgraded): keep waiting
        state=$(run "$vm" "systemctl is-active $NAME" /dev/null)
        case $state in inactive|failed) break ;; esac
    done
    case $state in
        inactive|failed) ;;
        *) echo "timeout after $TIMEOUT s, the upgrade is still running in the VM, see $VMLOG there" | tee -a "$log"
           return 4 ;;
    esac
    out=$(run "$vm" "cat $VMLOG" "$log") || return 3
    sed '/^EXIT: /d' <<< "$out"
    rc=$(sed -n 's/^EXIT: //p' <<< "$out" | tail -1)
    case $rc in
        0)        return 0 ;;
        pre-hook) return 7 ;;
        '')       return 5 ;;
        *)        PMRC=$rc; return 1 ;;
    esac
}

interrupted() {
    echo
    echo "interrupted${vm:+ while processing $vm}: an upgrade running in the VM continues, its output is in $VMLOG there"
    logger -t $NAME "interrupted${vm:+ while processing $vm}"
    exit 130
}

### main

mapfile -t RUNNING < <(virsh list --name | grep .)
is_running() { printf '%s\n' "${RUNNING[@]}" | grep -qxF "$1"; }

if [ -n "$LIST" ]; then
    printf '%-24s %-16s %-8s %s\n' VM OS UPDATE "PRETTY NAME"
    for vm in "${RUNNING[@]}"; do
        info=$(osinfo "$vm")
        os=$(jq -r '.id // empty' <<< "$info")
        pm=$(pm "$os")
        printf '%-24s %-16s %-8s %s\n' "$vm" "${os:-(no agent)}" "${pm:--}" "$(jq -r '."pretty-name" // empty' <<< "$info")"
    done
    exit 0
fi

trap interrupted INT TERM HUP
if [ $# -gt 0 ]; then VMS=("$@"); else VMS=("${RUNNING[@]}"); fi

mkdir -p "$LOGDIR"
logger -t $NAME "start${CHECKONLY:+ (check only)}"
summary=()
alert=()        # VMs reported with priority err: manual upgrade needed, or a hook failed
ignored=()
for vm in "${VMS[@]}"; do
    reason=""
    if ! is_running "$vm"; then
        reason="not running"
    else
        os=$(osinfo "$vm" | jq -r '.id // empty')
        pm=$(pm "$os")
        if [ -z "$os" ]; then reason="no guest agent"
        elif [ -z "$pm" ]; then reason="$os is not supported"
        elif [ ${#OSES[@]} -gt 0 ] && ! printf '%s\n' "${OSES[@]}" | grep -qxF "$os"; then reason="$os is not selected"
        fi
    fi
    if [ -n "$reason" ]; then
        # named VMs: always reported; all VMs: those not selected by -o are left out silently
        if [ $# -gt 0 ]; then summary+=("$vm: skipped, $reason")
        elif [[ $reason != *"not selected" ]]; then ignored+=("$vm ($reason)")
        fi
        continue
    fi

    echo "=== $vm ($os)"
    log=$LOGDIR/$vm.log
    run "$vm" "${CHECK[$pm]}" "$log"
    rc=$?
    n=$(sed -n 's/^UPGRADES: //p' "$log" | tail -1)
    case $rc in
        0)  if [ "${n:-0}" = 0 ] && [ -z "$HOOKSALWAYS" ]; then result="up to date"
            elif [ -n "$CHECKONLY" ]; then result="$n packages, no questions expected"
            else
                only=""
                [ "${n:-0}" = 0 ] && only=hooks-only
                upgrade "$vm" "$pm" "$log" $only
                rc=$?
                case $rc in
                    0) if [ -n "$only" ]; then result="up to date, hooks run"; else result="upgraded $n packages"; fi ;;
                    1) result="FAILED: ${UPGRADENAME[$pm]}, exit code $PMRC, see $VMLOG in the VM" ;;
                    3) result="FAILED: guest agent, the upgrade did not start" ;;
                    4) result="TIMEOUT, the upgrade is still running in the VM" ;;
                    5) result="FAILED: no result, see $VMLOG in the VM" ;;
                    6) result="SKIPPED: an upgrade is already running in the VM" ;;
                    7) result="SKIPPED: a pre hook failed, nothing upgraded" ;;
                    *) result="FAILED: exit code $rc" ;;
                esac
                cf=$(sed -n 's/^CONFIG FILES TO MERGE: //p' "$log" | tail -1)
                [ -n "$cf" ] && result="$result, CONFIG FILES TO MERGE: $cf"
                hf=$(sed -n 's/^HOOK FAILED: //p' "$log" | paste -sd ';' | sed 's/;/; /g')
                [ -n "$hf" ] && { result="$result, HOOK FAILED: $hf"; alert+=("$vm"); }
            fi ;;
        10) result="MANUAL UPGRADE NEEDED, $n packages, questions expected:$(questions "$pm" "$log")"
            alert+=("$vm") ;;
        1)  result="FAILED: ${CHECKNAME[$pm]}" ;;
        3)  result="FAILED: guest-exec" ;;
        4)  result="TIMEOUT in the check" ;;
        *)  result="FAILED: check, exit code $rc" ;;
    esac
    repo=$(sed -n 's/^REPOSITORY PROBLEM: //p' "$log" | head -1)
    [ -n "$repo" ] && result="$result, REPOSITORY PROBLEM: $repo"
    # after the check, or after the upgrade has finished
    reboot=$(run "$vm" "${REBOOTCHECK[$pm]}" "$log" | sed -n 's/^REBOOT REQUIRED: //p')
    [ -n "$reboot" ] && result="$result, REBOOT REQUIRED: $reboot"
    echo "$result"
    summary+=("$vm: $result")
done
vm=""

echo
echo "=== Summary${CHECKONLY:+ (check only)}, logs in $LOGDIR"
printf '%s\n' "${summary[@]}"
[ ${#ignored[@]} -gt 0 ] && echo "not updated: $(printf '%s, ' "${ignored[@]}" | sed 's/, $//')"
logger -t $NAME "done: $(printf '%s; ' "${summary[@]}")"
# priority err: for a log monitor to report right away
[ -z "$CHECKONLY" ] && for vm in "${alert[@]}"; do
    for s in "${summary[@]}"; do
        [[ $s == "$vm: "* ]] && logger -p user.err -t $NAME "$s"
    done
done
exit 0
