erplibre/script/todo/qemu_cache_menu.py
Mathieu Benoit a7706adf3d [ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris
VMs of one host pulled the same packages again and again, and an install
could not be proven to work without the network. A Go service intercepts the
bridge transparently: a package file is served from disk, an index is always
revalidated upstream and replayed only when upstream is mute, git is mirrored.
Deployment poses the cache authority in QEMU and Proxmox VE guests, and can cut
the network for a whole install. The TODO menu installs, diagnoses, fills,
cleans and copies the store; long_test/qemu_cache.py measures that a second VM
pulls no package upstream. Covered by Go tests and test/test_qemu_cache_*.py.

--- FR ---

Les VM d'un hôte tiraient sans cesse les mêmes paquets, et rien ne prouvait
qu'une installation marche sans réseau. Un service Go intercepte le pont de
façon transparente : un fichier de paquet est servi du disque, un index est
toujours revalidé à l'amont et rejoué seulement quand l'amont est muet, git
est mis en miroir. Le déploiement pose l'autorité du cache dans les invités
QEMU et Proxmox VE, et peut couper le réseau pour toute une installation. Le
menu TODO installe, diagnostique, remplit, nettoie et emporte le magasin ;
long_test/qemu_cache.py mesure qu'une seconde VM ne tire aucun paquet de
l'amont. Couvert par les tests Go et test/test_qemu_cache_*.py.

Assisted-by: Claude Opus 5
2026-09-14 16:46:15 -04:00

2356 lines
96 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le cache des VM QEMU : poser, constater, conduire, comprendre, mesurer.
Cinq gestes qui ne se ressemblent pas. L'installation touche au système et
demande sudo ; le diagnostic ne fait que LIRE ; conduire le service allume et
éteint ; le guide n'exécute rien ; les tests créent de vraies machines. Les
mêler dans une seule entrée obligeait à lancer une installation pour savoir
si le cache tournait.
Le service a son propre sous-menu parce que l'ARRÊTER est le seul moyen de
désactiver le cache : l'unité retire ses règles en partant. Retirer l'autorité
d'une VM ne la soustrait pas au détournement, cela lui fait seulement refuser
un certificat qu'elle ne reconnaît plus.
Le diagnostic existe pour une panne précise, et elle est silencieuse : le
réseau libvirt « default » ne sert pas toujours 192.168.122.0/24 — il est
déplacé sur un /24 libre dès que ce préfixe entre en collision, ce qui est le
cas de tout orchestrateur qui est lui-même une VM. Des règles posées sur
l'autre préfixe existent bel et bien dans le noyau, l'installation réussit,
et aucune VM ne traverse le cache. Rien ne le dit. Comparer les deux
préfixes est donc le premier contrôle, pas le dernier.
"""
import json
import os
import re
import shlex
import subprocess
import time
from urllib.parse import urljoin, urlsplit
import click
from script.qemu import cache_offline
from script.todo.todo_i18n import t
# Ce que l'installateur pose. Ces chemins sont comparés à ceux du script par
# un test : le menu qui chercherait ailleurs annoncerait un cache absent.
CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache"
CACHE_CA = "/var/lib/erplibre_go_qemu_cache/ca.crt"
CACHE_SERVICE = "erplibre-go-qemu-cache.service"
CACHE_CONF = "/etc/erplibre_go_qemu_cache/env"
CACHE_TABLE = "erplibre_qemu_cache"
CACHE_BYPASS = "/etc/erplibre_go_qemu_cache/bypass"
CACHE_MIROIR_GIT = "/var/cache/erplibre_go_qemu_cache/git"
CACHE_DIR = "/var/cache/erplibre_go_qemu_cache"
# L'installateur, tel qu'il se lance depuis un checkout ERPLibre. Le chemin
# reste RELATIF : il est imprimé pour une AUTRE machine, dont le répertoire
# de travail n'a aucune raison d'être celui d'ici.
INSTALLATEUR = "script/install/install_qemu_cache.sh"
# Le fichier que le mode en deux temps dépose dans le compte d'arrivée. Il
# pèse autant que le magasin — un cache ne contient que des paquets et des
# archives git, déjà comprimés — et la dernière commande le retire, le pic
# d'occupation valant sinon deux fois le magasin.
TRANSFERT_FICHIER = "erplibre_cache.tar.zst"
# La racine du dépôt, d'où se lance le lecteur du journal d'accès : le menu
# tourne depuis n'importe quel répertoire, et un chemin relatif n'y survit pas.
RACINE_DEPOT = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
# L'unité que l'installateur écrit. Remplir un miroir lui reprend
# l'environnement qu'elle donne à git, plutôt que d'en tenir une copie.
CACHE_UNITE = f"/etc/systemd/system/{CACHE_SERVICE}"
# Combler ce qui a manqué hors ligne : les jours de journal relus, le délai
# d'un rejeu, et les redirections suivies — autant que le cache en suit
# lui-même (maxRedirections) avant de renoncer.
JOURS_RECENTS = 7
REJEU_DELAI = 60
SAUTS_MAX = 5
REDIRECTIONS = (301, 302, 303, 307, 308)
# Les points de négociation git : la liste vit dans cache_offline, que le
# pré-vol du formulaire lit aussi ; un test la compare au Go.
GIT_NEGOCIATION = cache_offline.GIT_NEGOCIATION
# Les marques que les lectures de la coupure et du guet écrivent quand leur
# commande réussit. Une marque à nous plutôt qu'un message de nft, de sudo
# ou de systemctl : les leurs se traduisent selon la langue du compte.
NFT_LISIBLE = "erplibre-nft-lisible"
GUET_ACTIF = "erplibre-guet-actif"
# La source Go qui déclare les hôtes passés d'office en tunnel.
MITM_GO = os.path.join("script", "qemu_cache", "mitm.go")
# L'ordre d'affichage des issues du journal. Ce n'est PAS une liste de ce qui
# existe : tout ce que le journal porte est montré, ce qui n'est pas nommé ici
# venant à la fin. Une liste fermée avait déjà tu les deux issues les plus
# nombreuses, faute d'avoir été relue quand elles sont apparues.
ORDRE_ISSUES = (
"hit",
"mirror",
"stored",
"stale",
"stored-status",
"stale-status",
"keep",
"offline-miss",
"fetched",
"passthrough",
"error",
)
CACHE_SET = "bypass"
LONGTEST = "long_test/qemu_cache.py"
class QemuCacheMenuMixin:
# ------------------------------------------------------------------
# Lectures : aucune ne modifie quoi que ce soit
# ------------------------------------------------------------------
@staticmethod
def _cache_lire(cmd, delai=15):
"""Sortie d'une commande, ou "". Ne lève jamais : un diagnostic qui
s'interrompt sur sa première mesure absente ne diagnostique rien."""
try:
p = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
timeout=delai,
)
return (p.stdout or "") + (p.stderr or "")
except (OSError, subprocess.SubprocessError):
return ""
@classmethod
def _cache_prefixe_regles(cls):
"""Les trois premiers octets que les règles détournent, "" quand
aucune règle n'est posée, None quand on ne peut pas le savoir.
Même lecture que `_cache_amont_coupe`, et pour la même raison : sur
un hôte où sudo exige un mot de passe, « sudo -n » échoue sans rien
rendre. Confondre ce silence avec une absence de règle fait annoncer
« aucun détournement » à un cache qui détourne pourtant, et envoie
réinstaller ce qui marche. La marque n'est écrite que si nft a
répondu ; sans elle, l'appelant tranche.
"""
vu = cls._cache_lire(
f"sudo -n nft list tables >/dev/null 2>&1 && echo {NFT_LISIBLE};"
f" sudo -n nft list table ip {CACHE_TABLE}"
)
m = re.search(r"saddr (\d+\.\d+\.\d+)\.", vu)
if m:
return m.group(1)
return "" if NFT_LISIBLE in vu.split() else None
@classmethod
def _cache_amont_coupe(cls):
"""La coupure d'amont est-elle posée ? True, False, ou None quand on
ne peut pas le savoir.
Lue par « sudo -n », qui échoue plutôt que de demander un mot de
passe. Sur un hôte où sudo en exige un, rien ne se lit : conclure
« pas coupé » ferait rejouer sous la coupure, et chaque rejeu
n'ajouterait qu'un manque. La marque NFT_LISIBLE n'est écrite que si
nft a répondu ; sans elle, la réponse est None, et l'appelant
tranche. None est faux en contexte booléen : un diagnostic ne crie
pas à la coupure sur une lecture impossible.
La coupure survit LÉGITIMEMENT au déploiement qui la pose tant que le
guet tourne (`_cache_guet_actif`) : les installations sont
détachées, et le guet ne la lève qu'à la fin de la dernière, 12 h au
plus. Sans guet, elle ne survit qu'à ce qu'un « finally » ne rattrape
pas : un processus tué net, une panne de courant. Le cache rend alors
« 504 » à chaque VM, l'installation échoue sur « failed retrieving
file … 504 » depuis TOUS les miroirs, et rien dans ce message ne
parle d'une règle de pare-feu.
"""
vu = cls._cache_lire(
f"sudo -n nft list tables >/dev/null 2>&1 && echo {NFT_LISIBLE};"
f" sudo -n nft list table inet {cache_offline.TABLE}"
)
if "meta skuid" in vu:
return True
return False if NFT_LISIBLE in vu.split() else None
@classmethod
def _cache_guet_actif(cls):
"""Le guet d'un déploiement hors ligne tourne-t-il ?
Tant qu'il tourne, la coupure est tenue exprès, jusqu'à la fin de la
dernière installation détachée. Sans sudo : l'état d'une unité se lit
de tout compte. Passe par `_cache_lire`, qui ne lève jamais : une
lecture impossible rend False.
"""
vu = cls._cache_lire(
f"{cache_offline.guet_actif_cmd()} && echo {GUET_ACTIF}"
)
return GUET_ACTIF in vu.split()
@staticmethod
def _cache_dire_coupure_tenue(marque, *entre):
"""Les lignes d'une coupure que le guet tient.
Le geste donné ARRÊTE le guet, dont la levée retire la table. Retirer
la table seule laisserait le guet tourner pour rien, et le
déploiement hors ligne suivant serait refusé tant qu'il tourne.
`entre` : lignes ajoutées avant le coût de la levée.
"""
print(
f" {marque} {t('Upstream CUT by an offline deployment still installing,')}"
)
print(
f" {t('held until its last installation ends (12 h at most).')}"
)
for ligne in entre:
print(f" {ligne}")
print(
f" {t('Lifting it now makes those installations finish online.')}"
)
print(
f" {t('Lift it now with:')} {cache_offline.lever_maintenant_cmd()}"
)
def _cache_diag_coupure(self):
"""Les lignes du diagnostic sur la coupure d'amont et le guet.
Muet quand tout va bien — une ligne « amont branché » à chaque
diagnostic n'apprendrait rien. Le guet d'abord : tant qu'il tourne,
la coupure est tenue exprès, et donner le retrait de la table ferait
finir en ligne les installations qui tournent encore. Une lecture de
nft impossible (None) ne dément pas le guet. Un guet sans coupure est
nommé : resté sans rien à lever, il ferait refuser le déploiement
hors ligne suivant.
"""
coupe = self._cache_amont_coupe()
guet = self._cache_guet_actif()
if guet and coupe is not False:
self._cache_dire_coupure_tenue("⚠")
elif guet:
print(
f" ⚠ {t('The lift watcher still runs, with no cut left to lift.')}"
)
print(
f" {t('Stop it with:')} {cache_offline.lever_maintenant_cmd()}"
)
elif coupe:
print(
f" ✗ {t('Upstream CUT: the cache can pull nothing from the internet')}"
)
print(f" {t('Every VM then gets a 504 from every mirror.')}")
print(
f" {t('The VMs have no direct way out either: only the host answers them.')}"
)
print(f" {t('Lift it with:')} {cache_offline.restore_cmd()}")
elif coupe is None:
print(
f" · {t('Cannot tell whether the upstream is cut: reading nft needs a sudo password here.')}"
)
def _cache_combler_permis(self):
"""Le rejeu de l'entrée 9 peut-il partir ? Dit pourquoi sinon.
Il faut le binaire et le service. Sous la coupure, un rejeu
n'ajouterait que des manques au journal. Le guet est lu d'abord,
sans sudo : tant qu'il tourne, la coupure est tenue exprès, et le
geste donné l'arrête. Une coupure illisible — sudo exige un mot de
passe — n'est pas prise pour une absence : le menu le dit et
demande, non par défaut.
"""
if not os.path.isfile(CACHE_BIN):
print(f" ✗ {t('Not installed:')} {CACHE_BIN}\n")
return False
if not self._cache_actif():
print(f" ✗ {t('Service:')} {t('Service is stopped')}")
print(f" {t('Start it from entry 3 of this menu.')}\n")
return False
if self._cache_guet_actif():
self._cache_dire_coupure_tenue(
"✗", t("A replay now would only record more misses.")
)
print()
return False
coupe = self._cache_amont_coupe()
if coupe:
print(
f" ✗ {t('Upstream CUT: the cache can pull nothing from the internet')}"
)
print(f" {t('A replay now would only record more misses.')}")
print(f" {t('Lift it with:')} {cache_offline.restore_cmd()}\n")
return False
if coupe is None:
print(
f" ⚠ {t('Cannot tell whether the upstream is cut: reading nft needs a sudo password here.')}"
)
print(
f" {t('Under the cut, a replay would only record more misses.')}"
)
if not click.confirm(t("Replay anyway?"), default=False):
print()
return False
return True
@classmethod
def _cache_prefixe_libvirt(cls):
"""Les trois premiers octets que libvirt sert vraiment, ou ""."""
vu = cls._cache_lire("virsh -c qemu:///system net-dumpxml default")
m = re.search(r"address='(\d+\.\d+\.\d+)\.", vu)
return m.group(1) if m else ""
@classmethod
def _cache_actif(cls):
"""Le service tourne-t-il ? La première ligne doit valoir
« active » ENTIÈRE : « inactive » la contient, et un test
d'inclusion lirait un service arrêté comme en marche."""
vu = cls._cache_lire(f"systemctl is-active {CACHE_SERVICE}")
return vu.split("\n")[0].strip() == "active"
@staticmethod
def _cache_journal():
"""Chemin du journal d'accès, lu dans la configuration du service."""
try:
with open(CACHE_CONF, encoding="utf-8") as fh:
for ligne in fh:
if ligne.startswith("EL_ACCESS_LOG="):
return ligne.split("=", 1)[1].strip()
except OSError:
pass
return ""
@classmethod
def _cache_compte_issues(cls):
"""Ce que le cache a fait, par issue. Le journal EST la mesure."""
chemin = cls._cache_journal()
compte = {}
if not chemin or not os.path.exists(chemin):
return compte
try:
with open(chemin, encoding="utf-8", errors="replace") as fh:
for ligne in fh:
try:
issue = json.loads(ligne).get("outcome", "?")
except ValueError:
continue
compte[issue] = compte.get(issue, 0) + 1
except OSError:
pass
return compte
# ------------------------------------------------------------------
# Le menu
# ------------------------------------------------------------------
def prompt_execute_qemu_cache(self):
print(f"📦 {t('QEMU download cache for local VMs')}")
choices = [
{"prompt_description": t("Cache - Install or reinstall")},
{"prompt_description": t("Cache - Diagnose: does it serve?")},
{"prompt_description": t("Cache - Service state")},
{"prompt_description": t("Cache - VMs kept out of the cache")},
{"prompt_description": t("Cache - Git mirrors: fill them ahead")},
{"prompt_description": t("Cache - Age and cleanup")},
{"prompt_description": t("Cache - Guide: how it works")},
{"prompt_description": t("Cache - Tests and performance report")},
{"prompt_description": t("Cache - Fill what offline runs lacked")},
{"prompt_description": t("Cache - Logs")},
{"prompt_description": t("Cache - Copy it to another machine")},
]
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
elif status == "1":
self._deploy_qemu_cache()
elif status == "2":
self._cache_diagnostic()
elif status == "3":
self._cache_service()
elif status == "4":
self._cache_exceptions()
elif status == "5":
self._cache_miroir_git()
elif status == "6":
self._cache_age()
elif status == "7":
self._cache_guide()
elif status == "8":
self._cache_tests()
elif status == "9":
self._cache_combler()
elif status == "10":
self._cache_journaux()
elif status == "11":
self._cache_transfert()
else:
print(t("Command not found !"))
# ------------------------------------------------------------------
# [2] Diagnostic
# ------------------------------------------------------------------
def _cache_diagnostic(self):
"""Constate, ne répare pas. Chaque ligne est une lecture."""
print(f"\n{t('Diagnosis of the QEMU download cache')}\n")
actif = self._cache_actif()
print(
f" {'✓' if actif else '✗'} {t('Service:')} "
f"{t('Service is running') if actif else t('Service is stopped')}"
)
if not os.path.isfile(CACHE_BIN):
print(f" ✗ {t('Not installed:')} {CACHE_BIN}")
print(f"\n {t('Install it from entry 1 of this menu.')}\n")
return
# LE contrôle. Deux préfixes qui divergent, et le cache ne sert
# personne pendant que tout paraît réussi.
regles = self._cache_prefixe_regles()
libvirt = self._cache_prefixe_libvirt()
if regles is None:
# Ni ✓ ni ✗ : on ne sait pas. Le point marque ce que le
# diagnostic n'a pas pu lire, comme pour la coupure d'amont.
print(
" · "
f"{t('Cannot tell where the rules point: reading nft needs a sudo password here.')}"
)
elif regles and regles == libvirt:
print(f" ✓ {t('Redirection:')} {libvirt}.x → {t('the cache')}")
elif not regles:
print(f" ✗ {t('No redirection rule is posted')}")
else:
print(
f" ✗ {t('MISMATCH — rules on')} {regles}.x, "
f"{t('libvirt serves')} {libvirt or '?'}.x"
)
print(f" {t('Reinstall: the cache reads libvirt by itself.')}")
# Après le détournement : c'est la même classe de fait, une règle
# posée sur l'hôte.
self._cache_diag_coupure()
print(f" · {t('Authority:')} {CACHE_CA}")
# Le répertoire du miroir est passé au relevé : sans lui, le binaire
# ne mesure que les objets, et les dépôts git — qui pèsent bien plus —
# disparaissent du seul endroit où l'on surveille la place.
releve = self._cache_lire(
f"{CACHE_BIN} --status --git-mirror-dir {CACHE_MIROIR_GIT}",
delai=60,
)
for ligne in [
l
for l in releve.split("\n")
if l.strip()
and not l.startswith(("autorité", "empreinte", "exceptions"))
][:5]:
if ligne.strip():
print(f" · {ligne.strip()}")
# Une VM exceptée ne traverse pas le cache, et c'est voulu ; une
# exception dont la VM n'existe plus ne l'est pas, et elle est
# invisible partout ailleurs — la machine qui hérite de la MAC
# télécharge normalement, le journal reste seulement muet sur elle.
exceptions = self._cache_bypass_lire()
if exceptions:
orphelines = self._cache_bypass_orphelines(exceptions)
marque = "⚠" if orphelines else "·"
print(
f" {marque} {t('Exceptions:')} {len(exceptions)}"
f" ({len(orphelines)} {t('with no VM left')})"
)
if orphelines:
print(f" {t('Remove them from entry 4 of this menu.')}")
# Par MACHINE, et pas seulement en tout. Un doute sur
# l'accélération ne s'instruit pas sur un total : il faut pouvoir
# séparer ce qu'une VM a tiré du réseau de ce qu'une autre a été
# servie du disque, et le journal ne le disait pas.
par_vm = self._cache_par_machine()
if par_vm:
print(f"\n {t('What each VM pulled:')}")
print(
f" {t('address'):<18}{t('from disk'):>12}"
f"{t('upstream'):>12}"
)
for adresse, (disque, amont) in par_vm:
print(
f" {adresse:<18}{self._cache_humain(disque):>12}"
f"{self._cache_humain(amont):>12}"
)
compte = self._cache_compte_issues()
if compte:
print(f"\n {t('What the cache has done:')}")
# L'ordre est celui de la lecture — ce qui a servi d'abord, ce qui
# est sorti ensuite. Mais TOUT ce que le journal porte est montré,
# y compris une issue que cette liste ne connaît pas : la version
# d'avant en écrivait cinq en dur et taisait les deux plus
# nombreuses, dont celle qui porte le trafic git.
for issue in ORDRE_ISSUES + tuple(
sorted(set(compte) - set(ORDRE_ISSUES))
):
if issue in compte:
print(f" {issue:<14} {compte[issue]}")
else:
print(
f"\n ⚠ {t('The access log is empty: nothing has gone through the cache.')}"
)
print(
f" {t('A VM that installs while this stays at zero does not use it.')}"
)
print()
@staticmethod
def _cache_humain(n):
for unite in ("o", "Kio", "Mio", "Gio"):
if n < 1024 or unite == "Gio":
return (
f"{n:.0f} {unite}" if unite == "o" else f"{n:.1f} {unite}"
)
n /= 1024
return f"{n:.1f} Tio"
@classmethod
def _cache_par_machine(cls, limite=8):
"""(adresse, (octets du disque, octets de l'amont)) par VM.
Les plus gros consommateurs d'abord : c'est ce qu'on cherche quand on
se demande si une machine a été servie ou si elle a téléchargé. Les
lignes sans client viennent d'un journal écrit avant que le champ
existe — elles sont écartées plutôt que rangées sous un nom faux.
"""
chemin = cls._cache_journal()
if not chemin or not os.path.exists(chemin):
return []
par = {}
try:
with open(chemin, encoding="utf-8", errors="replace") as fh:
for ligne in fh:
try:
d = json.loads(ligne)
except ValueError:
continue
client = d.get("client")
if not client:
continue
disque, amont = par.get(client, (0, 0))
octets = d.get("bytes", 0) or 0
if d.get("upstream"):
amont += octets
else:
disque += octets
par[client] = (disque, amont)
except OSError:
return []
return sorted(par.items(), key=lambda kv: -(kv[1][0] + kv[1][1]))[
:limite
]
# ------------------------------------------------------------------
# [3] État du service
# ------------------------------------------------------------------
def _cache_systemctl(self, verbe, montrer=True):
"""Un geste systemd, la commande annoncée avant d'être lancée.
Arrêter n'éteint pas seulement le service : l'unité retire ses règles
en partant, donc plus aucune VM n'est détournée. C'est ce qui fait de
« stop » le seul moyen vrai de désactiver le cache, et c'est dit à
l'écran plutôt que dans une note qu'on ne lit pas.
"""
cmd = f"sudo systemctl {verbe} {CACHE_SERVICE}"
print(f"\n{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
if montrer:
print(f"\n {t('Service:')} {self._cache_etat_court()}")
def _cache_etat_court(self):
"""« actif, au démarrage » et ce qu'il en manque, en une ligne."""
actif = self._cache_actif()
# « is-enabled » rend un mot par ligne : enabled, enabled-runtime,
# disabled, static, masked. Comparer le MOT et non l'y chercher —
# une sous-chaîne ferait passer « masked » pour un service au boot le
# jour où systemd ajoute un état composé.
au_boot = self._cache_lire(
f"systemctl is-enabled {CACHE_SERVICE}"
).split("\n")[0].strip() in ("enabled", "enabled-runtime")
return (
f"{t('Service is running') if actif else t('Service is stopped')}"
f", {t('starts at boot') if au_boot else t('not at boot')}"
)
def _cache_service(self):
print(f"\n⚙ {t('State of the cache service')}")
print(f" {self._cache_etat_court()}")
print(
f" {t('Stopping it removes the rules: no VM is redirected.')}\n"
)
choices = [
{"prompt_description": t("Service - Start (start)")},
{"prompt_description": t("Service - Start at boot (enable)")},
{
"prompt_description": t(
"Service - Do not start at boot (disable)"
)
},
{"prompt_description": t("Service - Stop (stop)")},
{"prompt_description": t("Service - Detailed state (status)")},
{"prompt_description": t("Service - Logs (log)")},
]
verbes = {"1": "start", "2": "enable", "3": "disable", "4": "stop"}
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
if status in verbes:
self._cache_systemctl(verbes[status])
elif status == "5":
self._cache_systemctl("status --no-pager", montrer=False)
elif status == "6":
self._cache_journal_service()
else:
print(t("Command not found !"))
def _cache_journal_service(self):
"""Deux journaux, et ils ne disent pas la même chose.
Celui de systemd porte ce que le service dit de lui-même — démarrages,
erreurs, hôtes retenus en tunnel. Le journal d'ACCÈS porte ce qu'il a
servi, une ligne par requête : c'est celui qui prouve qu'une VM le
traverse.
"""
cmd = f"sudo journalctl -u {CACHE_SERVICE} -n 40 --no-pager"
print(f"\n{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
chemin = self._cache_journal()
if not chemin or not os.path.exists(chemin):
return
print(f"\n {t('Access log, last requests:')} {chemin}")
try:
with open(chemin, encoding="utf-8", errors="replace") as fh:
lignes = fh.readlines()[-10:]
except OSError:
return
for ligne in lignes:
try:
d = json.loads(ligne)
except ValueError:
continue
print(
f" {d.get('outcome', '?'):<13}"
f"{str(d.get('url', '')).rsplit('/', 1)[-1][:58]}"
)
# ------------------------------------------------------------------
# [10] Journaux
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# [11] Emporter le cache sur une autre machine
# ------------------------------------------------------------------
def _cache_transfert(self):
"""Copie le magasin vers une autre machine qui porte ERPLibre.
Ce qui voyage est le MAGASIN, pas le service : les objets sont rangés
sous une clé tirée de l'URL, jamais de la machine qui les a pris, et
un dépôt git miroir est un dépôt. Les réglages, eux, restent : le
pont, le sous-réseau et l'autorité appartiennent à l'hôte, et les
emporter ferait servir une autorité dont aucune VM de là-bas n'a la
clé.
« tar » et non « rsync » : le second manque sur bien des hôtes, et le
premier est partout. Le flux est compressé au passage — un magasin
se compte en dizaines de gigaoctets, et un lien lent le rend
autrement en une nuit.
"""
print(f"\n📦 {t('Copy the cache to another machine')}")
if not os.path.isfile(CACHE_BIN):
print(f" ✗ {t('Not installed:')} {CACHE_BIN}\n")
return
cache_dir = cache_offline.reglage("EL_CACHE_DIR", CACHE_CONF) or (
CACHE_DIR
)
cible = click.prompt(
t("Target machine (user@host, or an ssh alias)"), default=""
).strip()
if not cible:
print(f" {t('Cancelled.')}\n")
return
# Trois pannes qu'un seul code de retour confondait : une machine
# injoignable, un cache absent, un cache posé sans son compte de
# service. Chacune appelle un geste différent, donc chacune a son
# message. Le jeton « FIN » termine toujours la sonde : son absence
# dénonce le LIEN, là où un code non nul seul accusait le cache.
sonde = (
f"test -x {shlex.quote(CACHE_BIN)} && echo binaire;"
f" id -u {shlex.quote(cache_offline.SERVICE_USER)}"
" >/dev/null 2>&1 && echo compte;"
" sudo -n true 2>/dev/null && echo sudo;"
" echo compte_ssh=$(id -un);"
" echo place_magasin=$(df -B1 --output=avail"
f" {shlex.quote(cache_dir)} 2>/dev/null | tail -1);"
' echo place_compte=$(df -B1 --output=avail "$HOME"'
" 2>/dev/null | tail -1); echo FIN"
)
code, sortie = self._cache_ssh(cible, sonde)
if code or "FIN" not in sortie:
self._cache_dire_ssh_muet(cible)
return
if "binaire" not in sortie:
self._cache_dire_poser_la_bas(cible)
return
# Le compte de service porte le magasin : sans lui, les fichiers
# arriveraient à root et le service ne les lirait pas.
if "compte" not in sortie:
print(
f" ✗ {t('The cache is there but its service account is not:')}"
f" {cache_offline.SERVICE_USER}"
)
print(
" "
f"{t('Reinstall it there: the installer creates the account.')}"
)
print(f" sudo bash {INSTALLATEUR}\n")
return
# Le magasin voyage dans l'entrée standard de ssh, qui porte des
# octets et non un terminal : un sudo qui réclame un mot de passe
# là-bas n'échoue pas à l'arrivée du flux, il l'empêche de partir.
if "sudo" not in sortie:
self._cache_sans_sudo_la_bas(cible, cache_dir, sortie)
return
print(f" {t('What travels:')} {cache_dir}")
for quoi, chemin in (
(t("objects"), cache_dir),
(t("git mirrors"), os.path.join(cache_dir, "git")),
):
print(f" {quoi:<14}{self._cache_poids(chemin)}")
cmd = self._cache_transfert_cmd(cible, cache_dir)
print(f"\n{t('Will execute:')} {cmd}")
print(f" {t('The settings stay here: bridge, subnet and authority')}")
print(f" {t('belong to this host, and are posed by entry 1 there.')}")
if not click.confirm(t("Copy now?"), default=False):
print(f" {t('Cancelled.')}\n")
return
self.execute.exec_command_live(cmd, source_erplibre=False)
@staticmethod
def _cache_dire_ssh_muet(cible):
"""Le LIEN est en cause, pas le cache : la sonde n'a pas tourné.
Le transfert tube « tar » dans ssh, sans terminal : un accès qui
réclame un mot de passe échouerait au milieu du flux, après des
gigaoctets. Il s'éprouve avant, par une commande qui ne coûte rien.
"""
q = shlex.quote(cible)
print(f" ✗ {t('Cannot reach it over ssh:')} {cible}")
print(f" {t('This entry needs a password-less ssh access:')}")
print(f" ssh {q} true")
print(f" {t('If it asks for a password, post a key there:')}")
print(f" ssh-copy-id {q}\n")
@staticmethod
def _cache_branche_ici():
"""La branche de CE dépôt, ou '' si git ne répond pas.
L'installateur et le menu sont des fichiers du dépôt : une machine
restée sur une branche qui ne les porte pas ne les a tout
simplement pas, et « sudo bash … » y répond « fichier introuvable »
— une panne qui ne ressemble en rien à un cache manquant. Nommer la
branche d'ici épargne de la deviner.
"""
try:
res = subprocess.run(
[
"git",
"-C",
RACINE_DEPOT,
"rev-parse",
"--abbrev-ref",
"HEAD",
],
capture_output=True,
text=True,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
return ""
nom = (res.stdout or "").strip()
return "" if res.returncode or nom == "HEAD" else nom
@classmethod
def _cache_dire_poser_la_bas(cls, cible):
"""Les gestes à faire SUR la machine d'arrivée, un par ligne.
L'entrée 1 pose le cache ICI : y renvoyer fait relancer une
installation sur l'hôte qui en a déjà une, et la machine d'arrivée
reste sans rien. Le magasin voyage ; le service, lui, se compile
là-bas, contre la distribution de là-bas.
L'installateur lit le réseau libvirt « default » pour trouver le
pont. Sur un hôte où libvirt est arrêté, il meurt donc sur un réseau
« introuvable » qui existe pourtant, et démarrer ce réseau seul ne
suffit pas : sans hyperviseur joignable, rien ne répond. D'où les
deux issues nommées ensemble — lever libvirt, ou nommer le pont.
"""
q = shlex.quote(cible)
# La branche d'ici, faute de pouvoir lire celle de là-bas : c'est
# celle qui porte l'installateur, et un dépôt en tête détachée n'en
# nomme aucune — la phrase générique vaut alors mieux qu'un nom faux.
branche = cls._cache_branche_ici() or t("the branch used here")
for ligne in (
f" ✗ {t('The target has no cache installed:')} {cible}",
" "
f"{t('Entry 1 installs the cache HERE; the target needs its own.')}",
f" {t('Steps, ON the target machine:')}",
f" 1. ssh {q}",
f" 2. {t('go to its ERPLibre checkout, then:')}",
f" git fetch && git switch {branche}",
f" {t('without that branch, the installer is not there')}",
f" 3. sudo bash {INSTALLATEUR}",
" "
f"{t('or, in its own TODO: Execute > Deploy > QEMU cache, entry 1')}",
"",
f" {t('The installer reads the « default » libvirt network to find')}",
f" {t('the bridge; a stopped libvirt makes it die on « not found »:')}",
" sudo systemctl start libvirtd.socket",
" sudo virsh -c qemu:///system net-start default",
f" {t('Or name the bridge by hand, libvirt being optional then:')}",
" sudo EL_BRIDGE=virbr0 EL_SUBNET=192.168.122.0/24 \\",
f" bash {INSTALLATEUR}",
"",
f" {t('Then come back to this entry.')}",
"",
):
print(ligne)
@staticmethod
def _cache_jeton(sortie, nom):
"""La valeur d'un « nom=valeur » rendu par la sonde, ou ''."""
for ligne in sortie.splitlines():
if ligne.startswith(f"{nom}="):
return ligne.split("=", 1)[1].strip()
return ""
def _cache_octets(self, chemin):
"""La taille du magasin en octets, ou 0 si elle ne se lit pas.
Le privilège est nécessaire : les objets appartiennent au compte de
service et ne sont pas lisibles autrement, si bien qu'un « du »
ordinaire rendrait un total très inférieur au vrai — et ferait
croire que la place suffit à l'arrivée.
"""
try:
res = subprocess.run(
["sudo", "-n", "du", "-sb", chemin],
capture_output=True,
text=True,
timeout=120,
)
except (OSError, subprocess.SubprocessError):
return 0
morceau = (res.stdout or "").split("\t")[0].strip()
return int(morceau) if morceau.isdigit() else 0
def _cache_sans_sudo_la_bas(self, cible, cache_dir, sortie):
"""Sudo réclame un mot de passe à l'arrivée : deux issues, au choix.
Le magasin occupe l'entrée standard de ssh — un canal d'octets, pas
un terminal — et sudo refuse de lire un mot de passe ailleurs que
sur un terminal. Un ticket pris d'avance n'y change rien : sudo
l'attache au terminal qui l'a obtenu, et la session qui porte le
flux n'en a aucun.
Restent deux voies. Élargir les droits une fois, et le flux direct
redevient possible. Ou passer par un fichier que le compte
d'arrivée écrit lui-même — aucun privilège pendant le transfert —
puis l'extraire dans SON terminal, où le mot de passe se tape. La
seconde ne coûte aucun droit, mais demande de la place : le fichier
et le magasin extrait coexistent le temps de l'extraction.
"""
q = shlex.quote(cible)
compte = self._cache_jeton(sortie, "compte_ssh") or "<compte>"
print(f" ✗ {t('sudo asks for a password on the target:')} {cible}")
print(
" "
f"{t('The store travels on ssh stdin, which carries no terminal,')}"
)
print(f" {t('so nothing can type it. Two ways out:')}\n")
print(f" {t('1) Allow it there without a password, once:')}")
print(f" ssh -t {q} \\")
print(
f" \"echo '{compte} ALL=(root) NOPASSWD: ALL'"
' | sudo tee /etc/sudoers.d/erplibre_cache"'
)
print(f" {t('Then come back to this entry.')}\n")
print(
" "
f"{t('2) Carry it in two steps, the last one in your terminal there:')}"
)
taille = self._cache_octets(cache_dir)
libre = min(
int(self._cache_jeton(sortie, "place_magasin") or 0),
int(self._cache_jeton(sortie, "place_compte") or 0),
)
if taille:
print(
f" {t('needed there:')} {self._cache_humain(2 * taille)}"
f" {t('free there:')} {self._cache_humain(libre)}"
)
if taille and libre < 2 * taille:
print(
f" ✗ {t('Not enough room there: the two-step mode is out.')}\n"
)
return
if not click.confirm(
t("Send it now, and print the command to finish there?"),
default=False,
):
print(f" {t('Cancelled.')}\n")
return
code = self.execute.exec_command_live(
self._cache_envoi_fichier_cmd(cible, cache_dir),
source_erplibre=False,
)
if code:
print(f"\n ✗ {t('The send failed; nothing was extracted.')}\n")
return
print(
f"\n {t('Sent. To finish, ON the target machine, in a terminal:')}"
)
print(f" {self._cache_finir_la_bas_cmd(cache_dir)}")
print(f" {t('The last command removes the file.')}\n")
@staticmethod
def _cache_envoi_fichier_cmd(cible, cache_dir):
"""Le temps 1 : le magasin part dans le compte d'arrivée.
« cat » écrit dans le répertoire personnel du compte ssh, qui lui
appartient : aucun privilège n'est donc demandé là-bas pendant le
flux, et c'est exactement ce qui rend ce mode possible sans
terminal.
"""
q = shlex.quote
return (
f"sudo tar -C {q(cache_dir)} -cf - . | zstd -T0 -3"
f" | ssh {q(cible)} {q('cat > ~/' + TRANSFERT_FICHIER)}"
)
@staticmethod
def _cache_finir_la_bas_cmd(cache_dir):
"""Le temps 2, à taper dans le terminal de la machine d'arrivée.
Une seule invocation privilégiée porte l'extraction ET le
changement de propriétaire : en deux, la seconde redemanderait le
mot de passe après des dizaines de minutes. Le fichier est retiré
ensuite, son séjour étant ce qui double l'occupation.
"""
q = shlex.quote
interne = (
f"tar -C {q(cache_dir)} -xf - --numeric-owner"
f" && chown -R {cache_offline.SERVICE_USER}:"
f"{cache_offline.SERVICE_USER} {q(cache_dir)}"
)
return (
f"zstd -dc ~/{TRANSFERT_FICHIER} | sudo sh -c {q(interne)}"
f" && rm -f ~/{TRANSFERT_FICHIER}"
)
@staticmethod
def _cache_transfert_cmd(cible, cache_dir):
"""La commande qui emporte le magasin, en un seul flux.
Lue d'un bout à l'autre : « tar » lit le magasin ici, « zstd » le
comprime, « ssh » le porte, et là-bas le même trio le repose avant de
rendre les fichiers au compte du service. Rien n'est écrit sur le
disque entre les deux — un magasin de dizaines de gigaoctets n'a pas
à exister deux fois.
« --numeric-owner » à l'écriture et le « chown » à l'arrivée : le
même compte porte rarement le même numéro d'une machine à l'autre.
"""
q = shlex.quote
# UNE seule invocation privilégiée à l'arrivée. En deux — « tar »
# puis « chown » — un ticket sudo obtenu juste avant expire pendant
# le transfert, et le second geste réclame alors un mot de passe que
# plus rien ne peut saisir : le magasin serait posé, mais resterait
# illisible pour le compte qui doit le servir.
interne = (
f"tar -C {q(cache_dir)} -xf - --numeric-owner"
f" && chown -R {cache_offline.SERVICE_USER}:"
f"{cache_offline.SERVICE_USER} {q(cache_dir)}"
)
distant = f"zstd -d | sudo sh -c {q(interne)}"
return (
f"sudo tar -C {q(cache_dir)} -cf - . | zstd -T0 -3"
f" | ssh {q(cible)} {q(distant)}"
)
def _cache_ssh(self, cible, commande, timeout=30):
"""Une commande sur la machine d'arrivée. Rend (code, sortie)."""
try:
p = subprocess.run(
[
"ssh",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
cible,
commande,
],
capture_output=True,
text=True,
timeout=timeout,
)
except (OSError, subprocess.SubprocessError) as exc:
return 255, str(exc)
return p.returncode, p.stdout
@staticmethod
def _cache_poids(chemin):
"""La place qu'occupe un chemin, ou « ? » quand on ne peut pas lire."""
try:
p = subprocess.run(
["du", "-sh", chemin],
capture_output=True,
text=True,
timeout=60,
)
except (OSError, subprocess.SubprocessError):
return "?"
return p.stdout.split("\t")[0] if p.returncode == 0 else "?"
def _cache_journaux(self):
"""Les journaux en direct, pour regarder une installation passer.
Le journal d'ACCÈS porte une ligne par requête et dit, pour chacune,
si elle est sortie vers l'internet : c'est lui qui prouve qu'une VM
traverse le cache, et lui qui ne montre plus aucune sortie sous une
coupure. Le journal du SERVICE porte ce que le service dit de
lui-même — démarrages, erreurs, hôtes retenus en tunnel.
"""
chemin = self._cache_journal()
print(f"\n📜 {t('Logs of the download cache')}")
print(f" {t('Access log:')} {chemin or '—'}")
print(f" {t('Ctrl-C ends a live follow.')}\n")
choices = [
{"prompt_description": t("Logs - Requests, live")},
{
"prompt_description": t(
"Logs - Only requests that went to the internet, live"
)
},
{"prompt_description": t("Logs - Last 40 requests")},
{"prompt_description": t("Logs - Service journal, live")},
]
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
if status in ("1", "2", "3") and not (
chemin and os.path.exists(chemin)
):
print(
f" ✗ {t('No access log yet:')} {chemin or CACHE_CONF}\n"
)
continue
if status == "1":
self._cache_suivre(chemin)
elif status == "2":
self._cache_suivre(chemin, amont=True)
elif status == "3":
self._cache_suivre(chemin, suivre=False)
elif status == "4":
cmd = f"sudo journalctl -u {CACHE_SERVICE} -n 20 -f"
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
else:
print(t("Command not found !"))
def _cache_suivre(self, chemin, amont=False, suivre=True):
"""Le journal d'accès, mis en forme par `cache_journal.py`.
« tail » garde le fichier ouvert et le lecteur met en forme ligne à
ligne : un journal de plusieurs dizaines de Mio n'est jamais chargé
en entier, et le suivi écrit dès qu'une requête est servie.
"""
lecteur = os.path.join(
RACINE_DEPOT, "script", "qemu", "cache_journal.py"
)
# « -u » : sans lui, Python met sa sortie en tampon dès qu'elle n'est
# pas un terminal, et un tube l'est rarement.
lire = f"python3 -u {shlex.quote(lecteur)}" + (
" --amont" if amont else ""
)
cmd = (
f"tail -n 40 {'-f ' if suivre else ''}{shlex.quote(chemin)}"
f" | {lire}"
)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
# ------------------------------------------------------------------
# [4] Exceptions : les VM soustraites au détournement
# ------------------------------------------------------------------
@classmethod
def _cache_bypass_lire(cls):
"""Les exceptions posées, en couples (MAC, nom de VM).
Lues par le BINAIRE et non par ce fichier : lui seul sait normaliser
une adresse et sauter une ligne fautive, et une seconde lecture écrite
ici dériverait de la sienne.
"""
if not os.path.isfile(CACHE_BIN):
return []
sortie = cls._cache_lire(
f"{CACHE_BIN} --bypass-list --bypass-file {CACHE_BYPASS}"
)
out = []
for ligne in sortie.split("\n"):
champs = ligne.split(None, 1)
if champs and ":" in champs[0]:
out.append((champs[0], champs[1] if len(champs) > 1 else ""))
return out
@staticmethod
def _cache_domaines():
"""Les noms de domaine que libvirt connaît, VM éteintes comprises."""
sortie = QemuCacheMenuMixin._cache_lire(
"virsh -c qemu:///system list --all --name"
)
return {l.strip() for l in sortie.split("\n") if l.strip()}
@classmethod
def _cache_bypass_orphelines(cls, entrees=None):
"""Les exceptions dont la VM n'existe plus.
C'est LE danger de cette liste. Une adresse MAC se réattribue : une
exception laissée derrière une VM détruite soustrairait au cache une
machine neuve qui hériterait de l'adresse, sans que personne l'ait
demandé et sans que rien ne le dise. Une entrée sans nom ne peut pas
être jugée — elle a été posée à la main — et n'est jamais orpheline.
"""
vivants = cls._cache_domaines()
return [
(mac, nom)
for mac, nom in (
entrees if entrees is not None else cls._cache_bypass_lire()
)
if nom and nom not in vivants
]
def _cache_bypass_retirer(self, mac):
cmd = bypass_retrait_cmd(mac)
print(f"\n{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
def _cache_exceptions(self):
print(f"\n🎫 {t('VMs kept out of the download cache')}\n")
if not os.path.isfile(CACHE_BIN):
print(f" ✗ {t('Not installed:')} {CACHE_BIN}\n")
return
entrees = self._cache_bypass_lire()
if not entrees:
print(f" {t('No exception: every VM goes through the cache.')}")
print(f" {t('Tick the box when deploying to add one.')}\n")
return
orphelines = dict(self._cache_bypass_orphelines(entrees))
print(f" {'MAC':<20}{t('VM')}")
print(" " + "─" * 52)
for mac, nom in entrees:
marque = " ⚠ " + t("VM gone") if mac in orphelines else ""
print(f" {mac:<20}{nom or '—'}{marque}")
print()
if orphelines:
print(f" ⚠ {t('A freed MAC gets reused: such an entry would')}")
print(f" {t('quietly keep a NEW VM out of the cache.')}\n")
choices = [
{"prompt_description": t("Exceptions - Remove the stale ones")},
{"prompt_description": t("Exceptions - Remove one by its MAC")},
]
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
if status == "1":
if not orphelines:
print(t("Nothing is stale."))
continue
for mac in orphelines:
self._cache_bypass_retirer(mac)
return True
if status == "2":
mac = click.prompt(t("MAC to give back to the cache")).strip()
if mac:
self._cache_bypass_retirer(mac)
return True
print(t("Command not found !"))
# ------------------------------------------------------------------
# [5] Miroirs git
# ------------------------------------------------------------------
def _cache_miroir_git(self):
"""Prendre l'avance sur les clonages, plutôt que les subir.
À la demande, le miroir se remplit au fil des requêtes : la PREMIÈRE
machine paie chaque clonage. Pour un dépôt qui en tire trois cents, ce
n'est pas un coût qu'on supprime, c'est un coût qu'on déplace — sur la
machine qui, justement, attend.
"""
print(f"\n🪞 {t('Git mirrors of the ERPLibre manifests')}\n")
if not os.path.isfile(CACHE_BIN):
print(f" ✗ {t('Not installed:')} {CACHE_BIN}\n")
return
depots, octets = self._cache_miroir_occupation()
print(f" {t('Already mirrored:')} {depots}, {octets}")
racine = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
tous = depots_des_manifestes(racine)
if not tous:
print(f" ✗ {t('No repository found in manifest/')}\n")
return
# La version active décide de ce qu'un déploiement clone : sa base
# d'office, son extra seulement sur demande. Chaque liste est montrée
# avec ce qui lui manque, pour que le choix se fasse sur un compte et
# non sur un nom — remplir toutes les versions coûte des heures, là où
# la base d'une seule est souvent déjà complète.
version = version_active(racine)
base = depots_des_manifestes(racine, version) if version else []
extra = (
depots_des_manifestes(racine, fichiers=[manifeste_extra(version)])
if version
else []
)
print()
for libelle, liste in (
(f"{t('Base of Odoo')} {version or '?'}", base),
(f"{t('Extra of Odoo')} {version or '?'}", extra),
(t("Every manifest, all versions"), tous),
):
manque = cache_offline.miroirs_absents(liste) if liste else []
print(
f" {libelle:<36}{len(liste):>4} {t('repositories declared')},"
f" {len(manque):>4} {t('without a mirror')}"
)
# Un miroir est COMPLET : le dire en gigaoctets, pas en dépôts. Aucune
# éviction n'est écrite, et la place ne se rend pas toute seule.
print(f"\n ⚠ {t('A mirror is complete: this can take tens of GiB')}")
print(f" {t('and hours on the first run. Nothing erases it.')}")
print(f" {t('Free space:')} {self._cache_place_libre()}\n")
choices = [
{
"prompt_description": t(
"Mirrors - Fill the base of the active Odoo version"
)
},
{
"prompt_description": t(
"Mirrors - Fill the extra of the active Odoo version"
)
},
{
"prompt_description": t(
"Mirrors - Fill every manifest, all versions"
)
},
{"prompt_description": t("Mirrors - List them, heaviest first")},
{"prompt_description": t("Mirrors - Remove one")},
]
help_info = self.fill_help_info(choices)
sans_version = t("No .odoo-version: no active version to fill.")
rien = t("Nothing declared for this version.")
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
if status == "1":
if not version:
print(f" {sans_version}\n")
elif not base:
print(f" {rien}\n")
else:
self._cache_miroir_remplir(base)
elif status == "2":
if not version:
print(f" {sans_version}\n")
elif not extra:
print(f" {rien}\n")
else:
print(
f" {t('Extra modules install only with --with_extra.')}"
)
self._cache_miroir_remplir(extra)
elif status == "3":
self._cache_miroir_remplir(tous)
elif status == "4":
self._cache_miroir_lister()
elif status == "5":
self._cache_miroir_retirer()
else:
print(t("Command not found !"))
def _cache_miroir_remplir(self, liste):
"""Remplir les miroirs sous le compte du SERVICE, jamais sous root.
Le service rafraîchit ensuite ces dépôts sous son propre compte. Un
objet que root y a posé lui est interdit en écriture : le
rafraîchissement échoue, il est pris pour un amont muet, et le
miroir se fige sans rien dire. L'environnement est celui que l'unité
donne à git, relu dans l'unité plutôt que recopié ici.
"""
fichier = os.path.join(
os.path.expanduser("~/.erplibre"), "miroirs_git.txt"
)
os.makedirs(os.path.dirname(fichier), exist_ok=True)
with open(fichier, "w", encoding="utf-8") as fh:
fh.write("\n".join(liste) + "\n")
# La liste n'a que des URL publiques. Lisible de tous, le compte du
# service peut la rouvrir par /dev/stdin, qui ne parcourt pas le
# répertoire personnel de l'opérateur, souvent fermé aux autres.
os.chmod(fichier, 0o644)
cmd = miroir_prefetch_cmd(fichier, environnement_de_l_unite())
print(f"{t('Will execute:')} {cmd}")
if not click.confirm(t("Fill the git mirrors now?")):
return
self.execute.exec_command_live(cmd, source_erplibre=False)
def _cache_miroir_lister(self):
"""Du plus lourd au plus léger : c'est ce qu'on cherche quand on
surveille la place, et quelques dépôts font l'essentiel du total."""
cmd = (
f"{CACHE_BIN} --git-mirror-dir {CACHE_MIROIR_GIT}"
f" --git-mirror-list"
)
print(f"{t('Will execute:')} {cmd}\n")
self.execute.exec_command_live(cmd, source_erplibre=False)
def _cache_miroir_retirer(self):
"""Effacer un miroir est sans danger : il se refait au prochain
besoin, au prix du clonage. C'est ce qui permet de rendre de la place
sans tout perdre."""
nom = click.prompt(t("Repository to remove (as listed)")).strip()
if not nom:
return
cmd = (
f"sudo {CACHE_BIN} --git-mirror-dir {CACHE_MIROIR_GIT}"
f" --git-mirror-remove {shlex.quote(nom)}"
)
print(f"\n{t('Will execute:')} {cmd}")
print(f" {t('It will be mirrored again when a VM needs it.')}")
if not click.confirm(t("Remove this mirror?")):
return
self.execute.exec_command_live(cmd, source_erplibre=False)
@classmethod
def _cache_miroir_occupation(cls):
"""(nombre de dépôts, taille lisible) du miroir, lus du binaire."""
for ligne in cls._cache_lire(
f"{CACHE_BIN} --status --git-mirror-dir {CACHE_MIROIR_GIT}",
delai=120,
).split("\n"):
if ligne.startswith("dépôts git"):
valeur = ligne.split(":", 1)[1].strip()
nombre = valeur.split()[0]
return nombre, valeur.split(",", 1)[-1].strip()
return "0", "0 o"
@staticmethod
def _cache_place_libre():
"""Ce qui reste sur le système de fichiers qui porte le cache."""
try:
st = os.statvfs(os.path.dirname(CACHE_MIROIR_GIT))
except OSError:
return "?"
libre = st.f_bavail * st.f_frsize
for unite in ("o", "Kio", "Mio", "Gio", "Tio"):
if libre < 1024 or unite == "Tio":
return f"{libre:.1f} {unite}"
libre /= 1024
return "?"
# ------------------------------------------------------------------
# [6] Âge et nettoyage
# ------------------------------------------------------------------
def _cache_age(self):
"""Ce qui occupe, depuis quand, et de quoi en rendre.
L'âge retenu est celui du dernier USAGE : le service remet la date
d'un objet chaque fois qu'il le sert. « Vieux » veut donc dire « n'a
plus servi », et non « est entré il y a longtemps » — un paquet servi
tous les jours depuis un an n'est pas à jeter, l'effacer obligerait à
le retélécharger le lendemain.
"""
print(f"\n🧭 {t('Age of the cache, and cleanup')}\n")
if not os.path.isfile(CACHE_BIN):
print(f" ✗ {t('Not installed:')} {CACHE_BIN}\n")
return
print(f" {t('Free space:')} {self._cache_place_libre()}\n")
choices = [
{"prompt_description": t("Age - By day")},
{"prompt_description": t("Age - By week")},
{"prompt_description": t("Age - By month")},
{
"prompt_description": t(
"Clean - What has not served for a while"
)
},
{"prompt_description": t("Clean - Everything")},
]
grains = {"1": "jour", "2": "semaine", "3": "mois"}
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
if status in grains:
self._cache_lancer(
f"--age-report --age-par {grains[status]}", sudo=False
)
elif status == "4":
self._cache_nettoyer_age()
elif status == "5":
self._cache_nettoyer_tout()
else:
print(t("Command not found !"))
def _cache_lancer(self, options, sudo=True):
"""La commande du cache, annoncée puis lancée."""
cmd = (
f"{'sudo ' if sudo else ''}{CACHE_BIN}"
f" --cache-dir {CACHE_DIR} --git-mirror-dir {CACHE_MIROIR_GIT}"
f" {options}"
)
print(f"{t('Will execute:')} {cmd}\n")
self.execute.exec_command_live(cmd, source_erplibre=False)
def _cache_nettoyer_age(self):
"""Le délai est DEMANDÉ, puis montré à blanc avant d'effacer.
Une purge ne se rattrape pas : les octets sont rendus, il faut les
retélécharger — et pour un dépôt en miroir, cela se compte en minutes.
Voir d'abord ce qui partirait est le seul moyen de répondre à la
question posée.
"""
delai = click.prompt(
t("Not served since (e.g. 30j, 12h)"), default="30j"
).strip()
if not delai:
return
self._cache_lancer(
f"--purge-older-than {shlex.quote(delai)} --dry-run"
)
if not click.confirm(t("Erase what is listed above?")):
return
self._cache_lancer(f"--purge-older-than {shlex.quote(delai)}")
def _cache_nettoyer_tout(self):
"""Tout, objets ET dépôts. Montré à blanc d'abord, comme le reste."""
print(f" ⚠ {t('This empties the objects AND the git mirrors.')}")
print(f" {t('Refilling the mirrors takes minutes to hours.')}\n")
self._cache_lancer("--purge --dry-run")
if not click.confirm(t("Erase the whole cache?")):
return
self._cache_lancer("--purge")
# ------------------------------------------------------------------
# [7] Guide
# ------------------------------------------------------------------
def _cache_guide(self):
"""N'exécute rien. Dit ce qui n'est pas devinable en lisant l'écran."""
for ligne in (
"",
t("How the QEMU download cache works"),
"",
t(" Two VMs of the same distribution pull the same packages."),
t(
" The cache keeps what comes down and serves the copy to the next."
),
"",
f" {t('What is served from disk')}",
t(
" A package file: its name carries its version, so it never changes."
),
t(
" The host name is ignored: a rotating mirror list still hits."
),
"",
f" {t('What is always taken from upstream')}",
t(
" A repository index: it names the versions that exist right now."
),
t(
" Serving a stale one makes an install fail on a withdrawn package."
),
t(
" It is stored anyway, and only comes back out when upstream is mute."
),
"",
f" {t('Where things live')}",
f" {t('Objects:')} /var/cache/erplibre_go_qemu_cache",
f" {t('Authority:')} {CACHE_CA}",
f" {t('Settings:')} {CACHE_CONF}",
f" {t('Access log:')} {self._cache_journal() or '—'}",
t(" Entry 10 follows it live: a request that goes out to the"),
t(" internet shows there, and a cut leaves that view empty."),
f" {t('Git mirrors:')} {CACHE_MIROIR_GIT}",
"",
f" {t('Git is mirrored, not cached')}",
t(" Git's protocol is a negotiation: the server computes its"),
t(
" answer from what the client already holds, so no answer is"
),
t(
" reusable. A bare mirror per upstream repo is kept instead,"
),
t(" and served locally. A mirror already held serves with no"),
t(" network at all — but NOT what the cache may not decrypt:"),
t(" npm and poetry carry their own trust store, so they are"),
t(" tunnelled, and a tunnel carries nothing once cut."),
t(" A mirror is COMPLETE: it weighs what the upstream repo"),
t(" weighs, history included, and a few repositories make"),
t(" most of the total. Entry 5 lists them heaviest first and"),
t(" removes one — it comes back at the next need."),
"",
f" {t('No eviction is written')}",
t(" Neither the objects nor the mirrors shrink by themselves,"),
t(" and both live on the orchestrator's disk. The diagnosis"),
t(" entry says what each of the two occupies."),
t(" Entry 6 groups them by AGE OF LAST USE — an object served"),
t(" has its date renewed, so « old » means « no longer used »"),
t(" — and gives back what has not served for a while, or all."),
"",
f" {t('Turning it off')}",
t(" Interception is transparent and covers the whole bridge:"),
t(
" a VM cannot opt out from the inside. Omitting the authority"
),
t(
" does not bypass anything — the VM is redirected all the same"
),
t(" and fails on « self-signed certificate in chain »."),
"",
t(" For ONE VM: tick « keep this VM out of the cache » when"),
t(
" deploying, or pass --cache-bypass. Its MAC address is fixed"
),
t(
" before creation and an exception is posted on the host, so"
),
t(
" nothing redirects it. Entry 4 lists them; an exception whose"
),
t(" VM is gone must be removed, a freed MAC being reused."),
"",
t(" For EVERY VM: stop the service, entry 3 or"),
f" systemctl stop {CACHE_SERVICE}",
t(" The rules leave with it, so no VM stays redirected."),
"",
f" {t('Proxmox')}",
t(
" A Proxmox host that is itself a VM of this orchestrator crosses"
),
t(
" this bridge: the machines it carries come out behind its address,"
),
t(
" so the cache serves them, and the deployment poses the authority"
),
t(
" in each of them. A host that lives elsewhere is not concerned —"
),
t(
" install the cache ON it, the script being generic and Proxmox a"
),
t(
" Debian. Reserve: a bridge switched onto the LAN is only seen by"
),
t(" the rules when br_netfilter is enabled."),
"",
f" {t('Carrying it to another machine')}",
t(" Entry 11 copies the STORE, not the service: an object is"),
t(
" keyed by URL and a git mirror is a repository, so both are"
),
t(
" worth the same elsewhere. The settings stay here — bridge,"
),
t(" subnet and authority belong to the host that serves them."),
t(" The target must already carry the cache, installed from"),
t(" ITS own checkout, and answer ssh without a password."),
"",
):
print(ligne)
# ------------------------------------------------------------------
# [8] Tests
# ------------------------------------------------------------------
# Les trois essais, dans l'ordre où l'assistant les propose et les enchaîne.
# Chaque entrée est (option de ligne de commande, libellé) : le NOM des
# machines n'est pas écrit ici, il est demandé au script. Une seconde
# fabrique du nom dériverait, et le menu annoncerait alors des VM qui ne
# sont pas celles qui naissent — pire que de ne rien annoncer.
_CACHE_ESSAIS = (
("", "The cache: two VMs, measure the gain"),
("--hors-ligne", "The offline counter-proof"),
("--sans-cache", "The control: two VMs WITHOUT the cache"),
)
_CACHE_CHARGES = (
("minimum", "Minimum: a batch of packages, minutes"),
("erplibre", "ERPLibre + Odoo 18: the real thing, hours"),
)
@staticmethod
def _cache_module_test():
"""Le script du test long, chargé comme module.
À l'appel et non au démarrage du menu : il tire le catalogue du
déploiement, qui est lourd, et personne ne doit le payer pour afficher
un menu qu'il ne visite pas.
C'est LUI qui décide des systèmes offerts et du nom des machines. Le
menu ne recopie ni l'un ni l'autre : ce qui est annoncé à l'écran est
alors, par construction, ce qui va se passer.
"""
import importlib.util
chemin = os.path.join(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
),
LONGTEST,
)
spec = importlib.util.spec_from_file_location(
"qemu_cache_long", chemin
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# Une icône par système, pour le menu SEULEMENT. `distro_label` sert aussi
# aux formulaires de déploiement et aux journaux : l'icône y changerait
# des libellés que d'autres lisent. Une distribution ajoutée au catalogue
# sans icône retombe sur _CACHE_ICONE_REPLI, et une épreuve le signale.
_CACHE_ICONES_SYSTEMES = {
"almalinux": "🌸",
"arch": "🏹",
"debian": "🌀",
"fedora": "🎩",
"opensuse": "🦎",
"rocky": "⛰",
"ubuntu": "🟠",
}
_CACHE_ICONE_REPLI = "🐧"
@classmethod
def _cache_systemes(cls):
"""Les systèmes que le TEST accepte, avec leur libellé et son icône."""
module = cls._cache_module_test()
return [
(
d,
f"{cls._CACHE_ICONES_SYSTEMES.get(d, cls._CACHE_ICONE_REPLI)}"
f" {module.distro_label(d, module.DISTROS[d][1])}",
)
for d in sorted(module.systemes_mesurables())
]
@classmethod
def _cache_nom_des_machines(cls, options, distro, charge):
"""Le début du nom des VM de cet essai, demandé au script."""
module = cls._cache_module_test()
prefixe = {
"--sans-cache": module.NOM_BASE_SANS_CACHE,
"--hors-ligne": module.NOM_BASE_HORS_LIGNE,
}.get(options, module.NOM_BASE)
return module.nom_de_base(
prefixe, distro, module.DISTROS[distro][1], charge
)
def _cache_choisir(self, titre, options):
"""Une question numérotée, le premier choix par défaut.
Rend l'indice choisi, ou None si l'on renonce. Les trois questions de
l'assistant partagent cette forme : une seule façon de répondre, et
« 0 » ramène en arrière partout.
"""
print(f"\n{titre}")
for i, libelle in enumerate(options, 1):
print(f" [{i}] {libelle}")
print(f" [0] {t('Back')}")
while True:
reponse = click.prompt(t("Choice"), default="1").strip()
if reponse == "0":
return None
if reponse.isdigit() and 1 <= int(reponse) <= len(options):
return int(reponse) - 1
print(t("Command not found !"))
def _cache_assistant(self):
"""Trois questions, puis les essais choisis, l'un après l'autre.
Une seule confirmation à la fin, et non une par essai : la question
porte sur le LOT, et la reposer trois fois la rendrait machinale
— c'est ce qui fait qu'on cesse de la lire.
"""
essais = self._cache_choisir(
t("Which test?"),
[t(e[1]) for e in self._CACHE_ESSAIS]
+ [t("All three, one after another")],
)
if essais is None:
return
choisis = (
list(self._CACHE_ESSAIS)
if essais == len(self._CACHE_ESSAIS)
else [self._CACHE_ESSAIS[essais]]
)
charge = self._cache_choisir(
t("Which load?"), [t(c[1]) for c in self._CACHE_CHARGES]
)
if charge is None:
return
charge = self._CACHE_CHARGES[charge][0]
systemes = self._cache_systemes()
systeme = self._cache_choisir(
t("Which system?"), [libelle for _d, libelle in systemes]
)
if systeme is None:
return
distro = systemes[systeme][0]
commun = f"--distro {distro} --charge {charge}"
print(f"\n{t('About to run, one after another:')}")
for options, _libelle in choisis:
ligne = f"{LONGTEST} {commun} {options}".rstrip()
print(f" {ligne}")
nom = self._cache_nom_des_machines(options, distro, charge)
print(f" {t('Machines created:')} {nom}-1, -2…")
if charge == "erplibre":
# Des heures et non des minutes : une VM qui installe ERPLibre
# entier n'a rien à voir avec le lot de paquets, et découvrir la
# différence en cours de route est trop tard.
print(
f"\n ⚠ {t('The real load takes hours per VM, not minutes.')}"
)
if not click.confirm(t("Run these long tests?")):
return
for options, _libelle in choisis:
self._longtest_run(
"qemu_cache.py", f"{commun} {options}".rstrip(), demander=False
)
def _cache_tests(self):
print(f"\n{t('Cache tests: real VMs, several minutes')}\n")
choices = [
{"prompt_description": t("Test - Choose and run")},
{"prompt_description": t("Test - The plan only (dry-run)")},
{"prompt_description": t("Test - Performance report")},
{"prompt_description": t("Test - Undo the machines created")},
]
args = {"2": "--dry-run", "3": "--rapport", "4": "--detruire"}
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
if status == "1":
self._cache_assistant()
elif status in args:
self._longtest_run("qemu_cache.py", args[status])
else:
print(t("Command not found !"))
# ------------------------------------------------------------------
# [9] Combler ce qui a manqué hors ligne
# ------------------------------------------------------------------
@staticmethod
def _cache_raison(verdict):
"""Ce qu'un verdict de rejeu veut dire, dans la langue du menu."""
return {
"rejouable": t("replay through the cache"),
"jamais": t("never kept: the cache keeps only GET and HEAD"),
"non-cachable": t("the cache does not keep this address"),
"tunnel": t("host in tunnel: nothing to keep"),
"git": t("git negotiation: fill the mirror from entry 5"),
"adresse": t(
"not a host name: a replay could loop back into the cache"
),
}.get(verdict, verdict)
@classmethod
def _cache_refus_appris(cls):
"""Les hôtes que le service a appris à passer en tunnel.
Ces refus ne vivent qu'en mémoire du service, qui ne les oublie pas,
et seul son journal systemd les nomme. Lu sans privilège, puis par
« sudo -n », qui échoue plutôt que de demander un mot de passe. Un
journal illisible rend une liste vide.
"""
lire = (
f"journalctl -u {CACHE_SERVICE} -o cat --no-pager"
" | grep -F 'tunnel opaque retenu pour'"
)
vu = cls._cache_lire(lire) + cls._cache_lire(f"sudo -n {lire}")
return sorted(
set(re.findall(r"tunnel opaque retenu pour (\S+) \(", vu))
)
def _cache_exclus(self):
"""Les hôtes que le cache passe en tunnel : déclarés dans ses
sources, ajoutés par EL_EXCLUDE (séparés par des virgules, comme le
service les lit), et appris au fil des refus."""
racine = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
exclus = list(exclusions_declarees(racine))
exclus += [
e.strip()
for e in cache_offline.reglage("EL_EXCLUDE", CACHE_CONF).split(",")
if e.strip()
]
appris = self._cache_refus_appris()
if appris:
print(
f" {t('Tunnel refusals learned by the service:')}"
f" {', '.join(appris)}\n"
)
return exclus + appris
@staticmethod
def _cache_curl(argv, delai=REJEU_DELAI):
"""Les en-têtes que curl a reçus, ou "" s'il n'a rien obtenu.
Les variables de mandataire héritées de l'opérateur sont retirées :
un « https_proxy » enverrait le rejeu ailleurs que dans le cache, et
un « no_proxy » ferait ignorer « -x ».
"""
env = {
k: v
for k, v in os.environ.items()
if not k.lower().endswith("_proxy")
}
try:
p = subprocess.run(
argv,
capture_output=True,
text=True,
timeout=delai + 15,
env=env,
)
except (OSError, subprocess.SubprocessError):
return ""
return p.stdout or ""
def _cache_rejouer(self, methode, url, ports, exclus):
"""Rejoue une adresse, puis sa chaîne de redirections, saut par saut.
Rend [(url, statut)], le statut None quand curl n'a rien obtenu.
Pas de « -L » : curl suivrait seul, et un saut vers l'autre schéma
partirait par une connexion que le cache ne mène pas. Chaque saut
est donc une commande de la même forme que la première, et n'est
rejoué que s'il est lui-même rejouable, jamais deux fois. Seul un GET
suit : un HEAD lit sa redirection et s'arrête, comme le client qui
l'a émis.
"""
sauts = []
cible = url
for _ in range(SAUTS_MAX + 1):
sortie = self._cache_curl(
rejeu_cmd(methode, cible, ports[0], ports[1], CACHE_CA)
)
statut, suivante = statut_et_cible(sortie, cible)
sauts.append((cible, statut))
if (
methode.upper() != "GET"
or statut not in REDIRECTIONS
or not suivante
or verdict_de_rejeu("GET", suivante, exclus) != "rejouable"
or suivante in [u for u, _s in sauts]
):
break
cible = suivante
return sauts
def _cache_combler(self):
"""Rejouer, amont branché, ce que les VM hors ligne n'ont pas trouvé.
Chaque rejeu traverse le cache sous la clé qu'une VM produirait : le
nom d'hôte reste celui de l'URL, seule la connexion est menée à
l'écoute locale. Ce qui entre alors au cache est ce que la VM y
trouvera au prochain déploiement hors ligne.
Refusé sous la coupure : le rejeu n'ajouterait que des manques au
journal, et l'on croirait avoir rempli. Le guet est lu d'abord, sans
sudo : tant qu'il tourne, la coupure est tenue exprès, et le geste
donné est l'arrêt du guet. Une coupure illisible — sudo exige un mot
de passe — n'est pas prise pour une absence : le menu le dit, et
demande, non par défaut. Le plan est montré avant la confirmation du
rejeu, avec ce qui ne se rejoue pas et pourquoi.
"""
print(f"\n🩹 {t('What offline runs lacked')}\n")
if not self._cache_combler_permis():
return
def reglage(nom, defaut=""):
return cache_offline.reglage(nom, CACHE_CONF) or defaut
chemin = self._cache_journal()
manques = cache_offline.manques_recents(
chemin, reglage("EL_SUBNET"), time.time() - JOURS_RECENTS * 86400
)
if not manques:
print(
f" {t('No offline miss in the recent window: nothing to fill.')}\n"
)
return
cache_dir = reglage("EL_CACHE_DIR", CACHE_DIR)
verdicts = cache_offline.detient_interroger(
[(m["methode"], m["url"]) for m in manques], CACHE_BIN, cache_dir
)
selon_journal = verdicts is None
if selon_journal:
verdicts = {}
tenus = cache_offline.tenus_selon_journal(
chemin,
{(m["methode"], m["url"]): m["dernier"] for m in manques},
)
else:
tenus = {
c
for c, v in verdicts.items()
if v["verdict"] in cache_offline.DETENTION
}
restants = [
m for m in manques if (m["methode"], m["url"]) not in tenus
]
avis_journal = t("according to the log: a purge can make it wrong")
if not restants:
print(f" ✓ {t('Everything that was missed is held now.')}")
if selon_journal:
print(f" {avis_journal}")
print()
return
exclus = self._cache_exclus()
plan = []
for m in restants:
cle = (m["methode"], m["url"])
if verdicts.get(cle, {}).get("verdict") == "non-cachable":
verdict = "non-cachable"
else:
verdict = verdict_de_rejeu(m["methode"], m["url"], exclus)
plan.append((m, verdict))
marque = "↻" if verdict == "rejouable" else "·"
print(f" {marque} {m['methode']:<5}{m['url']}")
print(f" {self._cache_raison(verdict)} ({m['n']}×)")
if selon_journal:
print(f"\n ⚠ {avis_journal}")
rejouables = [m for m, v in plan if v == "rejouable"]
if not rejouables:
print(f"\n {t('Nothing here can be replayed.')}\n")
return
# Le magasin ne tient compte ni de Vary ni des en-têtes de la
# requête : il garde ce que curl reçoit, et le sert tel quel à la VM.
entetes_1 = t(
"The replay sends curl's own headers: a server that varies on"
)
entetes_2 = t(
"User-Agent or Accept may keep another answer than the VM's."
)
print(f"\n ⚠ {entetes_1}")
print(f" {entetes_2}")
# Les ports par défaut de l'installateur, quand le réglage manque.
ports = (
reglage("EL_HTTP_PORT", "8898"),
reglage("EL_TLS_PORT", "8899"),
)
premier = rejouables[0]
exemple = rejeu_cmd(
premier["methode"], premier["url"], ports[0], ports[1], CACHE_CA
)
print(f"\n{t('Will execute:')} {shlex.join(exemple)}")
if not click.confirm(
t("Replay these addresses through the cache now?")
):
return
resultats = [
(m, self._cache_rejouer(m["methode"], m["url"], ports, exclus))
for m in rejouables
]
# Revérifié au MAGASIN : un 200 reçu par curl ne prouve pas que
# l'objet a été gardé.
verification = cache_offline.detient_interroger(
[
(m["methode"], url)
for m, sauts in resultats
for url, _s in sauts
],
CACHE_BIN,
cache_dir,
)
print()
for m, sauts in resultats:
for rang, (url, statut) in enumerate(sauts):
cle = (m["methode"], url)
if verification is None:
etat = t("not re-checked: this binary has no --detient")
elif (
verification.get(cle, {}).get("verdict")
in cache_offline.DETENTION
):
etat = "✓ " + t("held")
else:
etat = "✗ " + t("not held")
recu = (
statut if statut is not None else t("curl got no answer")
)
debut = f" {m['methode']:<5}" if rang == 0 else " → "
print(f"{debut}{url}")
print(f" [{recu}] {etat}")
print()
def exclusions_declarees(racine):
"""Les hôtes que le cache passe d'office en tunnel, lus dans SA source.
DefaultExclusions vit en Go. Le lire là plutôt que d'en tenir une copie
empêche qu'un hôte ajouté côté Go soit rejoué d'ici. Une source illisible
rend une liste vide.
"""
try:
with open(os.path.join(racine, MITM_GO), encoding="utf-8") as fh:
src = fh.read()
except OSError:
return []
bloc = re.search(r"var DefaultExclusions = \[\]string\{([^}]*)\}", src)
return re.findall(r'"([^"]+)"', bloc.group(1)) if bloc else []
def hote_exclu(hote, exclus):
"""La règle du service : le nom exact, ou un suffixe qui commence par un
point et couvre alors tout le domaine."""
hote = (hote or "").lower()
for e in exclus:
e = (e or "").strip().lower()
if e and (hote == e or (e.startswith(".") and hote.endswith(e))):
return True
return False
def _est_une_adresse_ip(hote):
import ipaddress
try:
ipaddress.ip_address(hote)
except ValueError:
return False
return True
def verdict_de_rejeu(methode, url, exclus=()):
"""Que faire d'une adresse manquée : « rejouable », « jamais », « git »,
« tunnel » ou « adresse ».
Seuls GET et HEAD se gardent. Une négociation git ne se garde pas : c'est
un DÉPÔT que le cache tient, et l'entrée 5 le remplit. Un hôte en tunnel
— https sur une adresse IP, sans SNI, ou hôte exclu — n'a rien à garder,
et rejoué depuis l'hôte il revient dans le cache : sans détournement, la
destination d'origine d'une connexion locale est l'écoute elle-même, et
le tunnel s'y rappelle sans fin. Pour la même raison, une adresse IP ou
« localhost » en http n'est pas rejouée.
"""
if (methode or "").upper() not in cache_offline.METHODES_GARDABLES:
return "jamais"
try:
morceaux = urlsplit(url)
hote = (morceaux.hostname or "").lower()
except ValueError:
return "adresse"
if morceaux.scheme not in ("http", "https") or not hote:
return "adresse"
if _est_une_adresse_ip(hote):
return "tunnel" if morceaux.scheme == "https" else "adresse"
if hote == "localhost":
return "adresse"
if cache_offline.depot_git(url):
return "git"
if morceaux.scheme == "https" and hote_exclu(hote, exclus):
return "tunnel"
return "rejouable"
def rejeu_cmd(methode, url, http_port, tls_port, ca, delai=REJEU_DELAI):
"""La commande curl qui rejoue une adresse PAR le cache, en arguments.
http passe par le mandataire du cache (« -x »), qui rebâtit l'adresse
depuis la ligne de requête. https garde son nom — SNI et en-tête Host —
et seule sa connexion est menée à l'écoute TLS : « --connect-to » sans
hôte ni port d'origine vaut pour tous. La clé est alors celle qu'une VM
produirait. L'autorité du cache est APPROUVÉE par « --cacert », jamais
contournée.
« -D - » rend les en-têtes, qui portent la redirection ; le corps va à
/dev/null, le cache le garde de son côté.
« -q » en PREMIER argument — curl ne l'honore qu'à cette place — écarte
le ~/.curlrc de l'opérateur : un « proxy » y enverrait le rejeu https
ailleurs que dans le cache, et « location », « compressed » ou un
en-tête changeraient ce qui entre au magasin sous la clé de la VM.
"""
argv = ["curl", "-q", "-sS", "-o", "/dev/null", "-D", "-"]
argv += ["--max-time", str(delai)]
if urlsplit(url).scheme == "https":
argv += ["--connect-to", f"::127.0.0.1:{tls_port}", "--cacert", ca]
else:
argv += ["-x", f"http://127.0.0.1:{http_port}"]
if (methode or "").upper() == "HEAD":
argv.append("-I")
argv.append(url)
return argv
def statut_et_cible(entetes, url):
"""(statut, cible absolue de la redirection ou "") lus dans « curl -D - ».
Le DERNIER bloc compte : un « 100 Continue » précède la vraie réponse.
Une cible relative est résolue contre l'adresse demandée. Rend (None, "")
quand curl n'a rien reçu.
"""
statut, cible = None, ""
for ligne in (entetes or "").splitlines():
ligne = ligne.strip()
if ligne.startswith("HTTP/"):
champs = ligne.split()
statut = (
int(champs[1])
if len(champs) > 1 and champs[1].isdigit()
else None
)
cible = ""
elif ligne.lower().startswith("location:"):
cible = urljoin(url, ligne.split(":", 1)[1].strip())
return statut, cible
def environnement_de_l_unite(unite=None):
"""Les « NOM=valeur » que l'unité systemd donne au service, dans l'ordre.
Lus dans l'unité posée, qui porte les valeurs résolues à l'installation.
Une unité illisible rend une liste vide.
"""
out = []
try:
with open(unite or CACHE_UNITE, encoding="utf-8") as fh:
for ligne in fh:
ligne = ligne.strip()
if not ligne.startswith("Environment="):
continue
try:
out.extend(shlex.split(ligne.split("=", 1)[1]))
except ValueError:
continue
except OSError:
pass
return out
def miroir_prefetch_cmd(fichier, environnement):
"""La commande qui remplit les miroirs sous le compte du service.
La liste arrive sur l'entrée standard, ouverte par le shell de
l'opérateur : « /dev/stdin » la rouvre sans parcourir le chemin, là où
le compte du service ne traverse pas le répertoire personnel qui la
porte.
"""
mots = ["sudo", "-u", cache_offline.SERVICE_USER, "env"]
mots += [shlex.quote(e) for e in environnement]
mots += [
CACHE_BIN,
"--git-mirror-dir",
CACHE_MIROIR_GIT,
"--git-mirror-prefetch",
"/dev/stdin",
"<",
shlex.quote(fichier),
]
return " ".join(mots)
def bypass_retrait_cmd(mac):
"""La commande qui retire une exception du fichier ET du noyau.
Les deux, parce qu'ils ne disent pas la même chose : le fichier est ce que
le service reposera au prochain démarrage, l'ensemble du noyau est ce qui
s'applique en ce moment. N'en faire qu'un laisse l'exception vivante
jusqu'au redémarrage, ou la fait revenir après.
"""
return (
f"sudo {CACHE_BIN} --bypass-del {shlex.quote(mac)}"
f" --bypass-file {CACHE_BYPASS} | sudo nft -f -"
)
def bypass_menage(execute):
"""Retire les exceptions dont la VM n'existe plus. Rend leur nombre.
Appelée après une suppression de VM. Sans ce ménage, une adresse MAC
libérée puis réattribuée soustrairait au cache une machine neuve que
personne n'a exceptée, et rien ne le dirait : ni la VM, qui télécharge
normalement, ni le cache, dont le journal reste simplement muet à son
sujet.
Ne fait rien quand le cache n'est pas posé : il n'y a alors aucune liste.
"""
if not os.path.isfile(CACHE_BIN):
return 0
orphelines = QemuCacheMenuMixin._cache_bypass_orphelines()
for mac, _nom in orphelines:
execute.exec_command_live(
bypass_retrait_cmd(mac), source_erplibre=False
)
return len(orphelines)
def version_active(racine):
"""La version d'Odoo que le checkout porte (« .odoo-version »), ou ""."""
try:
with open(
os.path.join(racine, ".odoo-version"), encoding="utf-8"
) as fh:
return fh.read().strip()
except OSError:
return ""
def manifeste_extra(version):
"""Le manifeste des modules extra d'une version, relatif à la racine.
Il ne s'installe que sur demande (« --with_extra ») : ni la base ni le
verdict du pré-vol ne le comptent, seul son propre remplissage le vise.
"""
return os.path.join("manifest", f"git_manifest_extra_odoo{version}.xml")
def manifestes_de_version(racine, version, extra=False):
"""Les manifestes qu'une installation de cette version fusionne.
La règle est celle de script/git/git_merge_repo_manifest.py. Les
manifestes communs sont lus dans les MÊMES listes que lui —
conf/git_manifest_odoo.csv et conf/git_manifest.csv — plutôt que recopiés
ici : une copie divergerait au premier manifeste ajouté. S'y ajoutent
celui de la version et son « _dev ».
Deux manifestes n'entrent pas d'office, comme dans la fusion : l'extra,
qui ne s'installe que sur demande, et le mobile, qui n'entre que si son
répertoire existe — ce qui n'est pas le cas d'une VM fraîchement clonée.
Les compter ferait annoncer comme manquants des dépôts qu'aucun
déploiement par défaut ne clone.
"""
import csv
fichiers = []
def ajouter(chemin):
if chemin and chemin not in fichiers:
fichiers.append(chemin)
def lire_liste(nom):
try:
with open(
os.path.join(racine, "conf", nom), encoding="utf-8"
) as fh:
for ligne in csv.DictReader(fh):
ajouter(ligne.get("filepath") or "")
except OSError:
pass
lire_liste("git_manifest_odoo.csv")
ajouter(os.path.join("manifest", f"git_manifest_odoo{version}.xml"))
ajouter(os.path.join("manifest", f"git_manifest_odoo{version}_dev.xml"))
lire_liste("git_manifest.csv")
if extra:
ajouter(manifeste_extra(version))
return fichiers
def depots_des_manifestes(racine, version="", fichiers=None):
"""Les dépôts git que les manifestes du dépôt déclarent, sans doublon.
Un manifeste Google Repo nomme des « remote » — l'URL de base d'une forge —
et des « project » qui s'y rattachent. L'URL complète est la concaténation
des deux, et un même projet figure dans plusieurs manifestes, un par
version d'Odoo.
`fichiers` nomme exactement les manifestes à lire, relatifs à la racine.
À défaut, `version` les déduit par `manifestes_de_version` : un
déploiement n'installe qu'une version, et additionner les dépôts des
autres fait compter comme manquant ce que personne ne clonera. Sans l'un
ni l'autre, tous les manifestes sont lus — ce dont le remplissage complet
a besoin pour prendre de l'avance sur toutes les versions.
Un manifeste illisible ou absent est SAUTÉ plutôt que fatal : la liste
sert à prendre de l'avance, et en perdre une partie vaut mieux que de ne
rien prendre.
"""
import glob
import xml.etree.ElementTree as ET
if fichiers is None and version:
fichiers = manifestes_de_version(racine, version)
if fichiers is None:
chemins = sorted(glob.glob(os.path.join(racine, "manifest", "*.xml")))
else:
chemins = [os.path.join(racine, f) for f in fichiers]
vus = set()
out = []
for fichier in chemins:
try:
arbre = ET.parse(fichier).getroot()
except (ET.ParseError, OSError):
continue
bases = {
e.get("name"): (e.get("fetch") or "")
for e in arbre.findall("remote")
}
for projet in arbre.findall("project"):
base = bases.get(projet.get("remote"), "")
nom = projet.get("name") or ""
if not base or not nom:
continue
url = base.rstrip("/") + "/" + nom.lstrip("/")
if url not in vus:
vus.add(url)
out.append(url)
return out