Aucune lecture de code ne les aurait trouvés. Son image n'a pas de secteur d'amorçage BIOS : une VM créée en SeaBIOS se déclare « running » avec une console MUETTE, d'où un marqueur par distribution — Debian 13 démarre en SeaBIOS sur le même hôte, et renverser le défaut coûterait un disque EFI à chaque VM du parc. « --ciuser » s'en remet au compte par DÉFAUT de l'image, que NixOS nomme autrement : le cloud-config du dépôt part en extrait pour toutes les distributions. Et le lecteur cloud-init passe sur le bus SCSI, une image bâtie pour virtio seul ne voyant jamais un lecteur IDE. --- EN --- No code reading would have found them. Its image has no BIOS boot sector: a VM created in SeaBIOS reports "running" with a SILENT console, hence a per-distribution marker — Debian 13 boots in SeaBIOS on the same host, and flipping the default would cost an EFI disk on every VM. "--ciuser" defers to the image's DEFAULT account, which NixOS names otherwise: the repository's cloud-config now travels as a snippet for every distribution. And the cloud-init drive moves to the SCSI bus, an image built for virtio alone never seeing an IDE drive. Assisted-by: Claude Opus 5
183 lines
7.7 KiB
Python
183 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Le cloud-init des VM créées SUR un hôte Proxmox.
|
|
|
|
Deux corrections que seule une VM réelle pouvait dicter, mesurées sur un
|
|
Proxmox 9 :
|
|
|
|
**Le user-data est celui du DÉPÔT**, écrit comme extrait et donné par
|
|
« --cicustom user= ». « --ciuser » et « --sshkeys » s'en remettent au compte
|
|
par DÉFAUT de l'image : celle de NixOS en déclare un autre — « nixos » — et
|
|
les ignorait. La VM démarrait avec ce compte-là, sans la clé, donc
|
|
injoignable. Le user-data du dépôt porte un bloc « users: » explicite, avec le
|
|
nom, le shell et le sudo — le même que le chemin qemu envoie, et déjà éprouvé.
|
|
|
|
**Le lecteur cloud-init est sur le bus SCSI**, contrairement à ce que la
|
|
documentation de Proxmox montre. Une image bâtie pour virtio seul n'a pas de
|
|
pilote ATA et ne VOIT pas un lecteur IDE : /sys/block sans « sr0 »,
|
|
/dev/disk/by-label sans « cidata », et cloud-init qui passe aux sources
|
|
réseau faute de trouver la locale. Le même lecteur en scsi1 apparaît.
|
|
|
|
Éprouvé de bout en bout, séquence régénérée sans retouche : NixOS rend
|
|
« erplibre@… shell=/bin/sh sudo-ok », Debian 13 « shell=/bin/bash sudo-ok ».
|
|
"""
|
|
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.argv = ["todo.py"]
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
|
|
|
from script.proxmox import proxmox_deploy as pve # noqa: E402
|
|
|
|
SPEC = {
|
|
"name": "essai",
|
|
"storage": "local",
|
|
"bridge": "vmbr0",
|
|
"memory": 2048,
|
|
"vcpus": 2,
|
|
"disk": "20G",
|
|
"image": "image.qcow2",
|
|
"user": "erplibre",
|
|
"start": True,
|
|
"ipconfig": "ip=dhcp",
|
|
}
|
|
UD = "#cloud-config\nhostname: essai\nusers:\n - name: erplibre\n"
|
|
|
|
|
|
class LeLecteurCloudInit(unittest.TestCase):
|
|
def test_it_sits_on_the_scsi_bus(self):
|
|
"""Une image bâtie pour virtio seul ne voit pas un lecteur IDE, et
|
|
cloud-init passe alors aux sources réseau : la VM démarre sans compte
|
|
ni clé. Le contrôleur virtio-scsi est déjà là pour le disque."""
|
|
joint = " ".join(pve.create_cmds(100, SPEC))
|
|
self.assertIn("--scsi1 local:cloudinit", joint)
|
|
self.assertNotIn("--ide2", joint)
|
|
|
|
def test_the_boot_order_still_names_the_system_disk(self):
|
|
"""Le lecteur cloud-init n'est pas amorçable : sans « boot order »,
|
|
Proxmox laisse le disque importé hors de la liste."""
|
|
ligne = [c for c in pve.create_cmds(100, SPEC) if "--scsi1" in c][0]
|
|
self.assertIn("--boot order=scsi0", ligne)
|
|
self.assertIn("--bootdisk scsi0", ligne)
|
|
|
|
|
|
class LNomDeLExtrait(unittest.TestCase):
|
|
def test_it_is_carried_by_the_vmid(self):
|
|
"""Deux VM peuvent porter le même NOM sur deux nœuds, jamais le même
|
|
VMID sur un cluster. Un extrait écrasé par un homonyme donnerait à
|
|
une VM le compte d'une autre."""
|
|
self.assertEqual("erplibre-9030.yml", pve.snippet_name(9030))
|
|
self.assertNotEqual(pve.snippet_name(100), pve.snippet_name(101))
|
|
|
|
def test_it_ends_in_yml(self):
|
|
"""Proxmox lit l'extrait comme du YAML ; l'extension est ce qui le
|
|
distingue d'un hook ou d'un fichier de sauvegarde dans le dossier."""
|
|
self.assertTrue(pve.snippet_name(1).endswith(".yml"))
|
|
|
|
|
|
class LEcritureDeLExtrait(unittest.TestCase):
|
|
def test_the_path_is_asked_of_proxmox(self):
|
|
"""Un stockage « dir » range ses extraits sous son propre répertoire :
|
|
supposer /var/lib/vz marcherait pour « local » et pour lui seul."""
|
|
cmd = pve.snippet_write_cmd("autre", "x.yml", "a: b\n")
|
|
self.assertIn("pvesm path", cmd)
|
|
self.assertIn("autre:snippets/x.yml", cmd)
|
|
|
|
def test_the_directory_is_created(self):
|
|
"""Un stockage neuf n'a pas encore son dossier d'extraits."""
|
|
self.assertIn("mkdir -p", pve.snippet_write_cmd("local", "x", "y"))
|
|
|
|
def test_printf_and_not_echo(self):
|
|
"""echo interprète les séquences d'échappement sur certains shells :
|
|
un « \\n » dans un mot de passe haché serait corrompu en silence."""
|
|
cmd = pve.snippet_write_cmd("local", "x", "a: b\n")
|
|
self.assertIn("printf '%s'", cmd)
|
|
self.assertNotIn("echo ", cmd)
|
|
|
|
def test_the_content_is_quoted_whole(self):
|
|
"""Un YAML porte des deux-points, des espaces et des retours à la
|
|
ligne : non cité, il se disperse en arguments."""
|
|
cmd = pve.snippet_write_cmd("local", "x", "a: b\nc: 'd'\n")
|
|
self.assertIn("a: b", cmd)
|
|
self.assertIn("c: ", cmd)
|
|
|
|
|
|
class LaSequenceAvecUserData(unittest.TestCase):
|
|
def _cmds(self):
|
|
return pve.create_cmds(100, dict(SPEC, user_data=UD))
|
|
|
|
def test_the_repository_user_data_is_given_to_the_vm(self):
|
|
joint = " ".join(self._cmds())
|
|
self.assertIn("--cicustom user=local:snippets/erplibre-100.yml", joint)
|
|
|
|
def test_the_default_user_keys_are_dropped(self):
|
|
"""Les laisser ferait deux autorités sur le même compte."""
|
|
joint = " ".join(self._cmds())
|
|
self.assertNotIn("--ciuser", joint)
|
|
self.assertNotIn("--sshkeys", joint)
|
|
|
|
def test_the_network_stays_with_proxmox(self):
|
|
"""« --cicustom user= » ne remplace que la moitié utilisateur : le
|
|
réseau vient toujours d'ipconfig0, et sans lui la VM est muette."""
|
|
self.assertIn("--ipconfig0 ip=dhcp", " ".join(self._cmds()))
|
|
|
|
def test_the_snippet_is_written_before_it_is_referenced(self):
|
|
"""Proxmox lit l'extrait quand il fabrique l'ISO : référencer un
|
|
fichier absent laisse la VM sans user-data du tout."""
|
|
cmds = self._cmds()
|
|
i_ecrit = next(i for i, c in enumerate(cmds) if "pvesm path" in c)
|
|
i_ref = next(i for i, c in enumerate(cmds) if "--cicustom" in c)
|
|
self.assertLess(i_ecrit, i_ref)
|
|
|
|
def test_everything_comes_before_the_start(self):
|
|
cmds = self._cmds()
|
|
i_start = next(i for i, c in enumerate(cmds) if c == "qm start 100")
|
|
for marque in ("pvesm path", "--cicustom"):
|
|
with self.subTest(marque=marque):
|
|
i = next(i for i, c in enumerate(cmds) if marque in c)
|
|
self.assertLess(i, i_start)
|
|
|
|
|
|
class LaFormeDAvantEstGardee(unittest.TestCase):
|
|
"""Sans user-data, la séquence reste celle d'avant : `create_cmds` sert
|
|
aussi aux tests longs, qui n'en fabriquent pas."""
|
|
|
|
def test_the_old_keys_come_back(self):
|
|
cmds = pve.create_cmds(100, dict(SPEC, sshkey_path="/root/k.pub"))
|
|
ci = [c for c in cmds if "--ciuser" in c][0]
|
|
self.assertIn("--ciuser erplibre", ci)
|
|
self.assertIn("--sshkeys /root/k.pub", ci)
|
|
self.assertNotIn("--cicustom", " ".join(cmds))
|
|
|
|
def test_no_snippet_is_written(self):
|
|
joint = " ".join(pve.create_cmds(100, SPEC))
|
|
self.assertNotIn("pvesm path", joint)
|
|
|
|
|
|
class LeMenuFabriqueLeUserData(unittest.TestCase):
|
|
SRC = (RACINE / "script/todo/proxmox_menu.py").read_text(encoding="utf-8")
|
|
|
|
def test_it_comes_from_the_repository_builder(self):
|
|
"""Un user-data écrit à la main dans le menu divergerait de celui du
|
|
chemin qemu, et c'est celui-là qui est éprouvé."""
|
|
self.assertIn("mod.build_cloud_config(", self.SRC)
|
|
self.assertIn("def _pve_user_data", self.SRC)
|
|
|
|
def test_both_call_sites_use_it(self):
|
|
self.assertEqual(2, self.SRC.count('"user_data"'))
|
|
|
|
def test_an_unreadable_key_falls_back_instead_of_locking_out(self):
|
|
"""Un user-data sans clé donnerait une VM sans aucun moyen d'entrer :
|
|
mieux vaut la forme d'avant, qui pose au moins un compte."""
|
|
i = self.SRC.index("def _pve_user_data")
|
|
corps = self.SRC[i : i + 1600]
|
|
self.assertIn("SSH key unreadable:", corps)
|
|
self.assertIn('return ""', corps)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|