[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:
parent
c1f2f8db7f
commit
48a1d5ce20
8 changed files with 406 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 à
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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": (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue