Le cache ne diminuait jamais de lui-même et vit sur le disque de l'orchestrateur : un parc qui déploie chaque jour finissait par le remplir, sans que rien ne le signale. Deux règles existent désormais, éteintes par défaut et réglées depuis le menu : l'âge du dernier usage, et un plafond de taille qui efface le moins récemment servi jusqu'à tenir dessous, objets et dépôts git confondus. Un minuteur systemd quotidien les applique, et une réinstallation garde les valeurs choisies. Le menu montre d'abord ce qui partirait. --- EN --- The cache never shrank by itself and lives on the orchestrator's disk: a site deploying every day filled it up with nothing to say so. Two rules now exist, off by default and set from the menu: the age of last use, and a size ceiling that erases the least recently served until the cache fits under it, objects and git mirrors alike. A daily systemd timer applies them, and a reinstall keeps the chosen values. The menu first shows what would go. Assisted-by: Claude Opus 5
2532 lines
103 KiB
Python
2532 lines
103 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"
|
||
# Le nettoyage automatique : une unité ponctuelle et son minuteur quotidien,
|
||
# posés par l'installateur, sans effet tant que leurs réglages sont vides.
|
||
CACHE_SERVICE_PURGE = "erplibre-go-qemu-cache-purge.service"
|
||
CACHE_MINUTEUR_PURGE = "erplibre-go-qemu-cache-purge.timer"
|
||
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")},
|
||
{"prompt_description": t("Cache - Automatic cleanup")},
|
||
]
|
||
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()
|
||
elif status == "12":
|
||
self._cache_nettoyage_auto()
|
||
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()
|
||
|
||
# ------------------------------------------------------------------
|
||
# [12] Nettoyage automatique
|
||
# ------------------------------------------------------------------
|
||
|
||
def _cache_nettoyage_auto(self):
|
||
"""Le nettoyage automatique : un minuteur quotidien, sans effet tant
|
||
que ses deux réglages sont vides. Le nettoyage reste donc manuel par
|
||
défaut, et le régler ici suffit à l'activer, sans réinstaller."""
|
||
print(f"\n🧹 {t('Automatic cleanup of the cache')}")
|
||
choices = [
|
||
{"prompt_description": t("Cleanup - Set the age limit")},
|
||
{"prompt_description": t("Cleanup - Set the size ceiling")},
|
||
{"prompt_description": t("Cleanup - Preview now (dry run)")},
|
||
{"prompt_description": t("Cleanup - Run now")},
|
||
]
|
||
help_info = self.fill_help_info(choices)
|
||
while True:
|
||
self._cache_nettoyage_etat()
|
||
status = click.prompt(help_info)
|
||
print()
|
||
if status == "0":
|
||
return False
|
||
elif status == "1":
|
||
self._cache_nettoyage_regler(
|
||
"EL_PURGE_AGE",
|
||
t("Not served since (e.g. 90j), empty to disable"),
|
||
reglage_age_valide,
|
||
)
|
||
elif status == "2":
|
||
self._cache_nettoyage_regler(
|
||
"EL_MAX_SIZE",
|
||
t("Size ceiling (e.g. 50G), empty to disable"),
|
||
reglage_taille_valide,
|
||
)
|
||
elif status == "3":
|
||
self._cache_nettoyage_lancer(a_blanc=True)
|
||
elif status == "4":
|
||
self._cache_nettoyage_lancer(a_blanc=False)
|
||
else:
|
||
print(t("Command not found !"))
|
||
|
||
def _cache_nettoyage_etat(self):
|
||
"""Les deux réglages lus dans le fichier du service, et le minuteur."""
|
||
age = cache_offline.reglage("EL_PURGE_AGE", CACHE_CONF)
|
||
taille = cache_offline.reglage("EL_MAX_SIZE", CACHE_CONF)
|
||
print(f" · {t('Age limit:')} {age or t('disabled')}")
|
||
print(f" · {t('Size ceiling:')} {taille or t('disabled')}")
|
||
etat = self._cache_lire(f"systemctl is-enabled {CACHE_MINUTEUR_PURGE}")
|
||
etat = etat.strip().splitlines()[0] if etat.strip() else ""
|
||
if etat in ("enabled", "static"):
|
||
print(f" · {t('Daily timer:')} {etat}")
|
||
else:
|
||
# Un cache posé avant que l'installateur ne pose le minuteur.
|
||
print(
|
||
f" ✗ {t('Timer absent: reinstall the cache from entry 1.')}"
|
||
)
|
||
print()
|
||
|
||
def _cache_nettoyage_regler(self, cle, invite, valide):
|
||
"""Demande une valeur, la vérifie, montre la commande et l'écrit après
|
||
confirmation. Une valeur vide désactive le réglage."""
|
||
valeur = click.prompt(invite, default="", show_default=False).strip()
|
||
if valeur and not valide(valeur):
|
||
print(f" ✗ {t('Unreadable value:')} {valeur}\n")
|
||
return
|
||
cmd = commande_ecrire_reglage(cle, valeur)
|
||
print(f"{t('Will execute:')} {cmd}")
|
||
if not click.confirm(t("Write this setting?")):
|
||
return
|
||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||
|
||
def _cache_nettoyage_lancer(self, a_blanc):
|
||
"""À blanc : ce que chaque réglage effacerait, lu sous le compte du
|
||
service. Sinon : l'unité du minuteur, lancée tout de suite."""
|
||
age = cache_offline.reglage("EL_PURGE_AGE", CACHE_CONF)
|
||
taille = cache_offline.reglage("EL_MAX_SIZE", CACHE_CONF)
|
||
if not age and not taille:
|
||
print(f" {t('Nothing to do: both settings are empty.')}\n")
|
||
return
|
||
if not a_blanc:
|
||
cmd = (
|
||
f"sudo systemctl start {CACHE_SERVICE_PURGE}"
|
||
f" && sudo journalctl -u {CACHE_SERVICE_PURGE} -n 20 --no-pager"
|
||
)
|
||
print(f"{t('Will execute:')} {cmd}\n")
|
||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||
return
|
||
for option, valeur in (
|
||
("--purge-older-than", age),
|
||
("--purge-to-size", taille),
|
||
):
|
||
if not valeur:
|
||
continue
|
||
cmd = (
|
||
f"sudo -u {cache_offline.SERVICE_USER} {CACHE_BIN}"
|
||
f" --cache-dir {CACHE_DIR} --git-mirror-dir {CACHE_MIROIR_GIT}"
|
||
f" {option} {shlex.quote(valeur)} --dry-run {option_langue()}"
|
||
)
|
||
print(f"{t('Will execute:')} {cmd}\n")
|
||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||
|
||
|
||
def reglage_age_valide(valeur):
|
||
"""Vrai si le binaire lira ce délai (voir LireDuree) : « 90j », « 12h »,
|
||
« 1h30m ». Un délai nul est refusé : il effacerait tout chaque jour."""
|
||
v = (valeur or "").strip().lower()
|
||
if not re.fullmatch(r"\d+(\.\d+)?[jd]|(\d+(\.\d+)?(ms|h|m|s))+", v):
|
||
return False
|
||
return any(c in "123456789" for c in v)
|
||
|
||
|
||
def reglage_taille_valide(valeur):
|
||
"""Vrai si le binaire lira cette taille (voir LireTaille) : « 50G »,
|
||
« 500M », « 1.5 T ». Une taille nulle est refusée."""
|
||
v = (valeur or "").strip().lower().replace(" ", "")
|
||
m = re.fullmatch(r"(\d+(\.\d+)?)([kmgt])?(io|ib|b|o)?", v)
|
||
return bool(m) and float(m.group(1)) > 0
|
||
|
||
|
||
def commande_ecrire_reglage(cle, valeur, fichier=CACHE_CONF):
|
||
"""La commande qui pose « cle=valeur » dans le fichier de réglages du
|
||
service : la ligne est remplacée si elle existe, ajoutée sinon. Une valeur
|
||
vide la laisse présente et vide, ce qui désactive le réglage. La valeur
|
||
est déjà validée : elle ne porte ni « | » ni guillemet."""
|
||
ligne = f"{cle}={valeur}"
|
||
f = shlex.quote(fichier)
|
||
script = (
|
||
f"if grep -q {shlex.quote('^' + cle + '=')} {f}; then"
|
||
f" sed -i {shlex.quote('s|^' + cle + '=.*|' + ligne + '|')} {f};"
|
||
f" else echo {shlex.quote(ligne)} >> {f}; fi"
|
||
)
|
||
return f"sudo sh -c {shlex.quote(script)}"
|
||
|
||
|
||
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
|