[ADD] proxmox : l'écran remet pmxcfs debout lui-même

Le conseil « rejouer install_proxmox.sh sur l'hôte » ne pouvait PAS marcher :
la VM clone le dépôt distant, donc sa copie du script est celle du distant —
tant que le correctif n'y est pas, celle qui ne corrige rien. Trois hôtes de
suite sont tombés dessus, avec le même message inutile. L'écran répare donc :
gel de cloud-init, réécriture de /etc/hosts, relance des unités, constat du
montage — le pendant exact de l'offre de créer un pont.

Écrit, puis ATTAQUÉ par trois lentilles sur le code réel. Ce qu'elles ont
mesuré valait la peine.

/etc/hosts se réécrivait en DEUX écritures — « sed -i » puis « printf >> » —
alors que la docstring promettait l'inverse. Sed refusé et ajout réussi, la
ligne 127.0.1.1 survivait EN PREMIER et la nôtre s'ajoutait une fois par
tentative ; sed réussi et ajout refusé, l'hôte perdait l'entrée de son nom, et
chaque sudo y attendait ensuite le résolveur. C'est maintenant un fichier
complet bâti dans un temporaire, VÉRIFIÉ, puis recopié — « cat > » et non
« mv », qui remplacerait l'inode et perdrait mode et propriétaire.

Le contrôle final s'en remettait à « getent hosts », qui réussit via mDNS même
quand rien n'a été écrit — et acceptait les fe80:: que notre propre code
rejette. Il relit désormais ce qui a été écrit.

awk remplace sed pour filtrer : « print » émet un saut de ligne, donc un
/etc/hosts non terminé par un — cloud-init n'en met pas — est normalisé. Sans
ça notre ligne se collait à la précédente et le nom du nœud partait sur
l'adresse d'une autre machine.

Trois autres, du même acabit. Les dépendants de pmxcfs sont relancés eux
aussi : actifs pendant la panne, ils échouaient sur ipcc_send_rec, et les
laisser donnait une GUI en « communication failure » juste après notre ✓. Un
silence du lien n'est plus lu comme une absence de montage. Et l'adresse n'est
mise en cause que si pve-cluster a réellement démarré.

Les tests exécutent les commandes au lieu de les relire, bouchons capables
d'ÉCHOUER : écriture refusée, fichier sans saut de ligne final, tabulations,
start qui rate, montage qui disparaît pendant la reconfirmation. Prouvé par
mutation — trois HOSTS-KO changés en HOSTS-OK font rougir le test.

--- EN ---

The advice "replay install_proxmox.sh on the host" could NOT work: the VM
clones the remote, so its copy of the script is the remote's — while the fix
is not there, the one that fixes nothing. Three hosts in a row hit it with the
same useless message. So the screen repairs: freeze cloud-init, rewrite
/etc/hosts, restart the units, verify the mount — the exact counterpart of the
offer to create a bridge.

Written, then ATTACKED by three lenses on the real code. What they measured
was worth it.

/etc/hosts was rewritten in TWO writes — "sed -i" then "printf >>" — while the
docstring promised the opposite. Sed refused and append succeeded: the
127.0.1.1 line survived FIRST and ours was added once per attempt; sed
succeeded and append refused: the host lost its own name entry, and every sudo
then waited on the resolver. It is now a complete file built in a temporary,
VERIFIED, then copied over — "cat >" not "mv", which would replace the inode
and lose mode and owner.

The final check relied on "getent hosts", which succeeds via mDNS even when
nothing was written — and accepted the fe80:: our own code rejects. It now
re-reads what was written.

awk replaces sed for filtering: "print" emits a newline, so an /etc/hosts with
no final one — cloud-init omits it — gets normalised. Without that our line
glued onto the previous one and the node's name pointed at another machine's
address.

Three more of the same kind. pmxcfs's dependents are restarted too: active
throughout the outage, they failed on ipcc_send_rec, and leaving them gave a
GUI in "communication failure" right after our ✓. A silent link is no longer
read as a missing mount. And the address is only blamed if pve-cluster
actually started.

The tests execute the commands instead of reading them, with stubs able to
FAIL: refused write, file with no final newline, tabs, a start that fails, a
mount that vanishes during reconfirmation. Proven by mutation — three
HOSTS-KO turned into HOSTS-OK make the test go red.

Assisted-by: Claude Opus 5
(cherry picked from commit d4f9358c6cb562029cc2ca9eb478d80c6a0a19a4)
This commit is contained in:
Mathieu Benoit 2026-08-26 03:19:58 -04:00
parent e3138bea7e
commit 4bc2fa6097
4 changed files with 940 additions and 32 deletions

View file

@ -338,7 +338,19 @@ def parse_cluster_check(text: str) -> dict:
brut = strip_ssh_noise(text or "")
tete, sep1, reste = brut.partition("---ERPLIBRE-PVE-FS---")
milieu, sep2, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---")
adresses = [a for a in queue.split() if a[:1].isdigit() or ":" in a]
# Filtré sur ce qu'EST une adresse, pas sur sa ponctuation. run() colle
# stderr après stdout, donc tout ce que sudo écrit atterrit dans cette
# queue — et « sudo: unable to resolve host pve: … » se produit
# précisément dans la panne qu'on diagnostique. Mesuré : l'écran affichait
# « le nom d'hôte ne résout que vers 127.0.1.1 sudo: pve: ». Il affirmait
# des adresses là où la sonde n'avait rien mesuré.
adresses = []
for jeton in queue.split():
try:
ipaddress.ip_address(jeton)
except ValueError:
continue
adresses.append(jeton)
return {
"lu": bool(sep1 and sep2),
"actif": "active" in tete and "inactive" not in tete,
@ -348,6 +360,241 @@ def parse_cluster_check(text: str) -> dict:
}
# Marqueur de NOTRE ligne dans /etc/hosts. Il rend la réécriture exactement
# idempotente : on retire ce qui porte la marque, puis on ajoute. Sans lui, la
# garde devait s'indexer sur l'ADRESSE — et en DHCP une adresse qui change
# ajoutait une ligne de plus à chaque passage sans retirer la précédente.
HOSTS_MARK = "erplibre-hosts"
# Services relancés par la réparation. pve-firewall n'y est PAS : sa
# configuration vit dans /var/lib/pve-cluster/config.db, invisible tant que
# /etc/pve n'est pas monté — c'est-à-dire exactement l'état qu'on répare. Le
# démarrer appliquerait des règles illisibles sur la seule voie d'accès à la
# machine.
#
# rrdcached d'abord : pve-cluster le requiert, et une limite de démarrage
# atteinte sur lui fait échouer pve-cluster sur « dependency » sans que
# reset-failed sur pve-cluster n'y change quoi que ce soit.
PVE_UNITS = ("rrdcached", "pve-cluster", "pvestatd", "pvedaemon", "pveproxy")
def ssh_server_ip(text: str) -> str:
"""Adresse de l'hôte telle que NOTRE ssh l'atteint, depuis $SSH_CONNECTION.
« client_ip client_port SERVER_ip server_port » : le troisième champ. C'est
la seule adresse dont on SAIT qu'elle mène à la machine, rebond compris.
Les candidats habituels se trompent ici. Mesuré sur une Proxmox imbriquée :
« hostname -I » rend « 10.10.10.150 10.10.20.1 », et la seconde est le pont
interne que notre propre code vient de créer. La poser dans /etc/hosts
ferait s'identifier le nœud par une adresse que personne ne joint.
"""
champs = strip_ssh_noise(text or "").split()
return champs[2] if len(champs) >= 4 and _usable_address(champs[2]) else ""
# Deux sources pour les noms, dans cet ordre. La seconde est indispensable au
# REJEU : au second passage il n'y a plus de ligne 127.0.1.1 — c'est nous qui
# l'avons retirée — et sans elle un vrai FQDN était remplacé par
# « <court>.local ». La commande n'était donc pas idempotente sur ce qu'elle
# avait elle-même préservé. Attrapé par un test qui la rejoue deux fois.
_NOMS_DEPUIS_LOOPBACK = (
r"sed -nE 's/^[[:space:]]*127\.0\.1\.1[[:space:]]+([^#]*).*$/\1/p'"
)
_NOMS_DEPUIS_MARQUE = (
r"sed -nE 's/^[^[:space:]]+[[:space:]]+([^#]*)#[[:space:]]*"
+ HOSTS_MARK
+ r"[[:space:]]*$/\1/p'"
)
# Normalise les séparateurs. L'installeur Debian écrit /etc/hosts avec des
# TABULATIONS, et le test du nom court cherchait des ESPACES : au rejeu, la
# ligne écrite gagnait un « srv » de plus.
_ROGNE = r"sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//'"
def hosts_repair_cmd(ip: str) -> str:
"""UNE écriture ATOMIQUE de /etc/hosts, ou "" sans adresse utilisable.
La première version promettait « une seule commande » et n'en tenait rien :
« sed -i » puis « printf >> » sont DEUX écritures, sans set -e et sans
retour en arrière. Une attaque adversariale l'a mesuré sur trois états
réels — /etc en lecture seule, fichier rendu immuable par chattr, quota
atteint :
* sed refusé, ajout réussi -> la ligne 127.0.1.1 survit et reste PREMIÈRE,
donc gagnante, et notre ligne s'ajoute UNE FOIS PAR TENTATIVE. Le
marqueur, censé rendre l'opération idempotente, ne retirait rien puisque
c'est le sed qui portait la suppression.
* sed réussi, ajout refusé -> l'hôte n'a PLUS d'entrée pour son nom. Sur
une machine qu'on ne joint que par ssh, chaque sudo attend ensuite le
résolveur puis répond « unable to resolve host ». C'est exactement l'état
« pire qu'avant » que la docstring prétendait écarter.
Donc : on construit le fichier ENTIER dans un temporaire du même
répertoire, on vérifie ce qu'il contient, et on ne le recopie qu'ensuite.
« cat > » et non « mv » : le renommage remplace l'inode et perdrait mode,
propriétaire et contexte SELinux de /etc/hosts.
Bénéfice supplémentaire : « sed » sans -i ajoute le saut de ligne final
manquant. Sans lui, un /etc/hosts non terminé par \\n — cloud-init
« write_files » n'en met pas — voyait notre ligne se coller à la
précédente, et le nom du nœud partait sur l'adresse d'une AUTRE machine.
POSIX seulement (dash), et aucun « sudo » dedans : c'est wrap_privilege
qui porte le privilège, et sur un hôte root@ il n'enrobe rien.
"""
if not _usable_address(ip):
return ""
tmp = "/etc/hosts.erplibre.$$"
return (
"short=$(hostname -s); "
f"noms=$({_NOMS_DEPUIS_LOOPBACK} /etc/hosts | head -1 | {_ROGNE}); "
f'[ -n "$noms" ] || noms=$({_NOMS_DEPUIS_MARQUE} /etc/hosts'
f" | head -1 | {_ROGNE}); "
'[ -n "$noms" ] || noms="$short.local $short"; '
# Le nom court DOIT y être : c'est lui que pmxcfs résout. Le test se
# fait sur des séparateurs NORMALISÉS — l'installeur Debian écrit des
# tabulations, et « case " $noms " in *" $short "* » ne les voyait pas,
# d'où un « srv srv » au rejeu.
'case " $noms " in *" $short "*) ;; *) noms="$noms $short";; esac; '
# Le fichier complet d'abord, dans le MÊME répertoire : un temporaire
# ailleurs ne se recopierait pas forcément (montages séparés).
"{ "
# awk et non sed : « print » émet un saut de ligne par
# enregistrement, donc un /etc/hosts non terminé par \n est
# NORMALISÉ. sed, lui, préserve l'absence — vérifié — et notre ligne
# se collait alors à la précédente : le nom du nœud partait sur
# l'adresse d'une autre machine. mawk 1.3.4, celui de Debian, fait
# bien ce qu'on attend.
r"awk '!/^[ \t]*127\.0\.1\.1[ \t]/"
f" && !/#[ \\t]*{HOSTS_MARK}[ \\t]*$/' /etc/hosts"
f" && printf '%s\\t%s\\t# {HOSTS_MARK}\\n' {shlex.quote(ip)} \"$noms\""
f" ; }} > {tmp} || {{ rm -f {tmp}; echo HOSTS-KO; exit 0; }}; "
# On vérifie le TEMPORAIRE avant de toucher à l'original : notre ligne
# présente une seule fois, et plus aucune 127.0.1.1.
f"vu=$(sed -nE 's/^([^#[:space:]]+)[[:space:]].*#[[:space:]]*"
f"{HOSTS_MARK}[[:space:]]*$/\\1/p' {tmp}); "
f'if [ "$vu" != {shlex.quote(ip)} ] '
rf"|| grep -qE '^[[:space:]]*127\.0\.1\.1[[:space:]]' {tmp}; then "
f"rm -f {tmp}; echo HOSTS-KO; exit 0; fi; "
# La seule écriture destructive, et elle est la dernière.
f"cat {tmp} > /etc/hosts || {{ rm -f {tmp}; echo HOSTS-KO; exit 0; }}; "
f"rm -f {tmp}; echo HOSTS-OK"
)
def cloud_hosts_freeze_cmd() -> str:
"""Empêche cloud-init de réécrire /etc/hosts au prochain démarrage.
Gardé sur le CONTENU et non sur l'existence : « printf … > » TRONQUE avant
d'écrire, donc une coupure laisse zéro octet et une garde à l'existence
annonce « déjà gelé » pour toujours. Une redirection est de toute façon
idempotente : il n'y a rien d'autre à protéger.
"""
fichier = "/etc/cloud/cloud.cfg.d/99-erplibre-hosts.cfg"
return (
"[ -d /etc/cloud ] || { echo FREEZE-SANS-OBJET; exit 0; }; "
f"grep -qE '^[[:space:]]*manage_etc_hosts:[[:space:]]*false' {fichier}"
" 2>/dev/null && { echo FREEZE-DEJA; exit 0; }; "
"mkdir -p /etc/cloud/cloud.cfg.d; "
"printf '%s\\n' "
"'# Posé par ERPLibre : pmxcfs exige une adresse routable.' "
"'manage_etc_hosts: false' "
f"> {fichier} && echo FREEZE-OK || echo FREEZE-KO"
)
def pve_unit_cmd(unite: str, remonte: bool = False) -> str:
"""Relance UNE unité, sans jamais être fatale.
Par unité et non toutes ensemble : « systemctl start » BLOQUE jusqu'à
TimeoutStartSec (90 s par défaut), et cinq unités groupées dépassent le
délai de l'appel — on recevrait « timeout » sans savoir laquelle.
« restart » quand pve-cluster est ACTIF mais /etc/pve absent : le montage
FUSE est alors périmé (pmxcfs tué par l'OOM killer), et « start » sur une
unité active est un no-op qui rend 0 — la réparation ne convergeait jamais
et ne nommait rien.
Le journal accompagne un échec : c'est la seule façon de dire la cause à
quelqu'un dont le seul accès à l'hôte est cet outil.
"""
u = shlex.quote(unite)
# « active » ne prouve RIEN sur le lien à pmxcfs. Pour pve-cluster c'était
# déjà admis : actif sans /etc/pve, le montage FUSE est périmé et « start »
# est un no-op qui rend 0. Le même raisonnement vaut pour ses dépendants —
# pvestatd, pvedaemon et pveproxy tournaient pendant toute la panne, en
# échouant sur ipcc_send_rec. Les laisser en place après avoir remonté
# /etc/pve donnait une GUI qui répond « communication failure » juste
# après notre ✓. `remonte` dit que le montage était absent au diagnostic.
if unite == "pve-cluster":
actif = (
"[ -e /etc/pve/.version ] "
f'&& {{ echo "DEJA {unite}"; exit 0; }}; '
f"systemctl restart {u}"
)
elif remonte:
actif = f"systemctl restart {u}"
else:
actif = f'echo "DEJA {unite}"; exit 0'
return (
f"systemctl list-unit-files {u}.service >/dev/null 2>&1"
f' || {{ echo "SKIP {unite}"; exit 0; }}; '
f"etat=$(systemctl is-active {u} 2>/dev/null || true); "
f'if [ "$etat" = active ]; then {actif}; else '
f"systemctl reset-failed {u} 2>/dev/null || true; "
f"systemctl start {u}; fi "
f'|| {{ echo "KO {unite}"; '
f"journalctl -u {u} -n 20 --no-pager -o cat 2>/dev/null; }}"
)
def mount_wait_cmd(tours: int = 20, repos: int = 5) -> str:
"""Attend le montage de /etc/pve, puis le RECONFIRME.
En une seule commande : une boucle côté Python rouvrirait une connexion
par tour — deux poignées de main à travers un rebond, vingt fois — et si
le chemin vient d'être perdu, tous les tours rendraient « timeout » et on
accuserait pmxcfs de ce qui est une perte de contact.
Reconfirmé après une pause, parce qu'une seule observation ne prouve rien :
reset-failed vient d'effacer la limite de relance, donc un pmxcfs qui
battait repart pour une salve entière. Le voir monter puis mourir se lit
dans NRestarts, qu'on rend aussi.
"""
return (
f"i=0; while [ $i -lt {int(tours)} ]; do "
"[ -e /etc/pve/.version ] && break; sleep 1; i=$((i+1)); done; "
"if [ -e /etc/pve/.version ]; then "
f"sleep {int(repos)}; "
"if [ -e /etc/pve/.version ]; then echo MONTE; "
"else echo BATTEMENT; fi; "
"else echo ABSENT; fi; "
"printf 'NRESTARTS %s\\n' "
'"$(systemctl show -p NRestarts --value pve-cluster 2>/dev/null)"'
)
def parse_mount_wait(text: str) -> dict:
"""{"verdict": MONTE|BATTEMENT|ABSENT|INCONNU, "relances": int|None}.
INCONNU quand rien de lisible n'est revenu — délai dépassé, coupure. Ce
n'est pas « absent » : conclure « /etc/pve n'est pas monté » d'une perte
de contact envoie chercher dans journalctl une panne qui n'existe pas.
"""
brut = strip_ssh_noise(text or "")
verdict = "INCONNU"
for mot in ("BATTEMENT", "MONTE", "ABSENT"):
if mot in brut:
verdict = mot
break
trouve = re.search(r"NRESTARTS\s+(\d+)", brut)
return {
"verdict": verdict,
"relances": int(trouve.group(1)) if trouve else None,
}
def parse_storages(text: str) -> list:
"""Sortie de « pvesm status --content images » -> [{name, type, avail}]."""
out = []

View file

@ -343,10 +343,17 @@ class ProxmoxMenuMixin:
if not host:
return 255, ""
if not quiet:
# La forme RÉELLEMENT envoyée, enrobage sudo compris : une
# commande affichée doit pouvoir être recopiée telle quelle.
reel = pve.wrap_privilege(remote, host.get("sudo") or "")
print(f"\n{t('Will execute:')} ssh {host['target']} {reel}")
# La forme RÉELLEMENT envoyée : enrobage sudo, rebond et port
# compris. Sans « -J », la ligne copiée rendait « no route to
# host » — et c'est justement quand une étape échoue au milieu
# d'une réparation qu'on a besoin de la rejouer à la main.
argv = pve.ssh_argv(
host, pve.wrap_privilege(remote, host.get("sudo") or "")
)
print(
f"\n{t('Will execute:')} "
+ " ".join(shlex.quote(a) for a in argv)
)
code, out = pve.run(host, remote, timeout)
if out.strip() and not quiet:
print(out.rstrip())
@ -648,7 +655,29 @@ class ProxmoxMenuMixin:
parts = (sortie or "").split()
return parts[parts.index("dev") + 1] if "dev" in parts else ""
def _pve_cluster_reason(self, host):
def _pve_cluster_state(self, host):
"""L'état du cluster, LU UNE FOIS, plus ce qu'on peut en faire.
Rend (etat, quoi) où `quoi` vaut "" (rien à proposer), "hosts" (le
résolveur est en cause, une réécriture est justifiée) ou "unites" (le
nom résout déjà, seules les unités sont à terre).
Séparer les deux décisions, et non les fondre dans une garde unique :
interrompue après la réécriture de /etc/hosts, la réparation laissait
un hôte à un « systemctl start » de fonctionner — et la garde d'avant,
qui sortait dès que « routables » était non vide, refusait alors de le
finir. L'outil savait exactement quoi faire et s'y refusait
définitivement.
"""
from script.proxmox import proxmox_deploy as pve
_c, out = pve.run(host, pve.CLUSTER_CHECK_CMD, 40)
etat = pve.parse_cluster_check(out)
if not etat["lu"] or etat["monte"]:
return etat, ""
return etat, ("unites" if etat["routables"] else "hosts")
def _pve_cluster_reason(self, host, etat=None, quoi=None):
"""Pourquoi il n'y a AUCUN stockage. Liste vide si tout va bien.
« Il manque le stockage » est un symptôme, pas une cause : « pvesm »
@ -656,20 +685,13 @@ class ProxmoxMenuMixin:
liste est vide et l'écran s'arrête sur le symptôme — le défaut est
trois étages plus bas, et il a fallu lire un journal pour le trouver.
La cause la plus fréquente sur une image cloud : le nom d'hôte ne
résout que vers 127.0.1.1. pmxcfs cherche une adresse NON-bouclage et
n'en trouve pas. Notre installeur corrige /etc/hosts, mais cloud-init
le réécrit à chaque démarrage — donc la correction ne survivait pas au
redémarrage que nous faisons maintenant nous-mêmes."""
from script.proxmox import proxmox_deploy as pve
_c, out = pve.run(host, pve.CLUSTER_CHECK_CMD, 40)
etat = pve.parse_cluster_check(out)
# « La sonde n'a pas répondu » n'est PAS « rien n'est monté ». Un
# dépassement de délai — hostname bloqué sur un DNS injoignable — rend
# les mêmes vides, et on affirmait alors « le nom ne résout que vers
# ? » sans avoir rien mesuré. Affirmer une cause qu'on n'a pas
# constatée est pire que se taire.
`etat`/`quoi` viennent de `_pve_cluster_state` quand l'appelant l'a
déjà interrogé : une seule sonde, et surtout un seul verdict. Sondé
deux fois, on annonçait une réparation que la seconde lecture
refusait ensuite d'offrir — une promesse suivie de rien.
"""
if etat is None:
etat, quoi = self._pve_cluster_state(host)
if not etat["lu"]:
return [
f"⚠ {t('The cluster probe did not answer: cause unknown.')}"
@ -680,20 +702,147 @@ class ProxmoxMenuMixin:
f"✗ {t('pve-cluster is down: /etc/pve is not mounted.')}",
f" {t('Without it pvesm answers nothing, hence no storage.')}",
]
if not etat["routables"]:
if quoi == "hosts":
lignes += [
f" {t('The hostname only resolves to')}"
f" {' '.join(etat['adresses']) or '?'}"
f" — {t('pmxcfs needs a routable address.')}",
f" {t('cloud-init rewrites /etc/hosts at every boot.')}",
]
conseil = t(
"replay install_proxmox.sh on the host: it fixes /etc/hosts"
" and stops cloud-init undoing it."
)
lignes.append(f"→ {conseil}")
else:
# Le nom résout déjà : le résolveur n'est PAS en cause, et le dire
# évite d'envoyer réécrire un fichier système pour rien.
lignes.append(
f" {t('The hostname resolves fine: only the units are down.')}"
)
# La promesse UNIQUEMENT si l'offre suivra. Sinon on disait « cet écran
# peut le réparer » puis plus rien du tout.
lignes.append(f"→ {t('This screen can repair it (see below).')}")
return lignes
def _pve_ssh_ip(self, host):
"""Adresse par laquelle NOTRE ssh atteint l'hôte, ou "".
Lue SANS privilège, exprès : « sudo » remet l'environnement à zéro et
efface $SSH_CONNECTION. Cette lecture n'a besoin d'aucun droit.
"""
from script.proxmox import proxmox_deploy as pve
_c, out = pve.run(
dict(host, sudo=""), 'printf %s "$SSH_CONNECTION"', 20
)
return pve.ssh_server_ip(out)
def _pve_restart_units(self, remonte):
"""Relance les unités et rend (pivot_ok, lignes_de_cause).
`remonte` : /etc/pve était absent, donc les dépendants qui SEMBLENT
actifs parlaient à un pmxcfs mort. Leur état actif ne prouve rien sur
leur lien à pmxcfs — le même raisonnement qui impose un « restart » à
pve-cluster vaut pour eux, et sans cela la GUI répondait
« communication failure » après un ✓.
La sortie de chaque unité est LUE. pve-cluster est le pivot : les
trois suivantes le requièrent, donc s'il échoue, poursuivre ne produit
que soixante lignes de journal après la vraie cause.
"""
from script.proxmox import proxmox_deploy as pve
for unite in pve.PVE_UNITS:
_c, out = self._pve_show(
pve.pve_unit_cmd(unite, remonte=remonte), timeout=200
)
texte = pve.strip_ssh_noise(out)
if f"KO {unite}" in texte or f"SKIP {unite}" in texte:
if unite == "pve-cluster":
lignes = [
ligne
for ligne in texte.strip().splitlines()
if ligne.strip()
]
return False, lignes[-8:]
return True, []
def _pve_offer_cluster_fix(self, host, etat=None, quoi=None):
"""Propose de remettre pmxcfs debout, et le fait. Rend True si /etc/pve
est monté à la sortie.
Le pendant de `_pve_offer_bridge`, et pour la même raison : le terminal
est encore à nous, donc c'est ICI qu'on peut poser la question et
montrer ce qu'on exécute.
Pourquoi le faire au lieu de conseiller : le conseil était « rejouer
install_proxmox.sh sur l'hôte », et il ne pouvait PAS marcher. La VM
clone le dépôt distant, donc sa copie du script est celle du distant —
c'est-à-dire, tant que le correctif n'est pas poussé, celle qui ne
corrige rien. Trois hôtes de suite sont tombés dessus.
"""
from script.proxmox import proxmox_deploy as pve
if etat is None:
etat, quoi = self._pve_cluster_state(host)
if not quoi:
return bool(etat["monte"])
print(f"\n {t('Repair it from here?')}")
if quoi == "hosts":
print(
f" [1] {t('freeze cloud-init, fix /etc/hosts, restart pmxcfs')}"
)
else:
print(f" [1] {t('restart pmxcfs only (the address is fine)')}")
print(f" [0] {t('leave it alone')}")
if input(t("Choice: ")).strip() != "1":
return False
if quoi == "hosts":
ip = self._pve_ssh_ip(host)
if not ip:
print(
f" ✗ {t('Cannot tell which address reaches this host.')}"
)
return False
print(f" {t('address the node will answer for')} : {ip}")
# Le gel d'abord : sans lui la correction ne survit pas au
# prochain démarrage, et on aurait réparé pour une seule session.
for cmd in (
pve.cloud_hosts_freeze_cmd(),
pve.hosts_repair_cmd(ip),
):
code, sortie = self._pve_show(cmd, timeout=60)
if code or "-KO" in pve.strip_ssh_noise(sortie):
print(f" ✗ {t('Step failed, stopping here.')}")
return False
pivot, cause = self._pve_restart_units(remonte=True)
if not pivot:
print(f" ✗ pve-cluster {t('would not start:')}")
for ligne in cause:
print(f" {ligne}")
return False
_c, out = self._pve_show(pve.mount_wait_cmd(), timeout=120)
vu = pve.parse_mount_wait(out)
if vu["verdict"] == "MONTE":
print(f" ✓ /etc/pve {t('mounted')}")
return True
if vu["verdict"] == "INCONNU":
# Un silence du lien n'est PAS une absence de montage : conclure
# l'inverse envoie chercher dans journalctl une panne qui n'existe
# pas.
print(f" ⚠ {t('Cannot tell whether it mounted (link lost).')}")
return False
if vu["verdict"] == "BATTEMENT":
# Monté puis reperdu : le dire, parce qu'un ✓ suivi d'un « pvesm ne
# répond plus » dix secondes après est le pire des deux.
print(f" ⚠ /etc/pve {t('mounted then lost again')}")
else:
print(f" ✗ /etc/pve {t('still absent')}")
# L'adresse n'est mise en cause que quand pve-cluster a DÉMARRÉ et que
# le montage manque quand même : c'est le seul cas où le résolveur
# peut l'expliquer.
if quoi == "hosts":
print(
f" {t('The address written may not be the one pmxcfs needs.')}"
)
return False
def _pve_internal_cidr(self, host):
"""Réseau du futur pont interne, CHOISI d'après l'hôte.
@ -911,10 +1060,20 @@ class ProxmoxMenuMixin:
stockages = pve.parse_storages(out)
if not stockages:
# AVANT d'ouvrir l'écran : une fois Textual à l'affiche, ces
# lignes n'ont plus d'endroit où aller, et l'écran ne dirait que
# « aucun stockage ».
for ligne in self._pve_cluster_reason(host):
# lignes n'ont plus d'endroit où aller, l'écran ne dirait que
# « aucun stockage », et surtout il ne pourrait pas POSER la
# question — le terminal est encore à nous ici.
#
# UNE sonde, passée aux deux : sondé deux fois, on annonçait une
# réparation que la seconde lecture refusait ensuite d'offrir.
etat_pve, quoi_pve = self._pve_cluster_state(host)
for ligne in self._pve_cluster_reason(host, etat_pve, quoi_pve):
print(f" {ligne}")
if self._pve_offer_cluster_fix(host, etat_pve, quoi_pve):
_c, out = self._pve_show(
"pvesm status --content images", quiet=True
)
stockages = pve.parse_storages(out)
_c, out = self._pve_show("ip -o link show type bridge", quiet=True)
ponts = pve.parse_bridges(out)
if not ponts:
@ -1744,10 +1903,20 @@ class ProxmoxMenuMixin:
pont = pve.pick_bridge(ponts)
if not stockage:
print(f"\n ✗ {t('No storage able to hold a VM disk.')}")
# Le symptôme ne suffit pas : dire la CAUSE quand on la connaît.
for ligne in self._pve_cluster_reason(host):
# Le symptôme ne suffit pas : dire la CAUSE quand on la connaît,
# puis proposer d'y remédier. Une seule sonde pour les deux.
etat_pve, quoi_pve = self._pve_cluster_state(host)
for ligne in self._pve_cluster_reason(host, etat_pve, quoi_pve):
print(f" {ligne}")
return
if not self._pve_offer_cluster_fix(host, etat_pve, quoi_pve):
return
_c, out = self._pve_show(
"pvesm status --content images", quiet=True
)
stockage = pve.pick_storage(pve.parse_storages(out))
if not stockage:
print(f" ✗ {t('No storage able to hold a VM disk.')}")
return
if not pont and not dry_run:
pont = self._pve_offer_bridge()
if not pont:

View file

@ -3362,6 +3362,66 @@ TRANSLATIONS = {
"fr": "La sonde du cluster n'a pas répondu : cause inconnue.",
"en": "The cluster probe did not answer: cause unknown.",
},
"This screen can repair it (see below).": {
"fr": "Cet écran peut le réparer (voir ci-dessous).",
"en": "This screen can repair it (see below).",
},
"Repair it from here?": {
"fr": "Le réparer d'ici ?",
"en": "Repair it from here?",
},
"freeze cloud-init, fix /etc/hosts, restart pmxcfs": {
"fr": "geler cloud-init, corriger /etc/hosts, relancer pmxcfs",
"en": "freeze cloud-init, fix /etc/hosts, restart pmxcfs",
},
"leave it alone": {
"fr": "ne rien toucher",
"en": "leave it alone",
},
"Cannot tell which address reaches this host.": {
"fr": "Impossible de savoir quelle adresse atteint cet hôte.",
"en": "Cannot tell which address reaches this host.",
},
"address the node will answer for": {
"fr": "adresse sous laquelle le nœud répondra",
"en": "address the node will answer for",
},
"mounted then lost again": {
"fr": "monté puis reperdu",
"en": "mounted then lost again",
},
"restarts": {
"fr": "relances",
"en": "restarts",
},
"mounted": {
"fr": "monté",
"en": "mounted",
},
"The hostname resolves fine: only the units are down.": {
"fr": "Le nom d'hôte résout bien : seules les unités sont à terre.",
"en": "The hostname resolves fine: only the units are down.",
},
"restart pmxcfs only (the address is fine)": {
"fr": "relancer pmxcfs seulement (l'adresse est bonne)",
"en": "restart pmxcfs only (the address is fine)",
},
"would not start:": {
"fr": "n'a pas démarré :",
"en": "would not start:",
},
"Cannot tell whether it mounted (link lost).": {
"fr": "Impossible de constater le montage (lien perdu).",
"en": "Cannot tell whether it mounted (link lost).",
},
"still absent": {
"fr": "toujours absent",
"en": "still absent",
},
"The address written may not be the one pmxcfs needs.": {
"fr": "L'adresse écrite n'est peut-être pas celle qu'il faut à pmxcfs.",
"en": "The address written may not be the one pmxcfs needs.",
},
"pve-cluster is down: /etc/pve is not mounted.": {
"fr": "pve-cluster est à terre : /etc/pve n'est pas monté.",
"en": "pve-cluster is down: /etc/pve is not mounted.",

View file

@ -961,5 +961,437 @@ class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase):
self.assertEqual(res.returncode, 0, res.stderr)
class TestReparerEtcHosts(unittest.TestCase):
"""La réécriture de /etc/hosts, EXÉCUTÉE sur de faux fichiers.
Trois hôtes de suite sont tombés sur la même panne, et le conseil
« rejouer install_proxmox.sh » ne pouvait pas la corriger : la VM clone le
dépôt distant, donc sa copie du script est celle qui ne corrige rien.
L'outil répare donc lui-même — et une réécriture de /etc/hosts sur une
machine qu'on ne joint que par ssh doit être ÉPROUVÉE, pas relue.
Aucun bouchon de vérification ici : la commande relit elle-même ce qu'elle
a écrit. La première version s'en remettait à « getent hosts $short », qui
réussit via mDNS même quand rien n'a été écrit — et les tests bouchonnaient
getent à « return 0 », donc ils mesuraient le bouchon."""
def _joue(self, contenu, court="pve", passages=3, ecrivable=True):
"""Rejoue la commande RÉELLE `passages` fois sur un faux /etc/hosts."""
import os
import subprocess
import tempfile
d = tempfile.mkdtemp()
hosts = os.path.join(d, "hosts")
with open(hosts, "w", encoding="utf-8") as fh:
fh.write(contenu)
cmd = pve.hosts_repair_cmd("10.10.10.150").replace("/etc/hosts", hosts)
if not ecrivable:
os.chmod(d, 0o500)
verdicts = []
try:
for _ in range(passages):
res = subprocess.run(
["sh", "-c", f"hostname() {{ echo {court}; }}; " + cmd],
capture_output=True,
text=True,
)
verdicts.append(res.stdout.strip())
finally:
os.chmod(d, 0o700)
with open(hosts, encoding="utf-8") as fh:
brut = fh.read()
restes = [f for f in os.listdir(d) if f != "hosts"]
return {
"lignes": [ligne for ligne in brut.splitlines() if ligne.strip()],
"verdicts": verdicts,
"brut": brut,
"restes": restes,
}
def test_the_cloud_init_line_is_replaced(self):
vu = self._joue("127.0.1.1 pve pve\n127.0.0.1 localhost\n")
self.assertEqual(vu["verdicts"], ["HOSTS-OK"] * 3)
self.assertIn("10.10.10.150\tpve pve\t# erplibre-hosts", vu["lignes"])
self.assertFalse(
[ligne for ligne in vu["lignes"] if ligne.startswith("127.0.1.1")]
)
def test_a_refused_write_leaves_the_file_ALONE(self):
"""Le constat le plus grave de l'attaque, mesuré sur trois états
réels : /etc en lecture seule, fichier immuable, quota atteint.
« sed -i » puis « printf >> » étaient DEUX écritures. Sed refusé et
ajout réussi, la ligne 127.0.1.1 survivait EN PREMIER et notre ligne
s'ajoutait une fois par tentative. Sed réussi et ajout refusé, l'hôte
perdait l'entrée de son nom — et sur une machine qu'on ne joint que
par ssh, chaque sudo attend ensuite le résolveur.
Une seule écriture, la dernière, et elle est vérifiée avant."""
vu = self._joue(
"127.0.1.1 pve.lan pve\n127.0.0.1 localhost\n", ecrivable=False
)
self.assertEqual(vu["verdicts"], ["HOSTS-KO"] * 3)
self.assertEqual(
vu["lignes"], ["127.0.1.1 pve.lan pve", "127.0.0.1 localhost"]
)
self.assertEqual(vu["restes"], [], "aucun temporaire ne doit rester")
def test_a_file_without_a_final_newline(self):
"""cloud-init « write_files » n'en met pas.
sed PRÉSERVE l'absence — vérifié — et notre ligne se collait à la
précédente : « 192.168.1.9 autre-machine10.10.10.150 pve », donc le
nom du nœud résolvait vers l'adresse d'une AUTRE machine. awk émet un
saut de ligne par enregistrement, donc il normalise."""
vu = self._joue(
"127.0.0.1 localhost\n127.0.1.1 pve\n192.168.1.9 autre-machine"
)
self.assertEqual(vu["verdicts"], ["HOSTS-OK"] * 3)
self.assertIn("192.168.1.9 autre-machine", vu["lignes"])
self.assertIn("10.10.10.150\tpve\t# erplibre-hosts", vu["lignes"])
self.assertTrue(vu["brut"].endswith("\n"))
def test_a_real_fqdn_survives_every_pass(self):
"""Le défaut que le TROISIÈME passage a révélé.
Rejouée, la commande ne trouve plus de ligne 127.0.1.1 — c'est elle
qui l'a retirée — et retombait sur « <court>.local ». Un vrai FQDN
était donc remplacé par un nom réservé au mDNS, au deuxième passage,
par la réparation elle-même."""
vu = self._joue(
"127.0.1.1\tpve.lan.example.com pve\n127.0.0.1 localhost\n"
)
self.assertIn(
"10.10.10.150\tpve.lan.example.com pve\t# erplibre-hosts",
vu["lignes"],
)
self.assertNotIn("pve.local", " ".join(vu["lignes"]))
def test_nothing_accumulates(self):
# En DHCP l'adresse change : sans marqueur, une ligne s'ajoutait à
# chaque passage sans que la précédente soit retirée.
for contenu in (
"127.0.1.1 pve pve\n",
"10.0.0.9\tpve.lan.example.com pve\t# erplibre-hosts\n",
):
with self.subTest(depart=contenu.strip()):
vu = self._joue(contenu, passages=4)
marquees = [
ligne
for ligne in vu["lignes"]
if "erplibre-hosts" in ligne
]
self.assertEqual(len(marquees), 1, vu["lignes"])
def test_tabs_everywhere_do_not_duplicate_the_short_name(self):
"""L'installeur Debian écrit /etc/hosts avec des TABULATIONS.
Le test du nom court cherchait des ESPACES : « pve.example.com\tpve »
ne contenait pas « pve » entouré d'espaces, et le rejeu écrivait
« pve.example.com pve pve »."""
vu = self._joue(
"127.0.0.1\tlocalhost\n127.0.1.1\tpve.example.com\tpve\n"
)
self.assertIn(
"10.10.10.150\tpve.example.com pve\t# erplibre-hosts",
vu["lignes"],
)
def test_a_trailing_comment_is_stripped(self):
vu = self._joue("127.0.1.1 pve # posé à la main\n")
self.assertEqual(vu["lignes"], ["10.10.10.150\tpve\t# erplibre-hosts"])
def test_the_short_name_is_always_there(self):
# C'est lui que pmxcfs résout : une ligne sans lui ne sert à rien.
vu = self._joue("127.0.1.1 autre-nom\n", court="pve")
self.assertIn("pve", vu["lignes"][0].split())
def test_an_unusable_address_produces_no_command(self):
for mauvaise in (
"",
"127.0.0.1",
"fe80::1",
"169.254.3.4",
"pas-une-ip",
):
with self.subTest(ip=mauvaise):
self.assertEqual(pve.hosts_repair_cmd(mauvaise), "")
def test_no_sudo_in_the_body(self):
"""wrap_privilege porte le privilège, pas le corps.
Sur un hôte root@ il n'enrobe rien — et un Proxmox installé par l'ISO
n'a pas forcément le paquet sudo : « sh: 1: sudo: not found », code
127, au milieu d'une réécriture de /etc/hosts."""
for cmd in (
pve.hosts_repair_cmd("10.0.0.1"),
pve.cloud_hosts_freeze_cmd(),
pve.mount_wait_cmd(),
) + tuple(pve.pve_unit_cmd(u) for u in pve.PVE_UNITS):
with self.subTest(cmd=cmd[:40]):
self.assertNotIn("sudo", cmd)
class TestGelerCloudInit(unittest.TestCase):
"""Le gel EXÉCUTÉ, y compris sur le fichier tronqué à zéro octet."""
def _joue(self, etat):
import os
import subprocess
import tempfile
racine = tempfile.mkdtemp()
dossier = os.path.join(racine, "cloud.cfg.d")
fichier = os.path.join(dossier, "99-erplibre-hosts.cfg")
if etat != "sans-cloud":
os.makedirs(dossier)
if etat == "vide":
open(fichier, "w").close()
elif etat == "gele":
with open(fichier, "w", encoding="utf-8") as fh:
fh.write("manage_etc_hosts: false\n")
cmd = (
pve.cloud_hosts_freeze_cmd()
.replace("/etc/cloud/cloud.cfg.d", dossier)
.replace(
"/etc/cloud", racine if etat != "sans-cloud" else "/nexistepas"
)
)
res = subprocess.run(["sh", "-c", cmd], capture_output=True, text=True)
contenu = ""
if os.path.exists(fichier):
with open(fichier, encoding="utf-8") as fh:
contenu = fh.read()
return res.stdout.strip(), contenu
def test_a_fresh_host_gets_frozen(self):
verdict, contenu = self._joue("neuf")
self.assertEqual(verdict, "FREEZE-OK")
self.assertIn("manage_etc_hosts: false", contenu)
def test_an_empty_file_is_rewritten(self):
"""Le défaut que la garde à l'EXISTENCE laissait passer.
« printf … > » TRONQUE avant d'écrire : une coupure laisse zéro octet,
et la garde annonçait « déjà gelé » pour toujours. cloud-init
continuait de remettre 127.0.1.1 à chaque démarrage."""
verdict, contenu = self._joue("vide")
self.assertEqual(verdict, "FREEZE-OK")
self.assertIn("manage_etc_hosts: false", contenu)
def test_an_already_frozen_host_is_left_alone(self):
verdict, _c = self._joue("gele")
self.assertEqual(verdict, "FREEZE-DEJA")
def test_a_host_without_cloud_init_says_so(self):
verdict, _c = self._joue("sans-cloud")
self.assertEqual(verdict, "FREEZE-SANS-OBJET")
class TestQuelleAdressePourLeNoeud(unittest.TestCase):
"""L'adresse écrite doit être celle par laquelle on JOINT l'hôte.
Mesuré sur une Proxmox imbriquée : « hostname -I » rend
« 10.10.10.150 10.10.20.1 », et la seconde est le pont interne que notre
propre code vient de créer. La poser ferait s'identifier le nœud par une
adresse que personne ne joint."""
def test_the_server_field_of_ssh_connection(self):
self.assertEqual(
pve.ssh_server_ip("10.10.10.1 33580 10.10.10.150 22"),
"10.10.10.150",
)
def test_ssh_noise_does_not_shift_the_fields(self):
brut = (
"Warning: Permanently added 'x' (ED25519) to the list of known"
" hosts.\n10.10.10.1 33580 10.10.10.150 22"
)
self.assertEqual(pve.ssh_server_ip(brut), "10.10.10.150")
def test_an_empty_or_short_value_gives_nothing(self):
for brut in ("", "10.0.0.1 22", "n'importe quoi"):
with self.subTest(brut=brut):
self.assertEqual(pve.ssh_server_ip(brut), "")
def test_a_loopback_server_field_is_refused(self):
# Un tunnel local peut faire de l'hôte « 127.0.0.1 » : l'écrire dans
# /etc/hosts ne réglerait rien.
self.assertEqual(pve.ssh_server_ip("127.0.0.1 5555 127.0.0.1 22"), "")
class TestRelancerLesUnites(unittest.TestCase):
"""Chaque unité à part, jamais fatale, et le journal quand ça échoue.
Les bouchons ÉCHOUENT ici. La première version ne faisait jamais rater un
« start » : le journalctl bouchonné n'était donc jamais atteint, et
retirer complètement « reset-failed » de la commande laissait tous les
tests verts."""
def _joue(self, unite, etat, monte, existe=True, start_ok=True, **kw):
import os
import subprocess
import tempfile
temoin = os.path.join(tempfile.mkdtemp(), "version")
if monte:
open(temoin, "w").close()
bouchons = (
"systemctl() { "
' case "$1" in '
f" list-unit-files) return {0 if existe else 1};; "
f" is-active) echo {etat};; "
' reset-failed) echo "RESET $2";; '
f' start|restart) echo "STARTED $1 $2"; '
f" return {0 if start_ok else 1};; "
" esac; }; "
"journalctl() { echo LIGNE-DE-JOURNAL; }; "
)
cmd = pve.pve_unit_cmd(unite, **kw).replace(
"/etc/pve/.version", temoin
)
res = subprocess.run(
["sh", "-c", bouchons + cmd], capture_output=True, text=True
)
return res.returncode, res.stdout
def test_an_absent_unit_is_skipped_not_fatal(self):
code, out = self._joue("pveproxy", "failed", False, existe=False)
self.assertEqual(code, 0)
self.assertIn("SKIP pveproxy", out)
def test_a_failed_unit_is_RESET_then_started(self):
# Le reset débloque la limite de démarrage : sans lui, systemd refuse
# le start sans même le tenter. Son absence doit faire ROUGIR le test.
code, out = self._joue("pvestatd", "failed", False)
self.assertEqual(code, 0)
self.assertIn("RESET pvestatd", out)
self.assertIn("STARTED start", out)
self.assertLess(out.index("RESET"), out.index("STARTED"))
def test_a_start_that_fails_names_the_unit_and_shows_the_journal(self):
"""La seule façon de dire la cause à quelqu'un dont l'unique accès à
l'hôte est cet outil."""
code, out = self._joue("pve-cluster", "failed", False, start_ok=False)
self.assertEqual(code, 0, "jamais fatale")
self.assertIn("KO pve-cluster", out)
self.assertIn("LIGNE-DE-JOURNAL", out)
def test_a_stale_mount_gets_a_restart_not_a_start(self):
"""« start » sur une unité ACTIVE est un no-op qui rend 0.
pmxcfs tué par l'OOM killer laisse /etc/pve monté mais mort, l'unité
pouvant rester « active » : la réparation ne convergeait jamais et ne
nommait rien."""
code, out = self._joue("pve-cluster", "active", False)
self.assertEqual(code, 0)
self.assertIn("STARTED restart", out)
def test_an_active_unit_with_the_mount_is_left_alone(self):
code, out = self._joue("pve-cluster", "active", True)
self.assertEqual(code, 0)
self.assertIn("DEJA pve-cluster", out)
def test_the_dependents_are_restarted_when_the_mount_was_absent(self):
"""Leur état actif ne prouve rien sur leur lien à pmxcfs.
pvestatd, pvedaemon et pveproxy tournaient pendant toute la panne, en
échouant sur ipcc_send_rec. Les laisser après avoir remonté /etc/pve
donnait une GUI qui répond « communication failure » juste après le ✓
de la réparation."""
for unite in ("pvestatd", "pvedaemon", "pveproxy"):
with self.subTest(unite=unite):
_c, out = self._joue(unite, "active", True, remonte=True)
self.assertIn("STARTED restart", out)
_c, sans = self._joue(unite, "active", True)
self.assertIn(f"DEJA {unite}", sans)
def test_the_firewall_is_not_in_the_list(self):
# Sa configuration vit dans config.db, invisible tant que /etc/pve
# n'est pas monté : on appliquerait des règles illisibles sur la seule
# voie d'accès à la machine.
self.assertNotIn("pve-firewall", pve.PVE_UNITS)
def test_rrdcached_comes_before_pve_cluster(self):
# pve-cluster le requiert : une limite atteinte sur rrdcached fait
# échouer pve-cluster sur « dependency », et reset-failed sur
# pve-cluster n'y change rien.
units = list(pve.PVE_UNITS)
self.assertLess(units.index("rrdcached"), units.index("pve-cluster"))
class TestConstaterLeMontage(unittest.TestCase):
"""Une seule observation ne prouve rien, et un silence n'est pas une
absence."""
def test_a_mount_that_holds(self):
lu = pve.parse_mount_wait("MONTE\nNRESTARTS 0\n")
self.assertEqual((lu["verdict"], lu["relances"]), ("MONTE", 0))
def test_a_mount_that_flaps_is_not_a_success(self):
# reset-failed vient d'effacer la limite de relance : un pmxcfs qui
# battait repart pour une salve entière, et le ✓ serait suivi d'un
# « pvesm ne répond plus » dix secondes après.
lu = pve.parse_mount_wait("BATTEMENT\nNRESTARTS 4\n")
self.assertEqual((lu["verdict"], lu["relances"]), ("BATTEMENT", 4))
def test_silence_is_not_absence(self):
# Conclure « /etc/pve n'est pas monté » d'une perte de contact envoie
# chercher dans journalctl une panne qui n'existe pas.
for brut in ("", "timeout", "ssh: connect to host … port 22"):
with self.subTest(brut=brut):
self.assertEqual(
pve.parse_mount_wait(brut)["verdict"], "INCONNU"
)
def test_the_wait_is_a_single_round_trip(self):
cmd = pve.mount_wait_cmd()
self.assertIn("while", cmd)
self.assertIn("sleep", cmd)
self.assertEqual(cmd.count("NRESTARTS"), 1)
def _attends(self, present, disparait=False):
"""Exécute la commande RÉELLE, sentinelle créée puis retirée."""
import os
import subprocess
import tempfile
temoin = os.path.join(tempfile.mkdtemp(), "version")
if present:
open(temoin, "w").close()
cmd = pve.mount_wait_cmd(tours=2, repos=1).replace(
"/etc/pve/.version", temoin
)
if disparait:
# Retiré PENDANT la pause de reconfirmation — la SECONDE, celle
# qui suit « then ». La première est dans la boucle d'attente.
cmd = cmd.replace(
"then sleep 1;", f"then rm -f {temoin}; sleep 1;", 1
)
res = subprocess.run(
["sh", "-c", "systemctl() { echo 3; }; " + cmd],
capture_output=True,
text=True,
)
return pve.parse_mount_wait(res.stdout)
def test_a_mount_that_holds_is_measured(self):
self.assertEqual(self._attends(True)["verdict"], "MONTE")
def test_a_mount_that_disappears_is_a_flap(self):
"""La raison d'être de la reconfirmation.
reset-failed vient d'effacer la limite de relance, donc un pmxcfs qui
battait repart pour une salve entière : vu une fois, il peut mourir
dix secondes après notre ✓."""
self.assertEqual(
self._attends(True, disparait=True)["verdict"], "BATTEMENT"
)
def test_a_mount_that_never_comes_is_absent(self):
self.assertEqual(self._attends(False)["verdict"], "ABSENT")
if __name__ == "__main__":
unittest.main(verbosity=1)