From ffb96a6da2f8145394ab21e98020abfa183c1292 Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:01:17 +0200 Subject: [PATCH 1/2] Stop discarding cloud-init error messages --- pve/vm-core.func | 8 ++++---- vm/cloud-init.func | 24 ++++++++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/pve/vm-core.func b/pve/vm-core.func index abf9958..d59d93f 100644 --- a/pve/vm-core.func +++ b/pve/vm-core.func @@ -2069,7 +2069,7 @@ vm_select_storage() { if pvesm status -storage "$STORAGE" &>/dev/null; then # The interactive path ends with this too. Skipping it here left # DISK_IMPORT_FORMAT and friends unset for every preselected storage. - STORAGE_TYPE=$(pvesm status -storage "$STORAGE" | awk 'NR>1 {print $2}') + STORAGE_TYPE=$(pvesm status -storage "$STORAGE" 2>/dev/null | awk 'NR>1 {print $2}') vm_apply_storage_layout "$STORAGE_TYPE" msg_ok "Using ${CL}${BL}$STORAGE${CL} ${GN}for Storage Location." return 0 @@ -2090,9 +2090,9 @@ vm_select_storage() { msg_max_length=$((${#item} + offset)) fi storage_menu+=("$tag" "$item" "OFF") - done < <(pvesm status -content images | awk 'NR>1') + done < <(pvesm status -content images 2>/dev/null | awk 'NR>1') - valid_storage=$(pvesm status -content images | awk 'NR>1') + valid_storage=$(pvesm status -content images 2>/dev/null | awk 'NR>1') if [ -z "$valid_storage" ]; then msg_error "Unable to detect a valid storage location." exit 119 # no valid storage found @@ -2123,7 +2123,7 @@ vm_select_storage() { msg_ok "Using ${CL}${BL}$STORAGE${CL} ${GN}for Storage Location." msg_ok "Virtual Machine ID is ${CL}${BL}$VMID${CL}." - STORAGE_TYPE=$(pvesm status -storage "$STORAGE" | awk 'NR>1 {print $2}') + STORAGE_TYPE=$(pvesm status -storage "$STORAGE" 2>/dev/null | awk 'NR>1 {print $2}') vm_apply_storage_layout "$STORAGE_TYPE" } diff --git a/vm/cloud-init.func b/vm/cloud-init.func index e1aa06f..ecc6b34 100644 --- a/vm/cloud-init.func +++ b/vm/cloud-init.func @@ -237,10 +237,14 @@ function configure_cloudinit_ssh_keys() { # ------------------------------------------------------------------------------ # _ci_msg - Internal message helper with fallback # ------------------------------------------------------------------------------ -function _ci_msg_info() { msg_info "$1" 2>/dev/null || echo "[INFO] $1"; } -function _ci_msg_ok() { msg_ok "$1" 2>/dev/null || echo "[OK] $1"; } -function _ci_msg_warn() { msg_warn "$1" 2>/dev/null || echo "[WARN] $1"; } -function _ci_msg_error() { msg_error "$1" 2>/dev/null || echo "[ERROR] $1"; } +# The fallback used to be `msg_x "$1" 2>/dev/null || echo ...`, which discarded +# every message msg_warn and msg_error write - both go to stderr - and never +# reached the echo, because those return 0. Errors in here were invisible, so a +# failure surfaced as nothing but the trap's line number. +function _ci_msg_info() { if declare -F msg_info >/dev/null; then msg_info "$1"; else echo "[INFO] $1"; fi; } +function _ci_msg_ok() { if declare -F msg_ok >/dev/null; then msg_ok "$1"; else echo "[OK] $1"; fi; } +function _ci_msg_warn() { if declare -F msg_warn >/dev/null; then msg_warn "$1"; else echo "[WARN] $1" >&2; fi; } +function _ci_msg_error() { if declare -F msg_error >/dev/null; then msg_error "$1"; else echo "[ERROR] $1" >&2; fi; } # ------------------------------------------------------------------------------ # validate_ip_cidr - Validate IP address in CIDR format @@ -578,6 +582,8 @@ function display_cloud_init_info() { echo -e "${TAB:- }${DGN:-}User: ${BGN:-}${CLOUDINIT_USER:-root}${CL:-}" echo -e "${TAB:- }${DGN:-}Password: ${BGN:-}${CLOUDINIT_PASSWORD}${CL:-}" echo -e "${TAB:- }${DGN:-}Credentials: ${BL:-}${CLOUDINIT_CRED_FILE}${CL:-}" + echo -e "${TAB:- }${DGN:-}Console: ${BGN:-}xterm.js${CL:-}${DGN:-} - cloud images use the serial console, noVNC stays blank${CL:-}" + echo -e "${TAB:- }${DGN:-}Cloud-Init needs a minute on first boot before the login works${CL:-}" echo -e "${TAB:- }${RD:-}⚠️ Delete credentials file after noting password!${CL:-}" else echo "" @@ -585,6 +591,8 @@ function display_cloud_init_info() { echo " User: ${CLOUDINIT_USER:-root}" echo " Password: ${CLOUDINIT_PASSWORD}" echo " Credentials: ${CLOUDINIT_CRED_FILE}" + echo " Console: xterm.js - cloud images use the serial console, noVNC stays blank" + echo " Cloud-Init needs a minute on first boot before the login works" echo " ⚠️ Delete credentials file after noting password!" fi fi @@ -681,12 +689,16 @@ function setup_cloud_init_network_no_rename() { fi if [[ -z "$snippet_storage" ]]; then - _ci_msg_error "No active Proxmox storage supporting snippets was found" + _ci_msg_error "A static IP needs a storage with 'snippets' content, and none is active" + _ci_msg_warn "Enable Snippets under Datacenter > Storage on a directory storage, or set CLOUDINIT_SNIPPET_STORAGE" return 1 fi snippet_volid="${snippet_storage}:snippets/${snippet_name}" - snippet_path="$(pvesm path "$snippet_volid")" + if ! snippet_path="$(pvesm path "$snippet_volid" 2>/dev/null)" || [[ -z "$snippet_path" ]]; then + _ci_msg_error "Could not resolve a path for ${snippet_volid}" + return 1 + fi mkdir -p "$(dirname "$snippet_path")" From 3ae6cfe7d8fc78fa149a38f7d3e1017ccb3b068a Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:16:50 +0200 Subject: [PATCH 2/2] Report failed image steps and restore tty1 autologin without cloud-init --- incus/vm-core.func | 81 +++++++++++++++++++++++++++++++------- pve/vm-core.func | 97 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 140 insertions(+), 38 deletions(-) diff --git a/incus/vm-core.func b/incus/vm-core.func index 6c8a49d..afd31df 100644 --- a/incus/vm-core.func +++ b/incus/vm-core.func @@ -1487,46 +1487,97 @@ vm_expand_image() { return 0 } +# Same code as in pve/vm-core.func, defined here too because an Incus host may +# never load that file. Every step used to end in `>/dev/null 2>&1 || true` and +# the caller reported success either way, so an image none of them reached +# looked exactly like one that worked. +declare -ga _VM_PREPARE_FAILED=() +_VM_PREPARE_COLLECTING=0 + +_vm_customize() { + local label="${1:?label}" image="${2:?image}" + shift 2 + local log detail entry + log="$(mktemp)" || log=/dev/null + + if virt-customize -q -a "$image" "$@" >"$log" 2>&1; then + [[ "$log" != /dev/null ]] && rm -f "$log" + return 0 + fi + + detail="$(grep -m1 -iE 'error|cannot|failed' "$log" 2>/dev/null | + sed -E 's/^[^:]+: +//; s/[[:space:]]+$//' | cut -c1-140)" + [[ "$log" != /dev/null ]] && rm -f "$log" + + # Always 0: these run as bare statements under `set -e`, and the failure is + # recorded rather than lost. Callers that care compare the array length. + entry="${label}${detail:+ - ${detail}}" + _VM_PREPARE_FAILED+=("$entry") + ((_VM_PREPARE_COLLECTING)) || msg_warn "Image step failed: ${entry}" + return 0 +} + vm_prepare_cloud_image() { local image="${1:?image}" hostname="${2:-${HN:-vm}}" if ! vm_ensure_virt_customize; then - msg_warn "Importing the image unmodified" + msg_warn "Importing the image unmodified - virt-customize is unavailable" return 1 fi + _VM_PREPARE_FAILED=() + _VM_PREPARE_COLLECTING=1 + msg_info "Preparing the image" - virt-customize -q -a "$image" --hostname "$hostname" >/dev/null 2>&1 || true + _vm_customize "hostname" "$image" --hostname "$hostname" # A cloned machine-id gives every VM from this image the same DHCP lease. - virt-customize -q -a "$image" \ - --run-command 'truncate -s 0 /etc/machine-id; rm -f /var/lib/dbus/machine-id' \ - >/dev/null 2>&1 || true - virt-customize -q -a "$image" --run-command \ - 'sed -i "s/^#*PermitRootLogin.*/PermitRootLogin yes/;s/^#*PasswordAuthentication.*/PasswordAuthentication yes/" /etc/ssh/sshd_config' \ - >/dev/null 2>&1 || true + _vm_customize "machine-id" "$image" \ + --run-command 'truncate -s 0 /etc/machine-id; rm -f /var/lib/dbus/machine-id' + _vm_customize "sshd password login" "$image" --run-command \ + 'sed -i "s/^#*PermitRootLogin.*/PermitRootLogin yes/;s/^#*PasswordAuthentication.*/PasswordAuthentication yes/" /etc/ssh/sshd_config' vm_enable_consoles "$image" vm_install_guest_agent "$image" || true - msg_ok "Prepared the image" + _VM_PREPARE_COLLECTING=0 + + if ((${#_VM_PREPARE_FAILED[@]})); then + msg_error "Prepared the image - ${#_VM_PREPARE_FAILED[@]} step(s) failed" + local step + for step in "${_VM_PREPARE_FAILED[@]}"; do + msg_warn "$step" + done + else + msg_ok "Prepared the image" + fi return 0 } # `incus console` attaches to the first serial port, so a cloud image with no # getty on ttyS0 gives a console that shows nothing. Same fix as on Proxmox. vm_enable_consoles() { - local image="${1:?image}" + local image="${1:?image}" cloud_init="${2:-${USE_CLOUD_INIT:-yes}}" command -v virt-customize >/dev/null 2>&1 || return 0 - virt-customize -q -a "$image" \ - --run-command 'systemctl enable getty@tty1.service serial-getty@ttyS0.service' \ - >/dev/null 2>&1 || true + _vm_customize "console services" "$image" \ + --run-command 'systemctl enable getty@tty1.service serial-getty@ttyS0.service' + + # Without cloud-init nothing sets a password, and the nocloud images ship a + # locked root: the getty above then puts an unanswerable prompt on tty1. + # Those images get in through console autologin, so give tty1 the same + # treatment the serial console already has. + if [[ "$cloud_init" != "yes" ]]; then + _vm_customize "tty1 autologin" "$image" --run-command \ + 'mkdir -p /etc/systemd/system/getty@tty1.service.d + printf "[Service]\nExecStart=\nExecStart=-/sbin/agetty --autologin root --noclear %%I \$TERM\n" \ + >/etc/systemd/system/getty@tty1.service.d/autologin.conf' + fi - virt-customize -q -a "$image" --run-command \ + _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 - fi' >/dev/null 2>&1 || true + fi' return 0 } diff --git a/pve/vm-core.func b/pve/vm-core.func index d59d93f..36e741b 100644 --- a/pve/vm-core.func +++ b/pve/vm-core.func @@ -1938,24 +1938,35 @@ vm_prepare_cloud_image() { local image="${1:?image}" hostname="${2:-${HN:-vm}}" if ! vm_ensure_virt_customize; then - msg_warn "Importing the image unmodified" + msg_warn "Importing the image unmodified - virt-customize is unavailable" return 1 fi + _VM_PREPARE_FAILED=() + _VM_PREPARE_COLLECTING=1 + msg_info "Preparing the image" - virt-customize -q -a "$image" --hostname "$hostname" >/dev/null 2>&1 || true + _vm_customize "hostname" "$image" --hostname "$hostname" # A cloned machine-id gives every VM from this image the same DHCP lease. - virt-customize -q -a "$image" \ - --run-command 'truncate -s 0 /etc/machine-id; rm -f /var/lib/dbus/machine-id' \ - >/dev/null 2>&1 || true + _vm_customize "machine-id" "$image" \ + --run-command 'truncate -s 0 /etc/machine-id; rm -f /var/lib/dbus/machine-id' # Cloud-Init sets a root password, which is useless if sshd refuses it. - virt-customize -q -a "$image" --run-command \ - 'sed -i "s/^#*PermitRootLogin.*/PermitRootLogin yes/;s/^#*PasswordAuthentication.*/PasswordAuthentication yes/" /etc/ssh/sshd_config' \ - >/dev/null 2>&1 || true + _vm_customize "sshd password login" "$image" --run-command \ + 'sed -i "s/^#*PermitRootLogin.*/PermitRootLogin yes/;s/^#*PasswordAuthentication.*/PasswordAuthentication yes/" /etc/ssh/sshd_config' vm_enable_consoles "$image" vm_install_guest_agent "$image" || true - msg_ok "Prepared the image" + _VM_PREPARE_COLLECTING=0 + + if ((${#_VM_PREPARE_FAILED[@]})); then + msg_error "Prepared the image - ${#_VM_PREPARE_FAILED[@]} step(s) failed" + local step + for step in "${_VM_PREPARE_FAILED[@]}"; do + msg_warn "$step" + done + else + msg_ok "Prepared the image" + fi return 0 } @@ -1963,25 +1974,65 @@ vm_prepare_cloud_image() { # Proxmox's console stays black -- the VM is fine, nothing is drawing on it. # The nocloud image variants configure both consoles themselves, which is why # only the cloud-init path showed this. +# Every virt-customize step used to end in `>/dev/null 2>&1 || true` and the +# caller reported success either way, so an image none of them reached looked +# exactly like one that worked. Failures are named instead: collected while +# vm_prepare_cloud_image holds a msg block, warned about directly otherwise. +declare -ga _VM_PREPARE_FAILED=() +_VM_PREPARE_COLLECTING=0 + +_vm_customize() { + local label="${1:?label}" image="${2:?image}" + shift 2 + local log detail entry + log="$(mktemp)" || log=/dev/null + + if virt-customize -q -a "$image" "$@" >"$log" 2>&1; then + [[ "$log" != /dev/null ]] && rm -f "$log" + return 0 + fi + + detail="$(grep -m1 -iE 'error|cannot|failed' "$log" 2>/dev/null | + sed -E 's/^[^:]+: +//; s/[[:space:]]+$//' | cut -c1-140)" + [[ "$log" != /dev/null ]] && rm -f "$log" + + # Always 0: these run as bare statements under `set -e`, and the failure is + # recorded rather than lost. Callers that care compare the array length. + entry="${label}${detail:+ - ${detail}}" + _VM_PREPARE_FAILED+=("$entry") + ((_VM_PREPARE_COLLECTING)) || msg_warn "Image step failed: ${entry}" + return 0 +} + vm_enable_consoles() { - local image="${1:?image}" + local image="${1:?image}" cloud_init="${2:-${USE_CLOUD_INIT:-yes}}" command -v virt-customize >/dev/null 2>&1 || return 0 - # No autologin: cloud-init sets a password, so a prompt is the right thing. - virt-customize -q -a "$image" \ - --run-command 'systemctl enable getty@tty1.service serial-getty@ttyS0.service' \ - >/dev/null 2>&1 || true + # With cloud-init a password gets set, so a prompt is the right thing. + _vm_customize "console services" "$image" \ + --run-command 'systemctl enable getty@tty1.service serial-getty@ttyS0.service' + + # Without it nothing sets one, and the nocloud images ship a locked root: the + # getty above then puts an unanswerable prompt on tty1, which is exactly the + # login screen people hit in noVNC. Those images get in through console + # autologin, so give tty1 the same treatment the serial console already has. + if [[ "$cloud_init" != "yes" ]]; then + _vm_customize "tty1 autologin" "$image" --run-command \ + 'mkdir -p /etc/systemd/system/getty@tty1.service.d + printf "[Service]\nExecStart=\nExecStart=-/sbin/agetty --autologin root --noclear %%I \$TERM\n" \ + >/etc/systemd/system/getty@tty1.service.d/autologin.conf' + fi # A getty alone only gets you a login prompt after boot -- the kernel still # talks to ttyS0 only, so the screen stays black until then. Listing tty1 # first and ttyS0 last keeps qm terminal as the primary console while the # graphical one also shows the boot. - virt-customize -q -a "$image" --run-command \ + _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 - fi' >/dev/null 2>&1 || true + fi' return 0 } @@ -1991,16 +2042,16 @@ vm_enable_consoles() { # appliance, so this reports rather than fails. vm_install_guest_agent() { local image="${1:?image}" + local before=${#_VM_PREPARE_FAILED[@]} command -v virt-customize >/dev/null 2>&1 || return 0 - if virt-customize -q -a "$image" --install qemu-guest-agent >/dev/null 2>&1; then - virt-customize -q -a "$image" \ - --run-command 'systemctl enable qemu-guest-agent.service' >/dev/null 2>&1 || true - return 0 - fi + _vm_customize "qemu-guest-agent (VM shows no IP in Proxmox)" "$image" \ + --install qemu-guest-agent + ((${#_VM_PREPARE_FAILED[@]} > before)) && return 1 - msg_warn "Could not install qemu-guest-agent -- the VM will show no IP in Proxmox" - return 1 + _vm_customize "guest agent service" "$image" \ + --run-command 'systemctl enable qemu-guest-agent.service' + return 0 } # Disk first, CD second. An empty disk is not bootable so the installer still