From 918de1c5515dc8bb3a096f897ffc1a9ab634326f Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:54:56 +0200 Subject: [PATCH] Fix Fedora and BLS cloud image console setup Use grubby for BLS kernels, preserve serial console settings, keep GRUB regeneration failures visible, and cover both VM backends with regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/verify-split-files.yml | 5 + docs/backends.md | 22 +++ incus/vm-core.func | 49 ++++- pve/vm-core.func | 49 ++++- tests/vm-consoles.sh | 237 +++++++++++++++++++++++ 5 files changed, 354 insertions(+), 8 deletions(-) create mode 100644 tests/vm-consoles.sh diff --git a/.github/workflows/verify-split-files.yml b/.github/workflows/verify-split-files.yml index 0887025..e94b868 100644 --- a/.github/workflows/verify-split-files.yml +++ b/.github/workflows/verify-split-files.yml @@ -23,6 +23,7 @@ on: - "pve/**" - "incus/**" - "tests/vm-iso-storage.sh" + - "tests/vm-consoles.sh" - ".github/workflows/verify-split-files.yml" push: branches: [main] @@ -37,6 +38,7 @@ on: - "pve/**" - "incus/**" - "tests/vm-iso-storage.sh" + - "tests/vm-consoles.sh" - ".github/workflows/verify-split-files.yml" workflow_dispatch: @@ -61,6 +63,9 @@ jobs: - name: ISO storage regression tests run: bash tests/vm-iso-storage.sh + - name: VM console regression tests + run: bash tests/vm-consoles.sh + - name: Prefetch lists cover every engine file run: | set -euo pipefail diff --git a/docs/backends.md b/docs/backends.md index 7edaf23..a71bcf6 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -101,6 +101,28 @@ in what they can actually do: unset variable, and say once through `msg_warn` that they did nothing. They do not pretend to have succeeded. +### Cloud image consoles + +`vm_enable_consoles` enables both the graphical login on `tty1` and the serial +login on `ttyS0`. Fedora/BLS images use `grubby` to update installed kernel +entries, keeping existing non-graphical console arguments and their baud rates. +Images using `/etc/default/grub` use `update-grub`, `grub2-mkconfig`, or +`grub-mkconfig`, depending on which tool is installed. Regeneration failures +remain visible through the image-preparation warnings. A non-GRUB image without +that configuration is left unchanged. + +Kernel, SELinux policy-load and audit messages can appear over an interactive +console because the kernel sends output to its configured `console=` devices. +This is especially visible during package updates and policy reloads; a +policy-load message is not itself an access denial. Console setup does not +disable SELinux, auditing, or lower kernel log verbosity. +For a temporary quieter console, `sudo dmesg -n 5` suppresses kernel notices, +informational and debug messages on the console while keeping warnings/errors +and the kernel log itself. It resets at reboot and does not suppress all +userspace console output. + +Run the host-independent bootloader tests with `bash tests/vm-consoles.sh`. + ### Proxmox installer ISO storage Installer VMs call `vm_select_iso_storage "" [hostname]` before diff --git a/incus/vm-core.func b/incus/vm-core.func index f7139d3..38d6269 100644 --- a/incus/vm-core.func +++ b/incus/vm-core.func @@ -1586,10 +1586,51 @@ vm_enable_consoles() { fi' _vm_customize "grub console" "$image" --run-command \ - 'if [ -f /etc/default/grub ]; then - sed -i "s/console=ttyS0[^ \"]*/console=tty1 &/" /etc/default/grub - grep -q "console=tty1" /etc/default/grub || sed -i "s/\(GRUB_CMDLINE_LINUX_DEFAULT=\"\)/\1console=tty1 /" /etc/default/grub - command -v update-grub >/dev/null 2>&1 && update-grub + 'set -e + if command -v grubby >/dev/null 2>&1; then + kernel_info=$(grubby --info=DEFAULT) + if ! printf "%s\n" "$kernel_info" | grep -q "^args=\""; then + echo "error: grubby returned no default kernel arguments" >&2 + exit 1 + fi + all_kernel_info=$(grubby --info=ALL) + if ! printf "%s\n" "$all_kernel_info" | grep -q "^args=\""; then + echo "error: grubby returned no installed kernel arguments" >&2 + exit 1 + fi + remove_args=$(printf "%s\n" "$all_kernel_info" | + grep "^args=" | grep -o "console=[^ \"]*" | + sed "s/.*/console/" | tr "\n" " ") + console_args=$(printf "%s\n" "$kernel_info" | + grep "^args=" | grep -o "console=[^ \"]*" | + grep -vE "^console=tty[0-9]+(,|$)" | tr "\n" " ") + # Older grubby versions add before removing when both flags share a call. + if [ -n "$remove_args" ]; then + grubby --update-kernel=ALL --remove-args="$remove_args" + fi + grubby --update-kernel=ALL --args="console=tty1 ${console_args:-console=ttyS0,115200}" + elif [ -f /etc/default/grub ]; then + cmdline=GRUB_CMDLINE_LINUX + if ! grep -q "^${cmdline}=\"" /etc/default/grub; then + cmdline=GRUB_CMDLINE_LINUX_DEFAULT + fi + if ! grep -q "^${cmdline}=\"" /etc/default/grub; then + echo "error: No quoted GRUB kernel command line found" >&2 + exit 1 + fi + if ! grep "^${cmdline}=" /etc/default/grub | grep -Eq "console=tty1([[:space:],\"]|$)"; then + sed -i "s/^${cmdline}=\"/${cmdline}=\"console=tty1 /" /etc/default/grub + fi + if command -v update-grub >/dev/null 2>&1; then + update-grub + elif command -v grub2-mkconfig >/dev/null 2>&1; then + grub2-mkconfig -o /boot/grub2/grub.cfg + elif command -v grub-mkconfig >/dev/null 2>&1; then + grub-mkconfig -o /boot/grub/grub.cfg + else + echo "error: No supported GRUB configuration tool found" >&2 + exit 1 + fi fi' return 0 } diff --git a/pve/vm-core.func b/pve/vm-core.func index b0af6ca..5302dfd 100644 --- a/pve/vm-core.func +++ b/pve/vm-core.func @@ -2039,10 +2039,51 @@ vm_enable_consoles() { # first and ttyS0 last keeps qm terminal as the primary console while the # graphical one also shows the boot. _vm_customize "grub console" "$image" --run-command \ - 'if [ -f /etc/default/grub ]; then - sed -i "s/console=ttyS0[^ \"]*/console=tty1 &/" /etc/default/grub - grep -q "console=tty1" /etc/default/grub || sed -i "s/\(GRUB_CMDLINE_LINUX_DEFAULT=\"\)/\1console=tty1 /" /etc/default/grub - command -v update-grub >/dev/null 2>&1 && update-grub + 'set -e + if command -v grubby >/dev/null 2>&1; then + kernel_info=$(grubby --info=DEFAULT) + if ! printf "%s\n" "$kernel_info" | grep -q "^args=\""; then + echo "error: grubby returned no default kernel arguments" >&2 + exit 1 + fi + all_kernel_info=$(grubby --info=ALL) + if ! printf "%s\n" "$all_kernel_info" | grep -q "^args=\""; then + echo "error: grubby returned no installed kernel arguments" >&2 + exit 1 + fi + remove_args=$(printf "%s\n" "$all_kernel_info" | + grep "^args=" | grep -o "console=[^ \"]*" | + sed "s/.*/console/" | tr "\n" " ") + console_args=$(printf "%s\n" "$kernel_info" | + grep "^args=" | grep -o "console=[^ \"]*" | + grep -vE "^console=tty[0-9]+(,|$)" | tr "\n" " ") + # Older grubby versions add before removing when both flags share a call. + if [ -n "$remove_args" ]; then + grubby --update-kernel=ALL --remove-args="$remove_args" + fi + grubby --update-kernel=ALL --args="console=tty1 ${console_args:-console=ttyS0,115200}" + elif [ -f /etc/default/grub ]; then + cmdline=GRUB_CMDLINE_LINUX + if ! grep -q "^${cmdline}=\"" /etc/default/grub; then + cmdline=GRUB_CMDLINE_LINUX_DEFAULT + fi + if ! grep -q "^${cmdline}=\"" /etc/default/grub; then + echo "error: No quoted GRUB kernel command line found" >&2 + exit 1 + fi + if ! grep "^${cmdline}=" /etc/default/grub | grep -Eq "console=tty1([[:space:],\"]|$)"; then + sed -i "s/^${cmdline}=\"/${cmdline}=\"console=tty1 /" /etc/default/grub + fi + if command -v update-grub >/dev/null 2>&1; then + update-grub + elif command -v grub2-mkconfig >/dev/null 2>&1; then + grub2-mkconfig -o /boot/grub2/grub.cfg + elif command -v grub-mkconfig >/dev/null 2>&1; then + grub-mkconfig -o /boot/grub/grub.cfg + else + echo "error: No supported GRUB configuration tool found" >&2 + exit 1 + fi fi' return 0 } diff --git a/tests/vm-consoles.sh b/tests/vm-consoles.sh new file mode 100644 index 0000000..193b4bc --- /dev/null +++ b/tests/vm-consoles.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +set -euo pipefail + +CORE_DIR="${CORE_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)}" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf -- "$TEST_ROOT"' EXIT +passed=0 failed=0 + +msg_warn() { printf '%s\n' "$*" >>"$TEST_DIR/warnings"; } + +command() { + if [[ "${1:-}" == -v ]]; then + case "${2:-}" in + grubby | update-grub | grub2-mkconfig | grub-mkconfig) + [[ "$2" == "$MOCK_BOOT_TOOL" ]] + return + ;; + esac + fi + builtin command "$@" +} + +mock_update() { + printf '%s\n' "$@" >>"$TEST_DIR/updates" + if ((MOCK_UPDATE_RC)); then + echo "error: simulated bootloader update failure" >&2 + return "$MOCK_UPDATE_RC" + fi +} + +grubby() { + if [[ "$1" == --info=DEFAULT || "$1" == --info=ALL ]]; then + if [[ "$1" == --info=ALL ]]; then + if ((MOCK_ALL_INFO_RC)); then + echo "error: simulated installed kernel lookup failure" >&2 + return "$MOCK_ALL_INFO_RC" + fi + printf '%s\n' "${MOCK_ALL_KERNEL_INFO:-$MOCK_KERNEL_INFO}" + return 0 + fi + if ((MOCK_INFO_RC)); then + echo "error: simulated kernel lookup failure" >&2 + return "$MOCK_INFO_RC" + fi + printf '%s\n' "$MOCK_KERNEL_INFO" + else + mock_update grubby "$@" || return + if [[ "$*" == *--args=* ]] && ((MOCK_ADD_RC)); then + echo "error: simulated console argument addition failure" >&2 + return "$MOCK_ADD_RC" + fi + fi +} +update-grub() { mock_update update-grub "$@"; } +grub2-mkconfig() { mock_update grub2-mkconfig "$@"; } +grub-mkconfig() { mock_update grub-mkconfig "$@"; } + +virt-customize() { + local run="" + while (($#)); do + if [[ "$1" == --run-command ]]; then + shift + run="$1" + fi + shift + done + [[ "$run" == *"/etc/default/grub"* ]] || return 0 + run="${run//\/etc\/default\/grub/$TEST_DIR/etc/default/grub}" + run="${run//\/boot\/grub2\/grub.cfg/$TEST_DIR/boot/grub2/grub.cfg}" + run="${run//\/boot\/grub\/grub.cfg/$TEST_DIR/boot/grub/grub.cfg}" + printf '%s\n' "$run" >"$TEST_DIR/guest.sh" + sh -n "$TEST_DIR/guest.sh" || return + bash "$TEST_DIR/guest.sh" +} + +assert_equal() { + if [[ "$1" != "$2" ]]; then + printf 'Expected <%s>, got <%s>\n' "$2" "$1" >&2 + exit 1 + fi +} +assert_contains() { grep -qF -- "$2" "$1" || { + cat "$1" >&2 + exit 1 +}; } +assert_success() { + vm_enable_consoles image.qcow2 + assert_equal "${#_VM_PREPARE_FAILED[@]}" 0 +} +assert_failure() { + vm_enable_consoles image.qcow2 + assert_equal "${#_VM_PREPARE_FAILED[@]}" 1 + [[ "${_VM_PREPARE_FAILED[0]}" == "grub console"* ]] || exit 1 +} + +fedora_grubby() { + MOCK_BOOT_TOOL=grubby + assert_success + assert_contains "$TEST_DIR/updates" "--update-kernel=ALL" + assert_contains "$TEST_DIR/updates" "--remove-args=console" + assert_contains "$TEST_DIR/updates" "--args=console=tty1 console=ttyS0,9600n8" + assert_equal "$(grep -c '^grubby$' "$TEST_DIR/updates")" 2 + local remove_line add_line + remove_line="$(grep -n -- '--remove-args=' "$TEST_DIR/updates" | cut -d: -f1)" + add_line="$(grep -n -- '--args=' "$TEST_DIR/updates" | cut -d: -f1)" + ((remove_line < add_line)) || exit 1 +} +bls_without_grub_defaults() { + rm "$TEST_DIR/etc/default/grub" + fedora_grubby +} +grubby_no_serial() { + MOCK_BOOT_TOOL=grubby + MOCK_KERNEL_INFO='args="ro root=/dev/vda1 console=tty0 selinux=1 audit=1"' + assert_success + assert_contains "$TEST_DIR/updates" "--args=console=tty1 console=ttyS0,115200" +} +grubby_arm_serial() { + MOCK_BOOT_TOOL=grubby + MOCK_KERNEL_INFO='args="ro console=tty0 console=ttyAMA0,115200n8 selinux=1 audit=1"' + assert_success + assert_contains "$TEST_DIR/updates" "--args=console=tty1 console=ttyAMA0,115200n8" +} +grubby_lookup_failure() { + MOCK_BOOT_TOOL=grubby + MOCK_INFO_RC=9 + assert_failure +} +grubby_invalid_info() { + MOCK_BOOT_TOOL=grubby + MOCK_KERNEL_INFO='index=0' + assert_failure +} +grubby_update_failure() { + MOCK_BOOT_TOOL=grubby + MOCK_UPDATE_RC=7 + assert_failure +} +grubby_all_lookup_failure() { + MOCK_BOOT_TOOL=grubby + MOCK_ALL_INFO_RC=9 + assert_failure +} +grubby_all_invalid_info() { + MOCK_BOOT_TOOL=grubby + MOCK_ALL_KERNEL_INFO='index=0' + assert_failure +} +grubby_add_failure() { + MOCK_BOOT_TOOL=grubby + MOCK_ADD_RC=7 + assert_failure +} +grubby_kernel_count_variation() { + MOCK_BOOT_TOOL=grubby + MOCK_ALL_KERNEL_INFO=$'args="console=tty0 console=ttyS0,9600n8"\nargs="console=tty0 console=tty1 console=ttyS0,9600n8"' + assert_success + assert_contains "$TEST_DIR/updates" "--remove-args=console console console console console" +} +grubby_idempotent() { + MOCK_BOOT_TOOL=grubby + MOCK_KERNEL_INFO='args="ro console=tty1 console=ttyS0,9600n8"' + assert_success + assert_contains "$TEST_DIR/updates" "--args=console=tty1 console=ttyS0,9600n8" + ! grep -q 'console=tty1 console=tty1' "$TEST_DIR/updates" || exit 1 +} +debian_update_grub() { + MOCK_BOOT_TOOL=update-grub + assert_success + assert_contains "$TEST_DIR/etc/default/grub" 'GRUB_CMDLINE_LINUX="console=tty1 console=ttyS0,9600n8 selinux=1 audit=1"' + assert_contains "$TEST_DIR/updates" "update-grub" + assert_success + assert_equal "$(grep -o 'console=tty1' "$TEST_DIR/etc/default/grub" | wc -l | tr -d ' ')" 1 +} +default_cmdline_only() { + MOCK_BOOT_TOOL=update-grub + printf '%s\n' 'GRUB_CMDLINE_LINUX_DEFAULT="console=ttyS0,115200n8 quiet"' >"$TEST_DIR/etc/default/grub" + assert_success + assert_contains "$TEST_DIR/etc/default/grub" 'GRUB_CMDLINE_LINUX_DEFAULT="console=tty1 console=ttyS0,115200n8 quiet"' +} +grub2_fallback() { + MOCK_BOOT_TOOL=grub2-mkconfig + assert_success + assert_contains "$TEST_DIR/updates" "$TEST_DIR/boot/grub2/grub.cfg" +} +grub_fallback() { + MOCK_BOOT_TOOL=grub-mkconfig + assert_success + assert_contains "$TEST_DIR/updates" "$TEST_DIR/boot/grub/grub.cfg" +} +legacy_update_failure() { + MOCK_BOOT_TOOL=update-grub + MOCK_UPDATE_RC=7 + assert_failure +} +unsupported_grub_tool() { assert_failure; } +invalid_grub_defaults() { + MOCK_BOOT_TOOL=update-grub + printf '%s\n' 'GRUB_TIMEOUT=5' >"$TEST_DIR/etc/default/grub" + assert_failure +} +non_grub_image() { + rm "$TEST_DIR/etc/default/grub" + assert_success + [[ ! -s "$TEST_DIR/updates" ]] || exit 1 +} + +for backend in pve incus; do + eval "$(awk '/^_vm_customize\(\)/,/^}/' "$CORE_DIR/$backend/vm-core.func")" + eval "$(awk '/^vm_enable_consoles\(\)/,/^}/' "$CORE_DIR/$backend/vm-core.func")" + for test in fedora_grubby bls_without_grub_defaults grubby_no_serial grubby_arm_serial \ + grubby_lookup_failure grubby_invalid_info grubby_update_failure grubby_idempotent \ + grubby_all_lookup_failure grubby_all_invalid_info grubby_add_failure grubby_kernel_count_variation \ + debian_update_grub default_cmdline_only grub2_fallback grub_fallback \ + legacy_update_failure unsupported_grub_tool invalid_grub_defaults non_grub_image; do + TEST_DIR="$TEST_ROOT/${backend}-${test}" + mkdir -p "$TEST_DIR/etc/default" + printf '%s\n' 'GRUB_CMDLINE_LINUX_DEFAULT="quiet"' \ + 'GRUB_CMDLINE_LINUX="console=ttyS0,9600n8 selinux=1 audit=1"' >"$TEST_DIR/etc/default/grub" + : >"$TEST_DIR/updates" + MOCK_BOOT_TOOL="" MOCK_UPDATE_RC=0 MOCK_INFO_RC=0 MOCK_ALL_INFO_RC=0 MOCK_ADD_RC=0 MOCK_ALL_KERNEL_INFO="" + MOCK_KERNEL_INFO='args="ro root=/dev/vda1 console=ttyS0,9600n8 selinux=1 audit=1"' + _VM_PREPARE_FAILED=() + _VM_PREPARE_COLLECTING=1 + export TEST_DIR MOCK_BOOT_TOOL MOCK_UPDATE_RC MOCK_INFO_RC MOCK_KERNEL_INFO MOCK_ALL_INFO_RC MOCK_ADD_RC MOCK_ALL_KERNEL_INFO + export -f command mock_update grubby update-grub grub2-mkconfig grub-mkconfig + if ("$test"); then + printf 'PASS %s/%s\n' "$backend" "$test" + passed=$((passed + 1)) + else + printf 'FAIL %s/%s\n' "$backend" "$test" >&2 + failed=$((failed + 1)) + fi + done +done +printf '%s passed, %s failed\n' "$passed" "$failed" +((failed == 0))