[FIX] suivi : relevé Proxmox squelettique, index par nom, état terminal

Une cause, deux symptômes. « /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 — ni nom, ni mémoire, ni disque, et « status: unknown ». Le
relevé était indexé par NOM : l'entrée disparaissait donc, la VM passait pour
absente alors que l'hôte venait de la nommer, et trois tours plus tard 🗑.
Comme « effacée » est un état TERMINAL, la ligne comptait pour finie — d'où
« 1/1 terminées · 00:09 » sur une installation qui tournait.

Le relevé est maintenant indexé par VMID, seul identifiant unique d'un hôte
Proxmox, et la correspondance vers les noms se fait là où le manifeste est
sous les yeux. Une entrée squelettique reste donc une VM présente, avec ce que
l'hôte sait d'elle — sa taille occupée, que « du » donne par VMID.

Reste à savoir pourquoi pvestatd était mort. Son journal le dit mot pour mot :
« ipcc_send_rec failed: Connection refused » — pve-cluster absent, c'est-à-dire
la panne /etc/hosts d'hier. Tous les services de Proxmox avaient échoué
ensemble, et systemd n'y revient jamais seul. L'installation relançait le seul
pve-cluster ; elle relance désormais l'ensemble, pve-cluster d'abord puisqu'il
monte /etc/pve.

Vérifié sur l'hôte : pvestatd relancé, et les colonnes passent de « - - - » à
« 3.4G/4.0G, 2.3G/25G, 2.2G écrit ».

--- EN ---

One cause, two symptoms. "/cluster/resources" is built by pvestatd; with it
stopped the host still returns one entry per VM, but SKELETAL — no name, no
memory, no disk, and "status: unknown". Readings were indexed by NAME, so that
entry vanished, the VM looked absent although the host had just named it, and
three rounds later 🗑. Since "deleted" is a TERMINAL state the row counted as
finished — hence "1/1 done · 00:09" on a running install.

Readings are now indexed by VMID, a Proxmox host's only unique identifier, and
the mapping to names happens where the manifest is at hand. A skeletal entry
therefore stays a present VM, with whatever the host does know about it — its
used size, which "du" reports per VMID.

Why was pvestatd dead? Its journal says it verbatim: "ipcc_send_rec failed:
Connection refused" — no pve-cluster, that is yesterday's /etc/hosts fault. All
of Proxmox's services had failed together, and systemd never returns to them on
its own. The installer restarted pve-cluster alone; it now restarts the whole
set, pve-cluster first since it mounts /etc/pve.

Verified on the host: pvestatd restarted, and the columns go from "- - -" to
"3.4G/4.0G, 2.3G/25G, 2.2G written".

Assisted-by: Claude Opus 5
(cherry picked from commit 3fb85f66842d4b0e9d6ae6446691229b31ef725a)
This commit is contained in:
Mathieu Benoit 2026-08-25 05:05:39 -04:00
parent 855acd8e61
commit 98c2355ca0
4 changed files with 188 additions and 43 deletions

View file

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

View file

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

View file

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

View file

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