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
392 lines
16 KiB
Python
392 lines
16 KiB
Python
#!/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)
|