[ADD] qemu : déployer Proxmox VE (amd64, arm64)

Proxmox ne publie aucune image cloud : son ISO est un installateur qui formate
le disque. On prend donc la voie que l'amont documente lui-même — Proxmox VE
sur Debian — depuis l'image cloud trixie, partagée avec un déploiement
Debian 13 au lieu d'être téléchargée deux fois.

s390x n'y est pas et n'y sera pas par cette voie : le dépôt n'a aucun index
binary-s390x. arm64 y est, officiel depuis PVE 9. Le catalogue le dit AVANT le
déploiement, au lieu d'échouer au premier apt.

Vérifié sur une VM réelle : pve-manager 9.2.11, noyau 7.0.14-12-pve, quatre
services actifs, interface web en HTTP 200.

--- EN ---

Proxmox publishes no cloud image: its ISO is an installer that formats the
disk. So we take the path upstream documents itself — Proxmox VE on Debian —
from the trixie cloud image, shared with a Debian 13 deployment instead of
being downloaded twice.

s390x is not there and will not be by this route: the repository has no
binary-s390x index. arm64 is, official since PVE 9. The catalog says so BEFORE
the deployment rather than failing at the first apt.

Verified on a real VM: pve-manager 9.2.11, kernel 7.0.14-12-pve, four services
active, web UI answering HTTP 200.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-23 03:28:03 -04:00
parent 770de6b0ca
commit f12e79c3a9
8 changed files with 1073 additions and 3 deletions

495
script/proxmox/install_proxmox.sh Executable file
View file

@ -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 <<EOF
Installe Proxmox VE sur une Debian existante (amd64, arm64).
--dry-run dit ce qu'il ferait, sans rien changer
--help cette aide
Variables : PVE_SUITE (défaut trixie), PVE_REBOOT, PVE_KEEP_DEBIAN_KERNEL,
PVE_OS_RELEASE
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY=1 ;;
-h | --help) usage; exit 0 ;;
*) die "option inconnue : $1" ;;
esac
shift
done
# Toute action qui MODIFIE la machine passe par ici. En dry-run elle est
# annoncée, pas exécutée : c'est ce qui rend le script vérifiable de bout en
# bout sans hyperviseur ni dépôt à disposition.
run() {
if [ "${DRY}" = "1" ]; then
say " ${Yellow}[dry-run]${Color_Off} $*"
return 0
fi
"$@"
}
# --- 1. Architecture --------------------------------------------------------
case "$(uname -m)" in
x86_64) ARCH=amd64 ;;
aarch64 | arm64) ARCH=arm64 ;;
*)
die "Proxmox VE n'existe pas pour $(uname -m) :" \
"le dépôt ne publie que amd64 et arm64."
;;
esac
# --- 2. Debian, et la bonne suite ------------------------------------------
OS_RELEASE="${PVE_OS_RELEASE:-/etc/os-release}"
[ -r "${OS_RELEASE}" ] || die "${OS_RELEASE} illisible : Debian attendue."
# shellcheck disable=SC1090
. "${OS_RELEASE}"
if [ "${ID:-}" != "debian" ]; then
die "Proxmox VE s'installe sur Debian, pas sur « ${ID:-inconnu} »." \
"\n Redéployer la VM avec --distro proxmox (base Debian ${SUITE})."
fi
CODENAME="${VERSION_CODENAME:-}"
if [ -n "${CODENAME}" ] && [ "${CODENAME}" != "${SUITE}" ]; then
say "${Yellow}⚠${Color_Off} Debian « ${CODENAME} » alors que le dépôt visé" \
"est « ${SUITE} » : PVE_SUITE=${CODENAME} si c'est voulu."
fi
# --- 3. /etc/hosts : le nom d'hôte doit résoudre vers une IP ROUTABLE -------
# Ce n'est pas un détail de confort. Le système de grappe de Proxmox parcourt
# toutes les adresses du nom d'hôte jusqu'à en trouver une qui ne soit pas de
# bouclage ; l'entrée « 127.0.1.1 <nom> » 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

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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",
}

View file

@ -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): "))
)

View file

@ -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.",

392
test/test_qemu_proxmox.py Normal file
View file

@ -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)