diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh index aef37b6..2225365 100755 --- a/script/proxmox/install_proxmox.sh +++ b/script/proxmox/install_proxmox.sh @@ -198,29 +198,46 @@ fix_hosts() { "« 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})" - revive_pmxcfs + revive_pve_services } -# pmxcfs abandonne après cinq essais rapprochés : systemd marque l'unité -# « failed » et n'y revient JAMAIS de lui-même — « Start request repeated too -# quickly ». Corriger /etc/hosts ne suffit donc pas ; sans ce coup de pouce, -# l'hôte reste sans /etc/pve, donc sans stockage, et l'écran de déploiement -# s'arrête sur « il manque le stockage ». +# Les services de Proxmox abandonnent après cinq essais rapprochés : systemd +# marque l'unité « failed » et n'y revient JAMAIS de lui-même — « Start request +# repeated too quickly ». Or ils ont TOUS échoué pendant que /etc/hosts était +# faux. Corriger le fichier ne suffit donc pas. # -# « reset-failed » d'abord, sinon le démarrage est refusé sans même être tenté. -revive_pmxcfs() { +# L'ordre compte : pve-cluster d'abord, il monte /etc/pve dont les autres +# dépendent. +# +# pvestatd n'est pas un luxe. C'est lui qui remplit « /cluster/resources » ; +# arrêté, l'hôte rend une entrée SQUELETTIQUE par VM — ni nom, ni mémoire, ni +# disque, et « status: unknown ». Le tableau de bord n'a alors aucune colonne +# vivante, et il a même pris cette entrée pour une VM disparue. +PVE_SERVICES="pve-cluster pvestatd pvedaemon pveproxy pve-firewall" + +revive_pve_services() { command -v systemctl >/dev/null 2>&1 || return 0 - [ -e /etc/pve/.version ] && return 0 - say " pve-cluster : /etc/pve n'est pas monté, relance" + local unite etat casse="" + for unite in ${PVE_SERVICES}; do + systemctl list-unit-files "${unite}.service" >/dev/null 2>&1 || continue + etat="$(systemctl is-active "${unite}" 2>/dev/null || true)" + [ "${etat}" = "active" ] && continue + casse="${casse} ${unite}" + done + [ -n "${casse}" ] || return 0 + say " services à relancer :${casse}" if [ "${DRY}" = "1" ]; then - say " ${Yellow}[dry-run]${Color_Off} systemctl reset-failed" \ - "pve-cluster && systemctl start pve-cluster" + say " ${Yellow}[dry-run]${Color_Off} systemctl reset-failed puis" \ + "start :${casse}" return 0 fi - sudo systemctl reset-failed pve-cluster 2>/dev/null || true - if sudo systemctl start pve-cluster 2>&1; then + for unite in ${casse}; do + sudo systemctl reset-failed "${unite}" 2>/dev/null || true + sudo systemctl start "${unite}" 2>&1 || \ + say " ${Yellow}⚠${Color_Off} ${unite} :" \ + "journalctl -u ${unite} -n 30" CHANGED=1 - fi + done # Le montage n'est pas instantané : on le CONSTATE plutôt que de le # supposer, et on le dit quand il n'arrive pas. local i diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 57b52e4..8c58ba5 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -1524,12 +1524,24 @@ def parse_odoo_probe(text: str) -> set: def parse_pvestats(text: str) -> dict: - """Sortie de PVE_STATS_CMD -> {nom: relevé}, même forme que domstats. + """Sortie de PVE_STATS_CMD -> {VMID: relevé}, même forme que domstats. - Même forme exprès : les colonnes, le débit d'écriture et la RAM se - calculent alors sans savoir d'où vient la mesure. Une VM sur un hôte - Proxmox distant n'avait aucune de ces colonnes — elles viennent de virsh, - qui ne sait rien de cet hôte. + Par VMID et non par NOM, et c'est tout le sujet. « /cluster/resources » + est bâti par pvestatd ; celui-ci arrêté, l'hôte rend quand même une entrée + par VM, mais SQUELETTIQUE : + + {"id":"qemu/100","node":"…","status":"unknown","type":"qemu", + "vmid":100} + + Ni nom, ni mémoire, ni disque. Indexée par nom, cette entrée disparaissait + — la VM était donc « absente du relevé » alors que l'hôte venait de la + nommer. Trois tours plus tard : 🗑, état TERMINAL, et le suivi annonçait + « 1/1 terminées » au bout de neuf secondes sur une installation qui + tournait. Le VMID, lui, est toujours là ; c'est d'ailleurs le seul + identifiant unique d'un hôte Proxmox. + + Même forme que domstats exprès : les colonnes, le débit d'écriture et la + RAM se calculent alors sans savoir d'où vient la mesure. """ brut, _, tailles = (text or "").partition("---ERPLIBRE-DU---") try: @@ -1547,14 +1559,15 @@ def parse_pvestats(text: str) -> dict: out = {} maintenant = time.time() for r in ressources if isinstance(ressources, list) else (): - nom = r.get("name") - if not nom: + vmid = int(r.get("vmid") or 0) + if not vmid: continue total = int(r.get("maxdisk") or 0) - utilise = int(r.get("disk") or 0) or occupe.get( - int(r.get("vmid") or 0), 0 - ) - out[nom] = { + utilise = int(r.get("disk") or 0) or occupe.get(vmid, 0) + out[vmid] = { + # Le nom reste DANS le relevé : il ne sert plus de clé, mais il + # aide à lire un journal quand les deux divergent. + "name": r.get("name") or "", "ram_used": int(r.get("mem") or 0), "ram_total": int(r.get("maxmem") or 0), # Le relevé vient d'être fait : il n'est pas périmé, et c'est ce @@ -1678,11 +1691,21 @@ def _read_pvestats(vms, now=None): # analysable. Rien d'autre, et surtout pas le code. if _resources_parsable(sortie): ok = True + # {VMID: relevé} -> {nom du manifeste: relevé}. La correspondance + # se fait ICI, où le manifeste est sous les yeux : lui seul dit + # quel VMID porte quel nom, et l'hôte peut très bien ne pas + # nommer ses VM (pvestatd arrêté). releves = parse_pvestats(sortie) ouverts = parse_odoo_probe(sortie) - for nom, rec in releves.items(): - rec["odoo"] = adresses.get(nom) in ouverts - stats.update(releves) + for vm in vms or (): + pve_info = vm.get("pve") or {} + if pve_info.get("target") != target: + continue + rec = releves.get(int(pve_info.get("vmid") or 0)) + if not rec: + continue + rec["odoo"] = adresses.get(vm["name"]) in ouverts + stats[vm["name"]] = rec _PVE_CACHE.update({"at": maintenant, "stats": stats, "ok": ok}) return dict(stats), ok diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 94077e5..0037cb5 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -856,11 +856,26 @@ class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase): self.assertIn("/etc/cloud/cloud.cfg.d", self.src) self.assertIn("freeze_cloud_hosts", self.src) - def test_a_failed_pmxcfs_is_revived(self): - # systemd marque l'unité « failed » après cinq essais rapprochés et - # n'y revient jamais seul : corriger /etc/hosts ne suffit pas. - self.assertIn("reset-failed pve-cluster", self.src) - self.assertIn("start pve-cluster", self.src) + def test_every_failed_pve_service_is_revived(self): + """systemd marque l'unité « failed » après cinq essais rapprochés et + n'y revient jamais seul : corriger /etc/hosts ne suffit pas. + + Et ils ont TOUS échoué pendant que le fichier était faux — le journal + de pvestatd le dit mot pour mot : « ipcc_send_rec failed: Connection + refused », c'est-à-dire pve-cluster absent. Relancer le seul + pve-cluster laissait pvestatd mort, donc un hôte qui ne nomme même pas + ses VM.""" + self.assertIn("reset-failed", self.src) + for unite in ("pve-cluster", "pvestatd", "pvedaemon", "pveproxy"): + self.assertIn(unite, self.src, unite) + + def test_pve_cluster_comes_first(self): + # Il monte /etc/pve, dont les autres dépendent. + import re + + m = re.search(r'PVE_SERVICES="([^"]+)"', self.src) + self.assertIsNotNone(m) + self.assertEqual(m.group(1).split()[0], "pve-cluster") def test_the_mount_is_verified_not_assumed(self): self.assertIn("/etc/pve/.version", self.src) diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index 3bffe23..19f66eb 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -37,9 +37,18 @@ class TestLaLecture(unittest.TestCase): def setUp(self): self.releves = mon.parse_pvestats(REPONSE) - def test_the_vm_is_keyed_by_its_name(self): - # Le suivi raisonne en NOMS : c'est ce que porte le manifeste. - self.assertEqual(list(self.releves), ["pve-suivi"]) + def test_the_vm_is_keyed_by_its_vmid(self): + """Par VMID, et c'est tout le sujet. + + « /cluster/resources » est bâti par pvestatd ; celui-ci arrêté, l'hôte + rend une entrée SQUELETTIQUE — ni nom, ni mémoire, ni disque, et + « status: unknown ». Indexée par nom, elle disparaissait : la VM + passait pour absente du relevé alors que l'hôte venait de la nommer. + Trois tours plus tard, 🗑 — état TERMINAL — et le suivi annonçait + « 1/1 terminées » au bout de neuf secondes. Vécu sur une VM Arch dans + un Proxmox imbriqué.""" + self.assertEqual(list(self.releves), [100]) + self.assertEqual(self.releves[100]["name"], "pve-suivi") def test_the_shape_matches_the_virsh_one(self): # Même forme exprès : `ram_pair`, `WriteWindow` et les colonnes @@ -52,10 +61,10 @@ class TestLaLecture(unittest.TestCase): "disk_used", "disk_total", } - self.assertTrue(attendus <= set(self.releves["pve-suivi"])) + self.assertTrue(attendus <= set(self.releves[100])) def test_the_measures_are_the_ones_the_host_gave(self): - rec = self.releves["pve-suivi"] + rec = self.releves[100] self.assertEqual(rec["ram_used"], 385351680) self.assertEqual(rec["ram_total"], 536870912) self.assertEqual(rec["wr_bytes"], 328233472) @@ -65,13 +74,13 @@ class TestLaLecture(unittest.TestCase): def test_a_zero_disk_falls_back_to_the_real_size(self): # Sur un stockage en fichiers, Proxmox NE CALCULE PAS la taille # occupée et rapporte 0 : la colonne aurait affiché « 0/4G ». - self.assertEqual(self.releves["pve-suivi"]["disk_used"], 4294971392) - self.assertEqual(self.releves["pve-suivi"]["disk_total"], 4294967296) + self.assertEqual(self.releves[100]["disk_used"], 4294971392) + self.assertEqual(self.releves[100]["disk_total"], 4294967296) def test_the_reading_is_fresh_so_the_ram_is_shown(self): # `ram_pair` refuse un relevé périmé : sans horodatage, la RAM d'une # VM distante ne s'afficherait jamais. - rec = self.releves["pve-suivi"] + rec = self.releves[100] self.assertNotEqual(mon.ram_pair(rec, rec["ram_at"]), "-") def test_garbage_yields_nothing_rather_than_raising(self): @@ -336,6 +345,87 @@ class TestLeRedemarrageQuiFaitPartieDeLInstallation(unittest.TestCase): self.assertIn("_reboot_steps(log_q, reboot) if reboot else", src) +class TestUnHoteQuiNeNommePasSesVm(unittest.TestCase): + """pvestatd arrêté, l'hôte rend une entrée SQUELETTIQUE par VM. + + Vécu sur une VM Arch dans un Proxmox imbriqué : + + {"id":"qemu/100","node":"…","status":"unknown","type":"qemu", + "vmid":100} + + Le nom manque, donc la VM passait pour absente du relevé — alors que + l'hôte venait de la nommer. Trois tours plus tard : 🗑, état TERMINAL, et + le suivi annonçait « 1/1 terminées » au bout de neuf secondes sur une + installation qui tournait. Une cause, deux symptômes.""" + + SQUELETTE = ( + '[{"id":"qemu/100","node":"n","status":"unknown","type":"qemu",' + '"vmid":100}]\n' + "---ERPLIBRE-DU---\n" + "2248339456\t/var/lib/vz/images/100/\n" + "---ERPLIBRE-ODOO---\n" + ) + + def _lit(self, sortie, nom="vm-arch", vmid=100): + mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) + vm = { + "name": nom, + "pve": { + "target": "h", + "sudo": "", + "vmid": vmid, + "addr": "1.2.3.4", + }, + } + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(1, sortie) + ): + return mon.read_pvestats_detail([vm], now=50.0) + + def test_the_vm_is_still_found(self): + stats, ok = self._lit(self.SQUELETTE) + self.assertTrue(ok) + self.assertIn("vm-arch", stats, "trouvée par son VMID, pas par un nom") + + def test_an_unknown_status_is_not_a_deletion(self): + stats, _ok = self._lit(self.SQUELETTE) + etat = stats["vm-arch"]["state"] + self.assertEqual(mon.PVE_ETATS.get(etat, "running"), "running") + + def test_what_the_host_does_know_is_kept(self): + # Le « du » est indexé par VMID : la taille occupée survit même quand + # tout le reste manque. + stats, _ok = self._lit(self.SQUELETTE) + self.assertEqual(stats["vm-arch"]["disk_used"], 2248339456) + + def test_a_vmid_of_another_host_is_not_borrowed(self): + # Deux hôtes peuvent avoir un VMID 100. La correspondance ne vaut que + # pour les VM de CET hôte. + mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) + vms = [ + { + "name": "ici", + "pve": {"target": "h", "sudo": "", "vmid": 100}, + }, + { + "name": "ailleurs", + "pve": {"target": "autre", "sudo": "", "vmid": 100}, + }, + ] + + # Le bouchon répond PAR HÔTE : sans cela, la même sortie servirait + # aux deux et le test ne prouverait rien. + def par_hote(info, _cmd, _timeout=40): + if info.get("target") == "h": + return 1, self.SQUELETTE + return 1, "[]\n---ERPLIBRE-DU---\n---ERPLIBRE-ODOO---\n" + + with mock.patch("script.proxmox.proxmox_deploy.run", par_hote): + stats, _ok = mon.read_pvestats_detail(vms, now=60.0) + self.assertIn("ici", stats) + self.assertNotIn("ailleurs", stats) + + class TestTroisVmSurUnProxmox(unittest.TestCase): """Rapporté à l'usage : sur trois VM d'un même Proxmox, une seule avait ses colonnes vides — et les deux autres montraient les chiffres d'une @@ -701,7 +791,7 @@ class TestLeDisque(unittest.TestCase): "1268518912\t/var/lib/vz/images/101/\n" "4294967296\t/var/lib/vz/images/999/\n" ) - rec = mon.parse_pvestats(texte)["vm-a"] + rec = mon.parse_pvestats(texte)[101] self.assertEqual(rec["disk_used"], 1268518912) self.assertEqual(rec["disk_total"], 6442450944) self.assertEqual(