[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}"'
|
cmd_end += f' "./odoo_bin.sh db --drop --database {db_name}"'
|
||||||
lst_db_name.append(db_name)
|
lst_db_name.append(db_name)
|
||||||
if cmd_end:
|
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 :")
|
print("Database deleted :")
|
||||||
for db_name in lst_db_name:
|
for db_name in lst_db_name:
|
||||||
print(db_name)
|
print(db_name)
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,14 @@ qu'elles réutilisent volontairement plutôt que de les redire."""
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from script.todo import todo_prefs
|
from script.todo import todo_prefs
|
||||||
|
from script.todo.qemu_privilege import virsh_argv
|
||||||
from script.todo.todo_i18n import t
|
from script.todo.todo_i18n import t
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1733,6 +1735,112 @@ class ProxmoxMenuMixin:
|
||||||
return ""
|
return ""
|
||||||
return self._qemu_cache_ca_path()
|
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):
|
def _pve_set_cache_ca(self, cible, vm, ca, hors_ligne=False):
|
||||||
"""Pose l'autorité du cache DANS la VM, par ssh.
|
"""Pose l'autorité du cache DANS la VM, par ssh.
|
||||||
|
|
||||||
|
|
@ -1775,7 +1883,7 @@ class ProxmoxMenuMixin:
|
||||||
"no trust store for this distribution, its downloads "
|
"no trust store for this distribution, its downloads "
|
||||||
"will fail"
|
"will fail"
|
||||||
)
|
)
|
||||||
print(f" ⚠ {t(cle)} : {vm.get('distro') or '?'}")
|
self._pve_note(vm, f" ⚠ {t(cle)} : {vm.get('distro') or '?'}")
|
||||||
return False
|
return False
|
||||||
morceaux = []
|
morceaux = []
|
||||||
for chemin, mode, contenu, _proprio in fichiers:
|
for chemin, mode, contenu, _proprio in fichiers:
|
||||||
|
|
@ -1793,7 +1901,7 @@ class ProxmoxMenuMixin:
|
||||||
f" ⚠ {t('download cache authority not installed')} ({code})"
|
f" ⚠ {t('download cache authority not installed')} ({code})"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
print(f" ✓ {t('download cache authority installed')}")
|
self._pve_note(vm, f" ✓ {t('download cache authority installed')}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _pve_attendre_ssh(self, cible, delai=300, pas=10):
|
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
|
# 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é.
|
# 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_apt_mirror(vm["alias"], vm, mod_qemu)
|
||||||
self._pve_set_cache_ca(
|
# Un invité dont le magasin de confiance n'a pas de forme
|
||||||
vm["alias"], vm, ca_cache, hors_ligne=bool(coupee)
|
# 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
|
# 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
|
# nœud de rendu est une affaire de COMPTE, et il se donne
|
||||||
# dans l'invité.
|
# dans l'invité.
|
||||||
|
|
@ -2285,6 +2401,14 @@ class ProxmoxMenuMixin:
|
||||||
)
|
)
|
||||||
for vm in joignables
|
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
|
return resultat
|
||||||
# Sans suivi mais avec quelque chose à installer : en série, sortie à
|
# Sans suivi mais avec quelque chose à installer : en série, sortie à
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,7 @@ class QemuDeployMixin:
|
||||||
vm_tools=(),
|
vm_tools=(),
|
||||||
pve=None,
|
pve=None,
|
||||||
meta=None,
|
meta=None,
|
||||||
|
notes=None,
|
||||||
ai_agent="",
|
ai_agent="",
|
||||||
guet_hors_ligne=False,
|
guet_hors_ligne=False,
|
||||||
deploy_started=None,
|
deploy_started=None,
|
||||||
|
|
@ -386,6 +387,8 @@ class QemuDeployMixin:
|
||||||
`vm_tools` : outils cochés pour tout le parc, filtrés machine par
|
`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
|
machine (Android Studio n'existe qu'en x86_64, les extensions GNOME
|
||||||
n'ont pas de sens sous Cinnamon).
|
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
|
`meta` : {nom: (distro, version, arch)} quand l'appelant SAIT ce que
|
||||||
sont ces VM. Sans elle, on le demande à virsh — juste ici, donc faux
|
sont ces VM. Sans elle, on le demande à virsh — juste ici, donc faux
|
||||||
pour une VM qui vit sur un Proxmox distant."""
|
pour une VM qui vit sur un Proxmox distant."""
|
||||||
|
|
@ -449,6 +452,11 @@ class QemuDeployMixin:
|
||||||
# connaît pas.
|
# connaît pas.
|
||||||
if (pve or {}).get(name):
|
if (pve or {}).get(name):
|
||||||
entry["pve"] = pve[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
|
# Les outils imposent une commande PAR VM même quand tout le
|
||||||
# reste est commun : ils dépendent de l'architecture de la
|
# reste est commun : ils dépendent de l'architecture de la
|
||||||
# machine et de sa saveur de bureau, que seule cette boucle
|
# 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
|
bar = "=" * 64
|
||||||
titre = t("installation") if branch else t("VM start-up")
|
titre = t("installation") if branch else t("VM start-up")
|
||||||
ligne_branche = f" Branche : {branch}\n" if branch else ""
|
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 (
|
return (
|
||||||
f"{bar}\n"
|
f"{bar}\n"
|
||||||
f" ERPLibre — {titre}\n"
|
f" ERPLibre — {titre}\n"
|
||||||
|
|
@ -361,7 +371,9 @@ def _log_header(vm: dict, branch: str, when: str) -> str:
|
||||||
f" Architecture : {arch}\n"
|
f" Architecture : {arch}\n"
|
||||||
f"{ligne_branche}"
|
f"{ligne_branche}"
|
||||||
f" IP : {vm['ip']}\n"
|
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",
|
"fr": "autorité du cache de téléchargement non posée",
|
||||||
"en": "download cache authority not installed",
|
"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 "
|
"no trust store for this distribution, its downloads "
|
||||||
"will fail": {
|
"will fail": {
|
||||||
"fr": (
|
"fr": (
|
||||||
|
|
|
||||||
|
|
@ -146,5 +146,74 @@ class TestCompareDatabaseApplicationLogic(unittest.TestCase):
|
||||||
self.assertEqual(len(s2.difference(s1)), 0)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -2621,5 +2621,139 @@ class TestUnInviteQueLAutoriteNAtteintPas(unittest.TestCase):
|
||||||
self.assertNotIn("⚠", vu["ecrit"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,28 @@ class TestLeJournal(unittest.TestCase):
|
||||||
self.assertIn(mon.t("VM start-up"), head)
|
self.assertIn(mon.t("VM start-up"), head)
|
||||||
self.assertNotIn("Branche", 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):
|
def test_the_prologue_says_what_actually_follows(self):
|
||||||
"""« installation ERPLibre en cours » alors que rien ne s'installe."""
|
"""« installation ERPLibre en cours » alors que rien ne s'installe."""
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue