[FIX] cache et journal : hôte imbriqué soustrait, décision écrite

Un invité sans magasin de confiance ne peut RIEN recevoir, et sur un hôte
Proxmox c'est l'HÔTE qu'il faut excepter : imbriqué, l'invité sort masqué
derrière lui et le pont ne voit jamais sa propre adresse. Mesuré depuis
l'invité : code 000 et vérification SSL 19, puis 200 et 0. Sans cela le
gestionnaire de paquets se rabattait sur 564 dérivations à construire.

Le journal par VM ne portait AUCUN message du menu : la cause vivait sur
une console qui défile, et le fichier qu'on rouvre après l'échec n'avait
que le symptôme. Et « db_drop_all » annonçait détruites des bases qui ne
l'étaient pas, son code de retour jeté.

--- EN ---

A guest with no trust store can receive NOTHING, and on a Proxmox host it
is the HOST that must be exempted: nested, the guest leaves masqueraded
behind it and the bridge never sees its own address. Measured from the
guest: code 000 and SSL verification 19, then 200 and 0. Without it the
package manager fell back to building 564 derivations.

The per-VM log carried NO message from the menu: the cause lived on a
console that scrolls away, and the file reopened after failure held only
the symptom. And "db_drop_all" announced databases as dropped that were
not, its exit status discarded.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-16 00:43:40 -04:00
parent c1f2f8db7f
commit 48a1d5ce20
8 changed files with 406 additions and 7 deletions

View file

@ -72,7 +72,17 @@ def main():
cmd_end += f' "./odoo_bin.sh db --drop --database {db_name}"'
lst_db_name.append(db_name)
if cmd_end:
execute_shell(cmd_all + cmd_end)
code, sortie = execute_shell(cmd_all + cmd_end)
if code:
# Une destruction qui annonce un succès qu'elle n'a pas obtenu
# est pire que celle qui échoue : l'opérateur passe à la suite en
# croyant ses bases parties. Le cas s'atteint dès que « parallel »
# manque du PATH — le shell rend 127 et pas une base n'est
# touchée, pendant que la liste s'affiche.
print("Database NOT deleted :", file=sys.stderr)
if sortie:
print(sortie, file=sys.stderr)
return code
print("Database deleted :")
for db_name in lst_db_name:
print(db_name)

View file

@ -15,12 +15,14 @@ qu'elles réutilisent volontairement plutôt que de les redire."""
import os
import re
import shlex
import shutil
import subprocess
import time
import click
from script.todo import todo_prefs
from script.todo.qemu_privilege import virsh_argv
from script.todo.todo_i18n import t
@ -1733,6 +1735,112 @@ class ProxmoxMenuMixin:
return ""
return self._qemu_cache_ca_path()
@staticmethod
def _pve_note(vm, ligne):
"""Dit la ligne à l'écran ET la garde pour le journal de CETTE VM.
La console défile et se perd ; le journal est ce qu'on rouvre quand
l'installation a échoué, parfois le lendemain. Une décision prise ici
— l'autorité du cache posée ou non, l'exception — n'explique la panne
que si elle atteint le second. Sans cela le journal ne porte que le
symptôme : des centaines de lignes de construction et un certificat
refusé, sans un mot sur ce qui l'a voulu.
"""
print(ligne)
vm.setdefault("notes", []).append(ligne.strip())
def _pve_cache_bypass_hote(self, host, vm):
"""Soustrait au cache l'hôte Proxmox qui porte un invité sans magasin.
Rend True quand l'exception est en place, False quand il n'y a rien à
faire ou qu'elle a échoué.
POURQUOI LA MAC DE L'HÔTE, ET NON CELLE DE L'INVITÉ. Un invité
imbriqué sort MASQUÉ derrière son hôte : sur le pont d'ici, le cache
ne voit jamais que la MAC de l'hôte Proxmox, et c'est donc elle
qu'il faut excepter. Mesuré des deux côtés — sans l'exception, une
requête de l'invité vers cache.nixos.org rend code 000 et
vérification SSL 19 ; avec, code 200 et vérification 0.
CE QUE COÛTE L'ABSENCE DE REMÈDE. Une distribution dont le magasin de
confiance n'a pas de forme par fichier ne peut pas recevoir
l'autorité, et poser celle-ci par déclaration arriverait trop tard :
sur un système déclaratif, la première reconstruction EST le premier
téléchargement. Le gestionnaire de paquets ne lit alors plus son cache
binaire, se rabat sur la construction depuis les sources — des
centaines de dérivations — et ces sources échouent pour la même
raison. L'installation part pour une heure avant de rendre 1.
CE QU'ELLE COÛTE. L'exception vaut pour TOUT ce que l'hôte relaie, y
compris ses propres téléchargements : il cesse de profiter du cache.
C'est le prix, et il est dit plutôt que subi.
"""
try:
mod = self._qemu_import_module()
except Exception: # pragma: no cover - dépend du module
mod = None
if not mod:
# Un module qui ne se charge pas ne doit pas emporter la suite de
# la création : sans lui on ne sait pas si l'invité a un magasin
# de confiance, et l'autorité reste la voie par défaut.
return False
distro = vm.get("distro") or ""
if not mod.cache_sans_autorite(distro):
return False
nom = (host.get("target") or "").split("@")[-1]
if not nom or nom not in set(self._qemu_list_domains()):
# Un hôte Proxmox qui ne vit pas ici ne traverse pas ce pont.
return False
mac = self._qemu_domain_mac(nom)
if not mac:
self._pve_note(
vm, f" ⚠ {t('download cache: host MAC not found')} : {nom}"
)
return False
geste = (
f"{shlex.quote(mod.CACHE_BIN)} --bypass-add {shlex.quote(mac)}"
f" --bypass-name {shlex.quote(nom)}"
)
# Le binaire écrit le fichier d'exceptions et rend sur sa sortie le
# geste à chaud ; sans nft, seul le redémarrage du service repose la
# chaîne entière.
if shutil.which("nft"):
cmd = ["sudo", "sh", "-c", f"{geste} | nft -f -"]
else:
cmd = ["sudo", "sh", "-c", geste]
try:
fini = subprocess.run(cmd, capture_output=True, text=True)
except (OSError, subprocess.SubprocessError) as souci:
print(f" ⚠ {t('download cache bypass not set')} ({souci})")
return False
if fini.returncode:
print(
f" ⚠ {t('download cache bypass not set')}"
f" ({fini.returncode})"
)
return False
self._pve_note(
vm, f" ✓ {t('host taken out of the download cache')} : {nom}"
)
self._pve_note(
vm, f" {t('its own downloads stop being cached too.')}"
)
return True
def _qemu_domain_mac(self, nom):
"""Première MAC du domaine libvirt `nom`, ou ''."""
try:
res = subprocess.run(
virsh_argv("domiflist", nom),
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return ""
trouve = re.findall(r"[0-9a-f]{2}(?::[0-9a-f]{2}){5}", res.stdout)
return trouve[0].lower() if trouve else ""
def _pve_set_cache_ca(self, cible, vm, ca, hors_ligne=False):
"""Pose l'autorité du cache DANS la VM, par ssh.
@ -1775,7 +1883,7 @@ class ProxmoxMenuMixin:
"no trust store for this distribution, its downloads "
"will fail"
)
print(f" ⚠ {t(cle)} : {vm.get('distro') or '?'}")
self._pve_note(vm, f" ⚠ {t(cle)} : {vm.get('distro') or '?'}")
return False
morceaux = []
for chemin, mode, contenu, _proprio in fichiers:
@ -1793,7 +1901,7 @@ class ProxmoxMenuMixin:
f" ⚠ {t('download cache authority not installed')} ({code})"
)
return False
print(f" ✓ {t('download cache authority installed')}")
self._pve_note(vm, f" ✓ {t('download cache authority installed')}")
return True
def _pve_attendre_ssh(self, cible, delai=300, pas=10):
@ -2183,9 +2291,17 @@ class ProxmoxMenuMixin:
# range ses index sous l'hôte demandé, et une VM qui en
# réclame un autre ne retrouve rien de ce qui est gardé.
self._pve_set_apt_mirror(vm["alias"], vm, mod_qemu)
self._pve_set_cache_ca(
vm["alias"], vm, ca_cache, hors_ligne=bool(coupee)
)
# Un invité dont le magasin de confiance n'a pas de forme
# par fichier ne peut RIEN recevoir : on soustrait son
# hôte au cache à la place. L'autorité n'est posée que
# lorsqu'il y a quelqu'un pour la recevoir.
if not self._pve_cache_bypass_hote(host, vm):
self._pve_set_cache_ca(
vm["alias"],
vm,
ca_cache,
hors_ligne=bool(coupee),
)
# Après la création, qui a posé l'écran accéléré : l'accès au
# nœud de rendu est une affaire de COMPTE, et il se donne
# dans l'invité.
@ -2285,6 +2401,14 @@ class ProxmoxMenuMixin:
)
for vm in joignables
},
# Ce que l'hôte a DÉCIDÉ pour chaque VM avant de lancer
# l'installation. Sans cela, le journal ne porte que le
# symptôme, et la cause reste sur une console qui défile.
notes={
vm["name"]: vm.get("notes") or []
for vm in joignables
if vm.get("notes")
},
)
return resultat
# Sans suivi mais avec quelque chose à installer : en série, sortie à

View file

@ -366,6 +366,7 @@ class QemuDeployMixin:
vm_tools=(),
pve=None,
meta=None,
notes=None,
ai_agent="",
guet_hors_ligne=False,
deploy_started=None,
@ -386,6 +387,8 @@ class QemuDeployMixin:
`vm_tools` : outils cochés pour tout le parc, filtrés machine par
machine (Android Studio n'existe qu'en x86_64, les extensions GNOME
n'ont pas de sens sous Cinnamon).
`notes` : {nom: [lignes]} — ce que l'hôte a décidé pour cette VM
avant l'installation, recopié en tête de son journal.
`meta` : {nom: (distro, version, arch)} quand l'appelant SAIT ce que
sont ces VM. Sans elle, on le demande à virsh — juste ici, donc faux
pour une VM qui vit sur un Proxmox distant."""
@ -449,6 +452,11 @@ class QemuDeployMixin:
# connaît pas.
if (pve or {}).get(name):
entry["pve"] = pve[name]
# Ce que l'hôte a décidé AVANT de lancer l'installation :
# écrit en tête du journal, là où on le cherche quand ça a
# échoué. La console qui l'a dit a défilé depuis.
if (notes or {}).get(name):
entry["notes"] = list(notes[name])
# Les outils imposent une commande PAR VM même quand tout le
# reste est commun : ils dépendent de l'architecture de la
# machine et de sa saveur de bureau, que seule cette boucle

View file

@ -352,6 +352,16 @@ def _log_header(vm: dict, branch: str, when: str) -> str:
bar = "=" * 64
titre = t("installation") if branch else t("VM start-up")
ligne_branche = f" Branche : {branch}\n" if branch else ""
# Ce que l'hôte a décidé AVANT de lancer : l'autorité du cache posée ou
# non, l'exception, le miroir. Ces lignes sont dites à la console au
# moment où elles se prennent, et la console a défilé depuis. Le journal
# est ce qu'on rouvre quand l'installation a échoué — sans elles il ne
# porte que le symptôme.
notes = vm.get("notes") or []
bloc = ""
if notes:
corps = "\n".join(f" {ligne}" for ligne in notes)
bloc = f" {t('Prepared by the host:')}\n{corps}\n{bar}\n"
return (
f"{bar}\n"
f" ERPLibre — {titre}\n"
@ -361,7 +371,9 @@ def _log_header(vm: dict, branch: str, when: str) -> str:
f" Architecture : {arch}\n"
f"{ligne_branche}"
f" IP : {vm['ip']}\n"
f"{bar}\n\n"
f"{bar}\n"
f"{bloc}"
f"\n"
)

View file

@ -7268,6 +7268,26 @@ TRANSLATIONS = {
"fr": "autorité du cache de téléchargement non posée",
"en": "download cache authority not installed",
},
"Prepared by the host:": {
"fr": "Préparé par l'hôte :",
"en": "Prepared by the host:",
},
"host taken out of the download cache": {
"fr": "hôte soustrait au cache de téléchargement",
"en": "host taken out of the download cache",
},
"its own downloads stop being cached too.": {
"fr": "ses propres téléchargements cessent aussi d'être gardés.",
"en": "its own downloads stop being cached too.",
},
"download cache bypass not set": {
"fr": "exception au cache de téléchargement non posée",
"en": "download cache bypass not set",
},
"download cache: host MAC not found": {
"fr": "cache de téléchargement : MAC de l'hôte introuvable",
"en": "download cache: host MAC not found",
},
"no trust store for this distribution, its downloads "
"will fail": {
"fr": (

View file

@ -146,5 +146,74 @@ class TestCompareDatabaseApplicationLogic(unittest.TestCase):
self.assertEqual(len(s2.difference(s1)), 0)
class UneDestructionNAnnonceQueCeQuElleAFait(unittest.TestCase):
"""« make db_drop_all » composait une commande « parallel », jetait son
code de retour et imprimait la liste des bases comme détruites.
Le cas s'atteint dès que « parallel » manque du PATH : le shell rend 127,
pas une base n'est touchée, et l'opérateur passe à la suite en croyant
ses bases parties. Une destruction qui annonce un succès qu'elle n'a pas
obtenu est pire que celle qui échoue.
"""
def _module(self):
import importlib.util
chemin = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"script/database/db_drop_all.py",
)
spec = importlib.util.spec_from_file_location("db_drop_all", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _courir(self, code_destruction):
import contextlib
mod = self._module()
def faux_shell(cmd):
if "--list" in cmd:
return 0, "test_alpha\ntest_beta"
return code_destruction, (
"" if not code_destruction else "parallel: command not found"
)
mod.execute_shell = faux_shell
class Config:
database = ""
test_only = True
mod.get_config = lambda: Config()
sortie, erreur = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(sortie):
with contextlib.redirect_stderr(erreur):
code = mod.main()
return code, sortie.getvalue(), erreur.getvalue()
def test_a_failed_drop_is_not_announced_as_done(self):
code, sortie, erreur = self._courir(127)
self.assertEqual(127, code)
self.assertNotIn("Database deleted", sortie)
self.assertNotIn("test_alpha", sortie)
def test_the_cause_reaches_the_operator(self):
"""Le code de retour seul laisserait chercher : la sortie du shell
nomme ce qui manque."""
_code, _sortie, erreur = self._courir(127)
self.assertIn("NOT deleted", erreur)
self.assertIn("parallel", erreur)
def test_a_real_drop_is_still_announced(self):
"""Le cas ordinaire ne change pas : les bases détruites se disent."""
code, sortie, _erreur = self._courir(0)
self.assertEqual(0, code)
self.assertIn("Database deleted", sortie)
self.assertIn("test_alpha", sortie)
self.assertIn("test_beta", sortie)
if __name__ == "__main__":
unittest.main()

View file

@ -2621,5 +2621,139 @@ class TestUnInviteQueLAutoriteNAtteintPas(unittest.TestCase):
self.assertNotIn("⚠", vu["ecrit"])
class UnInviteImbriqueSortMasqueDerriereSonHote(unittest.TestCase):
"""Une distribution sans magasin de confiance par fichier ne peut RIEN
recevoir, et le poser par déclaration arriverait trop tard : sur un
système déclaratif, la première reconstruction EST le premier
téléchargement.
Le remède est de ne pas intercepter. Mais l'invité imbriqué sort MASQUÉ
derrière son hôte : sur le pont d'ici, le cache ne voit que la MAC de
l'hôte Proxmox, et c'est donc elle qu'il faut excepter.
"""
def _todo(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)
class _Mod:
CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache"
@staticmethod
def cache_sans_autorite(distro):
return distro == "nixos"
def _poser(self, distro, domaines=("pve-local",), mac="52:54:00:ab:cd:ef"):
import contextlib
import io
todo = self._todo()
vu = {"cmd": None}
todo._qemu_import_module = lambda: self._Mod
todo._qemu_list_domains = lambda: list(domaines)
todo._qemu_domain_mac = lambda nom: mac
class Fini:
returncode = 0
stdout = ""
stderr = ""
def faux_run(cmd, **kw):
vu["cmd"] = cmd
return Fini()
import subprocess as sp
vrai = sp.run
self.addCleanup(setattr, sp, "run", vrai)
sp.run = faux_run
with contextlib.redirect_stdout(io.StringIO()) as sortie:
vu["rendu"] = todo._pve_cache_bypass_hote(
{"target": "pve-local"}, {"distro": distro}
)
vu["ecrit"] = sortie.getvalue()
return vu
def test_a_guest_with_a_trust_store_is_left_alone(self):
"""L'autorité suffit pour lui, et excepter son hôte lui retirerait le
cache sans rien lui rendre."""
vu = self._poser("debian")
self.assertFalse(vu["rendu"])
self.assertIsNone(vu["cmd"], "une commande lancée pour rien")
def test_a_module_that_will_not_load_does_not_stop_the_creation(self):
"""Sans le module on ne sait pas si l'invité a un magasin de
confiance : l'autorité reste la voie par défaut, et la suite de la
création continue."""
import contextlib
import io
todo = self._todo()
todo._qemu_import_module = lambda: None
with contextlib.redirect_stdout(io.StringIO()):
self.assertFalse(
todo._pve_cache_bypass_hote(
{"target": "pve-local"}, {"distro": "nixos"}
)
)
def test_a_remote_host_never_crosses_this_bridge(self):
"""Un hôte Proxmox qui ne vit pas ici n'est pas intercepté : rien à
excepter, et sa MAC ne nous appartient pas."""
vu = self._poser("nixos", domaines=())
self.assertFalse(vu["rendu"])
self.assertIsNone(vu["cmd"])
def test_the_host_mac_is_the_one_excepted(self):
"""Celle de l'invité n'apparaît jamais sur ce pont : il est masqué."""
vu = self._poser("nixos")
self.assertTrue(vu["rendu"])
joint = " ".join(vu["cmd"])
self.assertIn("--bypass-add 52:54:00:ab:cd:ef", joint)
self.assertIn("--bypass-name pve-local", joint)
def test_the_decision_is_kept_for_the_log(self):
"""Dit à l'écran ET gardé : la console défile, le journal reste."""
import contextlib
import io
todo = self._todo()
vm = {"distro": "x"}
with contextlib.redirect_stdout(io.StringIO()) as sortie:
todo._pve_note(vm, " ✓ une décision")
self.assertIn("une décision", sortie.getvalue())
self.assertEqual(["✓ une décision"], vm["notes"])
def test_the_menu_hands_them_to_the_installer(self):
"""Gardées et non transmises, elles ne serviraient à personne."""
from pathlib import Path
racine = Path(__file__).resolve().parent.parent
src = (racine / "script/todo/proxmox_menu.py").read_text(
encoding="utf-8"
)
self.assertIn("notes={", src)
dep = (racine / "script/todo/qemu_deploy.py").read_text(
encoding="utf-8"
)
self.assertIn('entry["notes"] = list(notes[name])', dep)
def test_the_price_is_said(self):
"""L'exception vaut pour TOUT ce que l'hôte relaie, ses propres
téléchargements compris. Le taire ferait chercher plus tard pourquoi
le cache ne sert plus cet hôte."""
vu = self._poser("nixos")
self.assertIn("pve-local", vu["ecrit"])
self.assertIn("✓", vu["ecrit"])
self.assertTrue(
len(vu["ecrit"].strip().splitlines()) >= 2,
f"le prix n'est pas dit : {vu['ecrit']!r}",
)
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -215,6 +215,28 @@ class TestLeJournal(unittest.TestCase):
self.assertIn(mon.t("VM start-up"), head)
self.assertNotIn("Branche", head)
def test_what_the_host_decided_reaches_the_log(self):
"""La console qui a dit la décision a défilé ; le journal est ce
qu'on rouvre quand l'installation a échoué. Sans ces lignes il ne
porte que le symptôme — des centaines de lignes de construction et
un certificat refusé, sans un mot sur ce qui l'a voulu."""
vm = dict(
self._vm(),
notes=[
"⚠ pas de magasin de confiance : nixos",
"✓ hôte soustrait au cache : pve-local",
],
)
head = mon._log_header(vm, "develop", "2026-01-01 00:00:00")
self.assertIn(mon.t("Prepared by the host:"), head)
self.assertIn("pas de magasin de confiance", head)
self.assertIn("hôte soustrait au cache", head)
def test_a_log_without_notes_is_unchanged(self):
"""Le cas ordinaire ne gagne pas une section vide."""
head = mon._log_header(self._vm(), "develop", "2026-01-01 00:00:00")
self.assertNotIn(mon.t("Prepared by the host:"), head)
def test_the_prologue_says_what_actually_follows(self):
"""« installation ERPLibre en cours » alors que rien ne s'installe."""
import tempfile