diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh index 4bcbd7a..aef37b6 100755 --- a/script/proxmox/install_proxmox.sh +++ b/script/proxmox/install_proxmox.sh @@ -127,9 +127,48 @@ host_ip() { return 1 } +# Sans ceci, tout ce que fait fix_hosts est ANNULÉ au prochain démarrage. +# L'image cloud Debian règle « manage_etc_hosts: True » : cloud-init réécrit +# alors /etc/hosts depuis son gabarit à chaque boot, et y remet +# « 127.0.1.1 ». pmxcfs, qui cherche une adresse non-bouclage pour le +# nom d'hôte, ne démarre plus — /etc/pve n'est pas monté, « pvesm » répond +# « Connection refused », et l'écran de déploiement conclut « il manque le +# stockage ». Le vrai défaut est trois étages plus bas. +# +# Vécu, et révélé par le redémarrage désormais automatique : l'installation +# corrigeait /etc/hosts, le reboot amorçait le noyau Proxmox, et cloud-init +# défaisait la correction dans le même mouvement. +# +# Un fichier de surcharge plutôt qu'une édition de cloud.cfg : c'est la voie +# que cloud-init documente, et une mise à jour du paquet ne l'écrase pas. +freeze_cloud_hosts() { + local dossier=/etc/cloud/cloud.cfg.d + local fichier="${dossier}/99-erplibre-hosts.cfg" + [ -d /etc/cloud ] || return 0 + if [ -f "${fichier}" ]; then + say " cloud-init ne touche déjà plus à /etc/hosts" + return 0 + fi + say " cloud-init : gel de /etc/hosts (${fichier})" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} manage_etc_hosts: false" \ + "> ${fichier}" + return 0 + fi + sudo mkdir -p "${dossier}" + printf '%s\n' \ + "# Posé par ERPLibre : Proxmox exige que le nom d'hôte résolve vers" \ + "# une adresse ROUTABLE. cloud-init y remettait 127.0.1.1 à chaque" \ + "# démarrage, et pmxcfs ne démarrait plus." \ + "manage_etc_hosts: false" \ + | sudo tee "${fichier}" >/dev/null + CHANGED=1 +} + fix_hosts() { local ip fqdn short ip="$(host_ip)" || die "aucune adresse IPv4 routable : réseau absent ?" + freeze_cloud_hosts short="$(hostname -s)" fqdn="$(hostname -f 2>/dev/null || echo "${short}")" [ "${fqdn}" = "${short}" ] && fqdn="${short}.local" @@ -159,6 +198,42 @@ 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 +} + +# 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 ». +# +# « reset-failed » d'abord, sinon le démarrage est refusé sans même être tenté. +revive_pmxcfs() { + 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" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} systemctl reset-failed" \ + "pve-cluster && systemctl start pve-cluster" + return 0 + fi + sudo systemctl reset-failed pve-cluster 2>/dev/null || true + if sudo systemctl start pve-cluster 2>&1; then + CHANGED=1 + fi + # 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 + for i in 1 2 3 4 5 6 7 8 9 10; do + [ -e /etc/pve/.version ] && break + sleep 1 + done + if [ -e /etc/pve/.version ]; then + say " ${Green}✓${Color_Off} /etc/pve monté" + else + say " ${Yellow}⚠${Color_Off} /etc/pve toujours absent :" \ + "journalctl -u pve-cluster -n 30" + fi } # --- 4. Dépôt et clé -------------------------------------------------------- diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index 6893f74..d6eb108 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -282,6 +282,52 @@ def parse_qm_list(text: str) -> list: return out +# De quoi savoir POURQUOI il n'y a aucun stockage, en un aller-retour. +# +# « pvesm » ne parle qu'à travers /etc/pve, un système de fichiers monté par +# pmxcfs. pmxcfs à terre, la commande répond « Connection refused » et la liste +# est vide — l'écran conclut « il manque le stockage » alors que le défaut est +# trois étages plus bas. +CLUSTER_CHECK_CMD = ( + "systemctl is-active pve-cluster 2>/dev/null || true; " + "echo '---ERPLIBRE-PVE-FS---'; " + # « .version » et non « storage.cfg » : ce dernier N'EXISTE PAS sur une + # installation neuve — Proxmox se contente alors de ses stockages par + # défaut, et « local » répond parfaitement. Le tester revenait à déclarer + # /etc/pve absent sur un hôte sain. « .version » est un fichier virtuel de + # pmxcfs : il est là si et seulement si le montage est là. + "test -e /etc/pve/.version && echo MONTE || echo ABSENT; " + "echo '---ERPLIBRE-HOSTNAME-IP---'; " + "hostname --ip-address 2>/dev/null || true" +) + + +def parse_cluster_check(text: str) -> dict: + """{"actif": bool, "monte": bool, "adresses": [...]} depuis + CLUSTER_CHECK_CMD. + + `adresses` sans aucune adresse routable est la cause la plus fréquente : + pmxcfs parcourt les adresses du nom d'hôte jusqu'à en trouver une qui ne + soit pas de bouclage, et l'entrée « 127.0.1.1 » de l'image cloud le + mène dans le mur.""" + brut = strip_ssh_noise(text or "") + tete, _, reste = brut.partition("---ERPLIBRE-PVE-FS---") + milieu, _, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---") + adresses = [ + a + for a in queue.split() + if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", a) or ":" in a + ] + return { + "actif": "active" in tete and "inactive" not in tete, + "monte": "MONTE" in milieu, + "adresses": adresses, + "routables": [ + a for a in adresses if not a.startswith("127.") and a != "::1" + ], + } + + 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 9fbc126..65ea910 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -648,6 +648,43 @@ class ProxmoxMenuMixin: parts = (sortie or "").split() return parts[parts.index("dev") + 1] if "dev" in parts else "" + def _pve_cluster_reason(self, host): + """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 » + ne parle qu'à travers /etc/pve, monté par pmxcfs. pmxcfs à terre, la + 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) + if etat["monte"]: + return [] + lignes = [ + 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"]: + 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}") + return lignes + def _pve_internal_cidr(self, host): """Réseau du futur pont interne, CHOISI d'après l'hôte. @@ -863,6 +900,12 @@ class ProxmoxMenuMixin: vms = self._pve_vms() _c, out = self._pve_show("pvesm status --content images", quiet=True) 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): + print(f" {ligne}") _c, out = self._pve_show("ip -o link show type bridge", quiet=True) ponts = pve.parse_bridges(out) if not ponts: @@ -1685,6 +1728,9 @@ 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): + print(f" {ligne}") return if not pont and not dry_run: pont = self._pve_offer_bridge() diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c00adb5..20f5471 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3358,6 +3358,30 @@ TRANSLATIONS = { "fr": "Taille (+10G pour ajouter, 40G pour une cible) : ", "en": "Size (+10G to add, 40G for a target): ", }, + "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.", + }, + "Without it pvesm answers nothing, hence no storage.": { + "fr": "Sans lui, pvesm ne répond rien — d'où l'absence de stockage.", + "en": "Without it pvesm answers nothing, hence no storage.", + }, + "The hostname only resolves to": { + "fr": "Le nom d'hôte ne résout que vers", + "en": "The hostname only resolves to", + }, + "pmxcfs needs a routable address.": { + "fr": "pmxcfs exige une adresse routable.", + "en": "pmxcfs needs a routable address.", + }, + "cloud-init rewrites /etc/hosts at every boot.": { + "fr": "cloud-init réécrit /etc/hosts à chaque démarrage.", + "en": "cloud-init rewrites /etc/hosts at every boot.", + }, + "replay install_proxmox.sh on the host: it fixes /etc/hosts and stops cloud-init undoing it.": { + "fr": "rejouer install_proxmox.sh sur l'hôte : il corrige /etc/hosts et empêche cloud-init de le défaire.", + "en": "replay install_proxmox.sh on the host: it fixes /etc/hosts and stops cloud-init undoing it.", + }, "No storage able to hold a VM disk.": { "fr": "Aucun stockage capable d'héberger un disque de VM.", "en": "No storage able to hold a VM disk.", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 8285db3..94077e5 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -786,5 +786,95 @@ class TestLeRepliQuiNeCoupePasLaLigne(unittest.TestCase): self.assertEqual(res.stderr, "", res.stderr) +class TestPourquoiAucunStockage(unittest.TestCase): + """« Il manque le stockage » est un symptôme, pas une cause. + + « pvesm » ne parle qu'à travers /etc/pve, monté par pmxcfs. pmxcfs à + terre, la commande répond « Connection refused », la liste est vide, et + l'écran s'arrête sur le symptôme — le défaut est trois étages plus bas. + + Vécu sur un Proxmox imbriqué : le nom d'hôte ne résolvait que vers + 127.0.1.1, parce que cloud-init réécrit /etc/hosts à CHAQUE démarrage. Le + redémarrage désormais automatique défaisait donc la correction que + l'installation venait de poser.""" + + def _sortie(self, actif, monte, adresses): + return ( + f"{'active' if actif else 'inactive'}\n" + "---ERPLIBRE-PVE-FS---\n" + f"{'MONTE' if monte else 'ABSENT'}\n" + "---ERPLIBRE-HOSTNAME-IP---\n" + f"{' '.join(adresses)}\n" + ) + + def test_a_healthy_host(self): + lu = pve.parse_cluster_check( + self._sortie(True, True, ["10.10.10.152"]) + ) + self.assertTrue(lu["monte"]) + self.assertEqual(lu["routables"], ["10.10.10.152"]) + + def test_the_loopback_only_case(self): + lu = pve.parse_cluster_check(self._sortie(False, False, ["127.0.1.1"])) + self.assertFalse(lu["monte"]) + self.assertEqual(lu["routables"], []) + self.assertEqual(lu["adresses"], ["127.0.1.1"]) + + def test_the_probe_does_not_ask_for_storage_cfg(self): + """storage.cfg N'EXISTE PAS sur une installation neuve. + + Proxmox se contente alors de ses stockages par défaut, et « local » + répond parfaitement — mesuré sur l'hôte imbriqué, où /etc/pve était + monté sans ce fichier. Le tester revenait à déclarer /etc/pve absent + sur un hôte sain.""" + self.assertNotIn("storage.cfg", pve.CLUSTER_CHECK_CMD) + self.assertIn("/etc/pve/.version", pve.CLUSTER_CHECK_CMD) + + def test_inactive_is_not_read_as_active(self): + # « inactive » contient « active » : la naïveté coûterait un + # diagnostic inversé. + lu = pve.parse_cluster_check(self._sortie(False, False, [])) + self.assertFalse(lu["actif"]) + + +class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase): + """Deux gestes que l'installation ne faisait pas, et sans lesquels elle + laissait un hôte inutilisable.""" + + @classmethod + def setUpClass(cls): + from pathlib import Path as P + + cls.src = P("script/proxmox/install_proxmox.sh").read_text( + encoding="utf-8" + ) + + def test_cloud_init_stops_rewriting_etc_hosts(self): + # Sans ce gel, tout ce que fait fix_hosts est ANNULÉ au prochain + # démarrage — celui que nous déclenchons nous-mêmes désormais. + self.assertIn("manage_etc_hosts: false", self.src) + 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_the_mount_is_verified_not_assumed(self): + self.assertIn("/etc/pve/.version", self.src) + + def test_the_script_is_valid_shell(self): + import subprocess + + res = subprocess.run( + ["bash", "-n", "script/proxmox/install_proxmox.sh"], + capture_output=True, + text=True, + ) + self.assertEqual(res.returncode, 0, res.stderr) + + if __name__ == "__main__": unittest.main(verbosity=1)