Sur une installation complète, les index pip, les métadonnées npm et le bundle de repo pesaient environ 110 Mo par VM, repris en entier à chaque fois. Un index déjà gardé part désormais avec son ETag : l'amont juge chaque requête, et un « 304 » sert le corps depuis le disque. Sous « Vary: Accept », chaque représentation est rangée à part, faute de quoi deux clients qui alternent les retéléchargent tour à tour. Les métadonnées PEP 658 de pip, nommées « .whl.metadata », sont figées comme l'archive qu'elles décrivent. --- EN --- On a full install the pip indexes, npm metadata and repo bundle weighed about 110 MB per VM, taken whole every time. An index already kept now leaves with its ETag: upstream judges every request, and a « 304 » serves the body from disk. Under « Vary: Accept » each representation is stored apart, without which two clients alternating between them re-download each in turn. The PEP 658 metadata of pip, named « .whl.metadata », are as immutable as the archive they describe. Assisted-by: Claude Opus 5
2392 lines
97 KiB
Python
2392 lines
97 KiB
Python
#!/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 get_lang, 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"
|
||
|
||
# Ce que le menu LIT dans les sorties du binaire, dans ses deux langues. Le
|
||
# binaire traduit ses messages humains (catalogue_en.go) : une lecture qui ne
|
||
# connaîtrait que le français deviendrait aveugle sur un service réglé en
|
||
# anglais. test_qemu_cache_langue.py vérifie que le catalogue les porte.
|
||
REFUS_APPRIS = ("tunnel opaque retenu pour", "opaque tunnel kept for")
|
||
LIBELLES_TUS = (
|
||
"autorité",
|
||
"empreinte",
|
||
"exceptions",
|
||
"authority",
|
||
"fingerprint",
|
||
)
|
||
|
||
|
||
def option_langue(lue=False):
|
||
"""L'option --lang à donner au binaire.
|
||
|
||
Une sortie AFFICHÉE suit la langue de todo.py. Une sortie LUE par ce
|
||
fichier est demandée en français, la langue dont il connaît les
|
||
libellés. L'option et non EL_LANG : sudo retire l'environnement.
|
||
"""
|
||
return f"--lang {'fr' if lue else get_lang()}"
|
||
|
||
|
||
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",
|
||
"revalidated",
|
||
"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}"
|
||
f" {option_langue()}",
|
||
delai=60,
|
||
)
|
||
for ligne in [
|
||
l
|
||
for l in releve.split("\n")
|
||
if l.strip() and not l.startswith(LIBELLES_TUS)
|
||
][: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 {option_langue()}"
|
||
)
|
||
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)} {option_langue()}"
|
||
)
|
||
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}"
|
||
f" {option_langue(lue=True)}",
|
||
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} {option_langue()}"
|
||
)
|
||
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"
|
||
+ "".join(f" -e {shlex.quote(r)}" for r in REFUS_APPRIS)
|
||
)
|
||
vu = cls._cache_lire(lire) + cls._cache_lire(f"sudo -n {lire}")
|
||
return sorted(
|
||
set(
|
||
re.findall(
|
||
"(?:"
|
||
+ "|".join(map(re.escape, REFUS_APPRIS))
|
||
+ r") (\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,
|
||
option_langue(),
|
||
"--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} {option_langue()} | 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
|