diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh new file mode 100755 index 0000000..9c6cf5e --- /dev/null +++ b/script/proxmox/install_proxmox.sh @@ -0,0 +1,495 @@ +#!/usr/bin/env bash +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Installe Proxmox VE — https://www.proxmox.com — SUR UNE DEBIAN existante. +# +# Pourquoi sur Debian : Proxmox ne publie aucune image cloud. Son ISO est un +# installateur qui formate le disque, ce qui ne se pilote pas depuis un +# déploiement cloud-init. La voie que l'amont documente lui-même pour ce cas +# est « Install Proxmox VE on Debian » : on part de l'image cloud Debian, on +# ajoute le dépôt pve, et les paquets font le reste. Le résultat est le même +# hyperviseur, avec le noyau Proxmox et l'interface web sur :8006. +# +# Architectures : amd64 et arm64 (arm64 officiel depuis PVE 9 — le Release de +# trixie annonce « amd64 arm64 »). PAS s390x : l'index « binary-s390x » du +# dépôt répond 404, le port n'existe pas. +# +# Réglages, par variables d'environnement : +# PVE_SUITE suite Debian visée (défaut : trixie, = PVE 9) +# PVE_REBOOT à 1, redémarre à la fin (défaut : ne redémarre PAS — +# lancé par SSH, un reboot couperait la session et ferait +# passer une installation réussie pour un échec) +# PVE_KEEP_DEBIAN_KERNEL à 1, garde le noyau Debian à côté du noyau pve +# PVE_OS_RELEASE fichier os-release à lire (défaut : /etc/os-release) ; +# sert à vérifier le script depuis une autre distribution +set -euo pipefail + +Red='\033[0;31m' +Green='\033[0;32m' +Yellow='\033[0;33m' +Color_Off='\033[0m' + +SUITE="${PVE_SUITE:-trixie}" +KEYRING=/usr/share/keyrings/proxmox-archive-keyring.gpg +SOURCES=/etc/apt/sources.list.d/pve-install-repo.sources + +# Somme relevée sur la page amont « Install Proxmox VE on Debian 13 Trixie », +# et VÉRIFIÉE contre le fichier servi. Elle vaut ceinture ET bretelle : c'est +# la clé qui authentifiera tout le reste, et la télécharger sans la contrôler +# reviendrait à faire confiance au seul transport. +KEY_URL="https://enterprise.proxmox.com/debian/proxmox-archive-keyring-${SUITE}.gpg" +KEY_SHA256_trixie=136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45 + +# Ce qui a changé : décide du redémarrage final. Une installation déjà faite ne +# doit pas redémarrer la machine pour rien. +CHANGED=0 + +say() { echo -e "$@"; } +die() { say "${Red}✗${Color_Off} $*"; exit 1; } + +DRY=0 +usage() { + cat < » que pose l'image cloud Debian le +# mène droit dans le mur, et pveproxy comme pvecm s'en trouvent mal. +# « hostname --ip-address » est le test que l'amont donne lui-même. +host_ip() { + local ip="" + for ip in \ + "$(ip -4 route get 1 2>/dev/null | awk '{print $7; exit}')" \ + "$(hostname -I 2>/dev/null | awk '{print $1}')" \ + "$(ip -4 -o addr show scope global 2>/dev/null \ + | awk '{split($4, a, "/"); print a[1]; exit}')" + do + case "$ip" in + 127.*) continue ;; + [0-9]*.[0-9]*.[0-9]*.[0-9]*) echo "$ip"; return 0 ;; + esac + done + return 1 +} + +fix_hosts() { + local ip fqdn short + ip="$(host_ip)" || die "aucune adresse IPv4 routable : réseau absent ?" + short="$(hostname -s)" + fqdn="$(hostname -f 2>/dev/null || echo "${short}")" + [ "${fqdn}" = "${short}" ] && fqdn="${short}.local" + if grep -qE "^[[:space:]]*127\.0\.1\.1[[:space:]]" /etc/hosts; then + say " retrait de l'entrée 127.0.1.1 (bouclage) pour ${short}" + run sudo sed -i -E "/^[[:space:]]*127\.0\.1\.1[[:space:]]/d" \ + /etc/hosts + CHANGED=1 + fi + if ! grep -qE "^[[:space:]]*${ip//./\\.}[[:space:]]+.*\b${short}\b" \ + /etc/hosts; then + say " ${short} -> ${ip} dans /etc/hosts" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} ${ip} ${fqdn} ${short}" \ + ">> /etc/hosts" + else + printf '%s\t%s %s\n' "${ip}" "${fqdn}" "${short}" \ + | sudo tee -a /etc/hosts >/dev/null + fi + CHANGED=1 + fi + # Le test de l'amont, mot pour mot : au moins une adresse non-bouclage. + local vu routables + vu="$(hostname --ip-address 2>/dev/null || true)" + routables="$(printf '%s\n' ${vu} | grep -vE '^(127\.|::1$)' || true)" + [ -n "${routables}" ] || die \ + "« hostname --ip-address » rend « ${vu:-rien} » : le nom d'hôte ne" \ + "résout toujours pas vers une adresse routable." + say " hostname --ip-address : $(printf '%s ' ${routables})" +} + +# --- 4. Dépôt et clé -------------------------------------------------------- +add_repo() { + local attendu="KEY_SHA256_${SUITE}" + attendu="${!attendu:-}" + if [ ! -s "${KEYRING}" ]; then + say " clé du dépôt -> ${KEYRING}" + run sudo mkdir -p "$(dirname "${KEYRING}")" + run sudo wget -q "${KEY_URL}" -O "${KEYRING}" \ + || die "téléchargement de la clé impossible : ${KEY_URL}" + # Un wget qui rend 0 sans laisser de fichier — proxy captif, disque + # plein — ferait mourir la suite sur un message de sha256sum au lieu + # d'un diagnostic. + if [ "${DRY}" != "1" ]; then + sudo test -s "${KEYRING}" \ + || die "clé absente ou vide après téléchargement : ${KEYRING}" + fi + CHANGED=1 + fi + if [ -n "${attendu}" ] && [ "${DRY}" = "1" ]; then + say " clé à vérifier contre sha256 ${attendu:0:16}…" + elif [ -n "${attendu}" ]; then + local vu + vu="$(sudo sha256sum "${KEYRING}" 2>/dev/null | awk '{print $1}')" \ + || true + [ -n "${vu}" ] || die "somme de la clé illisible : ${KEYRING}" + if [ "${vu}" != "${attendu}" ]; then + # On efface la clé douteuse : la laisser en place ferait passer la + # prochaine exécution pour bonne, le fichier étant non vide. + run sudo rm -f "${KEYRING}" + die "somme de la clé inattendue :\n vue ${vu}\n" \ + " attendue ${attendu}" + fi + say " clé vérifiée (sha256 ${vu:0:16}…)" + else + say "${Yellow}⚠${Color_Off} aucune somme connue pour « ${SUITE} » :" \ + "clé acceptée sans contrôle." + fi + # Format deb822, celui que l'amont recommande sur Debian 13. + local voulu + voulu="$(printf 'Types: deb\nURIs: http://download.proxmox.com/debian/pve\nSuites: %s\nComponents: pve-no-subscription\nSigned-By: %s\n' \ + "${SUITE}" "${KEYRING}")" + if [ "$(cat "${SOURCES}" 2>/dev/null || true)" != "${voulu}" ]; then + say " dépôt pve-no-subscription -> ${SOURCES}" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} ${SOURCES} :" \ + "$(printf '%s' "${voulu}" | tr '\n' '|')" + else + printf '%s\n' "${voulu}" | sudo tee "${SOURCES}" >/dev/null + fi + CHANGED=1 + fi +} + +# --- 6. Dépôt entreprise ---------------------------------------------------- +# « proxmox-ve » installe SON dépôt entreprise (pve-enterprise.sources), qui +# exige un abonnement payant. Sans lui, tout « apt update » ultérieur échoue : +# « 401 Unauthorized » puis « The repository is not signed », et la machine ne +# peut plus rien installer — pas même une mise à jour de sécurité. Mesuré sur +# la VM d'essai, au deuxième passage du script. +# +# On le désactive au lieu de l'effacer : « Enabled: false » est la forme deb822 +# prévue pour cela, elle survit aux mises à jour du paquet, et il suffit de +# retirer la ligne le jour où un abonnement existe. +disable_enterprise() { + local f + for f in /etc/apt/sources.list.d/pve-enterprise.sources \ + /etc/apt/sources.list.d/ceph.sources; do + [ -e "${f}" ] || continue + if sudo grep -qiE '^[[:space:]]*Enabled:[[:space:]]*(false|no)' \ + "${f}"; then + continue + fi + say " dépôt entreprise désactivé : $(basename "${f}")" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} Enabled: false >> ${f}" + else + printf 'Enabled: false\n' | sudo tee -a "${f}" >/dev/null + fi + CHANGED=1 + done + # Les anciennes formes « .list », au cas où une mise à jour les remette. + for f in /etc/apt/sources.list.d/pve-enterprise.list; do + [ -e "${f}" ] || continue + sudo grep -qE '^[[:space:]]*#' "${f}" && continue + say " dépôt entreprise commenté : $(basename "${f}")" + run sudo sed -i -E 's/^([[:space:]]*deb)/#\1/' "${f}" + CHANGED=1 + done +} + +# --- 5. Les paquets --------------------------------------------------------- +# Disque d'amorçage, pour la préréponse de grub-pc. +boot_disk() { + local src + src="$(findmnt -no SOURCE /boot 2>/dev/null \ + || findmnt -no SOURCE / 2>/dev/null)" + [ -n "${src}" ] || return 1 + local dq + dq="$(lsblk -no pkname "${src}" 2>/dev/null | head -1)" + [ -n "${dq}" ] || return 1 + printf '/dev/%s\n' "${dq}" +} + +# Deux paquets posent des questions debconf, et une seule sans réponse suffit à +# faire échouer TOUTE la transaction apt : +# +# - postfix demande son type et son nom de courrier. Sans préréponse, +# l'installation attend une saisie que personne ne verra — un déploiement +# par SSH y reste pendu. +# - grub-pc demande SUR QUEL DISQUE s'installer. C'est le piège propre à +# l'image cloud : elle amorce en EFI (grub-cloud-amd64), les paquets pve +# tirent grub-pc par-dessus, et sa post-installation refuse de deviner — +# « You must correct your GRUB install devices before proceeding », mesuré, +# dpkg s'arrête et le noyau Proxmox reste à moitié configuré. +preseed_debconf() { + command -v debconf-set-selections >/dev/null 2>&1 || return 0 + local lignes disque + lignes="$(printf '%s\n' \ + "postfix postfix/main_mailer_type select Local only" \ + "postfix postfix/mailname string $(hostname -f 2>/dev/null \ + || hostname -s)")" + if disque="$(boot_disk)"; then + lignes="$(printf '%s\n%s\n' "${lignes}" \ + "grub-pc grub-pc/install_devices multiselect ${disque}")" + else + say "${Yellow}⚠${Color_Off} disque d'amorçage introuvable :" \ + "grub-pc pourrait demander où s'installer." + fi + # Pas de tube vers « run » : en dry-run il ne lirait pas son entrée, le + # printf recevrait SIGPIPE, et « set -o pipefail » emporterait le script — + # mort silencieuse sur un code 141. Vécu. + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} debconf-set-selections :" \ + "$(printf '%s' "${lignes}" | tr '\n' '|')" + return 0 + fi + printf '%s\n' "${lignes}" | sudo debconf-set-selections +} + +# « DPkg::Lock::Timeout » : sur une VM fraîchement déployée, cloud-init tient +# encore le verrou d'apt — mesuré, « E: Could not get lock +# /var/lib/apt/lists/lock. It is held by process 996 (apt-get) », et le script +# mourait 40 secondes après le démarrage. apt sait ATTENDRE son tour depuis la +# 1.9 ; sans cette option il abandonne immédiatement. +apt_get() { + run sudo env DEBIAN_FRONTEND=noninteractive \ + apt-get -o Dpkg::Options::=--force-confold \ + -o DPkg::Lock::Timeout=600 -y "$@" +} + +# Attendre la fin de cloud-init AVANT de toucher à apt : c'est lui qui pose les +# paquets de la première mise en route. Son état final n'est PAS un critère — +# vérifié sur l'image Debian 13, où il rend « error » pour deux modules sans +# rapport (console-setup absent, update-locale) tout en ayant terminé son +# travail. On attend qu'il ait fini, pas qu'il soit content. +wait_cloud_init() { + command -v cloud-init >/dev/null 2>&1 || return 0 + say " attente de la fin de cloud-init…" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} cloud-init status --wait" + return 0 + fi + timeout 600 cloud-init status --wait >/dev/null 2>&1 || true + return 0 +} + +install_pve() { + wait_cloud_init + preseed_debconf + # Réparer une transaction laissée à moitié par une exécution précédente : + # sans réponse à grub-pc, dpkg s'arrête au milieu et tout apt suivant + # refuse de travailler. Inoffensif quand rien n'est cassé. + if ! sudo dpkg -C >/dev/null 2>&1; then + say " paquets à moitié configurés : dpkg --configure -a" + run sudo env DEBIAN_FRONTEND=noninteractive dpkg --configure -a \ + || true + fi + # AVANT le premier apt : sur un second passage, le dépôt entreprise posé + # par proxmox-ve ferait échouer « apt update » (401) et rien n'irait plus + # loin — pas même la désactivation, si elle attendait la fin. + disable_enterprise + say "\n---- apt update ----" + apt_get update + # Le noyau d'abord, comme l'amont le prescrit : c'est lui qui porte les + # modules dont pve a besoin, et l'installer seul laisse une machine qui + # redémarre proprement même si la suite échoue. + if ! dpkg -s proxmox-default-kernel >/dev/null 2>&1; then + say "\n---- noyau Proxmox ----" + apt_get install proxmox-default-kernel + CHANGED=1 + else + say " noyau Proxmox déjà posé" + fi + if ! dpkg -s proxmox-ve >/dev/null 2>&1; then + say "\n---- proxmox-ve, postfix, open-iscsi, chrony ----" + apt_get install proxmox-ve postfix open-iscsi chrony + CHANGED=1 + # C'est CETTE installation qui vient de poser le dépôt entreprise : + # le désactiver tout de suite, avant que le ménage ne rappelle apt. + disable_enterprise + else + say " proxmox-ve déjà posé" + fi +} + +# --- 6. Ménage -------------------------------------------------------------- +# os-prober ajoute au menu d'amorçage les systèmes trouvés sur les disques des +# VM invitées : sur un hyperviseur, c'est une liste de faux départs. +# Le noyau Debian, lui, n'a plus de raison d'être une fois celui de pve en +# place — et laissé par défaut, grub peut y revenir. +cleanup() { + if dpkg -s os-prober >/dev/null 2>&1; then + say " retrait d'os-prober" + apt_get remove os-prober + CHANGED=1 + fi + [ "${PVE_KEEP_DEBIAN_KERNEL:-0}" = "1" ] && return 0 + # Ne JAMAIS retirer le noyau Debian si celui de Proxmox n'est pas posé : + # la machine ne redémarrerait plus. Vérifié, pas supposé — une étape apt + # peut avoir échoué plus haut sans arrêter le reste. + if ! dpkg -s proxmox-default-kernel >/dev/null 2>&1; then + say " noyau Proxmox absent : le noyau Debian reste en place." + return 0 + fi + local metas + # Tout « linux-image-* » SAUF ceux de Proxmox. L'amont écrit + # « linux-image-6.12* », la version de trixie ; le motif large couvre les + # versions suivantes, et l'exclusion protège le noyau qu'on vient de + # poser — le retirer laisserait une VM qui n'amorce plus. + # « db:Status-Status » filtre les paquets RÉELLEMENT installés : dpkg + # connaît aussi ceux qu'il a désinstallés (« config-files », + # « not-installed »), et les passer à apt donnait « is not installed, so + # not removed » — puis un CHANGED=1 qui réclamait un redémarrage inutile. + metas="$(dpkg-query -W -f '${Package} ${db:Status-Status}\n' \ + 'linux-image-*' 2>/dev/null \ + | awk '$2 == "installed" {print $1}' \ + | grep -vE 'pve|proxmox' || true)" + if [ -n "${metas}" ]; then + say " retrait du noyau Debian : $(echo "${metas}" | tr '\n' ' ')" + # shellcheck disable=SC2086 + apt_get remove ${metas} + run sudo update-grub 2>/dev/null || true + CHANGED=1 + fi + return 0 +} + +# --- 6bis. Amorçage EFI de secours ----------------------------------------- +# Une image cloud amorce par le CHEMIN DE SECOURS de l'UEFI — +# \EFI\BOOT\BOOTX64.EFI — parce qu'aucune entrée NVRAM ne la nomme. Les +# paquets grub de Proxmox y recopient bien leurs binaires, mais PAS le petit +# grub.cfg qui indique où trouver la vraie configuration. GRUB s'arrête alors +# sur son invite de secours « grub> » : ni menu, ni noyau. +# +# Vécu, capture d'écran à l'appui : après le premier redémarrage, la VM brûlait +# 100 % d'un cœur SANS lire une seule fois le disque, et l'écran affichait +# « starting Boot0001 UEFI Misc Device » suivi de « grub> ». +# +# On recopie le stub que Debian a généré pour son propre chemin : il porte +# l'UUID de la racine, donc il fonctionne depuis n'importe quel chargeur. +# « grub-install --removable » ferait la même chose en réécrivant le chargeur ; +# copier un fichier est plus sûr — cela ne touche pas au chemin qui marche. +fix_efi_fallback() { + local esp="${PVE_ESP:-/boot/efi}" + local secours="${esp}/EFI/BOOT" + [ -d "${secours}" ] || return 0 + [ -e "${secours}/grub.cfg" ] && return 0 + local stub="" candidat="" + for candidat in "${esp}"/EFI/*/grub.cfg; do + [ -e "${candidat}" ] || continue + case "${candidat}" in + */EFI/BOOT/grub.cfg) continue ;; + esac + stub="${candidat}" + break + done + if [ -z "${stub}" ]; then + say "${Yellow}⚠${Color_Off} aucun grub.cfg à recopier sous ${esp} :" \ + "vérifier l'amorçage avant de redémarrer." + return 0 + fi + say " amorçage de secours : $(dirname "${stub}" | xargs basename)/grub.cfg" \ + "-> EFI/BOOT/" + run sudo cp "${stub}" "${secours}/grub.cfg" + CHANGED=1 +} + +# --- 7. Déroulé ------------------------------------------------------------- +say "${Green}==>${Color_Off} Proxmox VE ${SUITE} sur ${ARCH}" +say "\n---- nom d'hôte et /etc/hosts ----" +fix_hosts +say "\n---- dépôt Proxmox ----" +add_repo +install_pve +say "\n---- ménage ----" +cleanup +say "\n---- amorçage ----" +fix_efi_fallback + +IP="$(host_ip || echo localhost)" +if [ "${DRY}" = "1" ]; then + # Ne rien annoncer qui n'ait eu lieu : en dry-run, rien n'a été installé. + say "\n${Yellow}✓${Color_Off} dry-run terminé : rien n'a été changé." + say " Ce qui serait joignable ensuite : https://${IP}:8006" + exit 0 +fi +say "\n${Green}✓${Color_Off} Proxmox VE installé." +say " Interface web : ${Green}https://${IP}:8006${Color_Off}" +# Proxmox authentifie par PAM : c'est le root du système qui ouvre l'interface. +# Sur une image cloud il est VERROUILLÉ (« passwd -S root » rend « L »), donc +# l'interface est inutilisable tant qu'on ne lui a pas donné un mot de passe. +# Le dire ici plutôt que de laisser chercher devant un formulaire qui refuse. +if [ "$(sudo passwd -S root 2>/dev/null | awk '{print $2}')" = "L" ]; then + say " Compte : root, ${Yellow}sans mot de passe${Color_Off} —" \ + "l'interface le refusera. À faire : ${Green}sudo passwd root${Color_Off}" +else + say " Compte : root (mot de passe du système)" +fi +say " Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités" +say " demandent la virtualisation imbriquée à tous les étages." + +if [ "${CHANGED}" = "0" ]; then + say "\n Rien n'a changé : pas de redémarrage." + exit 0 +fi +if [ "${PVE_REBOOT:-0}" != "1" ]; then + say "\n${Yellow}⚠${Color_Off} Redémarrage NÉCESSAIRE pour amorcer le" \ + "noyau Proxmox : sudo reboot" + exit 0 +fi +say "\n---- redémarrage pour amorcer le noyau Proxmox ----" +run sudo systemctl reboot diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index cf6668a..4f9afbf 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -184,10 +184,24 @@ Catalog, per architecture (`deploy_qemu.py` is the source of truth): | rocky | `9`, `10` (default) | ✔ | ✔ | ✔ | | opensuse | `16.0` (default), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora builds s390x only for the current release, and on a separate tree (`fedora-secondary`) — hence the single version there. +`proxmox` is Proxmox VE, and it deserves a word: it publishes **no cloud +image** — its ISO is an installer that formats the disk. So the deployment +does what upstream itself documents for every other case, *Proxmox VE on +Debian*: it downloads the **Debian trixie** cloud image (the very same file, so +a Debian 13 and a Proxmox deployment share one download) and the `pve` +packages turn it into a hypervisor — Proxmox kernel, web UI on `:8006`. + +The version number is Proxmox's, not Debian's: PVE 9 = trixie. arm64 has been +official since PVE 9 (the upstream `trixie` Release announces `amd64 arm64`, +and the arm64 index really serves `proxmox-ve`). s390x is absent and will stay +so by this route: the repository has no `binary-s390x` index at all — the +catalog says it before the deployment rather than failing at the first `apt`. + `opensuse` covers two distinct products, not two versions of one. Leap `16.0` is numbered and stable (SLE base) and is the default. `tumbleweed` is the rolling one, kept as a bellwether for breakage to come: its snapshot drift is @@ -218,6 +232,7 @@ Catalogue, par architecture (`deploy_qemu.py` fait autorité) : | rocky | `9`, `10` (défaut) | ✔ | ✔ | ✔ | | opensuse | `16.0` (défaut), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora ne construit s390x que pour la version courante, et sur une arborescence à part (`fedora-secondary`) — d'où la version unique. @@ -232,6 +247,38 @@ Les deux livrent un qpdf au-dessus du seuil de pikepdf : la compilation de qpdf, une demi-heure, ne s'y déclenche jamais — ce qui compte sous émulation s390x. +`proxmox`, c'est Proxmox VE, et il mérite un mot : il ne publie **aucune image +cloud** — son ISO est un installateur qui formate le disque. Le déploiement +fait donc ce que l'amont documente lui-même pour tous les autres cas, +*Proxmox VE sur Debian* : il télécharge l'image cloud **Debian trixie** (le +même fichier, si bien qu'un déploiement Debian 13 et un Proxmox se partagent un +seul téléchargement) et les paquets `pve` en font un hyperviseur — noyau +Proxmox, interface web sur `:8006`. + +Le numéro de version est celui de Proxmox, pas de Debian : PVE 9 = trixie. +arm64 est officiel depuis PVE 9 (le Release `trixie` de l'amont annonce +`amd64 arm64`, et l'index arm64 sert bien `proxmox-ve`). s390x est absent et le +restera par cette voie : le dépôt n'a aucun index `binary-s390x` — le catalogue +le dit avant le déploiement plutôt que d'échouer au premier `apt`. + +Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités demandent +la virtualisation imbriquée à tous les étages. L'installation se fait par le +profil « Hyperviseur Proxmox VE (sans Odoo) » du menu de déploiement, ou à la +main dans la VM : + +```bash +sudo ./script/proxmox/install_proxmox.sh --dry-run # dit ce qu'il ferait +sudo ./script/proxmox/install_proxmox.sh # puis : sudo reboot +``` + +Trois pièges de l'image cloud, tous rencontrés sur une VM réelle et traités par +le script : cloud-init tient encore le verrou d'`apt` au premier démarrage ; +`grub-pc`, tiré par les paquets `pve`, demande sur quel disque s'installer et +bloque toute la transaction sans préréponse ; et le chemin de secours UEFI +(`\EFI\BOOT\`) reçoit les binaires GRUB de Proxmox mais pas le `grub.cfg` +qui dit où trouver la configuration — sans quoi la VM s'arrête sur l'invite +`grub>`, sans menu ni noyau. + Fournissez un chemin d'image en argument positionnel pour surcharger l'emplacement de téléchargement automatique. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index fcff7ef..e63deef 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -103,6 +103,7 @@ Catalogue, par architecture (`deploy_qemu.py` fait autorité) : | rocky | `9`, `10` (défaut) | ✔ | ✔ | ✔ | | opensuse | `16.0` (défaut), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora ne construit s390x que pour la version courante, et sur une arborescence à part (`fedora-secondary`) — d'où la version unique. @@ -117,6 +118,38 @@ Les deux livrent un qpdf au-dessus du seuil de pikepdf : la compilation de qpdf, une demi-heure, ne s'y déclenche jamais — ce qui compte sous émulation s390x. +`proxmox`, c'est Proxmox VE, et il mérite un mot : il ne publie **aucune image +cloud** — son ISO est un installateur qui formate le disque. Le déploiement +fait donc ce que l'amont documente lui-même pour tous les autres cas, +*Proxmox VE sur Debian* : il télécharge l'image cloud **Debian trixie** (le +même fichier, si bien qu'un déploiement Debian 13 et un Proxmox se partagent un +seul téléchargement) et les paquets `pve` en font un hyperviseur — noyau +Proxmox, interface web sur `:8006`. + +Le numéro de version est celui de Proxmox, pas de Debian : PVE 9 = trixie. +arm64 est officiel depuis PVE 9 (le Release `trixie` de l'amont annonce +`amd64 arm64`, et l'index arm64 sert bien `proxmox-ve`). s390x est absent et le +restera par cette voie : le dépôt n'a aucun index `binary-s390x` — le catalogue +le dit avant le déploiement plutôt que d'échouer au premier `apt`. + +Une VM Proxmox est un hyperviseur DANS une VM : ses propres invités demandent +la virtualisation imbriquée à tous les étages. L'installation se fait par le +profil « Hyperviseur Proxmox VE (sans Odoo) » du menu de déploiement, ou à la +main dans la VM : + +```bash +sudo ./script/proxmox/install_proxmox.sh --dry-run # dit ce qu'il ferait +sudo ./script/proxmox/install_proxmox.sh # puis : sudo reboot +``` + +Trois pièges de l'image cloud, tous rencontrés sur une VM réelle et traités par +le script : cloud-init tient encore le verrou d'`apt` au premier démarrage ; +`grub-pc`, tiré par les paquets `pve`, demande sur quel disque s'installer et +bloque toute la transaction sans préréponse ; et le chemin de secours UEFI +(`\EFI\BOOT\`) reçoit les binaires GRUB de Proxmox mais pas le `grub.cfg` +qui dit où trouver la configuration — sans quoi la VM s'arrête sur l'invite +`grub>`, sans menu ni noyau. + Fournissez un chemin d'image en argument positionnel pour surcharger l'emplacement de téléchargement automatique. diff --git a/script/qemu/README.md b/script/qemu/README.md index 38cf1e2..dca1d18 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -97,10 +97,24 @@ Catalog, per architecture (`deploy_qemu.py` is the source of truth): | rocky | `9`, `10` (default) | ✔ | ✔ | ✔ | | opensuse | `16.0` (default), `tumbleweed` | ✔ | ✔ | ✔ | | arch | `latest` | ✔ | — | — | +| proxmox | `9` | ✔ | ✔ | — | Fedora builds s390x only for the current release, and on a separate tree (`fedora-secondary`) — hence the single version there. +`proxmox` is Proxmox VE, and it deserves a word: it publishes **no cloud +image** — its ISO is an installer that formats the disk. So the deployment +does what upstream itself documents for every other case, *Proxmox VE on +Debian*: it downloads the **Debian trixie** cloud image (the very same file, so +a Debian 13 and a Proxmox deployment share one download) and the `pve` +packages turn it into a hypervisor — Proxmox kernel, web UI on `:8006`. + +The version number is Proxmox's, not Debian's: PVE 9 = trixie. arm64 has been +official since PVE 9 (the upstream `trixie` Release announces `amd64 arm64`, +and the arm64 index really serves `proxmox-ve`). s390x is absent and will stay +so by this route: the repository has no `binary-s390x` index at all — the +catalog says it before the deployment rather than failing at the first `apt`. + `opensuse` covers two distinct products, not two versions of one. Leap `16.0` is numbered and stable (SLE base) and is the default. `tumbleweed` is the rolling one, kept as a bellwether for breakage to come: its snapshot drift is diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 86f700e..b874180 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -140,6 +140,30 @@ ARCH_VERSIONS: dict[str, tuple[str, str, int, str]] = { "latest": ("latest", "archlinux", 1024, "20G"), } +# Proxmox VE ne publie AUCUNE image cloud : son ISO est un installateur, et la +# voie que l'amont documente pour tout le reste est « Proxmox VE sur Debian ». +# La base est donc l'image cloud Debian, que les paquets pve transforment en +# hyperviseur. Le numéro est celui de PROXMOX, pas de Debian : PVE 9 = trixie. +# +# arm64 est officiel depuis PVE 9 — vérifié dans le dépôt amont, dont le +# Release de trixie annonce « amd64 arm64 » et dont l'index arm64 sert bien +# proxmox-ve. bookworm (PVE 8), lui, est amd64 seulement : d'où une seule +# version au catalogue, celle qui couvre les deux architectures. +# +# s390x n'y figure pas et n'y figurera pas par cette voie : l'index +# « binary-s390x » du dépôt répond 404. Ce n'est pas une difficulté, c'est une +# absence — et ARCH_DISTRO_SUPPORT la dit à la place d'un échec au montage. +# +# RAM/disque : 4 Gio et 32 Go, pas les 1 Gio/20 Go de Debian. Proxmox demande +# 2 Gio pour lui seul, et l'installation télécharge son propre noyau ; 20 Go ne +# laisseraient pas la place d'une seule VM invitée. +PROXMOX_VERSIONS: dict[str, tuple[str, str, int, str]] = { + "9": ("trixie", "debian13", 4096, "32G"), +} + +# Version Debian dont l'image sert de base à chaque version de Proxmox. +PROXMOX_DEBIAN_BASE: dict[str, str] = {"9": "13"} + # distro -> (table des versions, version par défaut). DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = { "ubuntu": (UBUNTU_VERSIONS, "24.04"), @@ -149,6 +173,7 @@ DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = { "rocky": (ROCKY_VERSIONS, "10"), "opensuse": (OPENSUSE_VERSIONS, "16.0"), "arch": (ARCH_VERSIONS, "latest"), + "proxmox": (PROXMOX_VERSIONS, "9"), } # Traduction de l'arch générique (amd64/arm64) vers le nom propre à la distro. @@ -235,6 +260,7 @@ ARM64_DISTROS: tuple[str, ...] = ( "almalinux", "rocky", "opensuse", + "proxmox", ) @@ -429,6 +455,13 @@ def image_candidates( f"{base}/{code}/latest/debian-{version}-genericcloud-{a}.qcow2" for base in DEBIAN_CLOUD_BASES ] + if distro == "proxmox": + # C'est bien l'image DEBIAN qu'on télécharge : « proxmox-ve », posé + # dessus, en fait l'hyperviseur. Le miroir ne connaît que le numéro de + # Debian, jamais celui de Proxmox. + return image_candidates( + "debian", code, arch, PROXMOX_DEBIAN_BASE[version], dry_run + ) if distro == "fedora": return [resolve_fedora_url(version, arch, dry_run)] if distro == "almalinux": @@ -525,6 +558,14 @@ def default_image_name(distro: str, code: str, arch: str, version: str) -> str: return f"ubuntu-{version}-server-cloudimg-{a}.img" if distro == "debian": return f"debian-{version}-genericcloud-{a}.qcow2" + if distro == "proxmox": + # Le fichier téléchargé EST celui de Debian : lui donner le même nom + # de cache fait qu'un déploiement Debian 13 et un Proxmox se + # PARTAGENT le téléchargement (325 Mio) au lieu d'en faire deux. Sans + # cette branche, le repli de fin nommait l'image « fedora-cloud-9 ». + return ( + f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2" + ) if distro == "arch": return f"arch-linux-{a}-cloudimg.qcow2" if distro == "opensuse": @@ -1615,6 +1656,7 @@ DISTRO_LABELS: dict[str, str] = { "rocky": "Rocky Linux", "opensuse": "openSUSE", "arch": "Arch Linux", + "proxmox": "Proxmox VE", } # Gestionnaire de paquets de chaque distribution du catalogue. @@ -1626,6 +1668,8 @@ DISTRO_PKG: dict[str, str] = { "rocky": "dnf", "opensuse": "zypper", "arch": "pacman", + # Debian dessous : c'est apt qui sert, et le guide de connexion le dit. + "proxmox": "apt", } diff --git a/script/todo/todo.py b/script/todo/todo.py index aac50f4..7010111 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1052,6 +1052,10 @@ class TODO: # deploy_qemu.py, qui fait autorité sur le catalogue. "opensuse": (["16.0", "tumbleweed"], "16.0"), "arch": (["latest"], "latest"), + # Proxmox VE : le numéro est celui de PVE, pas de Debian (9 = trixie). + # Une seule version au catalogue, la seule qui couvre amd64 ET arm64 — + # voir PROXMOX_VERSIONS dans deploy_qemu.py, qui fait autorité. + "proxmox": (["9"], "9"), } def _qemu_prompt_distro(self): @@ -5339,13 +5343,25 @@ class TODO: "make install_os && make install_dev && " + self._QEMU_QEMU_PKGS, ), + ( + t("Proxmox VE hypervisor (no Odoo)"), + "./script/proxmox/install_proxmox.sh", + ), ] return profiles - def _qemu_pick_install_profile(self): + def _qemu_pick_install_profile(self, distro=""): """Choix de CE QU'ON installe sur la VM. Renvoie (label, commande - finale exécutée dans ~/git/erplibre).""" + finale exécutée dans ~/git/erplibre). + + Sur une VM Proxmox, le profil hyperviseur passe en tête : choisir + « Proxmox VE » comme système, c'est demander qu'il soit installé, et + laisser Odoo 18 en défaut ferait poser un ERP sur un hyperviseur. + """ profiles = self._qemu_install_profiles() + if distro == "proxmox": + pve = t("Proxmox VE hypervisor (no Odoo)") + profiles.sort(key=lambda p: p[0] != pve) print(f"\n{t('What to install on the VM(s)?')}") for i, (label, _cmd) in enumerate(profiles, 1): print(f" [{i}] {label}{' *' if i == 1 else ''}") @@ -8922,11 +8938,28 @@ class TODO: ans = input( t("Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ") ) + if not self._is_yes_default_yes(ans) and any( + v.get("distro") == "proxmox" for v in vms + ): + # C'est par cette étape que passe l'installation de Proxmox : sans + # elle la VM reste une Debian nue, ce qui n'est pas ce qu'on a + # demandé en choisissant « Proxmox VE » comme système. + print( + f"\n ⚠ {t('Proxmox will NOT be installed: plain Debian VM.')}" + ) + print( + f" {t('Later, in the VM:')}" + " sudo ./script/proxmox/install_proxmox.sh" + ) if self._is_yes_default_yes(ans): branch = self._qemu_pick_branch() # dev (~/git, SELinux relâché) vs prod (/opt, confiné) prod = self._qemu_ask_prod() - label, cmd = self._qemu_pick_install_profile() + label, cmd = self._qemu_pick_install_profile( + "proxmox" + if any(v.get("distro") == "proxmox" for v in vms) + else "" + ) monitor = self._is_yes_default_yes( input(t("Interactive monitoring dashboard? (y/N): ")) ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 5794700..e6b1af2 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3115,6 +3115,18 @@ TRANSLATIONS = { "fr": "Nœud de rendu", "en": "Render node", }, + "Proxmox VE hypervisor (no Odoo)": { + "fr": "Hyperviseur Proxmox VE (sans Odoo)", + "en": "Proxmox VE hypervisor (no Odoo)", + }, + "Proxmox will NOT be installed: plain Debian VM.": { + "fr": "Proxmox ne sera PAS installé : la VM restera une Debian nue.", + "en": "Proxmox will NOT be installed: plain Debian VM.", + }, + "Later, in the VM:": { + "fr": "Plus tard, dans la VM :", + "en": "Later, in the VM:", + }, "sshfs mount failed.": { "fr": "Le montage sshfs a échoué.", "en": "sshfs mount failed.", diff --git a/test/test_qemu_proxmox.py b/test/test_qemu_proxmox.py new file mode 100644 index 0000000..d3b5e25 --- /dev/null +++ b/test/test_qemu_proxmox.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Proxmox VE au catalogue de déploiement : ce qui existe, et ce qui n'existe pas. + +Proxmox ne publie AUCUNE image cloud — son ISO est un installateur qui formate +le disque. La voie que l'amont documente pour tout le reste est « Proxmox VE +sur Debian » : on part de l'image cloud Debian trixie et les paquets pve en +font l'hyperviseur. Le catalogue le dit ainsi, et l'image téléchargée est +littéralement celle de Debian. + +Par architecture, vérifié dans le dépôt amont et non déduit : + +- amd64 : 1800 paquets, proxmox-ve présent. +- arm64 : 448 paquets, proxmox-ve présent — officiel depuis PVE 9, dont le + Release de trixie annonce « amd64 arm64 ». +- s390x : l'index « binary-s390x » répond 404. Ce n'est pas une difficulté, + c'est une absence, et le catalogue la dit avant le déploiement. + +Ce que ces tests gardent : + +- Le nom de cache est celui de Debian : sans lui, le repli de fin nommait + l'image « fedora-cloud-9 » et un même téléchargement se faisait deux fois. +- Les planchers RAM/disque sont ceux d'un hyperviseur, pas ceux de Debian. +- Le retrait du noyau Debian n'emporte JAMAIS celui de Proxmox — sans quoi la + VM ne redémarrerait plus. +""" + +import importlib.util +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +RACINE = pathlib.Path(__file__).resolve().parents[1] +SCRIPT = RACINE / "script/proxmox/install_proxmox.sh" + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 + + +def _deploy(): + spec = importlib.util.spec_from_file_location( + "deploy_qemu", RACINE / "script/qemu/deploy_qemu.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +DQ = _deploy() + +# Somme publiée par l'amont sur « Install Proxmox VE on Debian 13 Trixie », et +# recopiée ici EXPRÈS : deux copies indépendantes, c'est ce qui donne son sens +# à un condensat épinglé. Si l'une change sans l'autre, ce test le dit. +KEY_SHA256 = "136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45" + + +class TestCatalogue(unittest.TestCase): + def test_it_is_offered_as_a_distro(self): + self.assertIn("proxmox", DQ.DISTROS) + versions, defaut = DQ.DISTROS["proxmox"] + self.assertEqual("9", defaut) + self.assertIn("9", versions) + + def test_the_base_is_the_debian_trixie_cloud_image(self): + """C'est bien l'image DEBIAN qu'on télécharge : Proxmox n'en publie + aucune.""" + for arch in ("amd64", "arm64"): + url = DQ.image_url("proxmox", "trixie", arch, "9") + self.assertIn("trixie", url) + self.assertIn(f"debian-13-genericcloud-{arch}.qcow2", url) + + def test_the_cache_name_is_debians_so_the_download_is_shared(self): + """Un déploiement Debian 13 et un Proxmox visent le MÊME fichier : le + repli de fin nommait l'image « fedora-cloud-9 », et 325 Mio se + retéléchargeaient.""" + pve = DQ.default_image_name("proxmox", "trixie", "amd64", "9") + deb = DQ.default_image_name("debian", "trixie", "amd64", "13") + self.assertEqual(deb, pve) + self.assertNotIn("fedora", pve) + + def test_s390x_does_not_exist_and_is_refused(self): + """Le dépôt amont n'a pas d'index s390x (404). Le refus vaut mieux + qu'un déploiement qui échouera au premier apt.""" + self.assertNotIn("proxmox", DQ.ARCH_DISTRO_SUPPORT["s390x"]) + + def test_arm64_is_official_since_pve_9(self): + self.assertIn("proxmox", DQ.ARCH_DISTRO_SUPPORT["arm64"]) + + def test_the_floors_are_a_hypervisors_not_debians(self): + """Proxmox demande 2 Gio pour lui seul et télécharge son noyau : les + 1 Gio/20 Go de Debian donneraient une VM incapable d'héberger une + seule invitée.""" + _code, _osinfo, ram, disque = DQ.PROXMOX_VERSIONS["9"] + self.assertGreaterEqual(ram, 4096) + self.assertGreaterEqual(int(disque.rstrip("G")), 32) + deb_ram = DQ.DEBIAN_VERSIONS["13"][2] + self.assertGreater(ram, deb_ram) + + def test_it_reuses_debians_osinfo(self): + """Le système EST une Debian : libosinfo n'a pas d'entrée Proxmox, et + en inventer une ferait échouer virt-install.""" + self.assertEqual("debian13", DQ.PROXMOX_VERSIONS["9"][1]) + + def test_it_is_named_after_proxmox_not_after_the_key(self): + self.assertEqual("Proxmox VE 9", DQ.distro_label("proxmox", "9")) + + def test_the_login_guide_knows_it_is_apt(self): + self.assertEqual("apt", DQ.DISTRO_PKG["proxmox"]) + + +class TestLesDeuxCatalogues(unittest.TestCase): + """todo.py duplique le catalogue de deploy_qemu.py. Qu'ils s'accordent.""" + + def test_the_menu_offers_every_distro_of_the_catalogue(self): + menu = set(TODO._QEMU_DISTROS) + catalogue = set(DQ.DISTROS) + self.assertEqual( + catalogue, + menu, + "les deux catalogues divergent : " + f"menu seul {menu - catalogue}, deploy seul {catalogue - menu}", + ) + + def test_the_versions_agree_for_proxmox(self): + versions, defaut = TODO._QEMU_DISTROS["proxmox"] + self.assertEqual(["9"], list(versions)) + self.assertEqual("9", defaut) + + +class TestLeProfil(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_a_dedicated_profile_calls_the_dedicated_script(self): + profils = dict(self.todo._qemu_install_profiles()) + libelle = t("Proxmox VE hypervisor (no Odoo)") + self.assertIn(libelle, profils) + self.assertIn("install_proxmox.sh", profils[libelle]) + + def test_on_a_proxmox_vm_it_comes_first(self): + """Laisser « Odoo 18 » en défaut ferait poser un ERP sur un + hyperviseur : c'est le contraire de ce qu'on a demandé.""" + vus = [] + with mock.patch("builtins.input", lambda *a: ""): + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + libelle, cmd = self.todo._qemu_pick_install_profile("proxmox") + vus.append((libelle, cmd)) + self.assertIn("install_proxmox.sh", cmd) + + def test_on_any_other_vm_odoo_stays_the_default(self): + import contextlib + import io + + with mock.patch("builtins.input", lambda *a: ""): + with contextlib.redirect_stdout(io.StringIO()): + libelle, cmd = self.todo._qemu_pick_install_profile("debian") + self.assertNotIn("install_proxmox.sh", cmd) + self.assertIn("install_odoo_18", cmd) + + +class TestLeScript(unittest.TestCase): + def test_it_is_executable_and_valid_shell(self): + self.assertTrue(os.access(SCRIPT, os.X_OK), "pas exécutable") + res = subprocess.run( + ["bash", "-n", str(SCRIPT)], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_help_names_the_knobs_and_exits_clean(self): + res = subprocess.run( + ["bash", str(SCRIPT), "--help"], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode) + for knob in ("--dry-run", "PVE_SUITE", "PVE_REBOOT"): + self.assertIn(knob, res.stdout) + + def test_the_pinned_key_hash_is_the_published_one(self): + texte = SCRIPT.read_text(encoding="utf-8") + self.assertIn(KEY_SHA256, texte) + self.assertIn("proxmox-archive-keyring-", texte) + + def _lance(self, args=(), stubs=None, env=None): + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + for nom, corps in (stubs or {}).items(): + (bin_dir / nom).write_text(f"#!/bin/bash\n{corps}\n") + (bin_dir / nom).chmod(0o755) + osrel = pathlib.Path(tmp) / "os-release" + osrel.write_text("ID=debian\nVERSION_CODENAME=trixie\n") + return subprocess.run( + ["bash", str(SCRIPT), *args], + capture_output=True, + text=True, + env=dict( + os.environ, + PATH=f"{bin_dir}:/usr/bin:/bin", + PVE_OS_RELEASE=str(osrel), + **(env or {}), + ), + timeout=120, + ) + + def test_an_unpublished_architecture_is_refused_by_name(self): + res = self._lance(stubs={"uname": "echo s390x"}) + self.assertNotEqual(0, res.returncode) + self.assertIn("s390x", res.stdout + res.stderr) + + def test_another_distribution_is_refused_by_name(self): + """Le script s'installe SUR Debian : le dire vaut mieux qu'un apt qui + échouera sur un dépôt introuvable.""" + with tempfile.NamedTemporaryFile( + "w", suffix=".os", delete=False + ) as fh: + fh.write("ID=ubuntu\nVERSION_CODENAME=noble\n") + chemin = fh.name + self.addCleanup(os.unlink, chemin) + res = subprocess.run( + ["bash", str(SCRIPT)], + capture_output=True, + text=True, + env=dict(os.environ, PVE_OS_RELEASE=chemin), + timeout=60, + ) + self.assertNotEqual(0, res.returncode) + self.assertIn("ubuntu", res.stdout + res.stderr) + + def test_the_dry_run_follows_the_upstream_procedure(self): + res = self._lance(["--dry-run"]) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + out = res.stdout + for attendu in ( + "proxmox-archive-keyring-trixie.gpg", + "Types: deb", + "pve-no-subscription", + "Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg", + "proxmox-default-kernel", + "proxmox-ve postfix open-iscsi chrony", + ): + self.assertIn(attendu, out, attendu) + + def test_the_dry_run_changes_nothing_and_says_so(self): + """La leçon du montage sshfs : ne rien annoncer qui n'ait eu lieu.""" + res = self._lance(["--dry-run"]) + self.assertIn("dry-run", res.stdout) + self.assertNotIn("Proxmox VE installé", res.stdout) + + def test_postfix_is_preseeded_or_the_install_hangs(self): + """postfix pose deux questions debconf : sans préréponse, une + installation lancée par SSH reste pendue sur une saisie invisible.""" + res = self._lance(["--dry-run"]) + self.assertIn("debconf-set-selections", res.stdout) + self.assertIn("main_mailer_type", res.stdout) + + def test_it_never_removes_the_proxmox_kernel(self): + """Le noyau qu'on vient de poser : l'emporter laisserait une VM qui + n'amorce plus. dpkg-query rend ici les deux, seul celui de Debian doit + partir.""" + res = self._lance( + ["--dry-run"], + stubs={ + "uname": "echo x86_64", + "dpkg": "exit 0", + # Format réel de la requête : nom + état. dpkg connaît + # aussi les paquets DÉSINSTALLÉS (« config-files »), et les + # passer à apt réclamait un redémarrage pour rien. + "dpkg-query": ( + "printf 'linux-image-6.12.0-amd64 installed\\n" + "linux-image-amd64 config-files\\n" + "linux-image-6.14.11-1-pve installed\\n'" + ), + }, + ) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + ligne = [ + x for x in res.stdout.splitlines() if "remove linux-image" in x + ] + self.assertTrue(ligne, res.stdout) + self.assertIn("linux-image-6.12.0-amd64", ligne[0]) + self.assertNotIn("pve", ligne[0]) + # « config-files » = déjà désinstallé : ne pas le repasser à apt. + self.assertNotIn("linux-image-amd64", ligne[0]) + + def test_without_the_proxmox_kernel_nothing_is_removed(self): + """Une étape apt a pu échouer plus haut : on ne touche pas au noyau + Debian tant que celui de Proxmox n'est pas là.""" + res = self._lance( + ["--dry-run"], + stubs={"uname": "echo x86_64", "dpkg": "exit 1"}, + ) + self.assertNotIn("remove linux-image", res.stdout) + self.assertIn("noyau Debian reste en place", res.stdout) + + def test_grub_pc_is_preseeded_or_dpkg_stops(self): + """Le piège propre à l'image cloud : elle amorce en EFI, les paquets + pve tirent grub-pc, et sa post-installation refuse de deviner le + disque — « You must correct your GRUB install devices before + proceeding ». Mesuré : dpkg s'arrête et emporte la transaction.""" + res = self._lance(["--dry-run"]) + self.assertIn("grub-pc/install_devices", res.stdout) + self.assertRegex(res.stdout, r"install_devices multiselect /dev/\w+") + + def test_apt_waits_for_the_lock_instead_of_giving_up(self): + """Sur une VM fraîche, cloud-init tient encore le verrou : mesuré, + « held by process 996 (apt-get) », et le script mourait 40 secondes + après le démarrage.""" + texte = SCRIPT.read_text(encoding="utf-8") + self.assertIn("DPkg::Lock::Timeout", texte) + res = self._lance(["--dry-run"]) + self.assertIn("DPkg::Lock::Timeout", res.stdout) + + def test_it_waits_for_cloud_init_but_not_for_its_verdict(self): + """cloud-init rend « error » sur l'image Debian 13 pour deux modules + sans rapport (console-setup absent, update-locale) alors qu'il a bien + fini. On attend qu'il termine, pas qu'il soit content.""" + res = self._lance(["--dry-run"]) + self.assertIn("cloud-init status --wait", res.stdout) + # L'échec de cloud-init ne doit PAS arrêter le script : « || true » + # sur l'appel réel, pas sur l'écho du dry-run. + texte = SCRIPT.read_text(encoding="utf-8") + reel = [ + x + for x in texte.splitlines() + if "cloud-init status --wait" in x and "dry-run" not in x + ] + self.assertTrue(reel, texte) + self.assertIn("|| true", reel[0]) + + def _esp(self, avec_secours): + """Fausse partition EFI : un stub Debian, et le chemin de secours.""" + racine = tempfile.mkdtemp() + self.addCleanup( + lambda: subprocess.run(["rm", "-rf", racine], check=False) + ) + deb = pathlib.Path(racine, "EFI", "debian") + boot = pathlib.Path(racine, "EFI", "BOOT") + deb.mkdir(parents=True) + boot.mkdir(parents=True) + (deb / "grub.cfg").write_text( + "search.fs_uuid 81e2a465 root\nset prefix=($root)'/boot/grub'\n" + ) + (boot / "BOOTX64.EFI").write_text("binaire") + if avec_secours: + (boot / "grub.cfg").write_text("déjà là") + return racine + + def test_the_efi_fallback_stub_is_restored(self): + """Sans ce fichier, GRUB s'arrête sur « grub> » : ni menu ni noyau. + Vécu après le premier redémarrage — la VM brûlait 100 % d'un cœur sans + lire une seule fois le disque, capture d'écran à l'appui.""" + esp = self._esp(avec_secours=False) + res = self._lance(["--dry-run"], env={"PVE_ESP": esp}) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + self.assertIn("amorçage de secours", res.stdout) + self.assertIn("EFI/BOOT/", res.stdout) + + def test_a_working_fallback_is_left_alone(self): + """Ne pas réécrire ce qui marche : le stub en place peut avoir été + ajusté à la main.""" + esp = self._esp(avec_secours=True) + res = self._lance(["--dry-run"], env={"PVE_ESP": esp}) + self.assertNotIn("amorçage de secours", res.stdout) + + def test_an_esp_without_any_stub_is_said_not_guessed(self): + racine = tempfile.mkdtemp() + self.addCleanup( + lambda: subprocess.run(["rm", "-rf", racine], check=False) + ) + pathlib.Path(racine, "EFI", "BOOT").mkdir(parents=True) + res = self._lance(["--dry-run"], env={"PVE_ESP": racine}) + self.assertIn("aucun grub.cfg à recopier", res.stdout) + + def test_it_does_not_reboot_unless_asked(self): + """Lancé par SSH depuis le déploiement, un reboot couperait la session + et ferait passer une installation réussie pour un échec.""" + texte = SCRIPT.read_text(encoding="utf-8") + self.assertIn('PVE_REBOOT:-0}" != "1"', texte) + res = self._lance(["--dry-run"]) + self.assertNotIn("systemctl reboot", res.stdout) + + +if __name__ == "__main__": + unittest.main(verbosity=1)