Sur une installation complète, les index pip, les métadonnées npm et le bundle de repo pesaient environ 110 Mo par VM, repris en entier à chaque fois. Un index déjà gardé part désormais avec son ETag : l'amont juge chaque requête, et un « 304 » sert le corps depuis le disque. Sous « Vary: Accept », chaque représentation est rangée à part, faute de quoi deux clients qui alternent les retéléchargent tour à tour. Les métadonnées PEP 658 de pip, nommées « .whl.metadata », sont figées comme l'archive qu'elles décrivent. --- EN --- On a full install the pip indexes, npm metadata and repo bundle weighed about 110 MB per VM, taken whole every time. An index already kept now leaves with its ETag: upstream judges every request, and a « 304 » serves the body from disk. Under « Vary: Accept » each representation is stored apart, without which two clients alternating between them re-download each in turn. The PEP 658 metadata of pip, named « .whl.metadata », are as immutable as the archive they describe. Assisted-by: Claude Opus 5
1299 lines
50 KiB
Python
1299 lines
50 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
"""Couper l'internet d'un déploiement hors ligne, et le rendre.
|
|
|
|
La coupure ferme trois sorties et laisse l'hôte joignable :
|
|
- l'amont du SEUL service du cache, qui ne peut plus rien tirer de
|
|
l'internet : ce qu'il sert encore vient de son disque ;
|
|
- la sortie DIRECTE des VM, celle que l'hôte relaie : ping, autres ports,
|
|
UDP, IPv6. Un pas d'installation qui prendrait un autre chemin que le
|
|
cache — un clone par ssh, un dépôt sur un port à lui, du QUIC — échoue
|
|
donc, au lieu de réussir en ligne sans que rien ne le montre ;
|
|
- la résolution des noms par l'internet : un résolveur à part, sur l'hôte,
|
|
répond à TOUT nom une adresse fictive. Le détournement du cache vise un
|
|
PORT et non une adresse, et le cache reconnaît le site à son nom (SNI,
|
|
en-tête Host) : la VM joint donc encore ce que le cache détient, et rien
|
|
d'autre. Aucune requête DNS d'une VM ne quitte l'hôte.
|
|
|
|
Reste ce qu'une VM demande à l'HÔTE lui-même : son 80 et son 443, détournés
|
|
vers le cache avant tout routage ; les noms, auxquels l'hôte répond seul ;
|
|
la session ssh de l'opérateur ; le trafic entre VM du même pont.
|
|
|
|
La coupure du cache porte sur son COMPTE, jamais sur le port. Une règle
|
|
générale sur le 443 de l'orchestrateur emporterait la session ssh depuis
|
|
laquelle le déploiement est lancé, et la machine se couperait au milieu de la
|
|
commande qui la configure.
|
|
|
|
Le module REND le texte des règles et l'applique à part : un test vérifie
|
|
alors les règles au caractère près sans toucher au pare-feu de la machine qui
|
|
exécute les tests.
|
|
"""
|
|
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
|
|
# Le compte sous lequel le service tourne. Le script d'installation porte la
|
|
# même valeur — il est en shell et ne peut pas la lire ici ; un test compare
|
|
# les deux, la dérive entre deux copies étant le seul risque de cette
|
|
# duplication.
|
|
SERVICE_USER = "elqcache"
|
|
|
|
# Une table à nous, effaçable d'un geste. Distincte de celle du détournement :
|
|
# la coupure est temporaire et la redirection ne l'est pas, les mêler ferait
|
|
# tomber le détournement de tout le pont en rebranchant l'amont.
|
|
TABLE = "erplibre_qemu_cache_offline"
|
|
|
|
# Le pont des VM quand le service n'en nomme aucun : celui que libvirt donne
|
|
# à son réseau par défaut.
|
|
PONT_PAR_DEFAUT = "virbr0"
|
|
|
|
# Un nom d'interface tel que le noyau les accepte. Il entre tel quel dans les
|
|
# règles : une apostrophe ou un guillemet y casserait le jeu entier, et un nom
|
|
# faux donnerait une règle qui ne viserait rien — des VM restées en ligne
|
|
# sous une coupure annoncée.
|
|
_NOM_DE_PONT = re.compile(r"[A-Za-z0-9_.:-]{1,15}")
|
|
|
|
# Le résolveur fictif. Toute question reçoit la même adresse, que le
|
|
# détournement conduit au cache quelle qu'elle soit : 192.0.2.1 est réservée à
|
|
# la documentation (RFC 5737) et jamais routée. L'AAAA répond 100::1, du
|
|
# préfixe réservé au rejet (RFC 6666) : sans réponse, dnsmasq REFUSE la
|
|
# question, et un résolveur qui reçoit un refus peut écarter le serveur. Sans
|
|
# route IPv6, le client repasse aussitôt en IPv4 ; avec une route, la chaîne
|
|
# « transit » jette ce trafic.
|
|
ADRESSE_FICTIVE_V4 = "192.0.2.1"
|
|
ADRESSE_FICTIVE_V6 = "100::1"
|
|
|
|
# Hors des ports que tiennent déjà le résolveur de libvirt (53) et le mDNS
|
|
# de l'hôte (5353).
|
|
PORT_DNS = 10053
|
|
|
|
UNITE_DNS = "erplibre-qemu-offline-dns"
|
|
|
|
|
|
def nft_rules(
|
|
user: str = SERVICE_USER,
|
|
table: str = TABLE,
|
|
pont: str = PONT_PAR_DEFAUT,
|
|
refus: bool = True,
|
|
) -> str:
|
|
"""Le jeu de règles à passer à « nft -f - ».
|
|
|
|
Chaîne « sortie » : « meta skuid » filtre sur l'UID du processus
|
|
émetteur, si bien que seul ce que le service du cache envoie tombe ; la
|
|
session ssh et le reste de l'hôte ne sont pas touchés. Les deux ports : le
|
|
cache tire aussi en clair, et ne couper que le 443 laisserait passer tout
|
|
un miroir Debian.
|
|
|
|
Les deux chaînes de filtrage REFUSENT au lieu de jeter. Un paquet jeté
|
|
fait pendre l'établissement jusqu'au délai de celui qui compose : le
|
|
cache paierait ce délai à chaque adresse qu'il ne détient pas, et un
|
|
« apt-get update » en enchaîne des centaines. Refusé, l'établissement
|
|
échoue sur-le-champ, et le cache rend son 504 — ou sa copie — aussitôt.
|
|
« refus=False » rend la variante qui jette : un noyau mis à jour sans
|
|
redémarrage n'a plus ses modules sur le disque, et ne peut pas charger
|
|
celui du refus. `cut_cmd` la pose alors en repli.
|
|
|
|
Chaîne « transit » : elle refuse ce que le pont relaie vers une AUTRE
|
|
interface, c'est-à-dire la sortie directe des VM. Ce qui vise l'hôte — le
|
|
80 et le 443 détournés vers le cache avant le routage, le résolveur, la
|
|
session ssh — passe par « input » et n'est pas touché ; le trafic entre
|
|
VM ne quitte pas le pont. Une chaîne à soi suffit : un « accept » de
|
|
libvirt dans SA table n'empêche pas ce refus de jouer, un paquet
|
|
traversant toutes les chaînes de base de son point d'accroche.
|
|
|
|
Chaîne « noms » : le DNS des VM — UDP et TCP 53, quelle que soit la
|
|
destination, un résolveur public compris — est détourné vers le
|
|
résolveur fictif de `dns_cmd`. Elle ne touche ni le 80 ni le 443, que la
|
|
table du cache détourne de son côté.
|
|
|
|
Lève ValueError sur un nom de pont qu'une interface ne peut pas porter.
|
|
"""
|
|
if not _NOM_DE_PONT.fullmatch(pont or ""):
|
|
raise ValueError(f"nom de pont refusé : {pont!r}")
|
|
sortie, transit = (
|
|
("reject with tcp reset", "reject") if refus else ("drop", "drop")
|
|
)
|
|
return (
|
|
f"table inet {table} {{\n"
|
|
f" chain sortie {{\n"
|
|
f" type filter hook output priority 0; policy accept;\n"
|
|
f" meta skuid {user} tcp dport {{ 80, 443 }} {sortie}\n"
|
|
f" }}\n"
|
|
f" chain noms {{\n"
|
|
f" type nat hook prerouting priority dstnat; policy accept;\n"
|
|
f' iifname "{pont}" udp dport 53 redirect to :{PORT_DNS}\n'
|
|
f' iifname "{pont}" tcp dport 53 redirect to :{PORT_DNS}\n'
|
|
f" }}\n"
|
|
f" chain transit {{\n"
|
|
f" type filter hook forward priority 0; policy accept;\n"
|
|
f' iifname "{pont}" oifname != "{pont}" {transit}\n'
|
|
f" }}\n"
|
|
f"}}\n"
|
|
)
|
|
|
|
|
|
def pont_des_vm() -> str:
|
|
"""Le pont que le service du cache détourne, lu dans ses réglages."""
|
|
return reglage("EL_BRIDGE") or PONT_PAR_DEFAUT
|
|
|
|
|
|
def cut_cmd(
|
|
user: str = SERVICE_USER, table: str = TABLE, pont: str = "", dns: str = ""
|
|
) -> str:
|
|
"""La commande qui pose la coupure : la table, PUIS le résolveur fictif.
|
|
|
|
Tout ou rien. Si le résolveur ne part pas, la table est retirée aussitôt
|
|
et la commande échoue : sans lui, le port 53 des VM mènerait à un port
|
|
muet, et l'installation échouerait sur la résolution des noms — une
|
|
raison qui n'est pas celle qu'on mesure. Sans dnsmasq sur l'hôte, la
|
|
commande échoue d'emblée, pour la même raison.
|
|
|
|
Le jeu qui refuse est tenté d'abord, celui qui jette en repli, dans le
|
|
même sudo : nft pose un jeu entier ou rien, si bien qu'un refus que le
|
|
noyau ne sait pas charger ne laisse aucune demi-coupure derrière lui.
|
|
"""
|
|
pont = pont or pont_des_vm()
|
|
regles = nft_rules(user, table, pont)
|
|
repli = nft_rules(user, table, pont, refus=False)
|
|
dns = dns or dns_cmd(pont)
|
|
if not dns:
|
|
return "false"
|
|
pose = "sudo sh -c " + shlex.quote(
|
|
f"printf %s {shlex.quote(regles)} | nft -f - 2>/dev/null"
|
|
f" || printf %s {shlex.quote(repli)} | nft -f -"
|
|
)
|
|
return f"{pose} && {{ {dns} || {{ {restore_cmd(table)}; false; }}; }}"
|
|
|
|
|
|
def dnsmasq() -> str:
|
|
"""Le chemin de dnsmasq sur l'hôte, ou "" : la coupure des noms en
|
|
dépend, et le déploiement le vérifie avant de rien poser."""
|
|
return shutil.which("dnsmasq") or ""
|
|
|
|
|
|
def dns_cmd(
|
|
pont: str = PONT_PAR_DEFAUT,
|
|
binaire: str = "",
|
|
duree: int = 0,
|
|
unite: str = UNITE_DNS,
|
|
) -> str:
|
|
"""La commande qui fait répondre l'hôte à tout nom, le temps de la coupure.
|
|
|
|
Un dnsmasq à part, et non celui de libvirt, auquel on ne touche pas : il
|
|
reprend son rôle dès que la redirection du port 53 tombe avec la table.
|
|
Le nôtre écoute le seul pont, sur PORT_DNS, sans fichier de réglages,
|
|
sans amont (« --no-resolv »), sans /etc/hosts, avec un TTL nul : aucune
|
|
réponse fictive ne survit dans une VM à la levée. Il tourne en unité
|
|
transitoire bornée par RuntimeMaxSec, au-delà de celle du guet.
|
|
|
|
Rend "" quand dnsmasq est introuvable.
|
|
"""
|
|
binaire = binaire or dnsmasq()
|
|
if not binaire:
|
|
return ""
|
|
if not _NOM_DE_PONT.fullmatch(pont or ""):
|
|
raise ValueError(f"nom de pont refusé : {pont!r}")
|
|
parties = [
|
|
"sudo",
|
|
"systemd-run",
|
|
f"--unit={unite}",
|
|
"--collect",
|
|
"--description=ERPLibre QEMU offline DNS",
|
|
"-p",
|
|
f"RuntimeMaxSec={duree or DUREE_MAX_GUET + 3600}",
|
|
binaire,
|
|
"--keep-in-foreground",
|
|
"--conf-file=/dev/null",
|
|
"--no-resolv",
|
|
"--no-hosts",
|
|
"--no-poll",
|
|
f"--interface={pont}",
|
|
# dnsmasq ajoute la boucle locale à toute liste « --interface » :
|
|
# l'en retirer le cantonne au seul pont.
|
|
"--except-interface=lo",
|
|
"--bind-interfaces",
|
|
f"--port={PORT_DNS}",
|
|
f"--address=/#/{ADRESSE_FICTIVE_V4}",
|
|
f"--address=/#/{ADRESSE_FICTIVE_V6}",
|
|
"--local-ttl=0",
|
|
"--pid-file=",
|
|
"--log-facility=-",
|
|
]
|
|
return " ".join(shlex.quote(x) for x in parties)
|
|
|
|
|
|
# Le témoin que la levée touche pour annuler la mémoire des amonts muets du
|
|
# service. Son nom est tenu en accord avec « SentinelleAmonts » du code Go par
|
|
# un test : toucher un fichier que rien ne lit ne réveillerait personne.
|
|
SENTINELLE_AMONTS = ".amonts-oublies"
|
|
# Le magasin, quand les réglages posés ne se lisent pas. Le service et la
|
|
# levée doivent désigner le MÊME fichier, et la levée tourne souvent sans
|
|
# l'environnement de l'unité.
|
|
CACHE_DIR_DEFAUT = "/var/cache/erplibre_go_qemu_cache"
|
|
|
|
|
|
def sentinelle_amonts(cache_dir: str = "") -> str:
|
|
"""Le chemin du témoin, sous le magasin.
|
|
|
|
Le réglage posé prime : un magasin déplacé emporte son témoin, sans quoi
|
|
la levée toucherait un fichier hors du répertoire que le service lit.
|
|
"""
|
|
import os
|
|
|
|
racine = cache_dir or reglage("EL_CACHE_DIR") or CACHE_DIR_DEFAUT
|
|
return os.path.join(racine, SENTINELLE_AMONTS)
|
|
|
|
|
|
def restore_cmd(table: str = TABLE) -> str:
|
|
"""La commande qui la retire, muette si elle n'était pas là.
|
|
|
|
Toujours vraie : le rebranchement se fait dans un « finally », et une
|
|
coupure déjà retirée ne doit pas y lever d'erreur qui masquerait celle
|
|
d'origine.
|
|
"""
|
|
return f"sudo sh -c {shlex.quote(_retrait(table))}"
|
|
|
|
|
|
def _retrait(table: str = TABLE) -> str:
|
|
"""Le retrait nu, sans sudo : `restore_cmd` le lance sous « sudo sh -c »,
|
|
le guet le lance déjà en root.
|
|
|
|
Il retire la table, arrête le résolveur fictif, ET touche le témoin des
|
|
amonts muets. Arrêter le résolveur seul laisserait le port 53 des VM
|
|
détourné vers un port muet ; retirer la table seule laisserait tourner un
|
|
processus que plus rien n'interroge ; et sans le témoin, les amonts notés
|
|
muets pendant la coupure le resteraient jusqu'à la fin de leur fenêtre,
|
|
la première requête d'après la levée tombant dans le repli alors que le
|
|
réseau est revenu. Aucun canal n'existe vers le service en marche.
|
|
"""
|
|
return (
|
|
f"nft delete table inet {table} 2>/dev/null || true; "
|
|
f"systemctl stop {UNITE_DNS} 2>/dev/null || true; "
|
|
f"touch {shlex.quote(sentinelle_amonts())} 2>/dev/null || true"
|
|
)
|
|
|
|
|
|
# Le guet : une unité systemd transitoire, posée par root au lancement des
|
|
# installations, qui attend que chaque journal porte son marqueur de sortie
|
|
# puis lève la coupure. Les installations tournent détachées et survivent au
|
|
# tableau de bord : lever la coupure à sa fermeture les ferait finir en ligne
|
|
# sans que rien ne le dise, et l'y garder la ferait durer tant que l'écran
|
|
# reste ouvert. Un processus Python ne tient pas ce rôle : SIGKILL, une
|
|
# session ssh perdue ou une panne l'emportent sans dérouler aucun « finally ».
|
|
UNITE_GUET = "erplibre-qemu-offline-lift"
|
|
|
|
# Durée maximale du guet, en secondes. Au-delà, systemd l'arrête et la levée
|
|
# (ExecStopPost) court quand même : une installation dont le marqueur ne vient
|
|
# jamais — session ssh pendue, VM supprimée — ne prive pas le cache d'amont
|
|
# indéfiniment.
|
|
DUREE_MAX_GUET = 12 * 3600
|
|
|
|
# Période de relecture des journaux. Le marqueur est la dernière ligne écrite ;
|
|
# elle borne le retard de la levée sur la fin réelle.
|
|
PAS_GUET = 10
|
|
|
|
|
|
# systemd réécrit « ${VAR} », un « $VAR » pris comme argument entier et
|
|
# « $$ » dans les arguments qu'il exécute, et les spécificateurs « % » dans
|
|
# les valeurs passées par « -p ». Un chemin de journal qui en porte serait
|
|
# réécrit : le guet ne le trouverait jamais, et la coupure tiendrait jusqu'à
|
|
# RuntimeMaxSec au lieu de tomber avec la dernière installation.
|
|
CARACTERES_REECRITS = ("$", "%")
|
|
|
|
|
|
def chemins_surs(journaux) -> bool:
|
|
"""Vrai si aucun chemin ne porte un caractère que systemd réécrirait.
|
|
|
|
Refuser vaut mieux qu'échapper : « $$ » n'est juste que si systemd
|
|
substitue réellement, ce qui dépend de sa version et de la voie par
|
|
laquelle la commande lui arrive. Un refus ramène la levée au « finally »,
|
|
dont le comportement est connu.
|
|
"""
|
|
return all(
|
|
not any(c in str(j) for c in CARACTERES_REECRITS) for j in journaux
|
|
)
|
|
|
|
|
|
def script_attente(marqueur: str, pas: int = PAS_GUET) -> str:
|
|
"""Le script du guet : il rend 0 quand CHAQUE journal passé en argument
|
|
porte `marqueur` dans ses derniers octets, et repasse toutes les `pas`
|
|
secondes sinon.
|
|
|
|
Un journal ABSENT compte pour non fini. Le guet part juste après le
|
|
lancement des installations, et leurs journaux peuvent ne pas exister
|
|
encore : les compter pour finis lèverait la coupure sur-le-champ, et
|
|
l'installation se terminerait en ligne sans que rien ne le dise. Un
|
|
journal qui ne viendra jamais — son répertoire effacé — tient donc la
|
|
coupure jusqu'à RuntimeMaxSec, et la levée immédiate est affichée.
|
|
|
|
Sans pipefail, « tail | grep » rend le statut de grep : un fichier absent
|
|
ne livre rien, et le marqueur n'y est pas trouvé.
|
|
"""
|
|
return (
|
|
"while :; do fini=1; "
|
|
'for f in "$@"; do '
|
|
f'tail -c 4096 "$f" 2>/dev/null | grep -qF {shlex.quote(marqueur)}'
|
|
" || fini=0; "
|
|
'done; [ "$fini" = 1 ] && exit 0; '
|
|
f"sleep {int(pas)}; done"
|
|
)
|
|
|
|
|
|
def guet_cmd(
|
|
journaux,
|
|
marqueur: str,
|
|
duree: int = DUREE_MAX_GUET,
|
|
table: str = TABLE,
|
|
unite: str = UNITE_GUET,
|
|
) -> str:
|
|
"""La commande qui confie la levée à root, par une unité transitoire.
|
|
|
|
`journaux` : chemins des journaux d'installation, un par VM ; `marqueur` :
|
|
ce que l'enveloppe détachée écrit en dernière ligne à la fin de chacune.
|
|
Les chemins voyagent en ARGUMENTS du shell (« sh -c script sh j1 j2 … »)
|
|
et non dans le script : un espace ou une apostrophe dans un chemin ne
|
|
peut rien y casser.
|
|
|
|
ExecStopPost court à la fin normale, au dépassement de RuntimeMaxSec et à
|
|
un « systemctl stop » : la coupure tombe dans les trois cas. « --collect »
|
|
efface l'unité même en échec, sans quoi son nom resterait pris.
|
|
|
|
Le script d'attente vient de `script_attente` : un journal absent y
|
|
compte pour NON fini.
|
|
|
|
systemd remplace « ${VAR} » et « $$ » dans les arguments qu'il exécute : le
|
|
script n'en contient aucun, seules les formes « $f » et « "$@" », qu'il
|
|
laisse intactes, y figurent.
|
|
"""
|
|
attente = script_attente(marqueur)
|
|
parties = [
|
|
"sudo",
|
|
"systemd-run",
|
|
f"--unit={unite}",
|
|
"--collect",
|
|
"--description=ERPLibre QEMU offline cut lift",
|
|
"-p",
|
|
f"RuntimeMaxSec={duree}",
|
|
"-p",
|
|
f'ExecStopPost=/bin/sh -c "{_retrait(table)}"',
|
|
"/bin/sh",
|
|
"-c",
|
|
attente,
|
|
"sh",
|
|
*journaux,
|
|
]
|
|
return " ".join(shlex.quote(p) for p in parties)
|
|
|
|
|
|
def guet_actif_cmd(unite: str = UNITE_GUET) -> str:
|
|
"""Rend 0 tant que le guet tourne. Sans sudo : lire l'état d'une unité
|
|
est ouvert à tout compte."""
|
|
return f"systemctl is-active --quiet {shlex.quote(unite)}"
|
|
|
|
|
|
def lever_maintenant_cmd(unite: str = UNITE_GUET) -> str:
|
|
"""La levée immédiate quand le guet tourne : l'arrêter fait courir son
|
|
ExecStopPost, qui retire la table. Retirer la table seule laisserait le
|
|
guet attendre pour rien."""
|
|
return f"sudo systemctl stop {shlex.quote(unite)}"
|
|
|
|
|
|
def table_posee_cmd(table: str = TABLE) -> str:
|
|
"""Rend 0 si la coupure est posée, 1 si elle ne l'est pas, 2 si on ne
|
|
peut pas le savoir.
|
|
|
|
Le troisième cas est la raison d'être de la commande : « ! nft list
|
|
table » conclurait à l'absence dès que sudo refuse ou que nft manque, et
|
|
un rebranchement raté se lirait comme réussi. La table est cherchée
|
|
ligne entière dans « nft list tables » : un nom qui la prolongerait ne
|
|
compte pas.
|
|
"""
|
|
ligne = shlex.quote(f"table inet {table}")
|
|
return (
|
|
"(l=$(sudo nft list tables) || exit 2; "
|
|
f"printf '%s\\n' \"$l\" | grep -qxF {ligne} && exit 0; exit 1)"
|
|
)
|
|
|
|
|
|
# Là où le service écrit ses réglages. Le journal d'accès y est nommé : le
|
|
# chercher là plutôt que de recopier son chemin, qui est réglable.
|
|
CONF = "/etc/erplibre_go_qemu_cache/env"
|
|
|
|
# Les issues qui prouvent qu'un CORPS est en réserve. « revalidated » en est :
|
|
# l'amont a confirmé la copie gardée, et c'est elle qui est servie. « fetched »
|
|
# n'en est pas : elle couvre aussi le « 304 » rendu à un client qui posait sa
|
|
# propre condition, sans corps.
|
|
# « stored-status » et « stale-status » n'en sont pas davantage : elles
|
|
# portent un objet de STATUT seul — une redirection ou un refus gardés, sans
|
|
# corps — et l'index d'une suite gardé en 404 ne la rend pas lisible.
|
|
ISSUES_EN_RESERVE = ("stored", "hit", "stale", "revalidated")
|
|
|
|
|
|
def journal(conf: str = CONF) -> str:
|
|
"""Chemin du journal d'accès, ou "" s'il n'est pas lisible."""
|
|
try:
|
|
with open(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 ""
|
|
|
|
|
|
def jeton_de_suite(distro: str, version: str) -> str:
|
|
"""Ce qui, dans une URL, identifie la SUITE de dépôt d'un système.
|
|
|
|
Rend "" quand aucun jeton ne l'identifie de façon sûre : mieux vaut ne
|
|
pas se prononcer que rassurer à tort. Un dépôt Fedora et un dépôt openSUSE
|
|
se ressemblent dans l'URL — leur « /repodata/ » ne dit pas quelle version
|
|
il sert — et un cache rempli pour une version passerait pour rempli pour
|
|
toutes.
|
|
|
|
Les familles apt, elles, portent le nom de code dans « /dists/<nom>/ »,
|
|
et il est unique. Le nom vient du CATALOGUE du déploiement, jamais d'une
|
|
table recopiée ici.
|
|
"""
|
|
from script.qemu.deploy_qemu import DISTROS, cache_family
|
|
|
|
if cache_family(distro) != "apt":
|
|
return ""
|
|
versions = (DISTROS.get(distro) or [{}])[0]
|
|
entree = versions.get(version) or []
|
|
return f"/dists/{entree[0]}/" if entree else ""
|
|
|
|
|
|
def detient_la_suite(distro: str, version: str, chemin: str = "") -> bool:
|
|
"""Le cache a-t-il un corps en réserve pour cette suite de dépôt ?
|
|
|
|
Le journal d'accès EST la mesure : il porte l'URL et l'issue de chaque
|
|
requête, et une issue de la liste ci-dessus prouve qu'un corps existe.
|
|
|
|
Rend True dès qu'on ne sait pas juger — jeton inconnu, journal illisible :
|
|
un avertissement qui se déclenche sans savoir apprend à passer outre, et
|
|
c'est alors celui qui compte qu'on ne lit plus.
|
|
"""
|
|
jeton = jeton_de_suite(distro, version)
|
|
if not jeton:
|
|
return True
|
|
chemin = chemin or journal()
|
|
if not chemin:
|
|
return True
|
|
import json
|
|
import os
|
|
|
|
if not os.path.exists(chemin):
|
|
return True
|
|
try:
|
|
with open(chemin, encoding="utf-8", errors="replace") as fh:
|
|
for ligne in fh:
|
|
if jeton not in ligne:
|
|
continue
|
|
try:
|
|
d = json.loads(ligne)
|
|
except ValueError:
|
|
continue
|
|
if d.get("outcome") in ISSUES_EN_RESERVE and jeton in (
|
|
d.get("url") or ""
|
|
):
|
|
return True
|
|
except OSError:
|
|
return True
|
|
return False
|
|
|
|
|
|
def suites_absentes(vms) -> list:
|
|
"""Les (système, version) dont le cache n'a rien, sans doublon.
|
|
|
|
Ce qu'une VM hors ligne ne pourra pas lire : son apt échouera sur l'index
|
|
de sa suite de base, et aucun paquet de cette suite ne sera trouvable.
|
|
"""
|
|
vus, manquantes = set(), []
|
|
for vm in vms or ():
|
|
cle = (vm.get("distro") or "", vm.get("version") or "")
|
|
if not cle[0] or cle in vus:
|
|
continue
|
|
vus.add(cle)
|
|
if not detient_la_suite(*cle):
|
|
manquantes.append(cle)
|
|
return manquantes
|
|
|
|
|
|
# Ce qu'un bureau installe vraiment. Les quatre composants, sur la suite de
|
|
# base ET sur ses deux compagnes : « xrdp » vit dans « universe », ses
|
|
# dépendances dans « restricted », et les correctifs dans « -security ». Un
|
|
# cache rempli pour « main » seul laisse apt sans rien de tout cela.
|
|
COMPOSANTS_APT = ("main", "universe", "restricted", "multiverse")
|
|
SUFFIXES_APT = ("", "-updates", "-security")
|
|
|
|
# « /dists/<suite>/<composant>/binary-<arche>/ » : ce que porte l'URL d'un
|
|
# index de paquets, et la seule forme qui dise à la fois la suite, le
|
|
# composant et l'architecture.
|
|
_INDEX_APT = re.compile(r"/dists/([^/]+)/([^/]+)/binary-([^/]+)/")
|
|
|
|
|
|
def composants_absents(vms, chemin: str = "") -> list:
|
|
"""[(système, version, ["suite/composant", …])] — ce dont le cache n'a rien.
|
|
|
|
Le verdict par SUITE ne suffit pas : une seule URL en réserve sous
|
|
« /dists/<code>/ » le rend muet, alors que « restricted » ou
|
|
« -security » peuvent manquer en entier. apt ne le dit qu'à
|
|
l'installation, par « Unable to locate package », un message qui accuse
|
|
le dépôt et jamais le cache — vingt minutes après la coupure.
|
|
|
|
Muet quand on ne sait pas juger — famille sans jeton, journal illisible.
|
|
Muet aussi quand le cache n'a RIEN de ce système : `suites_absentes` le
|
|
dit déjà, et deux avertissements pour une cause apprennent à les
|
|
enchaîner.
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
chemin = chemin or journal()
|
|
if not chemin or not os.path.exists(chemin):
|
|
return []
|
|
vus = set()
|
|
try:
|
|
with open(chemin, encoding="utf-8", errors="replace") as fh:
|
|
for ligne in fh:
|
|
if "/dists/" not in ligne:
|
|
continue
|
|
try:
|
|
d = json.loads(ligne)
|
|
except ValueError:
|
|
continue
|
|
if d.get("outcome") not in ISSUES_EN_RESERVE:
|
|
continue
|
|
trouve = _INDEX_APT.search(d.get("url") or "")
|
|
if trouve:
|
|
vus.add(trouve.groups())
|
|
except OSError:
|
|
return []
|
|
|
|
deja, sortie = set(), []
|
|
for vm in vms or ():
|
|
distro = vm.get("distro") or ""
|
|
version = vm.get("version") or ""
|
|
arche = vm.get("arch") or "amd64"
|
|
cle = (distro, version, arche)
|
|
if not distro or cle in deja:
|
|
continue
|
|
deja.add(cle)
|
|
jeton = jeton_de_suite(distro, version)
|
|
if not jeton:
|
|
continue
|
|
code = jeton.strip("/").split("/")[-1]
|
|
attendus = [
|
|
(code + suffixe, composant)
|
|
for suffixe in SUFFIXES_APT
|
|
for composant in COMPOSANTS_APT
|
|
]
|
|
manque = [
|
|
f"{suite}/{composant}"
|
|
for suite, composant in attendus
|
|
if (suite, composant, arche) not in vus
|
|
]
|
|
# Tout manque : le cache ignore ce système, et `suites_absentes` le
|
|
# dira mieux.
|
|
if manque and len(manque) < len(attendus):
|
|
sortie.append((distro, version, manque))
|
|
return sortie
|
|
|
|
|
|
# Les paquets que le déploiement pose HORS du fil observé : leur pose part en
|
|
# unité détachée pour que cloud-init rende la main en quelques secondes, si
|
|
# bien qu'un échec ne remonte nulle part — ni au suivi, ni au journal des
|
|
# manques, qui ne connaît que ce qu'une coupure a déjà fait rater. Le pré-vol
|
|
# est donc le seul endroit où leur absence peut encore se dire À TEMPS.
|
|
PAQUETS_HORS_SUIVI = ("qemu-guest-agent",)
|
|
|
|
|
|
def paquets_absents(vms, noms=(), chemin: str = "") -> list:
|
|
"""Les paquets nommés dont le magasin n'a rien, ou [].
|
|
|
|
Le verdict par index ne descend jamais au FICHIER : un cache qui détient
|
|
l'index d'une suite passe pour complet alors qu'aucun octet du paquet
|
|
lui-même n'a jamais traversé. Hors ligne, l'installation échoue vingt
|
|
minutes plus tard sur « Unable to locate package », un message qui accuse
|
|
le dépôt et jamais le cache.
|
|
|
|
Jugé sur le journal, comme les autres verdicts : une URL en réserve qui
|
|
porte le nom du paquet suffit. Le nom d'un fichier de paquet porte sa
|
|
version et son architecture, jamais son miroir, si bien que la recherche
|
|
vaut quel que soit le miroir qui l'a servi.
|
|
|
|
Muet sans VM et quand le journal ne se lit pas : accuser un cache qu'on
|
|
ne peut pas interroger ferait taire l'avertissement le jour où il compte.
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
noms = tuple(noms) or PAQUETS_HORS_SUIVI
|
|
chemin = chemin or journal()
|
|
if not (vms or ()) or not chemin or not os.path.exists(chemin):
|
|
return []
|
|
trouves = set()
|
|
try:
|
|
with open(chemin, encoding="utf-8", errors="replace") as fh:
|
|
for ligne in fh:
|
|
restants = [n for n in noms if n not in trouves]
|
|
if not restants:
|
|
break
|
|
# Le test de chaîne AVANT le décodage : un journal de
|
|
# déploiement porte des dizaines de milliers de lignes, et
|
|
# les décoder toutes coûterait plus que le verdict ne vaut.
|
|
for nom in restants:
|
|
if nom not in ligne:
|
|
continue
|
|
try:
|
|
d = json.loads(ligne)
|
|
except ValueError:
|
|
continue
|
|
if d.get("outcome") in ISSUES_EN_RESERVE and nom in (
|
|
d.get("url") or ""
|
|
):
|
|
trouves.add(nom)
|
|
except OSError:
|
|
return []
|
|
return [nom for nom in noms if nom not in trouves]
|
|
|
|
|
|
def miroirs_absents(depots, racine: str = "") -> list:
|
|
"""Les dépôts git déclarés qui n'ont pas encore de miroir, ou [].
|
|
|
|
Une négociation git ne se garde pas : le cache tient un dépôt NU par
|
|
amont et le sert localement. Un dépôt jamais mirroré n'a donc rien à
|
|
servir une fois le réseau coupé, et le clone échoue — sans que rien ne
|
|
l'ait annoncé, le verdict par index ne parlant que d'apt et le journal
|
|
des manques ne connaissant que ce qu'une coupure a déjà fait rater.
|
|
|
|
Le chemin d'un miroir est « <hôte>/<chemin>.git » sous la racine : la
|
|
même règle que celle qui les pose, l'hôte en faisant partie parce que
|
|
deux forges peuvent servir le même chemin.
|
|
|
|
Muet quand la racine ne se lit pas : un magasin absent n'est pas un
|
|
miroir manquant, et deux causes sous un seul message font cesser de lire.
|
|
"""
|
|
import os
|
|
from urllib.parse import urlsplit
|
|
|
|
racine = racine or os.path.join(
|
|
reglage("EL_CACHE_DIR") or CACHE_DIR_DEFAUT, "git"
|
|
)
|
|
if not os.path.isdir(racine):
|
|
return []
|
|
manque = []
|
|
for depot in depots or ():
|
|
morceaux = urlsplit(depot)
|
|
chemin = morceaux.path.strip("/")
|
|
if chemin.endswith(".git"):
|
|
chemin = chemin[: -len(".git")]
|
|
if not morceaux.netloc or not chemin:
|
|
continue
|
|
attendu = os.path.join(racine, morceaux.netloc, chemin + ".git")
|
|
if not os.path.isdir(attendu):
|
|
manque.append(f"{morceaux.netloc}/{chemin}")
|
|
return manque
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ce que les déploiements hors ligne précédents n'ont pas trouvé
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Une ligne « offline-miss » du journal d'accès PROUVE qu'un invité a demandé
|
|
# une adresse que le cache n'avait pas, pendant que l'amont était muet. Jointe
|
|
# à un déploiement — l'adresse de sa VM et sa fenêtre [début, dernière
|
|
# écriture de son log] — elle dit ce que ce déploiement a manqué, donc ce que
|
|
# le suivant manquera encore si rien n'a été rempli entre-temps.
|
|
#
|
|
# Une liste de manques est une BORNE BASSE : l'installation s'arrête au premier
|
|
# manque fatal, et ce qui la suivait n'a jamais été demandé.
|
|
|
|
# Le binaire du service. Le menu du cache porte le même chemin ; un test
|
|
# compare les deux.
|
|
BINAIRE = "/usr/local/bin/erplibre_go_qemu_cache"
|
|
|
|
# Un répertoire par déploiement, écrit par le suivi des installations : un
|
|
# « session.json » et un log par VM.
|
|
RUNS = "~/.erplibre/qemu-install"
|
|
|
|
# Les méthodes que le cache sait garder. Toute autre le traverse sans copie :
|
|
# un POST manqué hors ligne le sera toujours, et le compter dans « au moins
|
|
# N » promettrait un remplissage impossible.
|
|
METHODES_GARDABLES = ("GET", "HEAD")
|
|
|
|
# Les points de négociation git (gitSmartPaths, côté Go) ; un test compare.
|
|
# Le cache ne garde pas ces échanges : c'est le DÉPÔT qu'il tient, dans son
|
|
# miroir, et l'entrée 5 du menu du cache le remplit.
|
|
GIT_NEGOCIATION = ("/info/refs", "/git-upload-pack", "/git-receive-pack")
|
|
|
|
ISSUE_MANQUE = "offline-miss"
|
|
|
|
# Les issues d'un amont muet : servies du disque faute de réponse. Elles
|
|
# naissent d'une coupure, mais aussi EN LIGNE : un amont qui vient de refuser
|
|
# une connexion est tenu pour muet quelques secondes, et les requêtes qui ont
|
|
# de quoi se rabattre sont servies du disque sans l'appeler. Leur présence
|
|
# dans la fenêtre d'une VM ne prouve donc pas qu'elle a tourné coupée ; seul
|
|
# le « offline » du manifeste le dit.
|
|
ISSUES_AMONT_MUET = ("stale", "stale-status", "keep", ISSUE_MANQUE)
|
|
|
|
# Ce qui prouve qu'une RÉPONSE est gardée, plus large que ISSUES_EN_RESERVE :
|
|
# la VM hors ligne qui reçoit la redirection ou le 404 que l'amont avait rendu
|
|
# obtient ce qu'elle aurait eu en ligne.
|
|
ISSUES_REPONSE_GARDEE = ISSUES_EN_RESERVE + ("stored-status", "stale-status")
|
|
|
|
# Les verdicts de « --detient » qui valent détention : un corps, ou un objet
|
|
# de statut seul.
|
|
DETENTION = ("garde", "statut")
|
|
|
|
# Combien de déploiements renseignés d'un même nom sont réunis, et jusqu'à
|
|
# quel âge. Au-delà, branche, catalogue et cache ont changé, et l'avertissement
|
|
# parlerait d'un autre système.
|
|
RUNS_RETENUS = 3
|
|
AGE_MAX = 30 * 86400
|
|
|
|
|
|
def reglage(nom: str, conf: str = CONF) -> str:
|
|
"""Valeur d'un réglage du service (« EL_… »), ou "" s'il est illisible."""
|
|
prefixe = f"{nom}="
|
|
try:
|
|
with open(conf, encoding="utf-8") as fh:
|
|
for ligne in fh:
|
|
if ligne.startswith(prefixe):
|
|
return ligne.split("=", 1)[1].strip()
|
|
except OSError:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def instant(texte):
|
|
"""Secondes depuis l'époque d'une date RFC 3339, ou None.
|
|
|
|
Le journal date en UTC ; une date sans fuseau est lue comme telle.
|
|
"""
|
|
import datetime
|
|
|
|
try:
|
|
d = datetime.datetime.fromisoformat(str(texte).replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
if d.tzinfo is None:
|
|
d = d.replace(tzinfo=datetime.timezone.utc)
|
|
return d.timestamp()
|
|
|
|
|
|
def _horodatage(label: str):
|
|
"""L'instant local que porte le nom d'un répertoire de déploiement."""
|
|
import time
|
|
|
|
try:
|
|
return time.mktime(time.strptime(label, "%Y%m%d-%H%M%S"))
|
|
except (ValueError, OverflowError):
|
|
return None
|
|
|
|
|
|
def lire_runs(racine: str = RUNS) -> list:
|
|
"""Les déploiements passés qui ont installé quelque chose.
|
|
|
|
Rend [{"label", "debut", "hors_ligne", "vms": [{"nom", "ip", "fin",
|
|
"code"}]}].
|
|
|
|
« hors_ligne » est le « offline » du manifeste : True ou False quand il
|
|
le porte, None sinon — un manifeste qui ne le porte pas ne dit pas si
|
|
son déploiement a tourné coupé.
|
|
|
|
« debut » est le « deploy_started » du manifeste quand il le porte :
|
|
l'instant où le déploiement commence, AVANT la création des VM. Le
|
|
premier démarrage d'une VM coupée — pose de l'agent invité, paquets de
|
|
cloud-init — passe par le cache avant le lancement des installations, et
|
|
ses manques tomberaient hors d'une fenêtre ouverte au lancement. À
|
|
défaut, « debut » est le plus tôt de l'horodatage du répertoire et du
|
|
« started », tous deux écrits au lancement des installations.
|
|
|
|
« fin » est la dernière écriture du log de la VM, c'est-à-dire son
|
|
marqueur de sortie ; « code » est ce marqueur, None quand il manque. Un
|
|
déploiement sans branche ne fait que démarrer ses VM, n'installe rien,
|
|
et n'est pas rendu.
|
|
"""
|
|
import glob
|
|
import json
|
|
import os
|
|
|
|
from script.todo.qemu_install_monitor import read_status
|
|
|
|
out = []
|
|
motif = os.path.join(os.path.expanduser(racine), "*", "session.json")
|
|
for manifeste in glob.glob(motif):
|
|
try:
|
|
with open(manifeste, encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
except (OSError, ValueError):
|
|
continue
|
|
if not isinstance(data, dict) or not data.get("branch"):
|
|
continue
|
|
label = os.path.basename(os.path.dirname(manifeste))
|
|
debut = data.get("deploy_started")
|
|
if not isinstance(debut, (int, float)) or isinstance(debut, bool):
|
|
debuts = [
|
|
d
|
|
for d in (data.get("started"), _horodatage(label))
|
|
if isinstance(d, (int, float))
|
|
]
|
|
if not debuts:
|
|
continue
|
|
debut = min(debuts)
|
|
vms = []
|
|
for vm in data.get("vms") or ():
|
|
if not isinstance(vm, dict):
|
|
continue
|
|
log = vm.get("log") or ""
|
|
try:
|
|
fin = os.path.getmtime(log)
|
|
except (OSError, TypeError):
|
|
continue
|
|
vms.append(
|
|
{
|
|
"nom": vm.get("name") or "",
|
|
"ip": vm.get("ip") or "",
|
|
"fin": fin,
|
|
"code": read_status(log)[1],
|
|
}
|
|
)
|
|
hors_ligne = data.get("offline")
|
|
out.append(
|
|
{
|
|
"label": label,
|
|
"debut": debut,
|
|
"hors_ligne": (
|
|
hors_ligne if isinstance(hors_ligne, bool) else None
|
|
),
|
|
"vms": vms,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _lignes(chemin: str, indices) -> list:
|
|
"""Les lignes du journal, décodées, dont le TEXTE contient un des indices.
|
|
|
|
Le filtre sur le texte précède le décodage : le journal n'est pas tourné,
|
|
il compte des dizaines de milliers de lignes, et seule une poignée
|
|
concerne la question posée. Un journal illisible rend une liste vide.
|
|
"""
|
|
import json
|
|
|
|
if not chemin or not indices:
|
|
return []
|
|
out = []
|
|
try:
|
|
with open(chemin, encoding="utf-8", errors="replace") as fh:
|
|
for ligne in fh:
|
|
if not any(i in ligne for i in indices):
|
|
continue
|
|
try:
|
|
d = json.loads(ligne)
|
|
except ValueError:
|
|
continue
|
|
if isinstance(d, dict):
|
|
out.append(d)
|
|
except OSError:
|
|
return []
|
|
return out
|
|
|
|
|
|
def releve_amont_muet(chemin: str, clients) -> list:
|
|
"""Les lignes servies amont muet à ces clients.
|
|
|
|
Rend [{"client", "t", "issue", "methode", "url"}], « t » en secondes.
|
|
"""
|
|
out = []
|
|
for d in _lignes(chemin, [f'"{i}"' for i in ISSUES_AMONT_MUET]):
|
|
if d.get("outcome") not in ISSUES_AMONT_MUET:
|
|
continue
|
|
if d.get("client") not in clients:
|
|
continue
|
|
quand = instant(d.get("time"))
|
|
if quand is None:
|
|
continue
|
|
out.append(
|
|
{
|
|
"client": d["client"],
|
|
"t": quand,
|
|
"issue": d["outcome"],
|
|
"methode": d.get("method") or "",
|
|
"url": d.get("url") or "",
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def bilan_du_nom(
|
|
runs,
|
|
nom: str,
|
|
releve,
|
|
maintenant: float,
|
|
retenus: int = RUNS_RETENUS,
|
|
age_max: float = AGE_MAX,
|
|
):
|
|
"""Ce que les derniers déploiements hors ligne de la VM « nom » ont manqué.
|
|
|
|
Le NOM est la clé : il porte le système, sa version et la saveur du
|
|
bureau, là où le manifeste peut laisser système et version vides. Une
|
|
ligne du relevé appartient à un déploiement quand son client est l'adresse
|
|
de sa VM et que son instant tombe dans la fenêtre de celle-ci : une
|
|
adresse IP se réattribue, et seule la fenêtre dit à qui elle était.
|
|
|
|
Les déploiements sont lus du plus récent au plus ancien :
|
|
· un déploiement qui a RÉUSSI coupé (manifeste « offline » vrai, code
|
|
0, des lignes d'amont muet) clôt la lecture des PLUS ANCIENS — ce
|
|
qui leur manquait a été rempli depuis, ou n'est plus demandé. Ses
|
|
PROPRES manques sont gardés : les blocs d'outils facultatifs
|
|
préviennent et rendent 0, et une installation qui en a manqué
|
|
plusieurs réussit quand même ;
|
|
· une réussite qu'on ne sait pas coupée ne clôt rien, le manifeste
|
|
l'ignorât-il seulement : les lignes d'amont muet naissent aussi en
|
|
ligne, et clore à tort ferait taire des manques réels. Ne pas clore
|
|
coûte peu — la lecture reste bornée par « retenus » et « age_max »,
|
|
et ce que le magasin détient depuis est ôté plus loin ;
|
|
· un déploiement sans manque ne renseigne pas : arrêté avant d'avoir
|
|
rien demandé, sur un verrou apt par exemple, ou mené en ligne ;
|
|
· les autres sont RÉUNIS, jusqu'à « retenus ». Prendre le seul dernier
|
|
rendrait muet l'avertissement dès qu'un déploiement s'arrête tôt.
|
|
|
|
Rend None quand aucun déploiement ne renseigne. Sinon {"nom", "age",
|
|
"runs", "manques"} : « age » est celui du plus récent retenu, « manques »
|
|
associe chaque (méthode, URL) au dernier instant où elle a manqué, le
|
|
déploiement le plus récent d'abord.
|
|
"""
|
|
candidats = sorted(
|
|
(
|
|
(run, vm)
|
|
for run in runs
|
|
for vm in run["vms"]
|
|
if vm["nom"] == nom and vm["ip"]
|
|
),
|
|
key=lambda rv: rv[1]["fin"],
|
|
reverse=True,
|
|
)
|
|
pris = []
|
|
for run, vm in candidats:
|
|
if maintenant - vm["fin"] > age_max:
|
|
break
|
|
dedans = [
|
|
ligne
|
|
for ligne in releve
|
|
if ligne["client"] == vm["ip"]
|
|
and run["debut"] <= ligne["t"] <= vm["fin"]
|
|
]
|
|
manques = [l for l in dedans if l["issue"] == ISSUE_MANQUE]
|
|
if dedans and vm["code"] == 0 and run.get("hors_ligne") is True:
|
|
if manques:
|
|
pris.append((run, vm, manques))
|
|
break
|
|
if not manques:
|
|
continue
|
|
pris.append((run, vm, manques))
|
|
if len(pris) >= retenus:
|
|
break
|
|
if not pris:
|
|
return None
|
|
manques = {}
|
|
for _run, _vm, lignes in pris:
|
|
for ligne in sorted(lignes, key=lambda l: l["t"]):
|
|
cle = (ligne["methode"], ligne["url"])
|
|
manques[cle] = max(manques.get(cle, 0.0), ligne["t"])
|
|
return {
|
|
"nom": nom,
|
|
"age": maintenant - pris[0][1]["fin"],
|
|
"runs": [run["label"] for run, _vm, _l in pris],
|
|
"manques": manques,
|
|
}
|
|
|
|
|
|
def tenus_selon_journal(chemin: str, manques) -> set:
|
|
"""Les (méthode, URL) dont le journal dit la réponse gardée APRÈS le manque.
|
|
|
|
« manques » associe chaque (méthode, URL) à l'instant de son dernier
|
|
manque. Le journal dit « a été gardé », pas « est encore là » : une purge
|
|
efface l'objet et laisse ses lignes. Seule une ligne postérieure au manque
|
|
compte — l'objet est entré, ou a été servi, après — ce qui écarte l'objet
|
|
purgé avant le manque. Une purge postérieure reste invisible, d'où
|
|
l'étiquette « selon le journal » partout où ce verdict est montré.
|
|
"""
|
|
from urllib.parse import urlsplit
|
|
|
|
hotes = set()
|
|
for _methode, url in manques:
|
|
try:
|
|
hote = urlsplit(url).hostname
|
|
except ValueError:
|
|
hote = None
|
|
if hote:
|
|
hotes.add(f"//{hote}")
|
|
tenus = set()
|
|
for d in _lignes(chemin, sorted(hotes)):
|
|
cle = (d.get("method") or "", d.get("url") or "")
|
|
if cle not in manques or d.get("outcome") not in ISSUES_REPONSE_GARDEE:
|
|
continue
|
|
quand = instant(d.get("time"))
|
|
if quand is not None and quand > manques[cle]:
|
|
tenus.add(cle)
|
|
return tenus
|
|
|
|
|
|
def detient_rendu(sortie: str) -> dict:
|
|
"""La sortie de « --detient », par (méthode, URL).
|
|
|
|
Une ligne par question, six champs séparés par des tabulations :
|
|
verdict, statut, date de garde (RFC 3339 ou « - »), classe, méthode, URL.
|
|
Une ligne mal formée est sautée plutôt que devinée.
|
|
"""
|
|
out = {}
|
|
for ligne in (sortie or "").splitlines():
|
|
champs = ligne.split("\t", 5)
|
|
if len(champs) < 6:
|
|
continue
|
|
verdict, statut, garde_le, classe, methode, url = champs
|
|
out[(methode, url)] = {
|
|
"verdict": verdict,
|
|
"statut": statut,
|
|
"garde_le": garde_le,
|
|
"classe": classe,
|
|
}
|
|
return out
|
|
|
|
|
|
def detient_interroger(paires, binaire: str = BINAIRE, cache_dir: str = ""):
|
|
"""Ce que le MAGASIN détient maintenant, par (méthode, URL).
|
|
|
|
Le magasin et non le journal : un objet effacé après son entrée garde ses
|
|
lignes « stored ». Rend None quand le binaire manque, ne connaît pas
|
|
« --detient » — il se sonde dans son aide — ou échoue : l'appelant se
|
|
rabat alors sur le journal, et le DIT. La question ne demande aucun
|
|
privilège, les casiers étant lisibles par tous.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
|
|
paires = list(paires)
|
|
if not paires:
|
|
return {}
|
|
if not os.path.isfile(binaire):
|
|
return None
|
|
try:
|
|
aide = subprocess.run(
|
|
[binaire, "--help"], capture_output=True, text=True, timeout=15
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if "-detient" not in (aide.stdout or "") + (aide.stderr or ""):
|
|
return None
|
|
argv = [binaire]
|
|
cache_dir = cache_dir or reglage("EL_CACHE_DIR")
|
|
if cache_dir:
|
|
argv += ["--cache-dir", cache_dir]
|
|
argv.append("--detient")
|
|
try:
|
|
p = subprocess.run(
|
|
argv,
|
|
input="".join(f"{m} {u}\n" for m, u in paires),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if p.returncode != 0:
|
|
return None
|
|
return detient_rendu(p.stdout)
|
|
|
|
|
|
def depot_git(url: str) -> str:
|
|
"""L'adresse du dépôt dont `url` est une négociation git, ou "".
|
|
|
|
Le suffixe de négociation est retiré du chemin, et la requête avec lui :
|
|
« …/d.git/info/refs?service=git-upload-pack » et « …/d.git/git-upload-pack »
|
|
rendent le même dépôt, compté une fois. Une adresse illisible rend "".
|
|
"""
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
try:
|
|
morceaux = urlsplit(url or "")
|
|
except ValueError:
|
|
return ""
|
|
for suffixe in GIT_NEGOCIATION:
|
|
if morceaux.path.endswith(suffixe):
|
|
chemin = morceaux.path[: -len(suffixe)] or "/"
|
|
return urlunsplit(
|
|
(morceaux.scheme, morceaux.netloc, chemin, "", "")
|
|
)
|
|
return ""
|
|
|
|
|
|
def manques_hors_ligne(
|
|
vms, racine: str = RUNS, chemin: str = "", maintenant=None, detient=None
|
|
) -> list:
|
|
"""Par VM de la spec, ce que ses derniers déploiements hors ligne ont
|
|
manqué et que le cache ne détient toujours pas.
|
|
|
|
Rend [{"nom", "age", "runs", "manquants", "git", "jamais",
|
|
"selon_journal"}], une entrée par nom qui manque encore quelque chose de
|
|
comblable. « manquants » ne porte que des méthodes gardables que le cache
|
|
ne détient pas. « git » : les dépôts, sans doublon, dont une négociation
|
|
a manqué — le miroir les remplit, pas un rejeu, et le magasin n'est pas
|
|
interrogé à leur sujet. « jamais » : ce que le cache ne gardera jamais.
|
|
Les deux derniers sont nommés à part, pour ne pas promettre un
|
|
remplissage impossible, et ne font pas parler l'avertissement à eux
|
|
seuls : ni le magasin ni le journal ne disent si un miroir a été rempli
|
|
depuis.
|
|
|
|
« detient » est la question posée au magasin (detient_interroger par
|
|
défaut) ; None en retour fait lire le journal à la place, et
|
|
« selon_journal » le dit.
|
|
|
|
Muet — [] — sans déploiement qui renseigne, et alors sans même lire le
|
|
journal ni lancer le binaire : un avertissement qui parle sans savoir
|
|
apprend à passer outre.
|
|
"""
|
|
import time
|
|
|
|
noms = []
|
|
for vm in vms or ():
|
|
nom = vm.get("name") or ""
|
|
if nom and nom not in noms:
|
|
noms.append(nom)
|
|
if not noms:
|
|
return []
|
|
runs = lire_runs(racine)
|
|
clients = {
|
|
vm["ip"]
|
|
for run in runs
|
|
for vm in run["vms"]
|
|
if vm["nom"] in noms and vm["ip"]
|
|
}
|
|
if not clients:
|
|
return []
|
|
chemin = chemin or journal()
|
|
releve = releve_amont_muet(chemin, clients)
|
|
maintenant = time.time() if maintenant is None else maintenant
|
|
bilans = [
|
|
b
|
|
for b in (bilan_du_nom(runs, n, releve, maintenant) for n in noms)
|
|
if b
|
|
]
|
|
if not bilans:
|
|
return []
|
|
|
|
derniers = {}
|
|
for b in bilans:
|
|
for cle, quand in b["manques"].items():
|
|
if _gardable(cle) and not depot_git(cle[1]):
|
|
derniers[cle] = max(derniers.get(cle, 0.0), quand)
|
|
verdicts = (detient or detient_interroger)(sorted(derniers))
|
|
selon_journal = verdicts is None
|
|
if selon_journal:
|
|
tenus, jamais_gardes = tenus_selon_journal(chemin, derniers), set()
|
|
else:
|
|
tenus = {c for c, v in verdicts.items() if v["verdict"] in DETENTION}
|
|
jamais_gardes = {
|
|
c for c, v in verdicts.items() if v["verdict"] == "non-cachable"
|
|
}
|
|
out = []
|
|
for b in bilans:
|
|
git, jamais, manquants = _trier_manques(
|
|
b["manques"], jamais_gardes, tenus
|
|
)
|
|
if manquants:
|
|
out.append(
|
|
{
|
|
"nom": b["nom"],
|
|
"age": b["age"],
|
|
"runs": b["runs"],
|
|
"manquants": manquants,
|
|
"git": git,
|
|
"jamais": jamais,
|
|
"selon_journal": selon_journal,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _gardable(cle) -> bool:
|
|
"""La méthode d'un (méthode, URL) est-elle de celles que le cache
|
|
garde ?"""
|
|
return cle[0].upper() in METHODES_GARDABLES
|
|
|
|
|
|
def _trier_manques(manques, jamais_gardes, tenus):
|
|
"""Répartit des (méthode, URL) manqués en (git, jamais, manquants).
|
|
|
|
« git » : les dépôts, triés et sans doublon, dont une négociation a
|
|
manqué. « jamais » : les méthodes que le cache ne garde pas, et ce que
|
|
le magasin dit non-cachable. « manquants » : le reste, moins `tenus`.
|
|
La négociation git est reconnue AVANT la méthode : un POST sur
|
|
« git-upload-pack » se remplit par le miroir, comme le GET qui le
|
|
précède.
|
|
"""
|
|
git, jamais, manquants = set(), [], []
|
|
for cle in manques:
|
|
depot = depot_git(cle[1])
|
|
if depot:
|
|
git.add(depot)
|
|
elif not _gardable(cle) or cle in jamais_gardes:
|
|
jamais.append(cle)
|
|
elif cle not in tenus:
|
|
manquants.append(cle)
|
|
return sorted(git), jamais, manquants
|
|
|
|
|
|
def manques_recents(chemin: str, sous_reseau: str = "", depuis: float = 0.0):
|
|
"""Ce que les invités n'ont pas obtenu hors ligne depuis « depuis ».
|
|
|
|
Rend [{"methode", "url", "dernier", "n", "clients"}], une entrée par
|
|
(méthode, URL), le manque le plus récent d'abord. Ne comptent que les
|
|
clients du sous-réseau des VM quand il est connu, et jamais la boucle
|
|
locale : un rejeu depuis l'hôte écrit ses propres lignes, et les
|
|
recompter ferait rejouer sans fin ce qui vient de l'être.
|
|
"""
|
|
import ipaddress
|
|
|
|
reseau = None
|
|
if sous_reseau:
|
|
try:
|
|
reseau = ipaddress.ip_network(sous_reseau, strict=False)
|
|
except ValueError:
|
|
reseau = None
|
|
par = {}
|
|
for d in _lignes(chemin, [f'"{ISSUE_MANQUE}"']):
|
|
if d.get("outcome") != ISSUE_MANQUE:
|
|
continue
|
|
client = d.get("client") or ""
|
|
try:
|
|
adresse = ipaddress.ip_address(client)
|
|
except ValueError:
|
|
continue
|
|
if adresse.is_loopback or (
|
|
reseau is not None and adresse not in reseau
|
|
):
|
|
continue
|
|
quand = instant(d.get("time"))
|
|
if quand is None or quand < depuis:
|
|
continue
|
|
cle = (d.get("method") or "", d.get("url") or "")
|
|
entree = par.setdefault(
|
|
cle,
|
|
{
|
|
"methode": cle[0],
|
|
"url": cle[1],
|
|
"dernier": quand,
|
|
"n": 0,
|
|
"clients": set(),
|
|
},
|
|
)
|
|
entree["n"] += 1
|
|
entree["dernier"] = max(entree["dernier"], quand)
|
|
entree["clients"].add(client)
|
|
return sorted(par.values(), key=lambda e: -e["dernier"])
|