diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index 671c2d8..ab49347 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -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 +# « .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 = [] diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 7be8e04..3d86b7f 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -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: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f76cb1e..213b843 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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.", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 2eef09d..1d4deff 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -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 « .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)