#!/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()