[ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris

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

--- FR ---

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

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-14 14:41:19 -04:00
parent ddde76f0cf
commit a7706adf3d
69 changed files with 26711 additions and 60 deletions

View file

@ -161,6 +161,48 @@ level must prove, not assume:
unloaded module, not a permissions problem. A level that fails these stops the
descent instead of prolonging it into the void.
## qemu_cache.py — does the download cache really serve the second VM?
Two sibling VMs, the same distribution, the same packages. The first fills the
cache, the second must be served by it.
**Zero upstream bytes is the headline, not the criterion.** Arch is a rolling
release: between the two deployments a mirror can publish a newer version,
which the second VM legitimately fetches — the cache never serves an index
while upstream answers, so the VM sees it. A criterion built on volume alone
would call the cache broken while it works.
The criterion is therefore: **no URL requested by BOTH VMs is fetched upstream
a second time.** What the second VM discovers on its own is counted, shown,
and does not fail.
`--hors-ligne` adds the counter-proof, which is what makes the test worth its
hours: it cuts the upstream of the cache SERVICE alone — by its system
account, not by a blanket rule that would take down the ssh session running
the test — and deploys a third VM, which must build from the stored index.
```
./long_test/qemu_cache.py # two VMs
./long_test/qemu_cache.py --dry-run # the plan, nothing created
./long_test/qemu_cache.py --hors-ligne # + the third VM, upstream cut
./long_test/qemu_cache.py --detruire # undo it
```
It needs the cache installed and running — `TODO › Deployment › QEMU cache` —
and it refuses to create anything before saying which prerequisite is missing.
Among those prerequisites: the rules must target the subnet libvirt actually
serves, which is not always 192.168.122.0/24.
What governs the duration is the FIRST VM's download, everything else being
boot and install: minutes on a machine with nested KVM and a nearby mirror,
much longer on a slow link. The second VM does not download at all — that is
what is being measured.
One limit the counter-proof exposes: with upstream cut, the repository
database SIGNATURES are missing from the cache, the mirror answering 404 for
them, so the cache returns its named 504. pacman treats them as optional and
carries on. A distribution that required them would stop there.
## Starting from a host you already have
Both scripts take `--hote`. Creating a head VM to host a hypervisor you
@ -355,6 +397,48 @@ Chaque étage doit donc prouver, et non supposer :
module non chargé, pas un problème de permission. Un étage qui échoue à cela
arrête la descente au lieu de la prolonger dans le vide.
## qemu_cache.py — le cache de téléchargement sert-il vraiment la seconde VM ?
Deux machines sœurs, la même distribution, les mêmes paquets. La première
remplit le cache, la seconde doit être servie par lui.
**« Zéro octet d'amont » est la manchette, pas le critère.** Arch est une
publication continue : entre les deux déploiements, un miroir peut publier une
version neuve, que la seconde VM tire légitimement — le cache ne sert jamais
un index tant que l'amont répond, donc elle la voit. Un critère fondé sur le
seul volume déclarerait le cache en panne alors qu'il fonctionne.
Le critère est donc : **aucune URL demandée par les DEUX VM n'est retirée de
l'amont une seconde fois.** Ce que la seconde découvre seule est compté,
montré, et n'échoue pas.
« --hors-ligne » ajoute la contre-épreuve, qui fait la valeur de ces heures :
elle coupe l'amont du SEUL service du cache — par son compte système, non par
une règle générale qui emporterait la session ssh depuis laquelle le test se
lance — et déploie une troisième VM, qui doit se bâtir sur l'index stocké.
```
./long_test/qemu_cache.py # deux VM
./long_test/qemu_cache.py --dry-run # le plan, rien de créé
./long_test/qemu_cache.py --hors-ligne # + la troisième VM, amont coupé
./long_test/qemu_cache.py --detruire # défaire
```
Il exige le cache installé et actif — « TODO › Déploiement › Cache QEMU » — et
refuse de rien créer avant d'avoir dit lequel des préalables manque. Parmi
eux : les règles doivent viser le sous-réseau que libvirt sert vraiment, qui
n'est pas toujours 192.168.122.0/24.
Ce qui gouverne la durée est le téléchargement de la PREMIÈRE VM, le reste
n'étant que démarrage et installation : quelques minutes sur une machine à
KVM imbriqué et miroir proche, bien davantage sur une liaison lente. La
seconde VM ne télécharge rien — c'est précisément ce qu'on mesure.
Une limite que la contre-épreuve met au jour : amont coupé, les SIGNATURES
des bases de dépôt manquent au cache, le miroir y répondant 404, et le cache
rend donc son 504 nommé. pacman les traite comme optionnelles et poursuit.
Une distribution qui les exigerait s'arrêterait là.
## Partir d'un hôte qu'on possède déjà
Les deux scripts acceptent `--hote`. Créer une VM de tête pour héberger un

View file

@ -166,6 +166,48 @@ Chaque étage doit donc prouver, et non supposer :
module non chargé, pas un problème de permission. Un étage qui échoue à cela
arrête la descente au lieu de la prolonger dans le vide.
## qemu_cache.py — le cache de téléchargement sert-il vraiment la seconde VM ?
Deux machines sœurs, la même distribution, les mêmes paquets. La première
remplit le cache, la seconde doit être servie par lui.
**« Zéro octet d'amont » est la manchette, pas le critère.** Arch est une
publication continue : entre les deux déploiements, un miroir peut publier une
version neuve, que la seconde VM tire légitimement — le cache ne sert jamais
un index tant que l'amont répond, donc elle la voit. Un critère fondé sur le
seul volume déclarerait le cache en panne alors qu'il fonctionne.
Le critère est donc : **aucune URL demandée par les DEUX VM n'est retirée de
l'amont une seconde fois.** Ce que la seconde découvre seule est compté,
montré, et n'échoue pas.
« --hors-ligne » ajoute la contre-épreuve, qui fait la valeur de ces heures :
elle coupe l'amont du SEUL service du cache — par son compte système, non par
une règle générale qui emporterait la session ssh depuis laquelle le test se
lance — et déploie une troisième VM, qui doit se bâtir sur l'index stocké.
```
./long_test/qemu_cache.py # deux VM
./long_test/qemu_cache.py --dry-run # le plan, rien de créé
./long_test/qemu_cache.py --hors-ligne # + la troisième VM, amont coupé
./long_test/qemu_cache.py --detruire # défaire
```
Il exige le cache installé et actif — « TODO › Déploiement › Cache QEMU » — et
refuse de rien créer avant d'avoir dit lequel des préalables manque. Parmi
eux : les règles doivent viser le sous-réseau que libvirt sert vraiment, qui
n'est pas toujours 192.168.122.0/24.
Ce qui gouverne la durée est le téléchargement de la PREMIÈRE VM, le reste
n'étant que démarrage et installation : quelques minutes sur une machine à
KVM imbriqué et miroir proche, bien davantage sur une liaison lente. La
seconde VM ne télécharge rien — c'est précisément ce qu'on mesure.
Une limite que la contre-épreuve met au jour : amont coupé, les SIGNATURES
des bases de dépôt manquent au cache, le miroir y répondant 404, et le cache
rend donc son 504 nommé. pacman les traite comme optionnelles et poursuit.
Une distribution qui les exigerait s'arrêterait là.
## Partir d'un hôte qu'on possède déjà
Les deux scripts acceptent `--hote`. Créer une VM de tête pour héberger un

View file

@ -156,6 +156,48 @@ level must prove, not assume:
unloaded module, not a permissions problem. A level that fails these stops the
descent instead of prolonging it into the void.
## qemu_cache.py — does the download cache really serve the second VM?
Two sibling VMs, the same distribution, the same packages. The first fills the
cache, the second must be served by it.
**Zero upstream bytes is the headline, not the criterion.** Arch is a rolling
release: between the two deployments a mirror can publish a newer version,
which the second VM legitimately fetches — the cache never serves an index
while upstream answers, so the VM sees it. A criterion built on volume alone
would call the cache broken while it works.
The criterion is therefore: **no URL requested by BOTH VMs is fetched upstream
a second time.** What the second VM discovers on its own is counted, shown,
and does not fail.
`--hors-ligne` adds the counter-proof, which is what makes the test worth its
hours: it cuts the upstream of the cache SERVICE alone — by its system
account, not by a blanket rule that would take down the ssh session running
the test — and deploys a third VM, which must build from the stored index.
```
./long_test/qemu_cache.py # two VMs
./long_test/qemu_cache.py --dry-run # the plan, nothing created
./long_test/qemu_cache.py --hors-ligne # + the third VM, upstream cut
./long_test/qemu_cache.py --detruire # undo it
```
It needs the cache installed and running — `TODO › Deployment › QEMU cache` —
and it refuses to create anything before saying which prerequisite is missing.
Among those prerequisites: the rules must target the subnet libvirt actually
serves, which is not always 192.168.122.0/24.
What governs the duration is the FIRST VM's download, everything else being
boot and install: minutes on a machine with nested KVM and a nearby mirror,
much longer on a slow link. The second VM does not download at all — that is
what is being measured.
One limit the counter-proof exposes: with upstream cut, the repository
database SIGNATURES are missing from the cache, the mirror answering 404 for
them, so the cache returns its named 504. pacman treats them as optional and
carries on. A distribution that required them would stop there.
## Starting from a host you already have
Both scripts take `--hote`. Creating a head VM to host a hypervisor you

1262
long_test/qemu_cache.py Executable file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,358 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# Pose erplibre_go_qemu_cache : le miroir de téléchargement des VM QEMU
# locales. Compile depuis les sources du dépôt, installe une unité systemd,
# et laisse le service poser lui-même ses règles de détournement.
#
# Les règles de pare-feu ne sont PAS écrites par ce script. L'unité les pose
# à son démarrage et les retire à son arrêt, en tubant « --print-nft » dans
# nft : une VM ne peut donc pas rester détournée vers un cache éteint, et le
# jeu de règles vient du code Go que les tests vérifient, jamais d'une copie.
#
# Usage :
# sudo bash install_qemu_cache.sh
# sudo EL_HTTP_PORT=8898 EL_TLS_PORT=8899 EL_BRIDGE=virbr0 \
# EL_SUBNET=192.168.122.0/24 bash install_qemu_cache.sh
set -e
EL_HTTP_PORT="${EL_HTTP_PORT:-8898}"
EL_TLS_PORT="${EL_TLS_PORT:-8899}"
# Vides par défaut : le pont et le sous-réseau sont LUS dans libvirt plus bas.
# Les supposer est le défaut qui rend l'installation silencieusement inutile —
# un hôte dont le réseau par défaut a été déplacé sur un /24 libre, ce qui
# arrive dès que 192.168.122 entre en collision, voit ses VM échapper au
# détournement sans que rien ne le signale.
EL_BRIDGE="${EL_BRIDGE:-}"
EL_SUBNET="${EL_SUBNET:-}"
EL_NET="${EL_NET:-default}"
EL_CACHE_DIR="${EL_CACHE_DIR:-/var/cache/erplibre_go_qemu_cache}"
EL_CA_DIR="${EL_CA_DIR:-/var/lib/erplibre_go_qemu_cache}"
EL_ACCESS_LOG="${EL_ACCESS_LOG:-/var/log/erplibre_go_qemu_cache.jsonl}"
EL_EXCLUDE="${EL_EXCLUDE:-}"
EL_BYPASS_FILE="${EL_BYPASS_FILE:-/etc/erplibre_go_qemu_cache/bypass}"
# Les dépôts git tenus en miroir. Sous le répertoire du cache : c'est le
# seul que le service peut écrire, et le miroir grossit comme le reste.
EL_GIT_MIRROR_DIR="${EL_GIT_MIRROR_DIR:-${EL_CACHE_DIR:-/var/cache/erplibre_go_qemu_cache}/git}"
BIN="/usr/local/bin/erplibre_go_qemu_cache"
CONF_DIR="/etc/erplibre_go_qemu_cache"
UNIT="/etc/systemd/system/erplibre-go-qemu-cache.service"
SERVICE_USER="elqcache"
GO_MIN_MAJOR=1
GO_MIN_MINOR=21
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# EL_SRC_DIR passe outre la déduction : le script se retrouve ainsi lançable
# depuis un chemin copié, et une vérification peut appeler UNE de ses
# fonctions sans que la déduction échoue faute de dépôt autour.
SRC_DIR="${EL_SRC_DIR:-$(cd "${SCRIPT_DIR}/../qemu_cache" && pwd)}"
log() { echo "[cache QEMU] $*"; }
die() {
echo "[cache QEMU] ERREUR : $*" >&2
exit 1
}
check_root() {
if [ "$EUID" -ne 0 ]; then
die "à lancer en root : sudo bash $0"
fi
}
detect_os() {
[ -f /etc/os-release ] || die "système inconnu (pas de /etc/os-release)"
# shellcheck disable=SC1091
. /etc/os-release
OS="${ID}"
OS_LIKE="${ID_LIKE:-}"
log "système : ${OS}"
}
is_arch_like() {
case "$OS" in
arch | manjaro | endeavouros | artix | garuda) return 0 ;;
*) echo "$OS_LIKE" | grep -q "arch" && return 0 || return 1 ;;
esac
}
is_debian_like() {
case "$OS" in
ubuntu | debian | linuxmint | pop | elementary | raspbian) return 0 ;;
*) echo "$OS_LIKE" | grep -qE "debian|ubuntu" && return 0 || return 1 ;;
esac
}
is_fedora_like() {
case "$OS" in
fedora | rhel | centos | almalinux | rocky) return 0 ;;
*) echo "$OS_LIKE" | grep -qE "fedora|rhel" && return 0 || return 1 ;;
esac
}
# ---------------------------------------------------------------------------
# Le réseau que les VM utilisent VRAIMENT
# ---------------------------------------------------------------------------
# detecter_reseau remplit EL_BRIDGE et EL_SUBNET depuis libvirt.
#
# Le réseau « default » ne sert pas toujours 192.168.122.0/24 : il est déplacé
# sur un /24 libre quand ce préfixe entre en collision avec ce que l'hôte
# route déjà — le cas de tout orchestrateur qui est lui-même une VM. Poser des
# règles sur le mauvais préfixe laisse une installation qui réussit, des
# règles bien présentes dans le noyau, et un cache que personne ne traverse.
detecter_reseau() {
local xml adresse masque
# Donnés à la main, les deux se suffisent. Une machine dont le réseau
# libvirt n'est pas démarré — ou qui gère son pont autrement — doit pouvoir
# poser le cache en nommant ce pont. Sonder quand même faisait mourir
# l'installation sur une absence qui n'empêchait rien, et le contournement
# annoncé en tête de ce fichier ne servait à rien.
if [ -n "$EL_BRIDGE" ] && [ -n "$EL_SUBNET" ]; then
log "réseau donné à la main : ${EL_BRIDGE}, ${EL_SUBNET}"
return 0
fi
xml="$(virsh -c qemu:///system net-dumpxml "$EL_NET" 2>/dev/null)" || {
die "réseau libvirt « ${EL_NET} » introuvable — virsh -c qemu:///system net-list" \
"le démarrer : sudo virsh -c qemu:///system net-start ${EL_NET}" \
"ou nommer le pont : EL_BRIDGE=virbr0 EL_SUBNET=192.168.122.0/24"
}
[ -n "$EL_BRIDGE" ] || EL_BRIDGE="$(echo "$xml" |
sed -n "s/.*<bridge name='\([^']*\)'.*/\1/p" | head -1)"
if [ -z "$EL_SUBNET" ]; then
adresse="$(echo "$xml" | sed -n "s/.*<ip address='\([^']*\)'.*/\1/p" | head -1)"
masque="$(echo "$xml" | sed -n "s/.*netmask='\([^']*\)'.*/\1/p" | head -1)"
[ "$masque" = "255.255.255.0" ] || die \
"le réseau « ${EL_NET} » ne sert pas un /24 (masque ${masque:-inconnu}) :" \
"passer EL_SUBNET à la main"
EL_SUBNET="${adresse%.*}.0/24"
fi
[ -n "$EL_BRIDGE" ] && [ -n "$EL_SUBNET" ] || die \
"pont ou sous-réseau illisibles dans le réseau « ${EL_NET} »"
log "réseau lu dans libvirt : ${EL_BRIDGE}, ${EL_SUBNET}"
}
# ---------------------------------------------------------------------------
# Go
# ---------------------------------------------------------------------------
# find_go cherche au-delà du PATH de root : un Go posé par mise ou par un
# gestionnaire de versions vit dans le compte de l'opérateur, que sudo ne
# traverse pas.
find_go() {
local candidat
for candidat in \
"$(command -v go 2>/dev/null || true)" \
/usr/local/go/bin/go \
/usr/lib/go/bin/go; do
[ -n "$candidat" ] && [ -x "$candidat" ] && echo "$candidat" && return 0
done
return 1
}
go_assez_recent() {
local go_bin="$1" v major minor
v="$("$go_bin" version 2>/dev/null | sed -n 's/.*go\([0-9]*\)\.\([0-9]*\).*/\1 \2/p')"
[ -z "$v" ] && return 1
major="${v% *}"
minor="${v#* }"
[ "$major" -gt "$GO_MIN_MAJOR" ] && return 0
[ "$major" -eq "$GO_MIN_MAJOR" ] && [ "$minor" -ge "$GO_MIN_MINOR" ] && return 0
return 1
}
installer_go() {
log "Go absent ou trop ancien : installation par le gestionnaire de paquets"
if is_arch_like; then
pacman -S --needed --noconfirm go
elif is_debian_like; then
DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq
DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 \
install -y golang-go
elif is_fedora_like; then
dnf install -y golang
else
die "distribution non prévue : installer Go ${GO_MIN_MAJOR}.${GO_MIN_MINOR}+ à la main"
fi
}
assurer_go() {
local go_bin
if go_bin="$(find_go)" && go_assez_recent "$go_bin"; then
GO="$go_bin"
log "Go : $("$GO" version)"
return
fi
installer_go
go_bin="$(find_go)" || die "Go reste introuvable après installation"
go_assez_recent "$go_bin" ||
die "le Go de la distribution est plus ancien que ${GO_MIN_MAJOR}.${GO_MIN_MINOR} ; poser une version récente à la main"
GO="$go_bin"
log "Go : $("$GO" version)"
}
# ---------------------------------------------------------------------------
# Compilation
# ---------------------------------------------------------------------------
compiler() {
[ -f "${SRC_DIR}/go.mod" ] || die "sources absentes : ${SRC_DIR}"
log "compilation depuis ${SRC_DIR}"
# Le cache de compilation dans un temporaire : root n'a pas à laisser
# d'état dans son compte pour une compilation unique.
local gocache
gocache="$(mktemp -d)"
(
cd "$SRC_DIR"
GOCACHE="$gocache" GOFLAGS=-mod=mod "$GO" build -o "$BIN" .
)
rm -rf "$gocache"
chmod 0755 "$BIN"
log "posé : ${BIN} ($("$BIN" --version))"
}
# ---------------------------------------------------------------------------
# Compte, répertoires, autorité
# ---------------------------------------------------------------------------
preparer_etat() {
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" \
2>/dev/null ||
useradd --system --no-create-home --shell /sbin/nologin "$SERVICE_USER"
log "compte de service créé : ${SERVICE_USER}"
fi
mkdir -p "$EL_CACHE_DIR" "$EL_CA_DIR" "$CONF_DIR" "$EL_GIT_MIRROR_DIR"
touch "$EL_ACCESS_LOG"
# La liste des exceptions appartient à root : elle décide qui échappe au
# cache, et le compte du service ne doit pas pouvoir s'y ajouter.
mkdir -p "$(dirname "$EL_BYPASS_FILE")"
[ -f "$EL_BYPASS_FILE" ] || cat >"$EL_BYPASS_FILE" <<'BYPASS'
# Exceptions du cache de téléchargement des VM QEMU.
# Une ligne « <MAC> <nom de la VM> » par machine que le détournement doit
# ignorer. Relu à chaque démarrage du service.
BYPASS
chmod 0644 "$EL_BYPASS_FILE"
chown -R "${SERVICE_USER}:${SERVICE_USER}" "$EL_CACHE_DIR" "$EL_CA_DIR" "$EL_ACCESS_LOG" "$EL_GIT_MIRROR_DIR"
chmod 0755 "$EL_CACHE_DIR" "$EL_CA_DIR"
}
# L'autorité est créée ICI plutôt qu'au premier démarrage : l'installation
# doit pouvoir en dire l'empreinte, que l'opérateur compare ensuite dans la VM.
creer_autorite() {
runuser -u "$SERVICE_USER" -- "$BIN" --init-ca --ca-dir "$EL_CA_DIR" ||
die "l'autorité n'a pas pu être créée dans ${EL_CA_DIR}"
}
# ---------------------------------------------------------------------------
# Configuration et unité
# ---------------------------------------------------------------------------
ecrire_config() {
cat >"${CONF_DIR}/env" <<CONF
# Réglages du cache de téléchargement des VM QEMU.
# Changer une valeur puis : systemctl restart erplibre-go-qemu-cache
EL_CACHE_DIR=${EL_CACHE_DIR}
EL_CA_DIR=${EL_CA_DIR}
EL_HTTP_PORT=${EL_HTTP_PORT}
EL_TLS_PORT=${EL_TLS_PORT}
EL_BRIDGE=${EL_BRIDGE}
EL_SUBNET=${EL_SUBNET}
EL_ACCESS_LOG=${EL_ACCESS_LOG}
EL_EXCLUDE=${EL_EXCLUDE}
EL_BYPASS_FILE=${EL_BYPASS_FILE}
EL_GIT_MIRROR_DIR=${EL_GIT_MIRROR_DIR}
CONF
chmod 0644 "${CONF_DIR}/env"
}
# Le détournement est choisi une fois, à l'installation : nft s'il est là,
# iptables sinon. Les deux jeux de règles viennent du binaire.
regles_apply_cmd() {
if command -v nft >/dev/null 2>&1; then
echo "${BIN} --print-nft --bridge \${EL_BRIDGE} --subnet \${EL_SUBNET} --http-port \${EL_HTTP_PORT} --tls-port \${EL_TLS_PORT} --bypass-file \${EL_BYPASS_FILE} | nft -f -"
else
echo "${BIN} --print-iptables --bridge \${EL_BRIDGE} --subnet \${EL_SUBNET} --http-port \${EL_HTTP_PORT} --tls-port \${EL_TLS_PORT} --bypass-file \${EL_BYPASS_FILE} | sh -s"
fi
}
regles_clear_cmd() {
if command -v nft >/dev/null 2>&1; then
echo "nft delete table ip erplibre_qemu_cache 2>/dev/null || true"
else
echo "${BIN} --print-iptables --bridge \${EL_BRIDGE} --subnet \${EL_SUBNET} --http-port \${EL_HTTP_PORT} --tls-port \${EL_TLS_PORT} --bypass-file \${EL_BYPASS_FILE} | sed 's/ -A / -D /' | sh -s || true"
fi
}
ecrire_unite() {
# « + » devant une commande la fait tourner en root même sous User= : poser
# une règle de pare-feu demande un privilège que le service n'a pas, et ne
# doit pas avoir, pendant qu'il sert des fichiers.
cat >"$UNIT" <<UNITE
[Unit]
Description=Cache de téléchargement ERPLibre pour les VM QEMU
After=network.target libvirtd.service
Wants=libvirtd.service
[Service]
Type=simple
EnvironmentFile=${CONF_DIR}/env
User=${SERVICE_USER}
Group=${SERVICE_USER}
ExecStartPre=+/bin/sh -c '$(regles_apply_cmd)'
ExecStart=${BIN} --cache-dir \${EL_CACHE_DIR} --ca-dir \${EL_CA_DIR} --http-port \${EL_HTTP_PORT} --tls-port \${EL_TLS_PORT} --bridge \${EL_BRIDGE} --subnet \${EL_SUBNET} --access-log \${EL_ACCESS_LOG} --exclude \${EL_EXCLUDE} --git-mirror-dir \${EL_GIT_MIRROR_DIR}
ExecStopPost=+/bin/sh -c '$(regles_clear_cmd)'
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=${EL_CACHE_DIR} ${EL_CA_DIR} ${EL_ACCESS_LOG} ${EL_GIT_MIRROR_DIR}
Environment=HOME=${EL_CACHE_DIR}
Environment=GIT_CONFIG_GLOBAL=/dev/null
Environment=GIT_TERMINAL_PROMPT=0
[Install]
WantedBy=multi-user.target
UNITE
chmod 0644 "$UNIT"
systemctl daemon-reload
}
# ---------------------------------------------------------------------------
main() {
check_root
detect_os
detecter_reseau
assurer_go
compiler
preparer_etat
creer_autorite
ecrire_config
ecrire_unite
systemctl enable erplibre-go-qemu-cache.service >/dev/null 2>&1 || true
systemctl restart erplibre-go-qemu-cache.service
# Un service qui ne tient pas doit le dire ici, et non se découvrir au
# prochain déploiement de VM.
sleep 2
if ! systemctl is-active --quiet erplibre-go-qemu-cache.service; then
systemctl status --no-pager -l erplibre-go-qemu-cache.service || true
die "le service ne tient pas ; les règles ont été retirées à son arrêt"
fi
log ""
log "cache actif sur ${EL_HTTP_PORT} (http) et ${EL_TLS_PORT} (tls)"
log "détournement : ${EL_BRIDGE}, depuis ${EL_SUBNET}, sortie du /24 seule"
log "autorité à faire approuver dans chaque VM :"
log " ${EL_CA_DIR}/ca.crt"
"$BIN" --status --cache-dir "$EL_CACHE_DIR" --ca-dir "$EL_CA_DIR" \
--git-mirror-dir "$EL_GIT_MIRROR_DIR" || true
}
main "$@"

View file

@ -112,7 +112,12 @@ el_pyenv_install() {
exe="$(el_pyenv_exec_path "${version}")"
if [[ ! -d "${root}" ]]; then
echo "---- Installation de pyenv dans ${root} ----" >&2
curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer \
# « -f » : sans lui, curl livre le CORPS d'une erreur HTTP à bash, qui
# l'exécute. Une page d'erreur de miroir, un portail captif ou le 504
# d'un cache hors ligne devient alors une suite de commandes, et le
# lecteur reçoit « command not found » à la place de la cause. Avec
# « -f », curl rend non nul et le repli ci-dessous s'énonce.
curl -fsSL https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer \
| bash >&2 || return 1
fi
export PATH="${root}/bin:$PATH"

View file

@ -0,0 +1,92 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le journal d'accès du cache, rendu lisible ligne à ligne.
Lit les lignes JSON du journal sur l'entrée standard et en écrit une par
requête servie. La flèche est la colonne qui compte sous une coupure : « ↑ »
dit que la requête est SORTIE vers l'internet, « · » qu'elle a été servie du
disque de l'hôte. Un déploiement hors ligne n'en produit aucune en « ↑ », et
« --amont » ne garde que celles-là : la commande reste alors muette tant que
rien ne sort.
Chaque ligne est écrite dès qu'elle est lue. « tail -f » y passe un flux sans
fin, et une sortie mise en tampon ne montrerait rien avant plusieurs
kilo-octets, c'est-à-dire des minutes sur un journal qui avance doucement.
Une ligne illisible est passée : le journal s'écrit pendant qu'on le lit, et
la dernière ligne d'un fichier en cours d'écriture est parfois tronquée.
"""
import argparse
import json
import sys
def taille(octets):
"""Une taille lisible, « - » quand la réponse n'avait pas de corps."""
n = float(octets or 0)
if not n:
return "-"
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"
def ligne_lisible(brut, amont_seul=False):
"""La ligne à écrire pour une ligne du journal, "" s'il n'y a rien.
Rend "" sur une ligne illisible et, sous « amont_seul », sur toute
requête qui n'est pas sortie vers l'internet.
"""
try:
d = json.loads(brut)
except (TypeError, ValueError):
return ""
if not isinstance(d, dict):
return ""
if amont_seul and not d.get("upstream"):
return ""
# L'horodatage du journal est en UTC, au format ISO : les positions 11 à
# 19 en sont l'heure.
heure = str(d.get("time", ""))[11:19] or "--:--:--"
client = str(d.get("client") or "—")
fleche = "↑" if d.get("upstream") else "·"
url = str(d.get("url", ""))
return (
f"{heure} {client:<15} {fleche} {d.get('status') or 0:>3} "
f"{str(d.get('outcome', '?')):<13}{taille(d.get('bytes')):>9} "
f"{url[-70:]}"
)
def main(argv=None, entree=None, sortie=None):
analyseur = argparse.ArgumentParser(
description="Met en forme le journal d'accès du cache QEMU."
)
analyseur.add_argument(
"--amont",
action="store_true",
help="ne montrer que les requêtes sorties vers l'internet",
)
args = analyseur.parse_args(argv)
entree = entree if entree is not None else sys.stdin
sortie = sortie if sortie is not None else sys.stdout
for brut in entree:
ligne = ligne_lisible(brut, args.amont)
if not ligne:
continue
sortie.write(ligne + "\n")
sortie.flush()
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except (BrokenPipeError, KeyboardInterrupt):
# Ctrl-C arrête le suivi, et « tail » fermé ferme le tube : ni l'un
# ni l'autre n'est une erreur à afficher.
sys.exit(0)

1297
script/qemu/cache_offline.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -53,11 +53,12 @@ import grp
import gzip
import hashlib
import ipaddress
import zlib
import os
import re
import shutil
import pwd
import re
import secrets
import shlex
import shutil
import socket
import stat as stat_mod
import subprocess
@ -68,6 +69,7 @@ import time
import urllib.error
import urllib.request
import warnings
import zlib
from pathlib import Path
# --------------------------------------------------------------------------- #
@ -1681,24 +1683,38 @@ APT_MIRRORS_MAIN = [
PORTS_ARCHES = ("s390x", "arm64", "aarch64", "ppc64el", "riscv64")
def apt_mirror_lines(arch: str, override: str | None = None) -> list[str]:
def apt_mirror_lines(
arch: str, override: str | None = None, fixe: bool = False
) -> list[str]:
"""Bloc « apt: » du cloud-config, ou [] si rien à écrire.
Ubuntu seulement : Debian, Fedora et Arch ont leurs propres dépôts, et
« search » y écrirait des URI qui n'existent pas.
Un miroir imposé (« override »), ou le premier de la liste quand « fixe »
est vrai, s'écrit « uri: » et non « search: ». La recherche de cloud-init
écarte tout miroir dont le nom se résout comme un nom inexistant :
derrière un résolveur qui répond à tout nom, elle les écarte tous et
retombe sur le dépôt officiel. Le cache range les index sous leur hôte,
si bien qu'une VM derrière lui doit tirer du même miroir que les
précédentes pour retrouver ce qu'elles ont gardé, en ligne comme hors
ligne.
"""
mirrors = (
[override]
if override
else (APT_MIRRORS_PORTS if arch in PORTS_ARCHES else APT_MIRRORS_MAIN)
)
lines = ["apt:", " primary:", " - arches: [default]", " search:"]
lines += [f" - {m}" for m in mirrors]
def bloc(nom):
tete = [f" {nom}:", " - arches: [default]"]
if override or fixe:
return tete + [f" uri: {mirrors[0]}"]
return tete + [" search:"] + [f" - {m}" for m in mirrors]
# La sécurité suit le même dépôt pour les arches ports ; sur amd64 elle a
# son propre hôte, que les miroirs répliquent sous le même chemin.
lines += [" security:", " - arches: [default]", " search:"]
lines += [f" - {m}" for m in mirrors]
return lines
return ["apt:"] + bloc("primary") + bloc("security")
def kvm_available() -> bool:
@ -2400,6 +2416,296 @@ def installer_guide_name(path: str) -> str:
return INSTALLER_GUIDE_PREFIX + path.strip("/").replace("/", "-")
# Où chaque famille de distribution range ses ancres de confiance, et par
# quelle commande elle les relit. La même table vit dans
# script/qemu_cache/rules.go, côté cache ; un test les compare, la dérive
# entre deux copies étant le seul risque de cette duplication.
#
# Les familles portent le nom de leur gestionnaire de paquets, comme
# « _QEMU_DISTRO_FAMILY » du menu.
# Trois valeurs par famille : où poser l'ancre, quelle commande relit le
# magasin, et quel FAISCEAU cette commande régénère.
CACHE_TRUST = {
"pacman": (
"/etc/ca-certificates/trust-source/anchors",
"trust extract-compat",
"/etc/ssl/certs/ca-certificates.crt",
),
"apt": (
"/usr/local/share/ca-certificates",
"update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt",
),
"dnf": (
"/etc/pki/ca-trust/source/anchors",
"update-ca-trust",
"/etc/pki/tls/certs/ca-bundle.crt",
),
"zypper": (
"/etc/pki/trust/anchors",
"update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt",
),
}
# pip embarque son propre jeu de certificats et IGNORE le magasin système ;
# npm fait de même. Poser l'autorité suffit à pacman et à apt, pas à eux.
#
# Les variables visent le FAISCEAU, non le certificat du cache : pointer
# celui-là ferait perdre à pip toutes les autres autorités — il échouerait sur
# le premier hôte que le cache ne déchiffre pas, et le jour où le cache
# disparaît alors que la VM garde sa variable.
CACHE_ENV_VARS = ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS")
CACHE_CERT_NAME = "erplibre-cache.crt"
def cache_family(distro: str) -> str:
"""Famille de gestionnaire de paquets d'un système du catalogue.
Lue de DISTRO_PKG, qui est LA table du catalogue, plutôt que recopiée
ici : une copie dérive, et le système qu'elle oublie est déployé SANS
l'autorité du cache, alors que le détournement, lui, porte sur tout le
pont. Chaque téléchargement HTTPS y échoue sur « self-signed certificate
in certificate chain », message qui ne dit rien d'une table incomplète.
"""
return DISTRO_PKG.get(distro, "")
# ---------------------------------------------------------------------------
# Soustraire une VM au cache : l'exception par adresse MAC
# ---------------------------------------------------------------------------
# Le détournement du cache est TRANSPARENT et vaut pour tout le pont. Ne pas
# donner l'autorité à une VM ne la dispense donc pas d'être interceptée : elle
# reçoit un certificat qu'elle ne reconnaît pas et échoue sur « self-signed
# certificate in certificate chain ». La seule exception qui vaille est posée
# sur l'HÔTE, et elle a besoin d'un identifiant stable — l'adresse MAC, fixée
# dans la définition du domaine, là où l'adresse IP vient d'un bail.
#
# D'où l'ordre imposé ici : la MAC est CHOISIE avant la création, l'exception
# est posée, et la VM démarre ensuite. L'inverse — créer puis lire la MAC —
# laisserait la fenêtre où cloud-init télécharge déjà.
CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache"
CACHE_SERVICE = "erplibre-go-qemu-cache.service"
# Le préfixe que QEMU/KVM se voit attribuer. S'en écarter ferait passer la VM
# pour une machine d'un autre constructeur auprès de ce qui lit les OUI.
MAC_PREFIXE = "52:54:00"
def mac_du_network(network: str) -> str:
"""La MAC déjà demandée dans l'argument --network, ou "".
Une MAC posée à la main l'emporte : l'appelant sait ce qu'il veut, et lui
en substituer une autre casserait une réservation DHCP.
"""
for champ in network.split(","):
cle, _, valeur = champ.partition("=")
if cle.strip() == "mac":
return valeur.strip()
return ""
def macs_deja_prises(runner: Runner) -> set[str]:
"""Les MAC que portent les domaines existants, en minuscules.
Libvirt refuse une MAC en double, mais l'erreur arrive au moment de la
création, après le téléchargement de l'image. La lire avant coûte deux
appels et rend le refus immédiat.
"""
code, sortie = runner.run(
["virsh", "-c", LIBVIRT_URI, "list", "--all", "--name"],
privileged=True,
check=False,
capture=True,
)
if code:
return set()
prises: set[str] = set()
for nom in (sortie or "").split("\n"):
nom = nom.strip()
if not nom:
continue
code, xml = runner.run(
["virsh", "-c", LIBVIRT_URI, "domiflist", nom],
privileged=True,
check=False,
capture=True,
)
if code:
continue
prises.update(
m.lower()
for m in re.findall(
r"\b([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b", xml or ""
)
)
return prises
def mac_neuve(prises: set[str]) -> str:
"""Une MAC du préfixe QEMU qu'aucun domaine ne porte.
Le tirage est aléatoire sur trois octets : la collision est improbable,
mais elle n'apparaîtrait qu'à la création, une fois l'image téléchargée.
Une poignée d'essais suffit à la rendre impossible en pratique.
"""
for _ in range(64):
octets = [secrets.randbelow(256) for _ in range(3)]
mac = MAC_PREFIXE + ":" + ":".join(f"{o:02x}" for o in octets)
if mac not in prises:
return mac
raise RuntimeError("aucune adresse MAC libre après 64 tirages")
def network_avec_mac(network: str, mac: str) -> str:
"""Ajoute « mac=… » à l'argument --network, sans toucher au reste."""
if mac_du_network(network):
return network
return f"{network},mac={mac}"
def cache_bypass_apply(args: argparse.Namespace, runner: Runner) -> str:
"""Pose l'exception et rend la MAC retenue, ou "" si rien n'est à faire.
Rend "" sans se plaindre quand le cache n'est pas là ou ne tourne pas :
dans ce cas rien n'intercepte, et la VM télécharge en direct — ce que
l'appelant demandait. Se plaindre alors serait exiger d'installer un cache
pour pouvoir s'en passer.
L'exception est écrite dans un fichier ET posée à chaud dans l'ensemble
nftables. Le fichier la fait survivre au redémarrage du service ;
l'ensemble évite d'avoir à reposer les règles, ce qui couperait les
téléchargements des autres VM en cours.
"""
if not getattr(args, "cache_bypass", False):
return ""
if not os.path.isfile(CACHE_BIN):
print(
" cache absent de cet hôte : rien n'intercepte, rien à excepter"
)
return ""
code, _ = runner.run(
["systemctl", "is-active", "--quiet", CACHE_SERVICE],
check=False,
capture=True,
)
if code and not args.dry_run:
print(" cache arrêté : rien n'intercepte, rien à excepter")
return ""
mac = mac_du_network(args.network) or mac_neuve(macs_deja_prises(runner))
args.network = network_avec_mac(args.network, mac)
geste = (
f"{shlex.quote(CACHE_BIN)} --bypass-add {shlex.quote(mac)}"
f" --bypass-name {shlex.quote(args.name)}"
)
if shutil.which("nft"):
# Le binaire écrit le fichier et rend sur sa sortie le geste à chaud.
runner.run(["sh", "-c", f"{geste} | nft -f -"], privileged=True)
else:
# Sans nft, les règles sont des « -A » iptables sans ensemble nommé :
# l'exception ne peut entrer qu'en reposant la chaîne entière, ce que
# fait le redémarrage du service.
runner.run(["sh", "-c", geste], privileged=True)
runner.run(["systemctl", "restart", CACHE_SERVICE], privileged=True)
print(f" VM soustraite au cache : {mac}")
return mac
def cache_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
"""L'autorité du cache, posée par cloud-init à l'étape init.
Rend une liste vide quand aucune autorité n'est demandée, ou quand la
distribution n'est pas dans la table : mieux vaut une VM qui télécharge
en direct qu'une VM dont le magasin de confiance a reçu un fichier au
mauvais endroit, où il ne servirait à rien sans que rien ne le dise.
Le fichier est écrit à l'étape INIT, donc avant « runcmd » et avant tout
téléchargement : ce dépôt n'installe aucun paquet par cloud-init et laisse
« package_update » à faux, si bien que rien ne sort sur le réseau entre le
démarrage et la commande de confiance.
"""
if not args.cache_ca:
return []
# Les deux ensemble n'ont pas de sens : une VM exceptée ne rencontre
# jamais le cache, et lui faire approuver cette autorité poserait dans son
# magasin une signature dont rien ne se sert. L'exception l'emporte, étant
# la demande la plus précise.
if getattr(args, "cache_bypass", False):
return []
famille = cache_family(args.distro)
if famille not in CACHE_TRUST:
return []
try:
with open(args.cache_ca, encoding="utf-8") as fh:
pem = fh.read()
except OSError:
# Une autorité illisible ne doit pas faire échouer un déploiement :
# sans elle, la VM télécharge en direct, ce qui marche.
return []
if "BEGIN CERTIFICATE" not in pem:
return []
anchors = CACHE_TRUST[famille][0]
return [(f"{anchors}/{CACHE_CERT_NAME}", "0644", pem, "")]
def cache_commands(args: argparse.Namespace) -> list[str]:
"""Ce qui rend l'autorité effective, et ce que pip et npm exigent en plus.
Des commandes SHELL, dans cet ordre : relire le magasin de confiance, puis
écrire les variables dans /etc/environment — que PAM lit pour TOUTE
session ssh, interactive ou non, ce qui est la seule façon d'atteindre le
bootstrap d'installation lancé par commande distante.
Du shell et non du YAML : cloud-init n'est pas la seule voie de livraison.
Une VM née sur un hôte Proxmox reçoit les mêmes gestes par ssh, « qm set »
ne sachant écrire aucun fichier. Une source unique, deux emballages.
Chaque commande est tolérante à son propre échec : une autorité déjà
approuvée, ou une variable déjà écrite, n'est pas une raison d'arrêter.
"""
if not cache_files(args):
return []
_, commande, faisceau = CACHE_TRUST[cache_family(args.distro)]
commandes = [f"{commande} || true"]
for var in CACHE_ENV_VARS:
commandes.append(
f"sh -c 'grep -q ^{var}= /etc/environment"
f" || echo {var}={faisceau} >> /etc/environment'"
)
return commandes
def cache_runcmd(args: argparse.Namespace) -> list[str]:
"""Les mêmes gestes, emballés en éléments de « runcmd » pour cloud-init."""
return [f" - {c}" for c in cache_commands(args)]
def cache_env_reload(fichier: str = "/etc/environment") -> str:
"""Relit les variables du cache dans la session shell EN COURS.
PAM lit /etc/environment à l'ouverture d'une session, jamais après. Une
commande distante qui attend cloud-init s'ouvre AVANT que runcmd n'y écrive
les variables : sa session ne les reçoit pas, alors que chaque « sudo », qui
rouvre une session PAM, les voit. Un « npm install » lancé sans sudo rejette
alors l'autorité du cache — « self-signed certificate in certificate chain »
— là où « sudo npm install -g » réussit une ligne plus haut.
Seules les variables du cache sont exportées : relire le fichier entier
remplacerait aussi le PATH de la session. Rend une instruction shell sans
séparateur final, sans effet quand le fichier est absent ou ne les porte
pas, et qui ne fait pas échouer une commande sous « set -e ».
"""
motif = "|".join(CACHE_ENV_VARS)
return (
f'if [ -r {fichier} ]; then eval "$(grep -E "^({motif})=" {fichier}'
' | sed "s/^/export /")"; fi'
)
def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
"""Fichiers d'accueil de la VM : le guide de connexion, l'identité git.
@ -2483,7 +2789,9 @@ def build_cloud_config(
lines.append(f"timezone: {args.timezone}")
if getattr(args, "distro", "ubuntu") == "ubuntu":
lines += apt_mirror_lines(
getattr(args, "arch", "amd64"), getattr(args, "apt_mirror", None)
getattr(args, "arch", "amd64"),
getattr(args, "apt_mirror", None),
fixe=bool(getattr(args, "cache_ca", None)),
)
lines += [
"keyboard:",
@ -2494,7 +2802,7 @@ def build_cloud_config(
# dès le PREMIER boot. C'est le point : ils sont là avant l'installation
# d'ERPLibre, et encore là si elle échoue — le moment où l'on se connecte
# justement à la main.
lines += write_files_lines(guide_files(args))
lines += write_files_lines(guide_files(args) + cache_files(args))
# apt update/upgrade désactivés par défaut : sur un réseau lent/instable
# ils font pendre cloud-init au 1er boot (et retardent la dispo SSH). SSH
# est déjà présent dans les images cloud ; on l'active via runcmd sans apt.
@ -2517,8 +2825,11 @@ def build_cloud_config(
# Active et démarre SSH quel que soit le nom du service (ssh sur
# Debian/Ubuntu, sshd sur Fedora/Arch) — sans quoi la VM peut booter
# sans SSH accessible.
lines += ["runcmd:"]
# En TÊTE : ce qui suit peut télécharger, et sans magasin de confiance à
# jour un invité rejette le certificat que le cache présente.
lines += cache_runcmd(args)
lines += [
"runcmd:",
" - systemctl enable --now ssh 2>/dev/null"
" || systemctl enable --now sshd 2>/dev/null || true",
# Getty sur la console qui EXISTE VRAIMENT.
@ -4094,6 +4405,23 @@ def build_parser() -> argparse.ArgumentParser:
help="N'injecte pas l'identité git de l'hôte (user.name, user.email, "
"core.editor) dans le ~/.gitconfig de la VM.",
)
g_cloud.add_argument(
"--cache-ca",
default="",
help="Chemin, SUR L'HÔTE, du certificat de l'autorité du cache de "
"téléchargement (erplibre_go_qemu_cache). Fourni, la VM approuve "
"cette autorité dès son premier démarrage et ses téléchargements "
"passent par le cache. Absent, rien n'est posé.",
)
g_cloud.add_argument(
"--cache-bypass",
action="store_true",
help="Soustrait CETTE VM au cache de téléchargement : une exception "
"par adresse MAC est posée sur l'hôte avant la création, et la VM "
"télécharge en direct. Sans cela, retirer --cache-ca ne suffit pas — "
"le détournement est transparent et la VM échouerait sur un "
"certificat inconnu.",
)
g_cloud.add_argument(
"--apt-update",
action="store_true",
@ -4410,6 +4738,8 @@ def main() -> None:
resolved_osinfo = osinfo_arg(osinfo, args.distro)
print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==")
ensure_network(network_name(args.network), runner)
# Avant la création, et non après : la VM télécharge dès cloud-init.
cache_bypass_apply(args, runner)
virt_install(args, disk, seed, resolved_osinfo, runner, installer)
if installer:
watch_and_restart(args.name, runner)

3
script/qemu_cache/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Le binaire compilé : « go build » l'écrit ici, il pèse ~9 Mo et se refait
# d'une commande. L'installation le pose dans /usr/local/bin.
/erplibre_go_qemu_cache

287
script/qemu_cache/age.go Normal file
View file

@ -0,0 +1,287 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Le cache ne diminue jamais de lui-même — c'est assumé, et la place se
// surveille à la main. Encore faut-il pouvoir la surveiller : savoir CE QUI
// occupe, et depuis QUAND, avant de décider ce qui peut partir.
//
// L'âge retenu est la date du dernier USAGE et non celle du stockage. Un
// paquet servi tous les jours depuis un an n'est pas vieux : le supprimer
// obligerait à le retélécharger le lendemain. C'est ce qu'un opérateur veut
// dire par « nettoyer ce qui ne sert plus », et c'est pour cela que le service
// remet la date d'un objet à chaque fois qu'il le sert.
// Tranche est un groupe d'objets d'un même âge.
type Tranche struct {
// Debut est le premier instant de la tranche ; Libelle la nomme.
Debut time.Time
Libelle string
Objets int
Octets int64
}
// Granularite dit comment les tranches sont découpées.
type Granularite string
const (
ParJour Granularite = "jour"
ParSemaine Granularite = "semaine"
ParMois Granularite = "mois"
)
// LireGranularite tolère l'anglais comme le français : le CLI est bilingue
// partout ailleurs dans ce dépôt.
func LireGranularite(s string) (Granularite, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "jour", "day", "j", "d":
return ParJour, nil
case "semaine", "week", "s", "w":
return ParSemaine, nil
case "mois", "month", "m":
return ParMois, nil
}
return "", fmt.Errorf("granularité inconnue %q : jour, semaine ou mois", s)
}
// debutDeTranche ramène un instant au premier de sa tranche.
//
// La semaine commence le LUNDI : c'est la convention ISO, et celle que
// « %V » et les outils du système emploient. Ramener au dimanche donnerait des
// tranches qui ne correspondent à aucun autre relevé de la machine.
func debutDeTranche(t time.Time, g Granularite) time.Time {
t = t.Local()
jour := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
switch g {
case ParSemaine:
recul := (int(jour.Weekday()) + 6) % 7
return jour.AddDate(0, 0, -recul)
case ParMois:
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
}
return jour
}
func libelleDeTranche(t time.Time, g Granularite) string {
switch g {
case ParSemaine:
an, sem := t.ISOWeek()
return fmt.Sprintf("%d-S%02d", an, sem)
case ParMois:
return t.Format("2006-01")
}
return t.Format("2006-01-02")
}
// TranchesStore groupe les objets du magasin par âge, du plus récent au plus
// ancien.
func (s *Store) Tranches(g Granularite) []Tranche {
par := map[time.Time]*Tranche{}
filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if horsCasier(s.Dir, p, info) {
return filepath.SkipDir
}
if info.IsDir() || filepath.Ext(p) != ".body" {
return nil
}
d := debutDeTranche(info.ModTime(), g)
tr, ok := par[d]
if !ok {
tr = &Tranche{Debut: d, Libelle: libelleDeTranche(d, g)}
par[d] = tr
}
tr.Objets++
tr.Octets += info.Size()
return nil
})
return trier(par)
}
// TranchesMiroirs fait de même pour les dépôts git.
//
// Un dépôt entier par tranche, pas ses fichiers : ce qui s'efface est un
// dépôt, et le compter au fichier donnerait un relevé qu'on ne peut pas
// suivre d'un geste.
func (g *GitMirror) Tranches(gran Granularite) []Tranche {
if g == nil || g.Dir == "" {
return nil
}
par := map[time.Time]*Tranche{}
for _, d := range g.Depots() {
debut := debutDeTranche(d.Maj, gran)
tr, ok := par[debut]
if !ok {
tr = &Tranche{Debut: debut, Libelle: libelleDeTranche(debut, gran)}
par[debut] = tr
}
tr.Objets++
tr.Octets += d.Octets
}
return trier(par)
}
func trier(par map[time.Time]*Tranche) []Tranche {
out := make([]Tranche, 0, len(par))
for _, tr := range par {
out = append(out, *tr)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Debut.After(out[j].Debut)
})
return out
}
// Purger efface les objets dont le dernier usage précède `avant`.
//
// Le méta part AVEC le corps : un méta orphelin ferait croire à une copie
// présente, et la lecture échouerait au moment de servir, c'est-à-dire au pire
// moment. Rend (objets effacés, octets rendus).
func (s *Store) Purger(avant time.Time) (int, int64, error) {
n, octets := 0, int64(0)
err := filepath.Walk(s.Dir, func(
p string, info os.FileInfo, err error,
) error {
if err != nil || info == nil {
return nil
}
if horsCasier(s.Dir, p, info) {
return filepath.SkipDir
}
if info.IsDir() || filepath.Ext(p) != ".body" {
return nil
}
if !info.ModTime().Before(avant) {
return nil
}
taille := info.Size()
if e := os.Remove(p); e != nil {
return nil
}
os.Remove(strings.TrimSuffix(p, ".body") + ".meta")
n++
octets += taille
return nil
})
return n, octets, err
}
// PurgerMiroirs efface les dépôts qui n'ont pas été rafraîchis depuis
// `avant`. Rend (dépôts effacés, octets rendus).
func (g *GitMirror) PurgerMiroirs(avant time.Time) (int, int64, error) {
if g == nil || g.Dir == "" {
return 0, 0, nil
}
n, octets := 0, int64(0)
for _, d := range g.Depots() {
if !d.Maj.Before(avant) {
continue
}
if err := g.Retirer(d.Chemin); err != nil {
return n, octets, err
}
n++
octets += d.Octets
}
return n, octets, nil
}
// LireDuree accepte les suffixes de Go et, en plus, « j » pour un jour.
//
// « 30d » n'existe pas dans la bibliothèque, et un opérateur qui nettoie
// raisonne en jours, pas en sept cent vingt heures.
func LireDuree(s string) (time.Duration, error) {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return 0, fmt.Errorf("durée vide")
}
if n, unite := s[:len(s)-1], s[len(s)-1]; unite == 'j' || unite == 'd' {
var jours float64
if _, err := fmt.Sscanf(n, "%g", &jours); err != nil || jours < 0 {
return 0, fmt.Errorf("durée illisible %q", s)
}
return time.Duration(jours * 24 * float64(time.Hour)), nil
}
d, err := time.ParseDuration(s)
if err != nil || d < 0 {
return 0, fmt.Errorf(
"durée illisible %q : essayer 30j, 12h, 90m", s)
}
return d, nil
}
// printAge écrit le relevé par âge : les objets, puis les dépôts.
//
// Les deux SÉPARÉMENT : ils ne s'effacent pas de la même façon — un objet est
// un fichier, un dépôt est un arbre — et un total commun cacherait qu'un seul
// dépôt pèse plus que mille objets.
func printAge(store *Store, miroir *GitMirror, gran Granularite) {
fmt.Printf("Âge du dernier usage, par %s.\n", gran)
fmt.Printf(
"\nUn objet servi voit sa date remise à jour : « vieux » veut donc " +
"dire\n« n'a plus servi », et non « est entré il y a " +
"longtemps ».\n")
ecrire := func(titre string, tranches []Tranche, quoi string) {
fmt.Printf("\n%s\n", titre)
if len(tranches) == 0 {
fmt.Println(" rien")
return
}
var n int
var octets int64
for _, tr := range tranches {
fmt.Printf(" %-12s %6d %-8s %10s\n",
tr.Libelle, tr.Objets, quoi, HumanBytes(tr.Octets))
n += tr.Objets
octets += tr.Octets
}
fmt.Printf(" %-12s %6d %-8s %10s\n", "total", n, quoi,
HumanBytes(octets))
}
ecrire("Objets du cache :", store.Tranches(gran), "objets")
if miroir.Actif() {
ecrire("Dépôts en miroir :", miroir.Tranches(gran), "dépôts")
}
}
// printPurgeABlanc dit ce qu'une purge emporterait, sans rien effacer.
//
// Une purge ne se rattrape pas — les octets sont rendus, il faut les
// retélécharger — et c'est le genre de geste qu'on veut relire avant.
func printPurgeABlanc(store *Store, miroir *GitMirror, avant time.Time) {
var n int
var octets int64
for _, tr := range store.Tranches(ParJour) {
if tr.Debut.Before(avant) {
n += tr.Objets
octets += tr.Octets
}
}
fmt.Printf("[à blanc] objets qui partiraient : %d, %s\n",
n, HumanBytes(octets))
var nd int
var octd int64
for _, d := range miroir.Depots() {
if d.Maj.Before(avant) {
nd++
octd += d.Octets
}
}
fmt.Printf("[à blanc] dépôts qui partiraient : %d, %s\n",
nd, HumanBytes(octd))
fmt.Println("[à blanc] rien n'a été effacé.")
}

View file

@ -0,0 +1,204 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
func TestLireGranularite(t *testing.T) {
for _, cas := range []struct {
brut string
attendue Granularite
}{
{"jour", ParJour}, {"day", ParJour}, {"J", ParJour},
{"semaine", ParSemaine}, {"week", ParSemaine}, {" W ", ParSemaine},
{"mois", ParMois}, {"month", ParMois}, {"m", ParMois},
} {
g, err := LireGranularite(cas.brut)
if err != nil || g != cas.attendue {
t.Errorf("%q → %q, %v", cas.brut, g, err)
}
}
if _, err := LireGranularite("trimestre"); err == nil {
t.Error("une granularité inconnue est acceptée")
}
}
// « 30d » n'existe pas dans la bibliothèque, et un opérateur qui nettoie
// raisonne en jours, pas en sept cent vingt heures.
func TestLireDuree(t *testing.T) {
for _, cas := range []struct {
brut string
attendue time.Duration
}{
{"30j", 30 * 24 * time.Hour},
{"7d", 7 * 24 * time.Hour},
{"12h", 12 * time.Hour},
{"90m", 90 * time.Minute},
{"0.5j", 12 * time.Hour},
} {
d, err := LireDuree(cas.brut)
if err != nil || d != cas.attendue {
t.Errorf("%q → %v, %v (attendu %v)", cas.brut, d, err, cas.attendue)
}
}
for _, brut := range []string{"", "3lunes", "-5j", "abc"} {
if d, err := LireDuree(brut); err == nil {
t.Errorf("%q accepté et rendu %v", brut, d)
}
}
}
// La semaine commence le LUNDI : c'est la convention ISO, celle des outils du
// système. La ramener au dimanche donnerait des tranches qui ne correspondent
// à aucun autre relevé de la machine.
func TestLaSemaineCommenceLundi(t *testing.T) {
// Un mercredi, et le dimanche qui le suit.
mercredi := time.Date(2026, 9, 2, 15, 0, 0, 0, time.Local)
dimanche := time.Date(2026, 9, 6, 15, 0, 0, 0, time.Local)
a := debutDeTranche(mercredi, ParSemaine)
b := debutDeTranche(dimanche, ParSemaine)
if !a.Equal(b) {
t.Errorf("mercredi et le dimanche suivant tombent dans deux"+
" semaines : %s contre %s", a, b)
}
if a.Weekday() != time.Monday {
t.Errorf("la semaine commence un %s", a.Weekday())
}
}
// magasinDate pose un objet du magasin avec une date choisie.
func magasinDate(t *testing.T, s *Store, cle string, quand time.Time, n int) {
t.Helper()
meta, corps := s.paths(cle)
if err := os.MkdirAll(filepath.Dir(corps), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(corps, make([]byte, n), 0o644); err != nil {
t.Fatal(err)
}
// La taille est écrite dans le méta : le magasin refuse un corps dont
// elle ne correspond plus, y voyant une écriture interrompue.
if err := os.WriteFile(
meta, []byte(fmt.Sprintf(`{"size":%d}`, n)), 0o644,
); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(corps, quand, quand); err != nil {
t.Fatal(err)
}
}
func TestTranchesGroupentParAge(t *testing.T) {
s := &Store{Dir: t.TempDir()}
maintenant := time.Now()
magasinDate(t, s, "aaaa1111", maintenant, 100)
magasinDate(t, s, "bbbb2222", maintenant, 200)
magasinDate(t, s, "cccc3333", maintenant.AddDate(0, 0, -40), 300)
jours := s.Tranches(ParJour)
if len(jours) != 2 {
t.Fatalf("%d tranches par jour : %+v", len(jours), jours)
}
// Du plus récent au plus ancien : c'est l'ordre de lecture.
if !jours[0].Debut.After(jours[1].Debut) {
t.Error("les tranches ne descendent pas dans le temps")
}
if jours[0].Objets != 2 || jours[0].Octets != 300 {
t.Errorf("tranche du jour : %+v", jours[0])
}
if n := len(s.Tranches(ParMois)); n != 2 {
t.Errorf("%d tranches par mois, deux attendues", n)
}
}
// Le méta part AVEC le corps : un méta orphelin ferait croire à une copie
// présente, et la lecture échouerait au moment de servir.
func TestPurgerEmporteLeMetaAvecLeCorps(t *testing.T) {
s := &Store{Dir: t.TempDir()}
vieux := time.Now().AddDate(0, 0, -40)
magasinDate(t, s, "aaaa1111", vieux, 500)
magasinDate(t, s, "bbbb2222", time.Now(), 700)
n, octets, err := s.Purger(time.Now().AddDate(0, 0, -30))
if err != nil {
t.Fatal(err)
}
if n != 1 || octets != 500 {
t.Fatalf("purge : %d objets, %d octets", n, octets)
}
meta, corps := s.paths("aaaa1111")
for _, f := range []string{meta, corps} {
if _, err := os.Stat(f); !os.IsNotExist(err) {
t.Errorf("%s subsiste", filepath.Base(f))
}
}
// Et le récent est INTACT : une purge par âge ne touche pas au reste.
if _, f, err := s.Get("bbbb2222"); err != nil {
t.Errorf("l'objet récent a été emporté : %v", err)
} else {
f.Close()
}
}
func TestPurgerToutNeLaisseRien(t *testing.T) {
s := &Store{Dir: t.TempDir()}
for i, cle := range []string{"aaaa1111", "bbbb2222", "cccc3333"} {
magasinDate(t, s, cle, time.Now().AddDate(0, 0, -i), 100)
}
if n, _, err := s.Purger(time.Now().Add(time.Minute)); err != nil ||
n != 3 {
t.Fatalf("purge totale : %d objets, %v", n, err)
}
if st, err := s.Stat(); err != nil || st.Objects != 0 {
t.Errorf("il reste %d objets", st.Objects)
}
}
// L'âge d'un objet est celui de son dernier USAGE, pas de son entrée.
//
// Sans cela, « nettoyer ce qui ne sert plus » emporterait un paquet servi tous
// les jours depuis un an, et il faudrait le retélécharger le lendemain — le
// contraire de ce qu'un cache est là pour faire.
func TestServirRajeunitUnObjet(t *testing.T) {
a := nouvelAmont(t, "charge utile")
p := proxyDeTest(t)
chemin := "/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst"
if w := demande(t, p, a.hote(), chemin); w.Code != 200 {
t.Fatalf("premier appel : %d", w.Code)
}
// L'objet est vieilli à la main, comme s'il dormait depuis quarante jours.
var corps string
filepath.Walk(p.Store.Dir, func(q string, i os.FileInfo, e error) error {
if e == nil && i != nil && filepath.Ext(q) == ".body" {
corps = q
}
return nil
})
if corps == "" {
t.Fatal("aucun objet gardé")
}
vieux := time.Now().AddDate(0, 0, -40)
if err := os.Chtimes(corps, vieux, vieux); err != nil {
t.Fatal(err)
}
if w := demande(t, p, a.hote(), chemin); w.Code != 200 {
t.Fatalf("second appel : %d", w.Code)
}
info, err := os.Stat(corps)
if err != nil {
t.Fatal(err)
}
if time.Since(info.ModTime()) > time.Minute {
t.Errorf("l'objet servi garde sa vieille date (%s) : un nettoyage"+
" par âge l'emporterait alors qu'il sert", info.ModTime())
}
}

146
script/qemu_cache/boucle.go Normal file
View file

@ -0,0 +1,146 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Le cache écoute sur toutes les adresses de l'hôte. Une requête qui le
// désigne LUI — son port, sur une adresse de cette machine — serait relayée
// vers sa propre écoute, qui la relaierait à son tour : chaque tour ouvre une
// connexion de plus, et la boucle épuise les descripteurs jusqu'à arrêter le
// service entier. Ce fichier la reconnaît et la refuse.
// viseLeCache dit si une URL désigne le cache lui-même.
//
// Le port d'abord : il ne coûte rien, et seul un port d'écoute du cache peut
// boucler. L'hôte ensuite — « localhost », une adresse de bouclage, une
// adresse non spécifiée ou celle d'une interface de cette machine. Un NOM
// n'est résolu que dans ce cas rare où le port concorde : le résoudre à
// chaque requête coûterait un aller-retour DNS à tout le trafic.
func (p *Proxy) viseLeCache(u *url.URL) bool {
if p == nil || len(p.Ecoutes) == 0 || u == nil {
return false
}
port := portDe(u)
concorde := false
for _, e := range p.Ecoutes {
if e == port {
concorde = true
break
}
}
if !concorde {
return false
}
hote := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
if hote == "localhost" || strings.HasSuffix(hote, ".localhost") {
return true
}
if ip := net.ParseIP(hote); ip != nil {
return estAdresseLocale(ip)
}
ctx, annule := context.WithTimeout(context.Background(), 2*time.Second)
defer annule()
ips, err := net.DefaultResolver.LookupIPAddr(ctx, hote)
if err != nil {
return false
}
for _, a := range ips {
if estAdresseLocale(a.IP) {
return true
}
}
return false
}
// portDe rend le port d'une URL, déduit du schéma quand elle n'en porte pas,
// et -1 quand il ne se déduit pas.
func portDe(u *url.URL) int {
if brut := u.Port(); brut != "" {
n, err := strconv.Atoi(brut)
if err != nil {
return -1
}
return n
}
switch strings.ToLower(u.Scheme) {
case "https":
return 443
case "http":
return 80
}
return -1
}
// estAdresseLocale dit si une adresse joint cette machine : bouclage,
// adresse non spécifiée — qu'une connexion traite comme l'hôte local — ou
// adresse de l'une de ses interfaces.
func estAdresseLocale(ip net.IP) bool {
if ip.IsLoopback() || ip.IsUnspecified() {
return true
}
adresses, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, a := range adresses {
if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) {
return true
}
}
return false
}
// boucle répond « 508 Loop Detected » sans rien demander à personne, et le
// journal le compte comme une erreur. Le message est inerte pour un shell,
// comme celui du hors-ligne : il peut finir dans « curl … | bash ».
func (p *Proxy) boucle(
w http.ResponseWriter, u *url.URL, class Class, method, client string,
) {
msg := enCommentaire(fmt.Sprintf(
"erplibre_go_qemu_cache : requête adressée au cache lui-même, refusée.\n"+
" demandé : %s\n"+
"La relayer la renverrait vers cette même écoute, sans fin.\n",
u))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-ERPLibre-Cache", OutcomeError)
w.WriteHeader(http.StatusLoopDetected)
fmt.Fprint(w, msg)
p.record(accessLine{
Method: method, URL: u.String(), Class: class.String(),
Outcome: OutcomeError, Status: http.StatusLoopDetected,
Client: client,
})
log.Printf("boucle refusée : %s vise le cache lui-même", u)
}
// memeAdresse compare deux « hôte:port » par leur VALEUR : « ::ffff:a.b.c.d »
// et « a.b.c.d » désignent la même machine, et une comparaison de textes les
// séparerait.
func memeAdresse(a, b string) bool {
ha, pa, err := net.SplitHostPort(a)
if err != nil {
return false
}
hb, pb, err := net.SplitHostPort(b)
if err != nil || pa != pb {
return false
}
ia, ib := net.ParseIP(ha), net.ParseIP(hb)
if ia == nil || ib == nil {
return strings.EqualFold(ha, hb)
}
return ia.Equal(ib)
}

View file

@ -0,0 +1,236 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
)
// portDeLAmont rend le port d'un amont de test, sous ses deux formes.
func portDeLAmont(t *testing.T, a *amontScripte) (int, string) {
t.Helper()
_, brut, err := net.SplitHostPort(a.hote())
if err != nil {
t.Fatal(err)
}
n, err := strconv.Atoi(brut)
if err != nil {
t.Fatal(err)
}
return n, brut
}
// Une requête qui vise le cache lui-même est refusée sans être relayée : la
// relayer la renverrait vers cette même écoute, qui la relaierait encore.
//
// L'amont de test JOUE le cache : son port est déclaré comme écoute. Un seul
// appel reçu prouve que la requête est partie.
func TestUneRequeteAuCacheLuiMemeEstRefusee(t *testing.T) {
soi := nouvelAmontScripte(t, servir("ne doit jamais répondre"))
port, brut := portDeLAmont(t, soi)
p := proxyDeTest(t)
journal := journalDeTest(t, p)
p.Ecoutes = []int{port}
for _, hote := range []string{"127.0.0.1:" + brut, "localhost:" + brut} {
w := joue(t, p, "GET", hote, "/boucle")
if w.Code != http.StatusLoopDetected {
t.Fatalf("%s : code %d, attendu 508", hote, w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeError {
t.Errorf("%s : servi « %s », attendu « %s »", hote, got, OutcomeError)
}
for _, l := range strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") {
if !strings.HasPrefix(l, "#") {
t.Errorf("ligne exécutable par un shell : %q", l)
}
}
}
if n := soi.appels(); n != 0 {
t.Errorf("la requête est partie %d fois vers le cache lui-même", n)
}
for _, l := range lignesDuJournal(t, journal) {
if l.Outcome != OutcomeError || l.Status != http.StatusLoopDetected {
t.Errorf("journal : %s %d, attendu error 508", l.Outcome, l.Status)
}
}
// Un autre port de la même machine reste un amont comme un autre.
autre := nouvelAmontScripte(t, servir("amont légitime"))
if w := joue(t, p, "GET", autre.hote(), "/fichier"); w.Code != http.StatusOK {
t.Errorf("un amont local sur un autre port est refusé : %d", w.Code)
}
}
// Une redirection qui pointe vers le cache n'est pas suivie par lui.
func TestUneRedirectionVersLeCacheNEstPasSuivie(t *testing.T) {
soi := nouvelAmontScripte(t, servir("ne doit jamais répondre"))
port, brut := portDeLAmont(t, soi)
pub := nouvelAmontScripte(t, rediriger(http.StatusFound,
"http://127.0.0.1:"+brut+"/outil_1.0_amd64.deb"))
p := proxyDeTest(t)
p.Ecoutes = []int{port}
w := joue(t, p, "GET", pub.hote(), "/outil_1.0_amd64.deb")
if w.Code != http.StatusFound {
t.Errorf("code %d, attendu la redirection rendue au client", w.Code)
}
if n := soi.appels(); n != 0 {
t.Errorf("la redirection a été suivie %d fois vers le cache", n)
}
}
func TestViseLeCache(t *testing.T) {
p := &Proxy{Ecoutes: []int{8898, 8899}}
cas := map[string]bool{
"http://127.0.0.1:8898/x": true,
"http://localhost:8898/x": true,
"https://LOCALHOST.:8899/x": true,
"http://[::1]:8899/x": true,
"http://0.0.0.0:8898/x": true,
"http://127.0.0.1:8897/x": false,
"http://192.0.2.10:8898/x": false,
"https://miroir.example/x.deb": false,
}
for brut, attendu := range cas {
u, _ := url.Parse(brut)
if got := p.viseLeCache(u); got != attendu {
t.Errorf("%s : %v, attendu %v", brut, got, attendu)
}
}
// Une adresse d'une interface de CETTE machine joint aussi l'écoute.
if adresses, err := net.InterfaceAddrs(); err == nil {
for _, a := range adresses {
n, ok := a.(*net.IPNet)
if !ok || n.IP.IsLoopback() || n.IP.To4() == nil {
continue
}
u, _ := url.Parse("http://" + n.IP.String() + ":8898/x")
if !p.viseLeCache(u) {
t.Errorf("%s, adresse de cette machine, n'est pas reconnue", u)
}
break
}
}
// Sans écoute déclarée, rien n'est refusé.
u, _ := url.Parse("http://127.0.0.1:8898/x")
if (&Proxy{}).viseLeCache(u) {
t.Error("un proxy sans écoute déclarée refuse une requête")
}
}
func TestMemeAdresse(t *testing.T) {
cas := []struct {
a, b string
attendu bool
}{
{"127.0.0.1:8899", "127.0.0.1:8899", true},
{"127.0.0.1:8899", "[::ffff:127.0.0.1]:8899", true},
{"127.0.0.1:8899", "127.0.0.1:8898", false},
{"192.0.2.1:443", "127.0.0.1:443", false},
{"pas une adresse", "127.0.0.1:443", false},
}
for _, c := range cas {
if got := memeAdresse(c.a, c.b); got != c.attendu {
t.Errorf("%s / %s : %v, attendu %v", c.a, c.b, got, c.attendu)
}
}
}
// ecouteComptee compte dans Accept et rend la socket TCP BRUTE : une
// connexion enveloppée ferait échouer originalDst avant le tunnel, et le test
// ne prouverait plus rien. Au plafond, elle se ferme : une boucle doit rester
// bornée, pour que l'échec du test ne fasse pas tomber la machine qui le
// lance.
type ecouteComptee struct {
*net.TCPListener
acceptees int64
plafond int64
}
func (l *ecouteComptee) Accept() (net.Conn, error) {
c, err := l.AcceptTCP()
if err != nil {
return nil, err
}
if atomic.AddInt64(&l.acceptees, 1) >= l.plafond {
l.TCPListener.Close()
}
return c, nil
}
// sondeOriginalDst saute le test là où le noyau ne rend pas, pour une
// connexion locale non détournée, l'écoute elle-même comme destination
// d'origine : sans suivi de connexion, originalDst échoue avant le tunnel et
// la garde n'a rien à garder.
func sondeOriginalDst(t *testing.T) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
client, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer client.Close()
c, err := ln.Accept()
if err != nil {
t.Fatal(err)
}
defer c.Close()
dst, err := originalDst(c)
if err != nil || !memeAdresse(dst, ln.Addr().String()) {
t.Skipf("destination d'origine indisponible ici (%q, %v)", dst, err)
}
}
// Une connexion non détournée vers le front TLS, pour un hôte en tunnel, a
// pour destination d'origine l'écoute elle-même. Le tunnel la rappelait :
// chaque tour acceptait une connexion de plus.
func TestUnTunnelNeSeRappellePasLuiMeme(t *testing.T) {
sondeOriginalDst(t)
brut, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ln := &ecouteComptee{TCPListener: brut.(*net.TCPListener), plafond: 16}
t.Cleanup(func() { ln.Close() })
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatal(err)
}
const hote = "tunnel.example.invalid"
front := &TLSFront{
CA: ca, Proxy: proxyDeTest(t), Refusals: NewRefusals([]string{hote}),
}
go front.Serve(ln)
c, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if _, err := c.Write(clientHelloBrut(t, hote)); err != nil {
t.Fatal(err)
}
c.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := io.ReadAll(c); err != nil {
t.Errorf("la connexion n'est pas refermée : %v", err)
}
if n := atomic.LoadInt64(&ln.acceptees); n != 1 {
t.Errorf("%d connexions acceptées pour une seule demande :"+
" le tunnel s'est rappelé lui-même", n)
}
}

253
script/qemu_cache/bypass.go Normal file
View file

@ -0,0 +1,253 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"bufio"
"fmt"
"net"
"os"
"path/filepath"
"sort"
"strings"
)
// Le détournement est TRANSPARENT : il vaut pour tout ce qui sort du pont, et
// une VM n'a aucun moyen de s'y soustraire depuis l'intérieur. Ne pas lui
// donner l'autorité ne la dispense pas d'être interceptée — cela lui fait
// seulement refuser un certificat qu'elle ne reconnaît pas, et échouer sur
// « self-signed certificate in certificate chain ».
//
// Une exception doit donc être posée sur l'HÔTE, avant que la VM démarre, et
// elle a besoin d'un identifiant que la VM porte de façon stable. L'adresse
// IP ne convient pas : elle vient d'un bail qui change. L'adresse MAC est
// fixée dans la définition du domaine et ne bouge plus.
//
// Elle est tenue dans un ensemble nommé nftables plutôt que dans les règles
// elles-mêmes : un élément s'ajoute et se retire à chaud, sans reposer le
// jeu de règles, donc sans couper les téléchargements des autres VM.
// BypassSetName : le nom de l'ensemble à l'intérieur de la table.
const BypassSetName = "bypass"
// BypassEntry associe l'adresse MAC soustraite au nom de la VM qui la porte.
//
// Le nom n'entre dans aucune règle ; il existe pour que la liste soit
// relisible et pour que l'on sache quelle entrée retirer quand la VM meurt.
// Une MAC orpheline est le mode de défaillance de ce fichier : réattribuée à
// une VM neuve, elle la soustrairait au cache sans que personne l'ait
// demandé, et rien ne le dirait.
type BypassEntry struct {
MAC string
Name string
}
// NormalizeMAC rend la forme canonique — six octets, minuscules,
// deux-points — ou dit pourquoi l'entrée ne peut pas en être une.
//
// Deux refus qui ne sont pas des caprices. L'adresse nulle correspond à tout
// ce qui n'a pas d'adresse et n'identifie donc rien. Une adresse de diffusion
// ou de groupe — bit de poids faible du premier octet à 1 — ne peut JAMAIS
// apparaître comme adresse SOURCE : l'entrée serait morte à l'écriture, et
// une exception qui ne s'applique jamais est pire qu'absente, puisqu'on la
// croit posée.
func NormalizeMAC(s string) (string, error) {
brut := strings.TrimSpace(s)
if brut == "" {
return "", fmt.Errorf("adresse MAC vide")
}
// Douze caractères hexadécimaux sans séparateur : la forme que rendent
// plusieurs outils, que net.ParseMAC ne lit pas.
if len(brut) == 12 && !strings.ContainsAny(brut, ":-.") {
var parts []string
for i := 0; i < 12; i += 2 {
parts = append(parts, brut[i:i+2])
}
brut = strings.Join(parts, ":")
}
adr, err := net.ParseMAC(brut)
if err != nil {
return "", fmt.Errorf("adresse MAC illisible %q", s)
}
if len(adr) != 6 {
return "", fmt.Errorf("adresse MAC de %d octets, six attendus", len(adr))
}
if adr[0]&1 == 1 {
return "", fmt.Errorf(
"%s est une adresse de groupe : jamais une adresse source", adr)
}
nulle := true
for _, o := range adr {
if o != 0 {
nulle = false
break
}
}
if nulle {
return "", fmt.Errorf("l'adresse nulle n'identifie aucune machine")
}
return strings.ToLower(adr.String()), nil
}
// BypassFile est la liste DURABLE. L'ensemble nftables vit dans le noyau et
// disparaît avec la table ; ce fichier est ce que le service relit à chaque
// démarrage pour reposer les mêmes exceptions.
type BypassFile struct {
Path string
}
// Load rend les entrées, triées par MAC. Un fichier absent n'est pas une
// erreur : c'est l'état d'une installation où personne n'a rien excepté.
//
// Une ligne illisible est SAUTÉE et non fatale. Le fichier est éditable à la
// main, et refuser de démarrer le service pour une faute de frappe dans une
// exception coûterait plus que d'ignorer la ligne.
func (b BypassFile) Load() ([]BypassEntry, error) {
fh, err := os.Open(b.Path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer fh.Close()
vues := map[string]bool{}
var out []BypassEntry
sc := bufio.NewScanner(fh)
for sc.Scan() {
ligne := strings.TrimSpace(sc.Text())
if i := strings.IndexByte(ligne, '#'); i >= 0 {
ligne = strings.TrimSpace(ligne[:i])
}
if ligne == "" {
continue
}
champs := strings.Fields(ligne)
mac, err := NormalizeMAC(champs[0])
if err != nil || vues[mac] {
continue
}
vues[mac] = true
nom := ""
if len(champs) > 1 {
nom = strings.Join(champs[1:], " ")
}
out = append(out, BypassEntry{MAC: mac, Name: nom})
}
if err := sc.Err(); err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool { return out[i].MAC < out[j].MAC })
return out, nil
}
// Save réécrit le fichier en entier, par un remplacement atomique : une
// écriture coupée en deux laisserait une liste tronquée que le prochain
// démarrage prendrait pour la vérité.
func (b BypassFile) Save(entries []BypassEntry) error {
if err := os.MkdirAll(filepath.Dir(b.Path), 0o755); err != nil {
return err
}
var sb strings.Builder
sb.WriteString("# Exceptions du cache de téléchargement des VM QEMU.\n")
sb.WriteString("# Une ligne « <MAC> <nom de la VM> » par machine que le\n")
sb.WriteString("# détournement doit ignorer. Relu à chaque démarrage du\n")
sb.WriteString("# service ; une MAC dont la VM n'existe plus est à retirer.\n")
for _, e := range entries {
if e.Name == "" {
sb.WriteString(e.MAC + "\n")
continue
}
fmt.Fprintf(&sb, "%s %s\n", e.MAC, e.Name)
}
tmp := b.Path + ".part"
if err := os.WriteFile(tmp, []byte(sb.String()), 0o644); err != nil {
return err
}
return os.Rename(tmp, b.Path)
}
// Add pose une exception et rend la MAC canonique. Reposer une MAC déjà
// présente met son nom à jour au lieu de créer un doublon : le déploiement
// est relancé, et deux lignes pour une machine rendraient le retrait
// incomplet.
func (b BypassFile) Add(mac, nom string) (string, error) {
canon, err := NormalizeMAC(mac)
if err != nil {
return "", err
}
entries, err := b.Load()
if err != nil {
return "", err
}
remplacee := false
for i := range entries {
if entries[i].MAC == canon {
entries[i].Name = nom
remplacee = true
break
}
}
if !remplacee {
entries = append(entries, BypassEntry{MAC: canon, Name: nom})
sort.Slice(entries, func(i, j int) bool {
return entries[i].MAC < entries[j].MAC
})
}
return canon, b.Save(entries)
}
// Del retire une exception. Rend faux si elle n'y était pas — retirer deux
// fois n'est pas une erreur, mais l'appelant doit pouvoir le dire.
func (b BypassFile) Del(mac string) (string, bool, error) {
canon, err := NormalizeMAC(mac)
if err != nil {
return "", false, err
}
entries, err := b.Load()
if err != nil {
return "", false, err
}
var reste []BypassEntry
trouvee := false
for _, e := range entries {
if e.MAC == canon {
trouvee = true
continue
}
reste = append(reste, e)
}
if !trouvee {
return canon, false, nil
}
return canon, true, b.Save(reste)
}
// MACs rend les seules adresses, dans l'ordre du fichier.
func MACs(entries []BypassEntry) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.MAC)
}
return out
}
// BypassAddElement et BypassDelElement rendent le geste À CHAUD, à passer à
// « nft -f - ». Modifier l'ensemble ne repose pas les règles : les
// téléchargements des autres VM ne sont pas coupés, ce qu'un redémarrage du
// service ferait.
//
// Comme le reste de ce paquet, ces lignes sont RENDUES et jamais exécutées :
// l'appelant les applique, et un test les vérifie sans toucher au pare-feu de
// la machine qui l'exécute.
func BypassAddElement(mac string) string {
return fmt.Sprintf("add element ip %s %s { %s }",
TableName, BypassSetName, mac)
}
func BypassDelElement(mac string) string {
return fmt.Sprintf("delete element ip %s %s { %s }",
TableName, BypassSetName, mac)
}

View file

@ -0,0 +1,186 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeMAC(t *testing.T) {
bons := map[string]string{
"52:54:00:AA:BB:CC": "52:54:00:aa:bb:cc",
"52-54-00-aa-bb-cc": "52:54:00:aa:bb:cc",
"525400aabbcc": "52:54:00:aa:bb:cc",
" 52:54:00:01:02:03 ": "52:54:00:01:02:03",
}
for brut, attendu := range bons {
got, err := NormalizeMAC(brut)
if err != nil {
t.Errorf("%q refusée : %v", brut, err)
continue
}
if got != attendu {
t.Errorf("%q → %q, attendu %q", brut, got, attendu)
}
}
}
// Deux refus qui protègent d'une exception morte à l'écriture.
func TestNormalizeMACRefuse(t *testing.T) {
for _, brut := range []string{
"",
"pas une adresse",
"52:54:00:aa:bb", // cinq octets
"52:54:00:aa:bb:cc:dd:ee", // huit : une adresse InfiniBand
"00:00:00:00:00:00", // n'identifie rien
"ff:ff:ff:ff:ff:ff", // diffusion : jamais une source
"01:00:5e:00:00:01", // groupe : jamais une source
} {
if got, err := NormalizeMAC(brut); err == nil {
t.Errorf("%q acceptée et rendue %q", brut, got)
}
}
}
func fichierNeuf(t *testing.T) BypassFile {
t.Helper()
return BypassFile{Path: filepath.Join(t.TempDir(), "bypass")}
}
// Un fichier absent est l'état normal d'une installation neuve, pas une
// panne : le service doit démarrer.
func TestChargerUnFichierAbsent(t *testing.T) {
b := BypassFile{Path: filepath.Join(t.TempDir(), "jamais-ecrit")}
entries, err := b.Load()
if err != nil {
t.Fatalf("un fichier absent lève : %v", err)
}
if len(entries) != 0 {
t.Errorf("%d entrées sorties de rien", len(entries))
}
}
func TestAjouterPuisRetirer(t *testing.T) {
b := fichierNeuf(t)
mac, err := b.Add("52:54:00:AA:BB:CC", "vm-essai")
if err != nil {
t.Fatalf("ajout : %v", err)
}
if mac != "52:54:00:aa:bb:cc" {
t.Errorf("MAC rendue %q", mac)
}
entries, _ := b.Load()
if len(entries) != 1 || entries[0].Name != "vm-essai" {
t.Fatalf("relecture : %+v", entries)
}
// La forme d'écriture ne doit pas décider du retrait.
_, avait, err := b.Del("525400AABBCC")
if err != nil || !avait {
t.Fatalf("retrait : avait=%v err=%v", avait, err)
}
entries, _ = b.Load()
if len(entries) != 0 {
t.Errorf("%d entrée(s) survivent au retrait", len(entries))
}
}
// Le déploiement est relancé : la même VM ne doit pas produire deux lignes,
// sans quoi un seul retrait laisserait l'exception en place.
func TestReposerLaMemeMACMetAJour(t *testing.T) {
b := fichierNeuf(t)
if _, err := b.Add("52:54:00:aa:bb:cc", "ancien"); err != nil {
t.Fatal(err)
}
if _, err := b.Add("52:54:00:AA:BB:CC", "neuf"); err != nil {
t.Fatal(err)
}
entries, _ := b.Load()
if len(entries) != 1 {
t.Fatalf("%d entrées pour une machine : %+v", len(entries), entries)
}
if entries[0].Name != "neuf" {
t.Errorf("nom resté %q", entries[0].Name)
}
}
func TestRetirerCeQuiNyEstPas(t *testing.T) {
b := fichierNeuf(t)
mac, avait, err := b.Del("52:54:00:00:00:09")
if err != nil {
t.Fatalf("un retrait à vide lève : %v", err)
}
if avait {
t.Error("dit avoir retiré une exception absente")
}
if mac == "" {
t.Error("la MAC canonique doit être rendue pour le geste à chaud")
}
}
// Le fichier est éditable à la main : une faute de frappe ne doit pas
// empêcher le service de démarrer, seulement perdre sa ligne.
func TestUneLigneIllisibleEstSautee(t *testing.T) {
b := fichierNeuf(t)
contenu := strings.Join([]string{
"# un commentaire",
"",
"52:54:00:11:22:33 vm-une # en fin de ligne",
"ceci n'est pas une adresse",
"ff:ff:ff:ff:ff:ff diffusion",
"52:54:00:44:55:66",
"52:54:00:11:22:33 doublon",
}, "\n")
if err := os.WriteFile(b.Path, []byte(contenu), 0o644); err != nil {
t.Fatal(err)
}
entries, err := b.Load()
if err != nil {
t.Fatalf("lecture : %v", err)
}
if len(entries) != 2 {
t.Fatalf("%d entrées retenues : %+v", len(entries), entries)
}
if entries[0].MAC != "52:54:00:11:22:33" || entries[0].Name != "vm-une" {
t.Errorf("première entrée : %+v", entries[0])
}
if entries[1].MAC != "52:54:00:44:55:66" {
t.Errorf("seconde entrée : %+v", entries[1])
}
}
// Le nom voyage avec la MAC : c'est lui qui dit quelle entrée retirer quand
// la VM meurt. Une entrée orpheline soustrairait au cache une machine neuve
// qui hériterait de la MAC, sans que personne l'ait demandé.
func TestLeNomSurvitAuxAllersRetours(t *testing.T) {
b := fichierNeuf(t)
if _, err := b.Add("52:54:00:77:88:99", "vm de démonstration"); err != nil {
t.Fatal(err)
}
entries, _ := b.Load()
if len(entries) != 1 || entries[0].Name != "vm de démonstration" {
t.Fatalf("nom perdu : %+v", entries)
}
}
func TestGestesAChaud(t *testing.T) {
mac := "52:54:00:aa:bb:cc"
ajout := BypassAddElement(mac)
retrait := BypassDelElement(mac)
for _, l := range []string{ajout, retrait} {
if !strings.Contains(l, TableName) ||
!strings.Contains(l, BypassSetName) ||
!strings.Contains(l, mac) {
t.Errorf("geste incomplet : %q", l)
}
}
if !strings.HasPrefix(ajout, "add element") {
t.Errorf("ajout : %q", ajout)
}
if !strings.HasPrefix(retrait, "delete element") {
t.Errorf("retrait : %q", retrait)
}
}

View file

@ -0,0 +1,233 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"net/url"
"path"
"regexp"
"strings"
)
// Class dit ce que le cache a le droit de faire d'une réponse.
//
// La distinction porte tout l'outil. Un fichier de paquet est IMMUABLE :
// son nom porte sa version, son contenu ne change jamais, et le servir du
// disque est exactement le gain cherché. Un index de dépôt est VOLATILE :
// il nomme les versions qui existent en ce moment, et servir un index périmé
// fait échouer l'installation sur un fichier retiré des miroirs — « failed
// retrieving file … 404 », le mode de défaillance que la préparation d'un
// invité Arch documente déjà.
//
// D'où la règle : seul l'immuable est servi du cache tant que l'amont répond.
// Le volatile est STOCKÉ quand même, et ne ressort que si l'amont est
// injoignable, ce qui rend le déploiement hors ligne possible sans jamais
// ouvrir de fenêtre de péremption quand le réseau est là.
type Class int
const (
// ClassImmutable : servi du disque dès qu'il y est.
ClassImmutable Class = iota
// ClassVolatile : toujours pris à l'amont, stocké, servi hors ligne seul.
ClassVolatile
// ClassNoStore : ni servi ni stocké.
ClassNoStore
)
func (c Class) String() string {
switch c {
case ClassImmutable:
return "immutable"
case ClassVolatile:
return "volatile"
default:
return "no-store"
}
}
// Suffixes d'un fichier dont le nom porte sa version. L'ordre n'importe pas ;
// le premier qui correspond gagne.
var immutableSuffixes = []string{
// paquets de distribution
".pkg.tar.zst", ".pkg.tar.xz", ".deb", ".rpm", ".apk",
// signatures détachées, aussi figées que ce qu'elles signent
".pkg.tar.zst.sig", ".pkg.tar.xz.sig",
// écosystèmes Python et Node
".whl", ".tgz",
// images et supports d'installation
".qcow2", ".iso", ".img", ".raw", ".vmdk",
// archives amont
".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".zip",
}
// Noms et suffixes d'un index de dépôt. Un « .db » d'Arch, un « InRelease »
// de Debian et un « repomd.xml » de Fedora décrivent tous l'état COURANT du
// miroir.
var volatileNames = []string{
"inrelease", "release", "release.gpg",
"packages", "packages.gz", "packages.xz", "packages.bz2",
"sources", "sources.gz", "sources.xz",
"repomd.xml", "repomd.xml.asc", "repomd.xml.key",
"index.json", "index.html",
}
var volatileSuffixes = []string{
".db", ".db.sig", ".db.tar.gz", ".files", ".files.tar.gz",
".xml.gz", ".xml.zck", ".sqlite.bz2", ".sqlite.gz",
}
// parEmpreinte reconnaît un index Debian publié sous l'empreinte de son
// contenu : « …/by-hash/SHA256/<hexadécimal> ». Le nom EST la somme du
// contenu, si bien qu'un contenu différent porte un autre nom : le fichier est
// aussi figé qu'un paquet, quoiqu'il n'ait aucune extension.
//
// Il se range SANS son hôte, et c'est ce qui le distingue d'un index nommé
// par sa suite : deux miroirs qui servent ce chemin servent le même octet,
// ou l'un des deux est corrompu et son client le rejette sur la somme. Le
// raisonnement inverse — « le même chemin ne vaut que si le miroir publie la
// même suite » — vaut pour « Packages », dont le contenu varie d'un miroir à
// l'autre sous un nom identique ; il ne vaut pas pour un nom qui EST la somme
// de son contenu, et l'appliquer ici vide le cache de ses index dès qu'une
// liste de miroirs tourne.
var parEmpreinte = regexp.MustCompile(
`/by-hash/(MD5Sum|SHA1|SHA256|SHA512)/[0-9a-fA-F]{32,128}$`)
// dernierePublication reconnaît « /<propriétaire>/<dépôt>/releases/latest/
// download/<fichier> » : un POINTEUR vers la dernière version publiée, dont
// la cible change à chaque publication.
//
// Son suffixe — « .tar.gz », « .zip » — le ferait passer pour figé : servi du
// disque sans jamais redemander, il resterait à la première version vue, et
// rangé sans son hôte il répondrait pour n'importe quelle forge. Il est donc
// volatile et attaché à son hôte, et la règle passe AVANT les suffixes.
var dernierePublication = regexp.MustCompile(
`^/[^/]+/[^/]+/releases/latest/download/[^/]+$`)
// Chemins du protocole « smart HTTP » de git. Ce sont des points de
// NÉGOCIATION : le serveur calcule sa réponse en fonction de ce que le client
// détient déjà. Rien n'y est réutilisable d'une requête à l'autre, et servir
// une réponse gardée y ferait croire à des références qui n'existent plus.
//
// Les fichiers du protocole « dumb », eux, restent cachables : un objet
// « .../objects/ab/cdef… » porte son empreinte dans son nom.
var gitSmartPaths = []string{
"/info/refs", "/git-upload-pack", "/git-receive-pack",
}
// EstGitSmart dit si l'URL vise l'un de ces points de négociation.
//
// Sert deux fois. Le contenu n'est ni gardé ni servi du cache. Et l'amont y a
// droit à BEAUCOUP plus de patience : un serveur git énumère ses références à
// la demande, ce qui prend des dizaines de secondes sur un dépôt chargé, là où
// un miroir de paquets répond en quelques centaines de millisecondes.
func EstGitSmart(u *url.URL) bool {
if u == nil {
return false
}
for _, s := range gitSmartPaths {
if strings.HasSuffix(u.Path, s) {
return true
}
}
return false
}
// Classify tranche pour une URL, sans regarder la réponse : la décision doit
// être prise AVANT d'interroger l'amont, puisqu'elle décide s'il faut
// l'interroger.
//
// Le doute profite au volatile. Une URL inconnue est donc toujours reprise à
// l'amont quand il répond, et ne sert de copie que hors ligne : le pire cas
// est une requête inutile, jamais une réponse fausse.
func Classify(u *url.URL) Class {
if u == nil {
return ClassNoStore
}
if EstGitSmart(u) {
return ClassNoStore
}
name := strings.ToLower(path.Base(u.Path))
// Une requête portant une chaîne de requête décrit un appel d'API et non
// un fichier : deux paramètres différents rendent deux réponses, et son
// nom de fichier ne dit rien de son contenu.
if u.RawQuery != "" {
return ClassVolatile
}
// Les deux règles de CHEMIN l'emportent sur celles du nom : l'une porte un
// suffixe figé qui ment, l'autre n'en porte aucun et dit pourtant vrai.
if dernierePublication.MatchString(u.Path) {
return ClassVolatile
}
if parEmpreinte.MatchString(u.Path) {
return ClassImmutable
}
for _, n := range volatileNames {
if name == n {
return ClassVolatile
}
}
for _, s := range volatileSuffixes {
if strings.HasSuffix(name, s) {
return ClassVolatile
}
}
// L'index « simple » de PyPI est une page sans extension sous /simple/.
if strings.Contains(u.Path, "/simple/") {
return ClassVolatile
}
for _, s := range immutableSuffixes {
if strings.HasSuffix(name, s) {
return ClassImmutable
}
}
return ClassVolatile
}
// PortableParChemin dit si le NOM du fichier suffit à l'identifier sur
// n'importe quel miroir de la même distribution.
//
// Vrai pour un paquet — sa version et son architecture sont dans son nom — et
// pour un index de dépôt, dont le chemin est le même partout. Faux pour le
// reste : « /index.html » n'identifie rien.
func PortableParChemin(u *url.URL) bool {
if u == nil || u.RawQuery != "" {
return false
}
if dernierePublication.MatchString(u.Path) {
return false
}
// Un objet adressé par son empreinte se range sans son hôte. La règle est
// POSITIVE parce qu'aucune des tables suivantes ne le reconnaîtrait : une
// somme hexadécimale n'a pas d'extension, et retirer la seule exclusion ne
// suffirait donc pas à le rendre portable.
if parEmpreinte.MatchString(u.Path) {
return true
}
name := strings.ToLower(path.Base(u.Path))
for _, s := range immutableSuffixes {
if strings.HasSuffix(name, s) {
return true
}
}
for _, s := range volatileSuffixes {
if strings.HasSuffix(name, s) {
return true
}
}
for _, n := range volatileNames {
// « index.html » et « index.json » nomment n'importe quoi : ils sont
// dans la table des index, mais pas portables pour autant.
if name == n && !strings.HasPrefix(n, "index.") {
return true
}
}
return false
}
// CacheableMethod : seules les lectures entrent au cache. Un POST ou un PUT
// change un état à l'amont et n'a pas de copie qui vaille.
func CacheableMethod(method string) bool {
return method == "GET" || method == "HEAD"
}

View file

@ -0,0 +1,249 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"net/url"
"strings"
"testing"
)
func TestClassify(t *testing.T) {
cas := []struct {
brut string
attendue Class
pourquoi string
}{
// Le nom porte la version : le contenu ne changera jamais.
{"https://miroir.example/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst",
ClassImmutable, "paquet Arch"},
{"https://miroir.example/debian/pool/main/b/bash/bash_5.2-1_amd64.deb",
ClassImmutable, "paquet Debian"},
{"https://miroir.example/fedora/Packages/b/bash-5.2-1.fc43.x86_64.rpm",
ClassImmutable, "paquet Fedora"},
{"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl",
ClassImmutable, "roue Python"},
{"https://images.example/arch/Arch-Linux-x86_64-cloudimg.qcow2",
ClassImmutable, "image cloud"},
// L'index décrit l'état COURANT du miroir.
{"https://miroir.example/arch/core/os/x86_64/core.db",
ClassVolatile, "base Arch"},
{"https://miroir.example/arch/core/os/x86_64/core.db.sig",
ClassVolatile, "signature de la base"},
{"https://miroir.example/debian/dists/trixie/InRelease",
ClassVolatile, "index Debian"},
{"https://miroir.example/debian/dists/trixie/main/binary-amd64/Packages.gz",
ClassVolatile, "liste Debian"},
{"https://miroir.example/fedora/repodata/repomd.xml",
ClassVolatile, "index Fedora"},
{"https://pypi.example/simple/requests/",
ClassVolatile, "index simple de PyPI"},
// Le doute profite au volatile : une requête paramétrée décrit un
// appel d'API, pas un fichier.
{"https://api.example/v1/paquet?nom=bash&version=5.2",
ClassVolatile, "chaîne de requête"},
{"https://exemple.example/page-quelconque",
ClassVolatile, "inconnu"},
}
for _, c := range cas {
u, err := url.Parse(c.brut)
if err != nil {
t.Fatalf("URL de test invalide %q : %v", c.brut, err)
}
if got := Classify(u); got != c.attendue {
t.Errorf("%s : %s classé « %s », attendu « %s »",
c.pourquoi, c.brut, got, c.attendue)
}
}
}
// Une extension immuable portée par une chaîne de requête ne suffit pas : le
// paramètre peut changer la réponse.
func TestChaineDeRequeteEmportreSurExtension(t *testing.T) {
u, _ := url.Parse("https://m.example/bash-5.2-1-x86_64.pkg.tar.zst?mirror=2")
if got := Classify(u); got != ClassVolatile {
t.Errorf("classé « %s », attendu « volatile »", got)
}
}
func TestCacheableMethod(t *testing.T) {
for _, m := range []string{"GET", "HEAD"} {
if !CacheableMethod(m) {
t.Errorf("%s devrait être cachable", m)
}
}
for _, m := range []string{"POST", "PUT", "DELETE", "PATCH", "CONNECT"} {
if CacheableMethod(m) {
t.Errorf("%s ne doit pas être cachable", m)
}
}
}
func TestClassifyURLNulle(t *testing.T) {
if got := Classify(nil); got != ClassNoStore {
t.Errorf("une URL absente vaut « %s », attendu « no-store »", got)
}
}
// Un même fichier, deux miroirs : le cache doit le reconnaître.
//
// Une liste de miroirs tourne, et pacman a réellement tiré « extra.db » de
// « geo » alors que le cache ne détenait que la copie de « fastly » — même
// fichier, autre nom d'hôte, défaut de cache et 504 hors ligne.
func TestMemeCheminSurDeuxMiroirs(t *testing.T) {
a, _ := url.Parse("https://fastly.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst")
b, _ := url.Parse("https://geo.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst")
if !PortableParChemin(a) {
t.Fatal("un paquet n'est pas reconnu portable")
}
if KeySansHote("GET", a) != KeySansHote("GET", b) {
t.Error("deux miroirs du même fichier donnent deux clés")
}
if Key("GET", a.String()) == Key("GET", b.String()) {
t.Error("la clé complète devrait, elle, distinguer les deux URL")
}
}
// L'index aussi : c'est lui qui a échoué hors ligne.
func TestIndexPortableEntreMiroirs(t *testing.T) {
a, _ := url.Parse("https://fastly.example/extra/os/x86_64/extra.db")
b, _ := url.Parse("https://geo.example/extra/os/x86_64/extra.db")
if !PortableParChemin(a) {
t.Fatal("un index de dépôt n'est pas reconnu portable")
}
if KeySansHote("GET", a) != KeySansHote("GET", b) {
t.Error("deux miroirs du même index donnent deux clés")
}
}
// Mais pas n'importe quoi : « /index.html » ne nomme rien, et deux sites sans
// rapport en portent un.
func TestUnePageQuelconqueNestPasPortable(t *testing.T) {
for _, brut := range []string{
"https://a.example/index.html",
"https://a.example/quelque-chose",
"https://a.example/simple/requests/",
"https://a.example/core.db?miroir=2",
} {
u, _ := url.Parse(brut)
if PortableParChemin(u) {
t.Errorf("%s est jugé portable, à tort", brut)
}
}
}
// Le protocole « smart » de git est une NÉGOCIATION : le serveur calcule sa
// réponse d'après ce que le client détient déjà. Rien n'y est réutilisable, et
// servir une réponse gardée ferait croire à des références disparues.
func TestGitSmartNestJamaisGarde(t *testing.T) {
for _, brut := range []string{
"https://git.example/org/depot.git/info/refs?service=git-upload-pack",
"https://git.example/org/depot.git/git-upload-pack",
"https://git.example/org/depot.git/git-receive-pack",
} {
u, _ := url.Parse(brut)
if !EstGitSmart(u) {
t.Errorf("%s n'est pas reconnu comme négociation git", brut)
}
if got := Classify(u); got != ClassNoStore {
t.Errorf("%s classé « %s », attendu « no-store »", brut, got)
}
}
}
// Le protocole « dumb », lui, sert des fichiers : un objet porte son empreinte
// dans son nom et se garde comme n'importe quel fichier figé.
func TestLesObjetsGitRestentCachables(t *testing.T) {
for _, brut := range []string{
"https://git.example/org/depot.git/objects/ab/cdef0123456789",
"https://git.example/org/depot.git/objects/pack/pack-abc.pack",
} {
u, _ := url.Parse(brut)
if EstGitSmart(u) {
t.Errorf("%s pris pour une négociation, à tort", brut)
}
}
}
func TestEstGitSmartURLNulle(t *testing.T) {
if EstGitSmart(nil) {
t.Error("une URL absente est prise pour du git")
}
}
// Un index Debian publié sous l'empreinte de son contenu est aussi figé
// qu'un paquet : son nom EST sa somme. Il se range donc SANS son hôte, un
// contenu différent portant forcément un autre nom.
func TestLesIndexParEmpreinteSontImmuables(t *testing.T) {
sha256 := strings.Repeat("0123456789abcdef", 4)
md5 := strings.Repeat("0123456789abcdef", 2)
for _, brut := range []string{
"https://miroir.example/debian/dists/trixie/main/binary-amd64/by-hash/SHA256/" + sha256,
"https://miroir.example/debian/dists/trixie/main/i18n/by-hash/MD5Sum/" + md5,
} {
u, _ := url.Parse(brut)
if got := Classify(u); got != ClassImmutable {
t.Errorf("%s classé « %s », attendu « immutable »", brut, got)
}
if !PortableParChemin(u) {
t.Errorf("%s n'est pas jugé portable", brut)
}
}
// Ce qui n'est pas une empreinte garde la règle d'avant.
for _, brut := range []string{
"https://miroir.example/debian/dists/trixie/by-hash/SHA256/pas-une-somme",
"https://miroir.example/debian/dists/trixie/by-hash/SHA256/" + sha256 + "?v=2",
} {
u, _ := url.Parse(brut)
if got := Classify(u); got != ClassVolatile {
t.Errorf("%s classé « %s », attendu « volatile »", brut, got)
}
}
}
// Le même index par empreinte, servi par deux miroirs. Sans clé portable,
// changer de miroir vide le cache de ses index : une installation hors ligne
// échoue alors sur des octets que le magasin détient pourtant, et le message
// accuse le dépôt.
func TestUnIndexParEmpreinteVautSurDeuxMiroirs(t *testing.T) {
somme := strings.Repeat("0123456789abcdef", 4)
chemin := "/debian/dists/trixie/main/binary-amd64/by-hash/SHA256/" + somme
a, _ := url.Parse("https://miroir-a.example" + chemin)
b, _ := url.Parse("https://miroir-b.example" + chemin)
if CleDe("GET", a) != CleDe("GET", b) {
t.Error("deux miroirs du même index donnent deux clés")
}
// La clé complète doit, elle, continuer de les distinguer : c'est ce qui
// prouve que le rangement passe bien par la clé SANS hôte, et non que les
// deux fonctions se sont mises à rendre la même chose.
if Key("GET", a.String()) == Key("GET", b.String()) {
t.Error("la clé complète ne distingue plus les deux URL")
}
}
// Un pointeur vers la dernière version publiée change de cible à chaque
// publication : son suffixe d'archive ne doit pas le figer sur le disque, ni
// le ranger sans son hôte.
func TestUnPointeurDeDerniereVersionEstVolatile(t *testing.T) {
for _, brut := range []string{
"https://forge.example/o/d/releases/latest/download/outil-x86_64-linux.tar.gz",
"https://forge.example/o/d/releases/latest/download/outil.zip",
} {
u, _ := url.Parse(brut)
if got := Classify(u); got != ClassVolatile {
t.Errorf("%s classé « %s », attendu « volatile »", brut, got)
}
if PortableParChemin(u) {
t.Errorf("%s jugé portable", brut)
}
}
// Une version NOMMÉE reste figée par son suffixe.
u, _ := url.Parse("https://forge.example/o/d/releases/download/v1.2.3/outil.tar.gz")
if got := Classify(u); got != ClassImmutable {
t.Errorf("une version nommée classée « %s », attendu « immutable »", got)
}
}

View file

@ -0,0 +1,153 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"bufio"
"fmt"
"io"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// « --detient » répond depuis le MAGASIN et non depuis le journal d'accès.
// Le journal dit qu'un objet est entré un jour ; un objet effacé depuis — par
// une purge, par âge ou en entier — y garde ses lignes « stored ». Seul le
// magasin sait ce qui sortira hors ligne.
//
// La lecture ne passe pas par Store.Get, qui remet la date du corps à
// maintenant : le relevé rajeunirait tout ce qu'il regarde, et l'âge du
// dernier usage ne voudrait plus rien dire. Elle ne lit que des métas (0644)
// et des casiers (0755) : aucun privilège n'est requis.
// Verdicts de « --detient ».
const (
// VerdictGarde : un corps 200 est en réserve, il sortira hors ligne.
VerdictGarde = "garde"
// VerdictStatut : un statut seul — redirection, refus, 200 d'un HEAD —
// est en réserve, rejoué sans corps quand l'amont est muet.
VerdictStatut = "statut"
// VerdictAbsent : la requête est cachable, rien n'est en réserve.
VerdictAbsent = "absent"
// VerdictNonCachable : le magasin ne tiendra jamais cette requête —
// méthode d'écriture, négociation git, URL illisible, ou HEAD d'une
// classe qui ne garde pas de statut seul.
VerdictNonCachable = "non-cachable"
)
// Detention dit ce que le magasin tient pour une méthode et une URL.
type Detention struct {
Verdict string
// Statut et StockeLe sont nuls quand rien n'est en réserve.
Statut int
StockeLe time.Time
Classe string
Methode string
URL string
}
// Ligne rend la détention en une ligne séparée par des tabulations :
// verdict, statut, date de stockage en RFC 3339, classe, méthode, URL. Un
// champ sans valeur vaut « - », pour que chaque ligne ait six champs.
func (d Detention) Ligne() string {
statut, date, classe := "-", "-", "-"
if d.Statut != 0 {
statut = strconv.Itoa(d.Statut)
}
if !d.StockeLe.IsZero() {
date = d.StockeLe.UTC().Format(time.RFC3339)
}
if d.Classe != "" {
classe = d.Classe
}
return strings.Join(
[]string{d.Verdict, statut, date, classe, d.Methode, d.URL}, "\t")
}
// Detenir dit ce que le magasin tient pour une requête, sans rien modifier.
//
// Les clés sont calculées comme le service les calcule — classe, méthode
// cachable, clé portable ou non —, par les mêmes fonctions, et lues dans le
// même ordre que la branche hors ligne : la clé du corps d'abord, qui ne
// compte que pour un 200 ; la clé du statut seul (CleStatut) ensuite. Un
// objet n'est tenu que si son corps a la taille que son méta annonce : c'est
// la condition à laquelle le service le sert, et un objet que le service
// refuserait n'est pas en réserve. L'URL est rendue telle que reçue, pour que
// l'appelant retrouve sa ligne.
//
// Un HEAD n'a jamais de corps gardé, et son statut ne se garde que pour une
// classe volatile sous une clé attachée à l'hôte : ailleurs, il est
// non-cachable et non absent, sans quoi l'appelant le croirait à remplir et
// le rejouerait pour rien, indéfiniment. C'est la négation exacte de la
// condition du statut seul dans Proxy.serve.
func (s *Store) Detenir(methode, brut string) Detention {
methode = strings.ToUpper(methode)
d := Detention{Verdict: VerdictNonCachable, Methode: methode, URL: brut}
u, err := url.Parse(brut)
if err != nil || !u.IsAbs() || u.Host == "" {
return d
}
class := Classify(u)
d.Classe = class.String()
if !CacheableMethod(methode) || class == ClassNoStore {
return d
}
if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) {
return d
}
d.Verdict = VerdictAbsent
if m, ok := s.lirePresent(CleDe(methode, u)); ok && !m.StatutSeul() {
d.Verdict, d.Statut, d.StockeLe = VerdictGarde, m.StatutReel(), m.StoredAt
return d
}
if m, ok := s.lirePresent(CleStatut(methode, u)); ok && m.StatutSeul() {
d.Verdict, d.Statut, d.StockeLe = VerdictStatut, m.StatutReel(), m.StoredAt
}
return d
}
// lirePresent rend le méta d'une clé quand l'objet est complet : méta lisible
// et corps de la taille annoncée. Ni l'un ni l'autre n'est modifié.
func (s *Store) lirePresent(key string) (*Meta, bool) {
m, err := s.LireMeta(key)
if err != nil {
return nil, false
}
_, bodyPath := s.paths(key)
if fi, err := os.Stat(bodyPath); err != nil || fi.Size() != m.Size {
return nil, false
}
return m, true
}
// EcrireDetentions lit des lignes « MÉTHODE URL » et écrit une ligne de
// détention pour chacune, dans l'ordre. Une ligne vide ou commençant par
// « # » est sautée ; une ligne d'un seul champ vaut « GET <champ> ». Chaque
// réponse part aussitôt écrite, pour qu'un appelant qui lit au fil de ses
// questions ne reste pas bloqué.
func EcrireDetentions(s *Store, entree io.Reader, sortie io.Writer) error {
sc := bufio.NewScanner(entree)
// Une URL signée dépasse volontiers les 64 Kio de la ligne par défaut.
sc.Buffer(make([]byte, 64*1024), 1<<20)
w := bufio.NewWriter(sortie)
for sc.Scan() {
ligne := strings.TrimSpace(sc.Text())
if ligne == "" || strings.HasPrefix(ligne, "#") {
continue
}
champs := strings.Fields(ligne)
methode, brut := "GET", champs[0]
if len(champs) >= 2 {
methode, brut = champs[0], champs[1]
}
fmt.Fprintln(w, s.Detenir(methode, brut).Ligne())
if err := w.Flush(); err != nil {
return err
}
}
return sc.Err()
}

View file

@ -0,0 +1,189 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"bytes"
"io"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
)
// « --detient » dit ce que le magasin tient, une ligne par question, avec la
// clé que le service calcule. Le magasin est rempli ici PAR le service, pour
// que toute divergence entre les deux calculs de clé se voie.
func TestDetientDitCeQueLeMagasinTient(t *testing.T) {
paquet := "/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst"
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/installer.sh":
rediriger(http.StatusFound, "https://ailleurs.example/bootstrap.sh")(w, r)
case "/depot/etat":
servir("état courant")(w, r)
default:
servir("contenu du paquet")(w, r)
}
})
p := proxyDeTest(t)
for _, c := range []string{"/installer.sh", "/depot/etat", paquet} {
joue(t, p, "GET", a.hote(), c)
}
joue(t, p, "HEAD", a.hote(), "/installer.sh")
base := "http://" + a.hote()
questions := []string{
"GET " + base + "/depot/etat",
"GET " + base + "/installer.sh",
"HEAD " + base + "/installer.sh",
"get " + base + "/jamais-vu.sh",
"POST " + base + "/depot/etat",
"GET " + base + "/o/d.git/info/refs?service=git-upload-pack",
// Un paquet est portable : le même chemin sur un autre miroir est
// le même fichier.
"GET http://autre-miroir.example.invalid" + paquet,
"",
"# une ligne de commentaire",
base + "/depot/etat",
"GET pas-une-url",
}
var sortie bytes.Buffer
if err := EcrireDetentions(
p.Store, strings.NewReader(strings.Join(questions, "\n")), &sortie,
); err != nil {
t.Fatal(err)
}
attendus := []struct {
verdict, statut, classe, methode, url string
date bool
}{
{"garde", "200", "volatile", "GET", base + "/depot/etat", true},
{"statut", "302", "volatile", "GET", base + "/installer.sh", true},
{"statut", "302", "volatile", "HEAD", base + "/installer.sh", true},
{"absent", "-", "volatile", "GET", base + "/jamais-vu.sh", false},
{"non-cachable", "-", "volatile", "POST", base + "/depot/etat", false},
{"non-cachable", "-", "no-store", "GET",
base + "/o/d.git/info/refs?service=git-upload-pack", false},
{"garde", "200", "immutable", "GET",
"http://autre-miroir.example.invalid" + paquet, true},
{"garde", "200", "volatile", "GET", base + "/depot/etat", true},
{"non-cachable", "-", "-", "GET", "pas-une-url", false},
}
lignes := strings.Split(strings.TrimRight(sortie.String(), "\n"), "\n")
if len(lignes) != len(attendus) {
t.Fatalf("%d lignes, attendu %d :\n%s", len(lignes), len(attendus), sortie.String())
}
for i, a := range attendus {
champs := strings.Split(lignes[i], "\t")
if len(champs) != 6 {
t.Errorf("ligne %d : %d champs, attendu 6 : %q", i+1, len(champs), lignes[i])
continue
}
if champs[0] != a.verdict || champs[1] != a.statut ||
champs[3] != a.classe || champs[4] != a.methode || champs[5] != a.url {
t.Errorf("ligne %d : %q, attendu %s %s … %s %s %s",
i+1, lignes[i], a.verdict, a.statut, a.classe, a.methode, a.url)
}
if a.date {
if _, err := time.Parse(time.RFC3339, champs[2]); err != nil {
t.Errorf("ligne %d : date %q illisible", i+1, champs[2])
}
} else if champs[2] != "-" {
t.Errorf("ligne %d : date %q pour un objet absent", i+1, champs[2])
}
}
}
// Le relevé ne rajeunit rien : l'âge d'un objet est celui de son dernier
// SERVICE, et une question posée au magasin n'en est pas un.
func TestDetientNeRajeunitPas(t *testing.T) {
a := nouvelAmontScripte(t, servir("état courant"))
p, _ := proxyEtCasier(t)
joue(t, p, "GET", a.hote(), "/depot/etat")
u, _ := url.Parse("http://" + a.hote() + "/depot/etat")
_, corps := p.Store.paths(CleDe("GET", u))
ancien := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
if err := os.Chtimes(corps, ancien, ancien); err != nil {
t.Fatal(err)
}
var sortie bytes.Buffer
EcrireDetentions(p.Store, strings.NewReader("GET "+u.String()+"\n"), &sortie)
if !strings.HasPrefix(sortie.String(), VerdictGarde+"\t") {
t.Fatalf("l'objet n'a pas été vu : %q", sortie.String())
}
fi, err := os.Stat(corps)
if err != nil {
t.Fatal(err)
}
if !fi.ModTime().Equal(ancien) {
t.Errorf("le relevé a rajeuni l'objet : %v au lieu de %v", fi.ModTime(), ancien)
}
}
// Un HEAD que le service ne gardera jamais est non-cachable, pas absent :
// l'appelant le croirait sinon à remplir, et le rejouerait pour rien. Le
// magasin est rempli ici PAR le service, l'amont répondant 200 à chaque
// HEAD : seul celui dont la classe garde un statut seul doit y entrer, et
// c'est le seul que « --detient » ne dit pas non-cachable.
func TestDetientUnHeadQueLeMagasinNeGardeJamais(t *testing.T) {
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1234")
w.WriteHeader(http.StatusOK)
})
p, dir := proxyEtCasier(t)
base := "http://" + a.hote()
attendus := map[string]string{
// Immuable et portable : un paquet.
"/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst": VerdictNonCachable,
// Immuable attaché à son hôte : un index par empreinte.
"/debian/dists/trixie/by-hash/SHA256/" + strings.Repeat("ab", 32): VerdictNonCachable,
// Volatile mais portable : un index de dépôt.
"/arch/core/os/x86_64/core.db": VerdictNonCachable,
// Volatile attaché à son hôte : le seul qui se garde.
"/api/bonjour": VerdictStatut,
}
for chemin, attendu := range attendus {
if got := p.Store.Detenir("HEAD", base+chemin).Verdict; got != VerdictNonCachable &&
got != VerdictAbsent {
t.Errorf("%s avant remplissage : %q", chemin, got)
}
joue(t, p, "HEAD", a.hote(), chemin)
if got := p.Store.Detenir("HEAD", base+chemin).Verdict; got != attendu {
t.Errorf("HEAD %s : %q, attendu %q", chemin, got, attendu)
}
// Le GET de la même URL reste à remplir : un corps s'y garde.
if got := p.Store.Detenir("GET", base+chemin).Verdict; got != VerdictAbsent {
t.Errorf("GET %s : %q, attendu %q", chemin, got, VerdictAbsent)
}
}
if n := corpsGardes(t, dir); n != 1 {
t.Errorf("%d objet(s) gardé(s), attendu 1 : le service et --detient"+
" divergent sur ce qu'un HEAD laisse au magasin", n)
}
if got := p.Store.Detenir("HEAD", base+"/api/jamais-vu").Verdict; got != VerdictAbsent {
t.Errorf("HEAD volatile jamais vu : %q, attendu %q", got, VerdictAbsent)
}
}
// Un corps dont la taille ne correspond plus à son méta n'est pas en
// réserve : le service refuserait de le servir.
func TestDetientRefuseUnCorpsAltere(t *testing.T) {
a := nouvelAmontScripte(t, servir("état courant"))
p, _ := proxyEtCasier(t)
joue(t, p, "GET", a.hote(), "/depot/etat")
u, _ := url.Parse("http://" + a.hote() + "/depot/etat")
_, corps := p.Store.paths(CleDe("GET", u))
if err := os.WriteFile(corps, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if d := p.Store.Detenir("GET", u.String()); d.Verdict != VerdictAbsent {
t.Errorf("verdict %q pour un corps altéré", d.Verdict)
}
EcrireDetentions(p.Store, strings.NewReader(""), io.Discard)
}

View file

@ -0,0 +1,184 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// Le front TLS de bout en bout : un vrai client, une vraie poignée de main,
// une vraie réponse.
//
// Les autres tests appellent le proxy en direct et ne traversent jamais ce
// chemin. Il portait un défaut qu'aucun d'eux ne pouvait voir : « Serve »
// traite la connexion dans une goroutine puis reboucle sur « Accept », si
// bien qu'un écouteur rendant la fin de flux tout de suite faisait fermer la
// connexion pendant que la réponse s'écrivait. Le client recevait « Empty
// reply from server » et le journal ne disait rien, la requête n'étant jamais
// arrivée au bout.
// frontDeTest monte le front TLS devant un amont qui compte ses requêtes.
//
// L'amont parle TLS, comme un vrai miroir : le front étiquette « https » ce
// qui lui arrive, et un amont en clair ferait échouer la reprise. Et le
// composeur du proxy est détourné vers cet amont, parce que le SNI doit
// porter un NOM — un client qui se connecte à une adresse IP n'envoie aucun
// SNI, et le front n'aurait alors rien à certifier.
const HOTE_AMONT = "miroir.example"
func frontDeTest(t *testing.T, corps string) (*amont, string, *CA) {
t.Helper()
a := &amont{corps: corps}
a.srv = httptest.NewTLSServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&a.appel, 1)
w.Header().Set("Content-Type", "application/octet-stream")
io.WriteString(w, a.corps)
}))
t.Cleanup(a.srv.Close)
alog, err := OpenAccessLog("")
if err != nil {
t.Fatalf("journal : %v", err)
}
proxy := NewProxy(&Store{Dir: t.TempDir()}, alog)
tr := proxy.Client.Transport.(*http.Transport)
cible := a.hote()
tr.DialContext = func(ctx context.Context, reseau, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, reseau, cible)
}
// L'amont de test signe lui-même son certificat : le vérifier n'apprendrait
// rien sur le cache, qui est ce qu'on mesure ici.
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatalf("autorité : %v", err)
}
front := &TLSFront{CA: ca, Proxy: proxy, Refusals: NewRefusals(nil)}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("écoute : %v", err)
}
t.Cleanup(func() { ln.Close() })
go front.Serve(ln)
return a, ln.Addr().String(), ca
}
// dialogueTLS fait UNE requête au front, en TLS, et rend la réponse.
func dialogueTLS(
t *testing.T, ca *CA, ecoute, hoteAmont, chemin string,
) (*http.Response, string) {
t.Helper()
pool := x509.NewCertPool()
pool.AddCert(ca.cert)
brut, err := net.DialTimeout("tcp", ecoute, 5*time.Second)
if err != nil {
t.Fatalf("connexion : %v", err)
}
defer brut.Close()
// Le SNI porte le nom de l'AMONT : c'est lui qui dit au front quel
// certificat fabriquer et où aller chercher.
tc := tls.Client(brut, &tls.Config{
ServerName: hoteAmont,
RootCAs: pool,
})
tc.SetDeadline(time.Now().Add(15 * time.Second))
if err := tc.Handshake(); err != nil {
t.Fatalf("poignée de main : %v", err)
}
fmt.Fprintf(tc, "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n",
chemin, hoteAmont)
brutRep, err := io.ReadAll(tc)
if err != nil && len(brutRep) == 0 {
t.Fatalf("lecture : %v", err)
}
texte := string(brutRep)
if texte == "" {
t.Fatal("réponse vide : la connexion s'est fermée avant la réponse")
}
return nil, texte
}
// L'invariant que le défaut violait : une requête TLS reçoit sa réponse.
func TestFrontTLSRendUneReponse(t *testing.T) {
a, ecoute, ca := frontDeTest(t, "contenu du paquet")
_, rep := dialogueTLS(
t, ca, ecoute, HOTE_AMONT,
"/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst",
)
if !strings.HasPrefix(rep, "HTTP/1.1 200") {
t.Errorf("réponse inattendue :\n%s", rep[:min(len(rep), 200)])
}
if !strings.Contains(rep, "contenu du paquet") {
t.Error("le corps de l'amont n'est pas parvenu au client")
}
if atomic.LoadInt64(&a.appel) != 1 {
t.Errorf("l'amont a reçu %d requêtes, attendu 1", a.appel)
}
}
// Et la seconde demande vient du disque, sans toucher au réseau : le gain de
// l'outil doit exister À TRAVERS le front, pas seulement dans le proxy nu.
func TestFrontTLSSertDuCacheALaSeconde(t *testing.T) {
a, ecoute, ca := frontDeTest(t, "contenu du paquet")
chemin := "/arch/core/os/x86_64/git-2.51-1-x86_64.pkg.tar.zst"
dialogueTLS(t, ca, ecoute, HOTE_AMONT, chemin)
_, rep := dialogueTLS(t, ca, ecoute, HOTE_AMONT, chemin)
if !strings.Contains(rep, "X-Erplibre-Cache: hit") &&
!strings.Contains(rep, "X-ERPLibre-Cache: hit") {
t.Errorf("la seconde demande n'a pas été servie du disque :\n%s",
rep[:min(len(rep), 300)])
}
if n := atomic.LoadInt64(&a.appel); n != 1 {
t.Errorf("l'amont a reçu %d requêtes, attendu 1", n)
}
}
// Le certificat présenté doit porter le nom demandé, sinon le client le
// rejette avant même d'envoyer sa requête.
func TestFrontTLSPresenteLeBonNom(t *testing.T) {
_, ecoute, ca := frontDeTest(t, "x")
hote := HOTE_AMONT
pool := x509.NewCertPool()
pool.AddCert(ca.cert)
brut, err := net.DialTimeout("tcp", ecoute, 5*time.Second)
if err != nil {
t.Fatalf("connexion : %v", err)
}
defer brut.Close()
tc := tls.Client(brut, &tls.Config{ServerName: hote, RootCAs: pool})
tc.SetDeadline(time.Now().Add(10 * time.Second))
if err := tc.Handshake(); err != nil {
t.Fatalf("poignée de main : %v", err)
}
vu := tc.ConnectionState().PeerCertificates[0]
if vu.Subject.CommonName != hote {
t.Errorf("certificat pour %q, attendu %q", vu.Subject.CommonName, hote)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,705 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/cgi"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
)
// Pourquoi un MIROIR et non un cache pour git.
//
// Le protocole « smart HTTP » de git est une NÉGOCIATION : le client annonce
// ce qu'il détient, le serveur calcule et envoie ce qui manque. Deux clients
// dans des états différents reçoivent des octets différents pour la même URL,
// si bien qu'aucune réponse ne se réutilise — c'est pour cela que le cache ne
// garde rien de ces échanges.
//
// Or c'est là que part l'essentiel du réseau d'une installation ERPLibre : le
// dépôt tire plus de trois cents dépôts, et le trafic git y pèse plusieurs
// fois celui des paquets. Un cache qui l'ignore laisse donc le gros du travail
// sortir deux fois.
//
// La réponse n'est pas de garder des réponses mais de tenir des DÉPÔTS. Un
// miroir nu par dépôt amont, rafraîchi quand l'amont répond, et le protocole
// servi depuis ce miroir par « git http-backend » — le programme de git
// lui-même, plutôt qu'une réimplémentation qui divergerait à la première
// version du protocole.
//
// Ce que cela coûte, et qu'il faut savoir : un miroir est COMPLET. Trois cents
// dépôts Odoo pèsent plusieurs gigaoctets sur le disque de l'orchestrateur, et
// rien ne les efface, comme pour le reste de ce cache.
// GitMirror tient les dépôts nus et les sert.
type GitMirror struct {
// Dir est la racine des miroirs. Vide, le miroir est éteint et git
// retombe sur le simple relais vers l'amont.
Dir string
// Frais borne le rafraîchissement : deux requêtes rapprochées sur le même
// dépôt ne déclenchent qu'une récupération. « repo sync » interroge chaque
// dépôt plusieurs fois de suite, et sans cette borne chacune paierait un
// aller-retour vers l'amont.
Frais time.Duration
// Backend est le chemin de « git-http-backend ». Vide, il est cherché aux
// endroits usuels.
Backend string
// Delai borne un CLONAGE. Un dépôt Odoo complet descend en minutes, pas
// en secondes, et l'abandonner à mi-chemin ne laisse rien d'utilisable.
Delai time.Duration
// DelaiMaj borne un RAFRAÎCHISSEMENT, et il est court à dessein.
//
// Un miroir qui existe est déjà servable : si l'amont ne répond pas, la
// bonne réponse est de servir ce qu'on a, tout de suite. Avec le délai du
// clonage, un amont coupé ferait attendre chaque dépôt jusqu'à son terme —
// pour les trois cents dépôts d'une installation, des heures d'attente
// pour un déploiement qui aurait pu être servi en entier depuis le disque.
DelaiMaj time.Duration
// PlancherLibre est la place qu'on refuse d'entamer. En dessous, aucun
// NOUVEAU miroir n'est créé et la requête repart vers l'amont : le cache
// perd son avance, il ne remplit pas le disque de l'orchestrateur.
//
// Un miroir est complet là où « repo sync » clone en profondeur un : le
// facteur entre les deux est celui de l'historique, et il ne se devine
// pas. Les miroirs DÉJÀ tenus continuent d'être rafraîchis et servis —
// une mise à jour ne coûte que ce qui a changé.
PlancherLibre int64
// Muets est la mémoire des amonts injoignables, partagée avec le relais.
// Elle ne retient que des établissements manqués. Nulle, rien n'est
// retenu d'une requête à l'autre.
Muets *Joignabilite
// Sonder vérifie qu'un amont accepte une connexion. Nul, une connexion
// TCP est ouverte puis refermée.
Sonder func(ctx context.Context, adresse string) error
// Mandataire dit par quel mandataire HTTP une URL sortirait. Nul, c'est
// http.ProxyFromEnvironment : l'environnement même que « git remote
// update » lit.
Mandataire func(*http.Request) (*url.URL, error)
mu sync.Mutex
verrous map[string]*sync.Mutex
vus map[string]time.Time
// figes retient les hôtes dont une sonde manquée a déjà été dite au
// journal : une ligne par panne, pas une par requête.
figes map[string]bool
}
// backendsUsuels : les chemins où les distributions posent git-http-backend.
// Aucune ne les met au même endroit, et il n'est pas dans le PATH.
var backendsUsuels = []string{
"/usr/lib/git-core/git-http-backend",
"/usr/libexec/git-core/git-http-backend",
"/usr/local/libexec/git-core/git-http-backend",
}
// TrouverBackend rend le chemin de git-http-backend, ou "" s'il manque.
func TrouverBackend() string {
for _, c := range backendsUsuels {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
// Actif dit si le miroir peut servir. Un répertoire sans le programme de git
// ne sert à rien : mieux vaut le relais vers l'amont, qui fonctionne.
func (g *GitMirror) Actif() bool {
return g != nil && g.Dir != "" && g.backend() != ""
}
func (g *GitMirror) backend() string {
if g.Backend == "" {
return TrouverBackend()
}
// Le chemin imposé est vérifié comme les autres : un miroir qui se dirait
// actif avec un programme absent servirait des 500 à chaque dépôt, là où
// le relais vers l'amont, lui, fonctionne.
if st, err := os.Stat(g.Backend); err == nil && !st.IsDir() {
return g.Backend
}
return ""
}
// DepotDeURL découpe une URL de négociation en (URL du dépôt amont, reste).
//
// « https://h/o/d.git/info/refs?service=… » rend « https://h/o/d.git » et
// « /info/refs ». Rend faux quand l'URL n'est pas une négociation : le
// découpage n'aurait alors aucun sens.
func DepotDeURL(u *url.URL) (string, string, bool) {
if u == nil {
return "", "", false
}
for _, s := range gitSmartPaths {
if strings.HasSuffix(u.Path, s) {
base := strings.TrimSuffix(u.Path, s)
if base == "" || base == "/" {
return "", "", false
}
amont := *u
amont.Path = base
amont.RawQuery = ""
return amont.String(), s, true
}
}
return "", "", false
}
// CheminMiroir rend le répertoire du miroir d'un dépôt.
//
// L'hôte fait partie du chemin : deux forges peuvent servir « /odoo/odoo », et
// les confondre donnerait à l'une le contenu de l'autre. Le « .git » final est
// posé une seule fois, l'amont l'écrivant tantôt et tantôt non.
func (g *GitMirror) CheminMiroir(depot string) (string, error) {
u, err := url.Parse(depot)
if err != nil || u.Host == "" {
return "", fmt.Errorf("dépôt illisible %q", depot)
}
chemin := strings.Trim(u.Path, "/")
chemin = strings.TrimSuffix(chemin, ".git")
if chemin == "" {
return "", fmt.Errorf("dépôt sans chemin %q", depot)
}
// Un « .. » dans le chemin ferait écrire hors de la racine.
for _, seg := range strings.Split(chemin, "/") {
if seg == "." || seg == ".." || seg == "" {
return "", fmt.Errorf("chemin de dépôt refusé %q", depot)
}
}
return filepath.Join(g.Dir, u.Host, chemin+".git"), nil
}
func (g *GitMirror) verrou(cle string) *sync.Mutex {
g.mu.Lock()
defer g.mu.Unlock()
if g.verrous == nil {
g.verrous = map[string]*sync.Mutex{}
}
if _, ok := g.verrous[cle]; !ok {
g.verrous[cle] = &sync.Mutex{}
}
return g.verrous[cle]
}
// Assurer rend le miroir prêt et dit s'il est utilisable.
//
// Trois issues, et la troisième est celle qui rend le hors-ligne possible :
// le miroir est créé, le miroir est rafraîchi, ou l'amont est muet mais un
// miroir existe déjà — il sert alors tel quel. Un amont muet SANS miroir rend
// faux, et l'appelant retombe sur le relais, qui donnera au client la vraie
// erreur du réseau plutôt qu'une erreur inventée ici.
func (g *GitMirror) Assurer(ctx context.Context, depot string) (string, bool) {
chemin, err := g.CheminMiroir(depot)
if err != nil {
return "", false
}
v := g.verrou(chemin)
v.Lock()
defer v.Unlock()
_, statErr := os.Stat(filepath.Join(chemin, "HEAD"))
existe := statErr == nil
if existe && g.recent(chemin) {
return chemin, true
}
if !existe {
if !g.placeSuffisante() {
log.Printf(
"miroir refusé pour %s : moins de %s libres sur le disque",
depot, HumanBytes(g.PlancherLibre))
return "", false
}
if err := os.MkdirAll(filepath.Dir(chemin), 0o755); err != nil {
return "", false
}
if err := g.git(ctx, "", "clone", "--mirror", depot, chemin); err != nil {
// Un clonage à moitié fait laisserait un répertoire que la
// prochaine requête prendrait pour un miroir valide.
os.RemoveAll(chemin)
return "", false
}
g.noter(chemin)
return chemin, true
}
if !g.amontJoignable(ctx, depot) {
// Amont connu muet, ou qui vient de refuser la sonde : le miroir
// sert tel quel, sans payer le délai d'une mise à jour vouée à
// l'échec.
return chemin, true
}
if err := g.gitBorne(
ctx, g.delaiMaj(), chemin, "remote", "update", "--prune",
); err != nil {
// Le miroir d'hier vaut mieux que rien : il sert, et c'est ce qui
// permet de déployer sans réseau. La tentative n'est notée comme un
// rafraîchissement — les requêtes suivantes du dépôt servies sans
// la rejouer pendant « Frais » — que si la sonde échoue désormais.
// Un amont qui l'accepte a échoué autrement : un 5xx, une
// poignée de main coupée, un délai sur un gros dépôt. La requête
// suivante retente alors, sans quoi le miroir servirait une branche
// en retard à une installation dont la forge répond.
if g.sondeRefusee(ctx, depot) {
g.noter(chemin)
}
return chemin, true
}
g.noter(chemin)
return chemin, true
}
// amontJoignable dit si l'amont d'un dépôt vaut une tentative de mise à jour.
//
// Faux quand la mémoire partagée le tient pour muet, ou quand la sonde se
// voit refuser la connexion (sondeRefusee) ; l'échec est alors retenu, et les
// dépôts suivants du même hôte n'essaient même plus. Sans cette sonde, chaque
// dépôt d'un amont coupé paie le délai entier d'une mise à jour : pour les
// centaines de dépôts d'une installation, l'attente domine tout le reste.
//
// Vrai quand l'adresse ne se déduit pas : git tentera, et son propre délai
// bornera l'attente.
func (g *GitMirror) amontJoignable(ctx context.Context, depot string) bool {
u, adresse, ok := adresseDuDepot(depot)
if !ok {
return true
}
if g.Muets.ConnuMuet(adresse) {
return false
}
return !g.sonderAmont(ctx, u, adresse)
}
// adresseDuDepot rend l'URL d'un dépôt et son « hôte:port », faux quand
// l'adresse ne se déduit pas.
func adresseDuDepot(depot string) (*url.URL, string, bool) {
u, err := url.Parse(depot)
if err != nil || u.Hostname() == "" {
return nil, "", false
}
adresse := adresseAmont(u)
if !strings.Contains(adresse, ":") {
return nil, "", false
}
return u, adresse, true
}
// sondeRefusee sonde l'amont d'un dépôt, sans consulter la mémoire, et dit
// si la connexion a échoué. Faux quand l'adresse ne se déduit pas, ou quand
// un mandataire porte l'URL.
func (g *GitMirror) sondeRefusee(ctx context.Context, depot string) bool {
u, adresse, ok := adresseDuDepot(depot)
if !ok {
return false
}
return g.sonderAmont(ctx, u, adresse)
}
// sonderAmont ouvre une connexion vers l'amont et dit si elle a échoué.
//
// Un échec d'établissement est retenu dans la mémoire partagée, et dit au
// journal UNE fois par panne et par hôte : un miroir servi sans
// rafraîchissement doit se voir, sans qu'une installation de trois cents
// dépôts n'y écrive trois cents lignes. La première réussite efface l'un et
// l'autre.
//
// Aucune sonde quand un mandataire porte l'URL : « git remote update » passe
// par lui, et une connexion directe, refusée sur un hôte dont c'est la seule
// sortie, figerait tous ses miroirs pour toujours. L'amont est alors tenu
// pour joignable, et c'est git qui tranche.
func (g *GitMirror) sonderAmont(
ctx context.Context, u *url.URL, adresse string,
) bool {
if g.parMandataire(u) {
return false
}
sonder := g.Sonder
if sonder == nil {
sonder = sonderTCP
}
err := sonder(ctx, adresse)
g.mu.Lock()
defer g.mu.Unlock()
if err == nil {
g.Muets.Reussite(adresse)
delete(g.figes, adresse)
return false
}
g.Muets.Echec(adresse, err)
if !g.figes[adresse] {
if g.figes == nil {
g.figes = map[string]bool{}
}
g.figes[adresse] = true
log.Printf("miroir git : sonde de %s en échec (%v) ; ses miroirs"+
" sont servis sans rafraîchissement jusqu'à ce qu'il réponde",
adresse, err)
}
return true
}
// parMandataire dit si l'URL sortirait par un mandataire HTTP.
func (g *GitMirror) parMandataire(u *url.URL) bool {
mandataire := g.Mandataire
if mandataire == nil {
mandataire = http.ProxyFromEnvironment
}
par, err := mandataire(&http.Request{URL: u, Header: http.Header{}})
return err == nil && par != nil
}
func (g *GitMirror) recent(chemin string) bool {
if g.Frais <= 0 {
return false
}
g.mu.Lock()
defer g.mu.Unlock()
t, ok := g.vus[chemin]
return ok && time.Since(t) < g.Frais
}
func (g *GitMirror) noter(chemin string) {
g.mu.Lock()
defer g.mu.Unlock()
if g.vus == nil {
g.vus = map[string]time.Time{}
}
g.vus[chemin] = time.Now()
}
// DelaiMajParDefaut : de quoi laisser un amont sain répondre, pas de quoi
// attendre un amont absent.
const DelaiMajParDefaut = 45 * time.Second
func (g *GitMirror) delaiMaj() time.Duration {
if g.DelaiMaj > 0 {
return g.DelaiMaj
}
return DelaiMajParDefaut
}
func (g *GitMirror) git(ctx context.Context, dir string, args ...string) error {
delai := g.Delai
if delai <= 0 {
delai = 30 * time.Minute
}
return g.gitBorne(ctx, delai, dir, args...)
}
func (g *GitMirror) gitBorne(
ctx context.Context, delai time.Duration, dir string, args ...string,
) error {
ctx, annule := context.WithTimeout(ctx, delai)
defer annule()
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
// Le délai tue « git », mais git délègue le réseau à un auxiliaire —
// « git-remote-https » — qui SURVIT et garde le tube ouvert. Sans ce
// second délai, la lecture de la sortie attend cet auxiliaire, donc pour
// toujours quand l'amont accepte la connexion et ne répond jamais : le
// délai qu'on vient de poser ne borne alors plus rien.
cmd.WaitDelay = 5 * time.Second
// Aucune invite : un dépôt privé doit ÉCHOUER et retomber sur le relais,
// et non bloquer le service en attendant un mot de passe que personne ne
// tapera jamais.
cmd.Env = append(os.Environ(),
"GIT_TERMINAL_PROMPT=0",
"GIT_ASKPASS=/bin/true",
"GCM_INTERACTIVE=never",
)
sortie, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git %s : %v : %s",
strings.Join(args, " "), err, court(string(sortie)))
}
return nil
}
func court(s string) string {
s = strings.TrimSpace(s)
if len(s) > 300 {
return s[:300] + "…"
}
return s
}
// Servir répond depuis le miroir, par le programme de git lui-même.
//
// « git http-backend » est une passerelle CGI : il attend le chemin du dépôt
// dans PATH_INFO, la racine dans GIT_PROJECT_ROOT, et rend le protocole exact,
// version 0 comme version 2. Le réimplémenter reviendrait à le suivre à chaque
// version.
// compteur retient ce qu'une réponse a réellement pesé.
//
// La passerelle CGI écrit directement dans la réponse : sans ce compteur, le
// journal note zéro octet pour tout ce que le miroir sert. Un outil dont le
// journal EST la mesure ne peut pas avoir un chemin qui ne compte pas — c'est
// justement celui qui porte l'essentiel du trafic d'une installation.
type compteur struct {
http.ResponseWriter
n int64
}
func (c *compteur) Write(p []byte) (int, error) {
n, err := c.ResponseWriter.Write(p)
c.n += int64(n)
return n, err
}
// Servir répond depuis le miroir et rend ce que la réponse a pesé.
func (g *GitMirror) Servir(
w http.ResponseWriter, r *http.Request, chemin, reste string,
) int64 {
racine := filepath.Dir(chemin)
h := &cgi.Handler{
Path: g.backend(),
Dir: racine,
Env: []string{
"GIT_PROJECT_ROOT=" + racine,
// Le miroir n'a pas de « git-daemon-export-ok », et n'en aura
// pas : il ne sert que le pont des VM de cet hôte.
"GIT_HTTP_EXPORT_ALL=1",
},
InheritEnv: []string{"PATH"},
}
// La passerelle lit le chemin du dépôt dans l'URL qu'on lui présente : ce
// n'est pas celle que la VM a demandée, mais celle du miroir local.
r2 := r.Clone(r.Context())
r2.URL = &url.URL{
Path: "/" + filepath.Base(chemin) + reste,
RawQuery: r.URL.RawQuery,
}
r2.RequestURI = ""
c := &compteur{ResponseWriter: w}
h.ServeHTTP(c, r2)
return c.n
}
// Occupation rend (nombre de dépôts, octets) du miroir.
//
// Le disque de l'orchestrateur est surveillé À LA MAIN — aucune éviction n'est
// écrite, ici pas plus qu'ailleurs — et un miroir est COMPLET : il pèse ce que
// pèse le dépôt amont, historique compris. Le relevé doit donc le dire, sans
// quoi la place disparaît sans que rien ne l'annonce.
func (g *GitMirror) Occupation() (int, int64) {
if g == nil || g.Dir == "" {
return 0, 0
}
depots, octets := 0, int64(0)
filepath.Walk(g.Dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if info.IsDir() {
if strings.HasSuffix(p, ".git") {
depots++
}
return nil
}
octets += info.Size()
return nil
})
return depots, octets
}
// Prefetch tient en miroir toute une liste de dépôts, d'avance.
//
// À la demande, le miroir se remplit au fil des requêtes : la PREMIÈRE machine
// paie chaque clonage, et pour un dépôt qui en tire trois cents cela déplace
// le coût plutôt que de le supprimer. Le pré-remplissage le paie une fois, à
// l'heure choisie par l'opérateur.
//
// Les dépôts sont pris à PLUSIEURS à la fois : un clonage passe l'essentiel de
// son temps à attendre le réseau, et les enchaîner un par un tiendrait des
// heures là où la bande passante n'est pas le facteur.
//
// Un dépôt qui échoue ne fait pas échouer les autres : sur une liste de cette
// taille, il y a toujours un dépôt privé, déplacé ou retiré, et tout arrêter
// pour lui perdrait le travail déjà fait. Rend (réussis, échoués).
func (g *GitMirror) Prefetch(
ctx context.Context, depots []string, parallele int, dire func(string),
) (int, int) {
if parallele < 1 {
parallele = 1
}
type resultat struct {
depot string
ok bool
}
taches := make(chan string)
sorties := make(chan resultat)
var wg sync.WaitGroup
for i := 0; i < parallele; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for d := range taches {
_, ok := g.Assurer(ctx, d)
sorties <- resultat{d, ok}
}
}()
}
go func() {
defer close(taches)
for _, d := range depots {
select {
case taches <- d:
case <-ctx.Done():
return
}
}
}()
go func() { wg.Wait(); close(sorties) }()
reussis, echoues, vus := 0, 0, 0
for r := range sorties {
vus++
if r.ok {
reussis++
} else {
echoues++
}
if dire != nil {
etat := "✓"
if !r.ok {
etat = "✗"
}
dire(fmt.Sprintf(" %s %d/%d %s", etat, vus, len(depots), r.depot))
}
}
return reussis, echoues
}
// DepotsDuFichier lit une liste de dépôts, un par ligne.
//
// Les lignes vides et les commentaires sautent, les doublons aussi : un même
// dépôt figure dans plusieurs manifestes, et le cloner deux fois ne ferait que
// perdre du temps.
func DepotsDuFichier(chemin string) ([]string, error) {
data, err := os.ReadFile(chemin)
if err != nil {
return nil, err
}
vus := map[string]bool{}
var out []string
for _, l := range strings.Split(string(data), "\n") {
l = strings.TrimSpace(l)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
if !vus[l] {
vus[l] = true
out = append(out, l)
}
}
return out, nil
}
// PlancherParDefaut : ce qu'on laisse au disque de l'orchestrateur. Assez pour
// qu'une VM en cours de déploiement finisse, et pour que le système respire.
const PlancherParDefaut int64 = 10 << 30
// placeSuffisante dit s'il reste de quoi créer un miroir de plus.
//
// La place est relue à CHAQUE appel : le disque se remplit pendant qu'on le
// remplit, et une valeur retenue au démarrage ne dirait rien de l'état où l'on
// est rendu.
func (g *GitMirror) placeSuffisante() bool {
plancher := g.PlancherLibre
if plancher <= 0 {
plancher = PlancherParDefaut
}
var st syscall.Statfs_t
if err := syscall.Statfs(g.Dir, &st); err != nil {
// Illisible : on laisse passer plutôt que de bloquer sur une mesure
// qu'on ne sait pas faire.
return true
}
return int64(st.Bavail)*int64(st.Bsize) > plancher
}
// Depot décrit un miroir tenu sur le disque.
type Depot struct {
// Chemin est le répertoire du dépôt nu.
Chemin string
// Nom est ce qu'il vaut mieux montrer : « github.com/OCA/server-tools ».
Nom string
// Octets est ce qu'il occupe, Maj la dernière fois qu'il a été rafraîchi.
Octets int64
Maj time.Time
}
// Depots rend les miroirs tenus, du plus lourd au plus léger.
//
// Par la TAILLE et non par le nom : la place se surveille à la main — aucune
// éviction n'est écrite — et ce qu'on cherche en la surveillant, c'est ce qui
// pèse. Trois dépôts font ici les trois quarts du total ; les lister par ordre
// alphabétique obligerait à les chercher.
func (g *GitMirror) Depots() []Depot {
if g == nil || g.Dir == "" {
return nil
}
var out []Depot
filepath.Walk(g.Dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil || !info.IsDir() ||
!strings.HasSuffix(p, ".git") {
return nil
}
d := Depot{
Chemin: p,
Nom: strings.TrimSuffix(
strings.TrimPrefix(p, g.Dir+string(os.PathSeparator)), ".git"),
Maj: info.ModTime(),
}
filepath.Walk(p, func(_ string, i os.FileInfo, e error) error {
if e == nil && i != nil && !i.IsDir() {
d.Octets += i.Size()
}
return nil
})
out = append(out, d)
// Un dépôt nu n'en contient pas d'autre : inutile de descendre.
return filepath.SkipDir
})
sort.Slice(out, func(i, j int) bool { return out[i].Octets > out[j].Octets })
return out
}
// Retirer efface un miroir. Il se refera au prochain besoin, au prix du
// clonage — c'est la propriété qui rend l'effacement sans danger.
//
// Le chemin est vérifié comme appartenant à la racine des miroirs : un appel
// mal formé ne doit pas pouvoir effacer autre chose.
func (g *GitMirror) Retirer(chemin string) error {
if g == nil || g.Dir == "" {
return fmt.Errorf("miroir éteint")
}
abs, err := filepath.Abs(chemin)
if err != nil {
return err
}
racine, err := filepath.Abs(g.Dir)
if err != nil {
return err
}
if !strings.HasPrefix(abs, racine+string(os.PathSeparator)) ||
!strings.HasSuffix(abs, ".git") {
return fmt.Errorf("hors des miroirs : %s", chemin)
}
v := g.verrou(abs)
v.Lock()
defer v.Unlock()
return os.RemoveAll(abs)
}

View file

@ -0,0 +1,506 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"context"
"net"
"net/http"
"net/http/cgi"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDepotDeURL(t *testing.T) {
cas := []struct {
brut, depot, reste string
}{
{"https://h/o/d.git/info/refs?service=git-upload-pack",
"https://h/o/d.git", "/info/refs"},
{"https://h/o/d/info/refs?service=git-upload-pack",
"https://h/o/d", "/info/refs"},
{"https://h/o/d.git/git-upload-pack", "https://h/o/d.git",
"/git-upload-pack"},
}
for _, c := range cas {
u, _ := url.Parse(c.brut)
depot, reste, ok := DepotDeURL(u)
if !ok || depot != c.depot || reste != c.reste {
t.Errorf("%s → (%q, %q, %v)", c.brut, depot, reste, ok)
}
}
}
func TestDepotDeURLRefuse(t *testing.T) {
for _, brut := range []string{
"https://h/o/d.git/objects/ab/cd",
"https://h/info/refs",
"https://h/miroir/core.db",
} {
u, _ := url.Parse(brut)
if _, _, ok := DepotDeURL(u); ok {
t.Errorf("%s pris pour une négociation", brut)
}
}
}
// Le chemin du miroir porte l'HÔTE : deux forges peuvent servir « /odoo/odoo »,
// et les confondre donnerait à l'une le contenu de l'autre.
func TestCheminMiroirSepareLesForges(t *testing.T) {
g := &GitMirror{Dir: "/var/x"}
a, err := g.CheminMiroir("https://github.com/odoo/odoo.git")
if err != nil {
t.Fatal(err)
}
b, _ := g.CheminMiroir("https://autre.example/odoo/odoo.git")
if a == b {
t.Error("deux forges partagent un miroir")
}
if !strings.HasSuffix(a, ".git") {
t.Errorf("le miroir n'est pas un dépôt nu : %s", a)
}
// « .git » écrit ou non par l'amont donne le même miroir.
c, _ := g.CheminMiroir("https://github.com/odoo/odoo")
if a != c {
t.Errorf("« .git » change le miroir : %s contre %s", a, c)
}
}
// Un chemin qui remonte écrirait hors de la racine.
func TestCheminMiroirRefuseCeQuiRemonte(t *testing.T) {
g := &GitMirror{Dir: "/var/x"}
for _, brut := range []string{
"https://h/../../etc/passwd",
"https://h/o/../../..",
"https://h/",
"pas une url",
} {
if chemin, err := g.CheminMiroir(brut); err == nil {
t.Errorf("%s accepté et rendu %q", brut, chemin)
}
}
}
func TestMiroirEteintQuandRienNestConfigure(t *testing.T) {
if (&GitMirror{}).Actif() {
t.Error("un miroir sans répertoire se dit actif")
}
// Un répertoire sans le programme de git ne sert à rien : mieux vaut le
// relais vers l'amont, qui fonctionne.
g := &GitMirror{Dir: "/var/x", Backend: "/nexiste/pas"}
if g.Actif() {
t.Error("un miroir sans git-http-backend se dit actif")
}
}
// amontGit monte un dépôt nu ET le serveur qui le publie, comme le ferait une
// forge. L'URL rendue est celle qu'un client — ou le miroir — interroge.
func amontGit(t *testing.T) (string, *httptest.Server) {
t.Helper()
if TrouverBackend() == "" {
t.Skip("git-http-backend absent de cette machine")
}
nu := depotDEssai(t)
racine := filepath.Dir(nu)
h := &cgi.Handler{
Path: TrouverBackend(),
Dir: racine,
Env: []string{
"GIT_PROJECT_ROOT=" + racine,
"GIT_HTTP_EXPORT_ALL=1",
},
InheritEnv: []string{"PATH"},
}
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return srv.URL + "/" + filepath.Base(nu), srv
}
// depotDEssai fabrique un dépôt nu local, servi comme s'il était l'amont.
func depotDEssai(t *testing.T) string {
t.Helper()
racine := t.TempDir()
nu := filepath.Join(racine, "amont.git")
travail := filepath.Join(racine, "travail")
lancer := func(dir string, args ...string) {
t.Helper()
c := exec.Command("git", args...)
c.Dir = dir
c.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e")
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v : %v : %s", args, err, out)
}
}
lancer(racine, "init", "-q", "--bare", "--initial-branch=main", nu)
lancer(racine, "init", "-q", "--initial-branch=main", travail)
if err := os.WriteFile(
filepath.Join(travail, "f.txt"), []byte("bonjour"), 0o644,
); err != nil {
t.Fatal(err)
}
lancer(travail, "add", "f.txt")
lancer(travail, "commit", "-qm", "premier")
lancer(travail, "push", "-q", nu, "HEAD:refs/heads/main")
return nu
}
// L'épreuve qui compte : un client clone à travers le miroir, sans que rien
// dans l'invité soit configuré, et le dépôt arrive complet.
func TestClonerAuTraversDuMiroir(t *testing.T) {
if TrouverBackend() == "" {
t.Skip("git-http-backend absent de cette machine")
}
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 2 * time.Minute}
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
u := &url.URL{Path: r.URL.Path, RawQuery: r.URL.RawQuery}
_, reste, ok := DepotDeURL(u)
if !ok {
http.NotFound(w, r)
return
}
// Tout chemin demandé désigne le dépôt qui joue l'amont : ce test
// mesure le miroir, pas le routage.
chemin, pret := g.Assurer(r.Context(), amont)
if !pret {
http.Error(w, "miroir indisponible", 502)
return
}
g.Servir(w, r, chemin, reste)
}))
defer srv.Close()
for essai := 1; essai <= 2; essai++ {
dest := filepath.Join(t.TempDir(), "copie")
c := exec.Command("git", "clone", "-q", srv.URL+"/essai.git", dest)
c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("clone %d : %v : %s", essai, err, out)
}
contenu, err := os.ReadFile(filepath.Join(dest, "f.txt"))
if err != nil || string(contenu) != "bonjour" {
t.Fatalf("clone %d : contenu %q, err %v", essai, contenu, err)
}
}
}
// Un amont muet mais un miroir déjà là : le miroir sert. C'est ce qui rend un
// déploiement sans réseau possible pour git, ce qu'aucun cache de réponses ne
// peut faire de ce protocole.
func TestUnMiroirExistantSertQuandLAmontEstMuet(t *testing.T) {
amont, srvAmont := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: time.Minute}
if _, pret := g.Assurer(context.Background(), amont); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
// L'amont disparaît : le serveur qui le publiait est fermé, exactement ce
// que voit une VM quand le réseau tombe.
srvAmont.Close()
g.Frais = 0 // forcer une tentative de rafraîchissement
chemin, pret := g.Assurer(context.Background(), amont)
if !pret {
t.Error("le miroir refuse de servir alors qu'il existe")
}
if _, err := os.Stat(filepath.Join(chemin, "HEAD")); err != nil {
t.Errorf("le miroir a été effacé : %v", err)
}
}
// Un amont muet SANS miroir doit rendre faux : l'appelant retombe alors sur le
// relais, qui donnera au client la vraie erreur du réseau plutôt qu'une erreur
// inventée ici.
func TestAucunMiroirEtAucunAmont(t *testing.T) {
g := &GitMirror{Dir: t.TempDir(), Delai: 20 * time.Second}
chemin, pret := g.Assurer(
context.Background(), "https://127.0.0.1:1/nexiste/pas.git")
if pret {
t.Errorf("se dit prêt avec %q", chemin)
}
// Aucun DÉPÔT ne doit rester derrière : un clonage à moitié fait serait
// pris pour un miroir valide à la requête suivante. Un répertoire vide,
// lui, ne trompe personne.
filepath.Walk(g.Dir, func(p string, info os.FileInfo, err error) error {
if err == nil && info != nil && info.Name() == "HEAD" {
t.Errorf("un miroir incomplet subsiste : %s", p)
}
return nil
})
}
func TestDepotsDuFichier(t *testing.T) {
f := filepath.Join(t.TempDir(), "liste")
contenu := strings.Join([]string{
"# un commentaire",
"",
"https://h/a.git",
" https://h/b.git ",
"https://h/a.git", // le même dépôt figure dans plusieurs manifestes
}, "\n")
if err := os.WriteFile(f, []byte(contenu), 0o644); err != nil {
t.Fatal(err)
}
got, err := DepotsDuFichier(f)
if err != nil {
t.Fatal(err)
}
attendu := []string{"https://h/a.git", "https://h/b.git"}
if len(got) != len(attendu) {
t.Fatalf("%d dépôts, %d attendus : %v", len(got), len(attendu), got)
}
for i := range attendu {
if got[i] != attendu[i] {
t.Errorf("dépôt %d : %q, attendu %q", i, got[i], attendu[i])
}
}
}
// Un dépôt qui échoue ne doit pas emporter les autres : sur une liste de
// trois cents, il y a toujours un dépôt privé, déplacé ou retiré, et tout
// arrêter pour lui perdrait le travail déjà fait.
func TestUnDepotEnEchecNEmportePasLesAutres(t *testing.T) {
bon, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
depots := []string{
bon,
"https://127.0.0.1:1/absent.git",
bon + "-autre",
}
reussis, echoues := g.Prefetch(context.Background(), depots, 3, nil)
if reussis < 1 {
t.Errorf("%d réussite(s) : le dépôt joignable n'a pas été pris",
reussis)
}
if echoues < 1 {
t.Error("aucun échec compté alors qu'un dépôt est injoignable")
}
if reussis+echoues != len(depots) {
t.Errorf("%d + %d ne fait pas %d", reussis, echoues, len(depots))
}
}
// Le pré-remplissage rend le miroir prêt : la machine suivante n'a plus qu'à
// être servie, ce qui est tout l'objet de l'avance.
func TestApresPrefetchLeMiroirEstPret(t *testing.T) {
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
if r, e := g.Prefetch(
context.Background(), []string{amont}, 2, nil,
); r != 1 || e != 0 {
t.Fatalf("pré-remplissage : %d réussis, %d échoués", r, e)
}
depots, octets := g.Occupation()
if depots != 1 || octets == 0 {
t.Errorf("occupation : %d dépôts, %d octets", depots, octets)
}
}
// Le plancher protège le disque de l'orchestrateur.
//
// Un miroir est COMPLET là où « repo sync » clone en profondeur un : le
// facteur entre les deux est celui de l'historique, et il ne se devine pas.
// Sans plancher, une seule installation qui tire trois cents dépôts peut
// remplir le disque de la machine qui héberge toutes les VM.
func TestLePlancherRefuseUnMiroirDeplus(t *testing.T) {
amont, _ := amontGit(t)
g := &GitMirror{
Dir: t.TempDir(),
Delai: 30 * time.Second,
// Plus que tout disque n'en offre : aucun miroir NEUF ne doit passer.
PlancherLibre: 1 << 62,
}
if chemin, pret := g.Assurer(context.Background(), amont); pret {
t.Errorf("un miroir a été créé sous le plancher : %s", chemin)
}
if depots, _ := g.Occupation(); depots != 0 {
t.Errorf("%d dépôt(s) créés malgré le plancher", depots)
}
}
// Un miroir DÉJÀ tenu continue d'être servi : une mise à jour ne coûte que ce
// qui a changé, et le refuser priverait de tout ce qui est déjà là.
func TestLePlancherNEmpechePasDeServirLexistant(t *testing.T) {
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
if _, pret := g.Assurer(context.Background(), amont); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
g.PlancherLibre = 1 << 62
g.Frais = 0
if _, pret := g.Assurer(context.Background(), amont); !pret {
t.Error("un miroir existant est refusé à cause du plancher")
}
}
// Ce que le miroir sert doit être COMPTÉ.
//
// La passerelle CGI écrit directement dans la réponse : le journal notait zéro
// octet pour tout ce que le miroir servait, et c'est le chemin qui porte
// l'essentiel du trafic d'une installation. Un outil dont le journal EST la
// mesure ne peut pas avoir un chemin muet.
func TestCeQueLeMiroirSertEstCompte(t *testing.T) {
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
chemin, pret := g.Assurer(context.Background(), amont)
if !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
var pese int64
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
u := &url.URL{Path: r.URL.Path, RawQuery: r.URL.RawQuery}
_, reste, ok := DepotDeURL(u)
if !ok {
http.NotFound(w, r)
return
}
pese += g.Servir(w, r, chemin, reste)
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "copie")
c := exec.Command("git", "clone", "-q", srv.URL+"/essai.git", dest)
c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("clone : %v : %s", err, out)
}
if pese <= 0 {
t.Error("le miroir a servi un clone entier et le journal dirait zéro")
}
}
// La liste est triée par TAILLE : c'est ce qu'on cherche quand on surveille
// la place à la main, et trois dépôts font les trois quarts du total.
func TestLesDepotsSontTriesParTaille(t *testing.T) {
g := &GitMirror{Dir: t.TempDir()}
for nom, poids := range map[string]int{
"h/petit.git": 10, "h/gros.git": 5000, "h/moyen.git": 500,
} {
d := filepath.Join(g.Dir, nom)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(
filepath.Join(d, "HEAD"), make([]byte, poids), 0o644,
); err != nil {
t.Fatal(err)
}
}
depots := g.Depots()
if len(depots) != 3 {
t.Fatalf("%d dépôts vus", len(depots))
}
for i, attendu := range []string{"h/gros", "h/moyen", "h/petit"} {
if depots[i].Nom != attendu {
t.Errorf("rang %d : %q, attendu %q", i, depots[i].Nom, attendu)
}
}
if depots[0].Octets <= depots[2].Octets {
t.Error("les tailles ne sont pas mesurées")
}
}
// Effacer un miroir est sans danger : il se refait au prochain besoin. Mais
// l'effacement doit rester DANS les miroirs — un appel mal formé ne doit pas
// pouvoir emporter autre chose.
func TestRetirerRefuseCeQuiEstDehors(t *testing.T) {
g := &GitMirror{Dir: t.TempDir()}
dehors := filepath.Join(t.TempDir(), "ailleurs.git")
if err := os.MkdirAll(dehors, 0o755); err != nil {
t.Fatal(err)
}
for _, cible := range []string{dehors, "/etc", g.Dir, g.Dir + "/x"} {
if err := g.Retirer(cible); err == nil {
t.Errorf("%s a été accepté", cible)
}
}
if _, err := os.Stat(dehors); err != nil {
t.Errorf("un répertoire hors des miroirs a été effacé : %v", err)
}
}
func TestRetirerEffaceLeMiroir(t *testing.T) {
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
chemin, pret := g.Assurer(context.Background(), amont)
if !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
if err := g.Retirer(chemin); err != nil {
t.Fatalf("effacement : %v", err)
}
if depots, _ := g.Occupation(); depots != 0 {
t.Errorf("%d dépôt(s) subsistent", depots)
}
// Et il se refait : c'est ce qui rend l'effacement sans danger.
if _, pret := g.Assurer(context.Background(), amont); !pret {
t.Error("le miroir ne se refait pas après effacement")
}
}
// Un miroir qui existe est déjà servable : quand l'amont ne répond pas, il
// doit sortir TOUT DE SUITE, pas au terme du délai d'un clonage.
//
// Sans ce délai propre, un amont coupé faisait attendre chaque dépôt jusqu'à
// son terme : pour les trois cents dépôts d'une installation, des heures pour
// un déploiement que le disque pouvait servir en entier.
func TestUnRafraichissementNattendPasCommeUnClonage(t *testing.T) {
amont, srv := amontGit(t)
g := &GitMirror{
Dir: t.TempDir(),
Delai: 30 * time.Minute,
DelaiMaj: 2 * time.Second,
}
if _, pret := g.Assurer(context.Background(), amont); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
// L'amont devient MUET : il accepte la connexion et ne répond jamais,
// exactement ce que fait un paquet jeté sans être refusé.
srv.Close()
muet, err := net.Listen("tcp", hoteDe(t, amont))
if err != nil {
t.Skipf("le port de l'amont n'a pas pu être repris : %v", err)
}
defer muet.Close()
go func() {
for {
c, err := muet.Accept()
if err != nil {
return
}
_ = c // accepté, jamais répondu
}
}()
g.Frais = 0
debut := time.Now()
chemin, pret := g.Assurer(context.Background(), amont)
ecoule := time.Since(debut)
if !pret || chemin == "" {
t.Fatal("le miroir existant n'a pas été servi")
}
if ecoule > 20*time.Second {
t.Errorf("le rafraîchissement a duré %v : le délai du clonage a"+
" été appliqué", ecoule)
}
}
func hoteDe(t *testing.T, brut string) string {
t.Helper()
u, err := url.Parse(brut)
if err != nil {
t.Fatal(err)
}
return u.Host
}

3
script/qemu_cache/go.mod Normal file
View file

@ -0,0 +1,3 @@
module erplibre_go_qemu_cache
go 1.23

View file

@ -0,0 +1,91 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// dossierSuite isole « …/dists/<suite>/ » du chemin d'un index apt.
var dossierSuite = regexp.MustCompile(`^(.*/dists/[^/]+/)`)
// indexIncoherent dit si le corps gardé sous cette clé appartient à un
// instantané PLUS RÉCENT que la signature gardée pour la même suite.
//
// apt lit d'abord « InRelease », puis exige de chaque index la taille et la
// somme qui y sont écrites. Hors ligne, chaque objet sort du magasin avec sa
// propre date : un index rafraîchi samedi, servi sous une signature gardée
// depuis vendredi, fait échouer apt sur « File has unexpected size » ou
// « Hash Sum mismatch ». L'installation s'arrête plus bas sur des
// dépendances introuvables — un message qui accuse le dépôt, jamais le
// cache. Ne rien servir vaut mieux : apt garde alors ses propres listes,
// dit « old ones used instead » et poursuit.
//
// La comparaison porte sur le « Last-Modified » de l'AMONT, jamais sur la
// date de stockage : celle-ci est renouvelée à chaque service, si bien
// qu'une signature servie après l'index paraîtrait plus jeune que lui.
//
// Trois objets échappent au jugement : ceux qui sont adressés par leur
// contenu (« by-hash »), dont le nom EST la somme ; les signatures
// elles-mêmes, qui sont l'étalon ; et tout ce qui ne vit pas sous
// « /dists/ ».
func (p *Proxy) indexIncoherent(u *url.URL, key string) bool {
if p == nil || p.Store == nil || u == nil {
return false
}
if strings.Contains(u.Path, "/by-hash/") {
return false
}
if strings.HasSuffix(u.Path, "/InRelease") ||
strings.HasSuffix(u.Path, "/Release") ||
strings.HasSuffix(u.Path, "/Release.gpg") {
return false
}
trouve := dossierSuite.FindStringSubmatch(u.Path)
if trouve == nil {
return false
}
signature := *u
signature.Path = trouve[1] + "InRelease"
signature.RawQuery = ""
metaSig, err := p.Store.LireMeta(CleDe("GET", &signature))
if err != nil {
// Aucune signature gardée : rien à contredire, et l'index gardé est
// tout ce que le hors-ligne possède.
return false
}
metaIdx, err := p.Store.LireMeta(key)
if err != nil {
return false
}
dateSig, okSig := dateAmont(metaSig)
dateIdx, okIdx := dateAmont(metaIdx)
if !okSig || !okIdx {
// Sans les deux dates, on ne sait pas : servir reste le comportement
// qui rend un déploiement hors ligne possible.
return false
}
return dateIdx.After(dateSig)
}
// dateAmont rend le « Last-Modified » écrit par l'amont, et dit s'il y en a
// un. C'est la seule date que le magasin ne retouche jamais.
func dateAmont(m *Meta) (t time.Time, ok bool) {
if m == nil || m.Header == nil {
return time.Time{}, false
}
brut := http.Header(m.Header).Get("Last-Modified")
if brut == "" {
return time.Time{}, false
}
quand, err := http.ParseTime(brut)
if err != nil {
return time.Time{}, false
}
return quand, true
}

View file

@ -0,0 +1,141 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"net/http"
"net/url"
"testing"
)
// vendredi et samedi : deux instantanés d'un dépôt apt, à un jour d'écart.
const (
vendredi = "Fri, 11 Sep 2026 07:18:59 GMT"
samedi = "Sat, 12 Sep 2026 13:06:19 GMT"
)
// garnir range un corps sous son URL, avec la date que l'amont lui donne.
func garnir(t *testing.T, p *Proxy, brut, corps, lastModified string) {
t.Helper()
u, err := url.Parse(brut)
if err != nil {
t.Fatal(err)
}
w, err := p.Store.NewWriter(CleDe("GET", u), Meta{
URL: u.String(), Method: "GET", Status: http.StatusOK,
Header: http.Header{"Last-Modified": []string{lastModified}},
Class: ClassVolatile.String(),
})
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(corps)); err != nil {
t.Fatal(err)
}
if err := w.Commit(int64(len(corps))); err != nil {
t.Fatal(err)
}
}
// Un index plus récent que la signature qui l'annonce n'est pas servi.
//
// apt exige de chaque index la taille et la somme écrites dans la
// signature : un index pris à un autre instantané le fait échouer sur
// « File has unexpected size », et l'installation s'arrête plus bas sur des
// dépendances introuvables — un message qui accuse le dépôt, jamais le
// cache.
func TestUnIndexPlusRecentQueSaSignatureNeSortPas(t *testing.T) {
a := nouvelAmont(t, "peu importe")
p := proxyDeTest(t)
base := "http://" + a.hote() + "/ubuntu/dists/resolute-updates/"
garnir(t, p, base+"InRelease", "signature de vendredi", vendredi)
garnir(t, p, base+"main/binary-amd64/Packages.xz", "index de samedi", samedi)
hote := a.hote()
a.srv.Close()
w := joue(t, p, "GET", hote, "/ubuntu/dists/resolute-updates/InRelease")
if w.Code != http.StatusOK {
t.Fatalf("la signature gardée doit sortir : code %d", w.Code)
}
w = joue(t, p, "GET", hote,
"/ubuntu/dists/resolute-updates/main/binary-amd64/Packages.xz")
if w.Code == http.StatusOK {
t.Errorf("l'index de samedi est sorti sous la signature de vendredi")
}
}
// L'inverse est le cas NORMAL : les deux viennent du même passage en ligne,
// et refuser de servir là romprait le déploiement hors ligne.
func TestUnIndexAussiVieuxQueSaSignatureSort(t *testing.T) {
a := nouvelAmont(t, "peu importe")
p := proxyDeTest(t)
base := "http://" + a.hote() + "/ubuntu/dists/resolute/"
garnir(t, p, base+"InRelease", "signature", vendredi)
garnir(t, p, base+"main/binary-amd64/Packages.xz", "index", vendredi)
hote := a.hote()
a.srv.Close()
w := joue(t, p, "GET", hote,
"/ubuntu/dists/resolute/main/binary-amd64/Packages.xz")
if w.Code != http.StatusOK || w.Body.String() != "index" {
t.Fatalf("code %d, corps %q : l'index du même instantané doit sortir",
w.Code, w.Body.String())
}
}
// Un objet adressé par son contenu ne se juge pas : son nom EST sa somme,
// et apt l'accepte quelle que soit la date de la signature.
func TestUnObjetParEmpreinteSortQuandMeme(t *testing.T) {
a := nouvelAmont(t, "peu importe")
p := proxyDeTest(t)
base := "http://" + a.hote() + "/ubuntu/dists/resolute-updates/"
garnir(t, p, base+"InRelease", "signature de vendredi", vendredi)
parHash := base + "main/binary-amd64/by-hash/SHA256/" +
"e55707cc7a5ad03d7f700ccb6646fd5c6ecbc852c4c826f5edbb5a321358d0ee"
garnir(t, p, parHash, "index de samedi", samedi)
hote := a.hote()
a.srv.Close()
w := joue(t, p, "GET", hote,
"/ubuntu/dists/resolute-updates/main/binary-amd64/by-hash/SHA256/"+
"e55707cc7a5ad03d7f700ccb6646fd5c6ecbc852c4c826f5edbb5a321358d0ee")
if w.Code != http.StatusOK {
t.Errorf("un objet par empreinte a été retenu : code %d", w.Code)
}
}
// Sans signature gardée, il n'y a rien à contredire : l'index gardé est tout
// ce que le hors-ligne possède, et le retenir ne servirait personne.
func TestSansSignatureLIndexSortQuandMeme(t *testing.T) {
a := nouvelAmont(t, "peu importe")
p := proxyDeTest(t)
garnir(t, p, "http://"+a.hote()+
"/ubuntu/dists/orpheline/main/binary-amd64/Packages.xz",
"index seul", samedi)
hote := a.hote()
a.srv.Close()
w := joue(t, p, "GET", hote,
"/ubuntu/dists/orpheline/main/binary-amd64/Packages.xz")
if w.Code != http.StatusOK || w.Body.String() != "index seul" {
t.Errorf("code %d, corps %q : l'index sans signature doit sortir",
w.Code, w.Body.String())
}
}
// Hors de « /dists/ », aucune signature n'entre en jeu : un paquet ne se
// juge pas sur la date d'un index.
func TestHorsDeDistsRienNestJuge(t *testing.T) {
a := nouvelAmont(t, "peu importe")
p := proxyDeTest(t)
garnir(t, p, "http://"+a.hote()+"/ubuntu/pool/main/x/xz_5.6_amd64.deb",
"le paquet", samedi)
hote := a.hote()
a.srv.Close()
w := joue(t, p, "GET", hote, "/ubuntu/pool/main/x/xz_5.6_amd64.deb")
if w.Code != http.StatusOK {
t.Errorf("un paquet a été retenu : code %d", w.Code)
}
}

View file

@ -0,0 +1,221 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"context"
"errors"
"net"
"net/url"
"os"
"strings"
"sync"
"syscall"
"time"
)
// DelaiEtablissement borne l'établissement d'une connexion vers l'amont, pour
// le relais comme pour la sonde du miroir git.
//
// Un pare-feu qui JETTE les paquets sans les refuser fait pendre
// l'établissement jusqu'à ce délai : c'est le prix que paie chaque tentative
// vers un amont coupé, et la raison d'être de la mémoire qui suit.
const DelaiEtablissement = 4 * time.Second
// FenetreMuetParDefaut : combien de temps un amont dont l'établissement vient
// d'échouer est tenu pour muet sans être retenté.
//
// Courte à dessein. Elle suffit à épargner le délai d'établissement aux
// dizaines de requêtes qui visent le même hôte en rafale, et elle rend l'amont
// à sa première chance dès qu'il revient.
const FenetreMuetParDefaut = 20 * time.Second
// SentinelleAmonts nomme le témoin que la levée d'une coupure touche dans le
// magasin pour annuler la mémoire des amonts muets. Le nom est tenu en accord
// avec le code qui pose la coupure : toucher un fichier que rien ne lit
// laisserait les amonts muets jusqu'à la fin de leur fenêtre.
const SentinelleAmonts = ".amonts-oublies"
// errAmontConnuMuet dit qu'aucune connexion n'a été tentée : l'établissement
// vers cet amont a échoué il y a moins d'une fenêtre.
var errAmontConnuMuet = errors.New(
"amont connu muet : l'établissement vient d'échouer, il n'est pas retenté")
// Joignabilite retient, par « hôte:port », l'instant du dernier
// établissement de connexion manqué.
//
// Sans elle, chaque requête vers un amont coupé repaie le délai
// d'établissement entier avant de rabattre sur la copie gardée : une
// installation hors ligne en enchaîne des centaines, et l'attente domine
// alors tout le reste. Avec elle, la première requête paie, les suivantes
// passent directement au repli.
//
// Seul un échec d'ÉTABLISSEMENT compte : délai de connexion, refus, hôte ou
// réseau injoignable. Un serveur lent à rendre ses en-têtes, une poignée de
// main TLS manquée ou un statut HTTP disent que l'amont est LÀ, et le tenir
// pour muet servirait une copie périmée à la place d'une réponse qui venait.
//
// Partagée entre le relais et le miroir git : un hôte coupé l'est pour les
// deux. Toutes les méthodes acceptent un récepteur nul, qui ne retient rien.
type Joignabilite struct {
// Fenetre borne la mémoire d'un échec. Nulle, la valeur par défaut
// s'applique.
Fenetre time.Duration
// Maintenant rend l'heure courante ; les tests la remplacent pour faire
// passer une fenêtre sans l'attendre.
Maintenant func() time.Time
// Sentinelle est un fichier dont la date annule la mémoire : tout échec
// antérieur est oublié. Il n'existe aucun canal vers le service en
// marche, et sans ce témoin les amonts notés muets PENDANT une coupure
// le restent jusqu'à la fin de la fenêtre — la première requête d'après
// la levée tombe alors dans le repli alors que le réseau est revenu.
// Vide, rien n'est consulté.
Sentinelle string
mu sync.Mutex
echecs map[string]time.Time
}
// NouvelleJoignabilite rend une mémoire vide, à la fenêtre par défaut.
func NouvelleJoignabilite() *Joignabilite {
return &Joignabilite{Fenetre: FenetreMuetParDefaut}
}
func (j *Joignabilite) maintenant() time.Time {
if j.Maintenant != nil {
return j.Maintenant()
}
return time.Now()
}
func (j *Joignabilite) fenetre() time.Duration {
if j.Fenetre > 0 {
return j.Fenetre
}
return FenetreMuetParDefaut
}
// ConnuMuet dit si l'établissement vers cette adresse a échoué depuis moins
// d'une fenêtre. Une entrée échue est oubliée au passage.
func (j *Joignabilite) ConnuMuet(adresse string) bool {
if j == nil {
return false
}
j.mu.Lock()
defer j.mu.Unlock()
quand, ok := j.echecs[adresse]
if !ok {
return false
}
if j.maintenant().Sub(quand) >= j.fenetre() {
delete(j.echecs, adresse)
return false
}
// Le témoin touché après l'échec : la coupure a été levée depuis, et
// l'amont mérite une nouvelle chance immédiate. Un témoin illisible ou
// absent ne change rien — la fenêtre reprend seule son office.
if j.Sentinelle != "" {
if info, err := os.Stat(j.Sentinelle); err == nil &&
info.ModTime().After(quand) {
delete(j.echecs, adresse)
return false
}
}
return true
}
// Echec retient l'adresse si l'erreur est un échec d'établissement, et dit
// si elle l'a été. Toute autre erreur est ignorée.
func (j *Joignabilite) Echec(adresse string, err error) bool {
if j == nil || !estEchecDEtablissement(err) {
return false
}
j.mu.Lock()
defer j.mu.Unlock()
if j.echecs == nil {
j.echecs = map[string]time.Time{}
}
j.echecs[adresse] = j.maintenant()
return true
}
// Reussite oublie l'adresse : l'amont vient de répondre.
func (j *Joignabilite) Reussite(adresse string) {
if j == nil {
return
}
j.mu.Lock()
defer j.mu.Unlock()
delete(j.echecs, adresse)
}
// estEchecDEtablissement dit si l'erreur vient de l'ÉTABLISSEMENT de la
// connexion, et non de ce qui la suit.
//
// La bibliothèque rend un établissement manqué comme un « *net.OpError »
// d'opération « dial », enveloppé par le client HTTP dans un « *url.Error » :
// errors.As traverse les deux. Le délai compte, et parmi les codes système
// ceux qui disent « personne ne répond à cette adresse ». Un nom qui n'existe
// pas n'en est pas : ce n'est pas un amont coupé, c'est un amont inexistant.
func estEchecDEtablissement(err error) bool {
var op *net.OpError
if err == nil || !errors.As(err, &op) || op.Op != "dial" {
return false
}
if op.Timeout() {
return true
}
var errno syscall.Errno
if errors.As(op.Err, &errno) {
switch errno {
case syscall.ECONNREFUSED, syscall.EHOSTUNREACH,
syscall.ENETUNREACH, syscall.ETIMEDOUT:
return true
}
}
return false
}
// adresseAmont rend la clé « hôte:port » d'une URL, le port déduit du schéma
// quand l'URL n'en porte pas : « https://h/x » et « https://h:443/y » visent
// la même machine et doivent partager leur mémoire.
func adresseAmont(u *url.URL) string {
if u == nil {
return ""
}
hote := strings.ToLower(u.Hostname())
port := u.Port()
if port == "" {
switch strings.ToLower(u.Scheme) {
case "https":
port = "443"
case "http":
port = "80"
default:
return hote
}
}
return net.JoinHostPort(hote, port)
}
// sonderTCP ouvre puis referme une connexion vers l'adresse, sans rien y
// dire. Rend l'erreur d'établissement, nulle quand l'amont a accepté.
func sonderTCP(ctx context.Context, adresse string) error {
d := net.Dialer{Timeout: DelaiEtablissement}
c, err := d.DialContext(ctx, "tcp", adresse)
if err != nil {
return err
}
return c.Close()
}
// decrireMuet rend la phrase du message hors ligne qui dit que l'amont n'a
// pas été retenté, ou "" quand la cause est un vrai échec du réseau.
func decrireMuet(cause error) string {
if !errors.Is(cause, errAmontConnuMuet) {
return ""
}
return "L'établissement vers cet amont vient d'échouer : aucune connexion\n" +
"n'a été tentée pour cette requête, l'amont sera retenté sous peu.\n"
}

View file

@ -0,0 +1,617 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"bytes"
"context"
"errors"
"log"
"net"
"net/http"
"net/http/cgi"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
)
// refusEtablissement est l'erreur que rend un établissement refusé : un
// « *net.OpError » d'opération « dial » portant ECONNREFUSED.
func refusEtablissement() error {
return &net.OpError{Op: "dial", Net: "tcp", Err: &os.SyscallError{
Syscall: "connect", Err: syscall.ECONNREFUSED,
}}
}
// Seul un échec d'ÉTABLISSEMENT fait tenir un amont pour muet. Un serveur
// lent, une poignée de main manquée ou un nom inexistant disent autre chose.
func TestEstEchecDEtablissement(t *testing.T) {
enveloppe := func(err error) error {
return &url.Error{Op: "Get", URL: "http://amont.example/x", Err: err}
}
muets := []error{
refusEtablissement(),
enveloppe(refusEtablissement()),
&net.OpError{Op: "dial", Err: os.ErrDeadlineExceeded},
&net.OpError{Op: "dial", Err: &os.SyscallError{Err: syscall.EHOSTUNREACH}},
&net.OpError{Op: "dial", Err: &os.SyscallError{Err: syscall.ENETUNREACH}},
}
for _, err := range muets {
if !estEchecDEtablissement(err) {
t.Errorf("%v n'est pas reconnu comme un établissement manqué", err)
}
}
vivants := []error{
nil,
// Un serveur lent à répondre est LÀ : le tenir pour muet servirait
// une copie périmée à la place d'une réponse qui venait.
&net.OpError{Op: "read", Err: os.ErrDeadlineExceeded},
enveloppe(errors.New("net/http: timeout awaiting response headers")),
errors.New("net/http: TLS handshake timeout"),
errors.New("remote error: tls: handshake failure"),
&net.OpError{Op: "dial", Err: &net.DNSError{
Err: "no such host", Name: "absent.example.invalid", IsNotFound: true,
}},
&net.OpError{Op: "dial", Err: context.Canceled},
}
for _, err := range vivants {
if estEchecDEtablissement(err) {
t.Errorf("%v pris pour un amont muet", err)
}
}
}
// La mémoire tient une fenêtre, puis rend l'amont à sa première chance.
func TestLaMemoireDUnAmontMuetSEteintAvecLaFenetre(t *testing.T) {
maintenant := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
j := &Joignabilite{
Fenetre: 20 * time.Second,
Maintenant: func() time.Time { return maintenant },
}
const a = "amont.example:443"
if !j.Echec(a, refusEtablissement()) || !j.ConnuMuet(a) {
t.Fatal("un établissement refusé n'est pas retenu")
}
maintenant = maintenant.Add(19 * time.Second)
if !j.ConnuMuet(a) {
t.Error("l'amont est oublié avant la fin de la fenêtre")
}
maintenant = maintenant.Add(time.Second)
if j.ConnuMuet(a) {
t.Error("l'amont reste muet au-delà de la fenêtre")
}
j.Echec(a, refusEtablissement())
j.Reussite(a)
if j.ConnuMuet(a) {
t.Error("une réussite n'efface pas l'échec")
}
if j.Echec(a, errors.New("net/http: timeout awaiting response headers")) ||
j.ConnuMuet(a) {
t.Error("une erreur qui n'est pas un établissement est retenue")
}
var nulle *Joignabilite
nulle.Echec(a, refusEtablissement())
if nulle.ConnuMuet(a) {
t.Error("une mémoire nulle retient quelque chose")
}
}
// La levée d'une coupure touche un témoin : les amonts notés muets pendant
// qu'elle tenait méritent leur chance tout de suite, sans attendre la fin de
// leur fenêtre. Rien d'autre ne le dit au service, qui n'a aucun canal.
func TestLeTemoinDeLeveeOublieLesAmontsMuets(t *testing.T) {
temoin := filepath.Join(t.TempDir(), SentinelleAmonts)
maintenant := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
j := &Joignabilite{
Fenetre: 20 * time.Second,
Maintenant: func() time.Time { return maintenant },
Sentinelle: temoin,
}
const a = "amont.example:443"
j.Echec(a, refusEtablissement())
if !j.ConnuMuet(a) {
t.Fatal("l'échec n'est pas retenu")
}
if err := os.WriteFile(temoin, nil, 0o644); err != nil {
t.Fatal(err)
}
// Un témoin ANTÉRIEUR à l'échec ne dit rien : c'est une levée d'avant, et
// l'amont a été trouvé muet depuis.
vieux := maintenant.Add(-time.Hour)
if err := os.Chtimes(temoin, vieux, vieux); err != nil {
t.Fatal(err)
}
if !j.ConnuMuet(a) {
t.Error("un témoin plus vieux que l'échec l'efface")
}
// Touché APRÈS : la coupure vient d'être levée.
neuf := maintenant.Add(time.Hour)
if err := os.Chtimes(temoin, neuf, neuf); err != nil {
t.Fatal(err)
}
if j.ConnuMuet(a) {
t.Error("le témoin ne rend pas sa chance à l'amont")
}
// Un témoin absent laisse la fenêtre faire son office, sans erreur.
j2 := &Joignabilite{
Fenetre: 20 * time.Second,
Maintenant: func() time.Time { return maintenant },
Sentinelle: filepath.Join(t.TempDir(), "jamais-cree"),
}
j2.Echec(a, refusEtablissement())
if !j2.ConnuMuet(a) {
t.Error("un témoin absent efface la mémoire")
}
}
// Un amont muet n'est pas recomposé à chaque requête : la première paie le
// délai d'établissement, les suivantes vont droit au repli — et le repli
// sert la copie gardée comme avant.
func TestUnAmontMuetNEstPasRecomposeAChaqueRequete(t *testing.T) {
a := nouvelAmontScripte(t, servir("index courant"))
p := proxyDeTest(t)
maintenant := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
p.Muets = &Joignabilite{Maintenant: func() time.Time { return maintenant }}
var coupe atomic.Bool
var composes int64
cible := a.hote()
tr := p.Client.Transport.(*http.Transport)
tr.Proxy = nil
// Chaque requête compose : une connexion gardée ouverte depuis le
// remplissage répondrait à la place de l'amont coupé.
tr.DisableKeepAlives = true
tr.DialContext = func(ctx context.Context, reseau, _ string) (net.Conn, error) {
if coupe.Load() {
atomic.AddInt64(&composes, 1)
return nil, refusEtablissement()
}
return (&net.Dialer{}).DialContext(ctx, reseau, cible)
}
const hote = "miroir.example.invalid"
if w := joue(t, p, "GET", hote, "/depot/etat"); w.Code != http.StatusOK {
t.Fatalf("remplissage : code %d", w.Code)
}
coupe.Store(true)
for i := 1; i <= 3; i++ {
w := joue(t, p, "GET", hote, "/depot/etat")
if w.Code != http.StatusOK || w.Body.String() != "index courant" {
t.Fatalf("hors ligne %d : %d %q", i, w.Code, w.Body.String())
}
}
if n := atomic.LoadInt64(&composes); n != 1 {
t.Errorf("%d tentatives d'établissement pour trois requêtes, attendu 1", n)
}
// Sans copie, rien ne remplace l'amont : la requête compose malgré la
// mémoire, et le 504 dit la vraie cause, pas un amont non retenté.
w := joue(t, p, "GET", hote, "/jamais-vu")
if w.Code != http.StatusGatewayTimeout {
t.Fatalf("code %d, attendu 504", w.Code)
}
if strings.Contains(w.Body.String(), "aucune connexion") {
t.Errorf("le message dit l'amont non retenté alors qu'il l'a été :\n%s",
w.Body.String())
}
if n := atomic.LoadInt64(&composes); n != 2 {
t.Errorf("%d tentatives, attendu 2 : une requête sans repli n'a pas"+
" composé", n)
}
// La fenêtre passée, l'amont a de nouveau sa chance, copie ou non.
maintenant = maintenant.Add(FenetreMuetParDefaut)
joue(t, p, "GET", hote, "/depot/etat")
if n := atomic.LoadInt64(&composes); n != 3 {
t.Errorf("%d tentatives après la fenêtre, attendu 3", n)
}
}
// La mémoire ne fait sauter l'amont qu'aux requêtes qui ont un repli. Ici
// l'amont répond pendant tout le test : seul l'hôte est retenu comme muet,
// ce que laisse un établissement manqué une fois.
//
// Avec un repli — corps gardé, copie du client, statut gardé —, la réponse
// sort sans composer. Sans repli — rien en réserve, méthode d'écriture —, la
// requête compose, et l'amont la sert : un aléa passager ne devient pas un
// « 504 » pour tout ce que le magasin n'a pas.
func TestUnAmontConnuMuetNEstSauteQuAvecUnRepli(t *testing.T) {
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/refus" {
refuser(http.StatusNotFound)(w, r)
return
}
servir("réponse de l'amont")(w, r)
})
p := proxyDeTest(t)
p.Muets = NouvelleJoignabilite()
hote := a.hote()
for _, c := range []string{"/depot/etat", "/refus"} {
joue(t, p, "GET", hote, c)
}
muet := func() { p.Muets.Echec(hote, refusEtablissement()) }
muet()
avant := a.appels()
for _, c := range []struct {
chemin, issue string
code int
entetes []string
}{
{"/depot/etat", OutcomeStale, http.StatusOK, nil},
{"/refus", OutcomeStaleStatus, http.StatusNotFound, nil},
{"/autre/etat", OutcomeKeep, http.StatusNotModified,
[]string{"If-None-Match", `"v1"`}},
} {
w := joue(t, p, "GET", hote, c.chemin, c.entetes...)
if w.Code != c.code || w.Header().Get("X-ERPLibre-Cache") != c.issue {
t.Errorf("%s : %d « %s », attendu %d « %s »", c.chemin, w.Code,
w.Header().Get("X-ERPLibre-Cache"), c.code, c.issue)
}
}
if n := a.appels() - avant; n != 0 {
t.Errorf("%d requête(s) vers un amont connu muet, alors qu'un repli"+
" existait", n)
}
w := joue(t, p, "GET", hote, "/jamais-vu")
if w.Code != http.StatusOK || w.Body.String() != "réponse de l'amont" {
t.Errorf("sans repli : %d %q, attendu la réponse de l'amont",
w.Code, w.Body.String())
}
if p.Muets.ConnuMuet(hote) {
t.Error("la réponse de l'amont n'a pas effacé l'hôte de la mémoire")
}
muet()
avant = a.appels()
if w := joue(t, p, "POST", hote, "/api/envoi"); w.Code != http.StatusOK {
t.Errorf("POST : code %d, attendu la réponse de l'amont", w.Code)
}
if n := a.appels() - avant; n != 1 {
t.Errorf("POST : %d requête(s) à l'amont, attendu 1", n)
}
}
// Un serveur lent n'est pas un serveur mort : l'attente des en-têtes échoue,
// mais la requête suivante le retente.
func TestUnServeurLentNEstPasTenuPourMuet(t *testing.T) {
var recues int64
libere := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&recues, 1)
<-libere
}))
t.Cleanup(srv.Close)
t.Cleanup(func() { close(libere) })
p := proxyDeTest(t)
p.Muets = NouvelleJoignabilite()
p.Client.Transport.(*http.Transport).ResponseHeaderTimeout = 200 * time.Millisecond
u, _ := url.Parse(srv.URL)
for i := 0; i < 2; i++ {
joue(t, p, "GET", u.Host, "/api/lente")
}
if n := atomic.LoadInt64(&recues); n != 2 {
t.Errorf("le serveur a reçu %d requêtes, attendu 2 : un serveur lent"+
" a été tenu pour muet", n)
}
}
// amontGitCompte monte un amont git qui compte ses requêtes, et qu'on peut
// mettre en panne en cours de test.
func amontGitCompte(t *testing.T) (string, *int64, *atomic.Bool) {
t.Helper()
depot, _, n, panne := amontGitEnPanne(t)
return depot, n, panne
}
// amontGitEnPanne est amontGitCompte qui rend aussi le chemin du dépôt nu
// servi, pour y pousser des commits en cours de test.
func amontGitEnPanne(t *testing.T) (string, string, *int64, *atomic.Bool) {
t.Helper()
if TrouverBackend() == "" {
t.Skip("git-http-backend absent de cette machine")
}
nu := depotDEssai(t)
racine := filepath.Dir(nu)
h := &cgi.Handler{
Path: TrouverBackend(),
Dir: racine,
Env: []string{
"GIT_PROJECT_ROOT=" + racine,
"GIT_HTTP_EXPORT_ALL=1",
},
InheritEnv: []string{"PATH"},
}
var n int64
var panne atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&n, 1)
if panne.Load() {
http.Error(w, "panne", http.StatusInternalServerError)
return
}
h.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
return srv.URL + "/" + filepath.Base(nu), nu, &n, &panne
}
// Un amont connu muet ne fait pas payer au miroir le délai d'une mise à jour
// vouée à l'échec : le miroir existant sert tel quel.
func TestUnAmontConnuMuetNeRafraichitPasLeMiroir(t *testing.T) {
depot, appels, _ := amontGitCompte(t)
g := &GitMirror{
Dir: t.TempDir(), Delai: 30 * time.Second, Muets: NouvelleJoignabilite(),
}
if _, pret := g.Assurer(context.Background(), depot); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
avant := atomic.LoadInt64(appels)
g.Muets.Echec(hoteDe(t, depot), refusEtablissement())
g.Frais = 0
if _, pret := g.Assurer(context.Background(), depot); !pret {
t.Fatal("le miroir existant n'est pas servi")
}
if n := atomic.LoadInt64(appels); n != avant {
t.Errorf("%d requête(s) vers un amont connu muet", n-avant)
}
}
// Une sonde refusée retient l'hôte et sert le miroir sans le rafraîchir ; le
// dépôt suivant du même hôte ne sonde même plus.
func TestUneSondeRefuseeRetientLHoteEtSertLeMiroir(t *testing.T) {
depot, appels, _ := amontGitCompte(t)
g := &GitMirror{
Dir: t.TempDir(), Delai: 30 * time.Second, Muets: NouvelleJoignabilite(),
}
if _, pret := g.Assurer(context.Background(), depot); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
avant := atomic.LoadInt64(appels)
var sondes int64
g.Sonder = func(context.Context, string) error {
atomic.AddInt64(&sondes, 1)
return refusEtablissement()
}
g.Frais = 0
for i := 0; i < 2; i++ {
if _, pret := g.Assurer(context.Background(), depot); !pret {
t.Fatal("le miroir existant n'est pas servi")
}
}
if n := atomic.LoadInt64(appels); n != avant {
t.Errorf("%d requête(s) vers un amont qui refuse la sonde", n-avant)
}
if n := atomic.LoadInt64(&sondes); n != 1 {
t.Errorf("%d sondes, attendu 1 : l'échec n'a pas été retenu", n)
}
if !g.Muets.ConnuMuet(hoteDe(t, depot)) {
t.Error("l'hôte n'est pas retenu comme muet")
}
}
// sansMandataire : aucune URL ne sort par un mandataire, quel que soit
// l'environnement du test.
func sansMandataire(*http.Request) (*url.URL, error) { return nil, nil }
// miroirCree tient un miroir du dépôt dans un répertoire neuf et le rend.
func miroirCree(t *testing.T, depot string) string {
t.Helper()
dir := t.TempDir()
creation := &GitMirror{Dir: dir, Delai: 30 * time.Second}
if _, pret := creation.Assurer(context.Background(), depot); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
return dir
}
// Une mise à jour manquée sur un amont qui accepte la connexion — un 5xx, une
// poignée de main coupée — n'est pas notée : la requête suivante retente, et
// l'hôte n'est pas tenu pour muet.
func TestUnRafraichissementManqueSurUnAmontJoignableEstRetente(t *testing.T) {
depot, appels, panne := amontGitCompte(t)
g := &GitMirror{
Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute,
Muets: NouvelleJoignabilite(),
Sonder: func(context.Context, string) error { return nil },
}
panne.Store(true)
g.Assurer(context.Background(), depot)
avant := atomic.LoadInt64(appels)
if _, pret := g.Assurer(context.Background(), depot); !pret {
t.Fatal("le miroir existant n'est pas servi")
}
if n := atomic.LoadInt64(appels); n == avant {
t.Error("la mise à jour manquée sur un amont joignable n'est pas" +
" retentée : elle a été notée comme un rafraîchissement")
}
if g.Muets.ConnuMuet(hoteDe(t, depot)) {
t.Error("un 500 a fait tenir l'hôte pour muet")
}
}
// Une mise à jour manquée sur un amont qui refuse désormais la connexion est
// notée, et l'hôte retenu : pendant « Frais », le dépôt ne la retente pas.
func TestUnRafraichissementManqueSurUnAmontQuiRefuseEstNote(t *testing.T) {
depot, appels, panne := amontGitCompte(t)
var sondes int64
g := &GitMirror{
Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute,
Muets: NouvelleJoignabilite(),
// La sonde d'avant la mise à jour passe ; celle d'après trouve
// l'amont tombé entre les deux.
Sonder: func(context.Context, string) error {
if atomic.AddInt64(&sondes, 1) == 1 {
return nil
}
return refusEtablissement()
},
}
panne.Store(true)
chemin, _ := g.Assurer(context.Background(), depot)
if n := atomic.LoadInt64(&sondes); n != 2 {
t.Errorf("%d sonde(s), attendu 2 : l'échec de la mise à jour n'a pas"+
" été sondé", n)
}
if !g.recent(chemin) {
t.Error("la mise à jour manquée sur un amont qui refuse n'est pas notée")
}
if !g.Muets.ConnuMuet(hoteDe(t, depot)) {
t.Error("l'hôte qui refuse n'est pas retenu comme muet")
}
avant := atomic.LoadInt64(appels)
if _, pret := g.Assurer(context.Background(), depot); !pret {
t.Fatal("le miroir existant n'est pas servi")
}
if n := atomic.LoadInt64(appels); n != avant {
t.Errorf("%d requête(s) dans la fraîcheur d'un amont qui refuse", n-avant)
}
}
// pousserUnCommit ajoute un commit au dépôt nu que sert l'amont et rend son
// empreinte. Le dépôt de travail est celui que depotDEssai laisse à côté.
func pousserUnCommit(t *testing.T, nu string) string {
t.Helper()
travail := filepath.Join(filepath.Dir(nu), "travail")
lancer := func(args ...string) string {
t.Helper()
c := exec.Command("git", args...)
c.Dir = travail
c.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e")
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("git %v : %v : %s", args, err, out)
}
return strings.TrimSpace(string(out))
}
if err := os.WriteFile(
filepath.Join(travail, "g.txt"), []byte("suite"), 0o644,
); err != nil {
t.Fatal(err)
}
lancer("add", "g.txt")
lancer("commit", "-qm", "second")
lancer("push", "-q", nu, "HEAD:refs/heads/main")
return lancer("rev-parse", "HEAD")
}
// Un 500 de la forge ne fige pas le miroir : l'amont publie un commit juste
// après, et la requête suivante le voit, sans attendre la fin de « Frais ».
// La sonde est la vraie, vers l'amont local qui accepte la connexion.
func TestUnRafraichissementEn500NeFigePasLeMiroir(t *testing.T) {
depot, nu, _, panne := amontGitEnPanne(t)
g := &GitMirror{
Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute,
Muets: NouvelleJoignabilite(), Mandataire: sansMandataire,
}
panne.Store(true)
if _, pret := g.Assurer(context.Background(), depot); !pret {
t.Fatal("le miroir existant n'est pas servi pendant la panne")
}
panne.Store(false)
attendu := pousserUnCommit(t, nu)
chemin, pret := g.Assurer(context.Background(), depot)
if !pret {
t.Fatal("le miroir n'est pas servi")
}
out, err := exec.Command(
"git", "--git-dir", chemin, "rev-parse", "refs/heads/main",
).CombinedOutput()
if err != nil {
t.Fatalf("rev-parse : %v : %s", err, out)
}
if got := strings.TrimSpace(string(out)); got != attendu {
t.Errorf("le miroir sert %s, attendu %s : il est resté figé après"+
" le 500", got, attendu)
}
}
// Quand un mandataire porte l'URL du dépôt, aucune sonde directe : git passe
// par lui, et la connexion directe, refusée sur un hôte dont c'est la seule
// sortie, figerait le miroir pour toujours.
func TestUnMandataireDispenseDeLaSonde(t *testing.T) {
var sondes int64
g := &GitMirror{
Sonder: func(context.Context, string) error {
atomic.AddInt64(&sondes, 1)
return refusEtablissement()
},
Mandataire: func(*http.Request) (*url.URL, error) {
return url.Parse("http://mandataire.example:3128")
},
}
const depot = "https://forge.example/o/d.git"
ctx := context.Background()
if !g.amontJoignable(ctx, depot) || g.sondeRefusee(ctx, depot) {
t.Error("l'amont derrière un mandataire est tenu pour injoignable")
}
if n := atomic.LoadInt64(&sondes); n != 0 {
t.Errorf("%d sonde(s) directe(s) malgré le mandataire", n)
}
g.Mandataire = sansMandataire
if g.amontJoignable(ctx, depot) {
t.Error("sans mandataire, une sonde refusée laisse l'amont joignable")
}
if n := atomic.LoadInt64(&sondes); n != 1 {
t.Errorf("%d sonde(s) sans mandataire, attendu 1", n)
}
}
// Une sonde manquée se dit au journal UNE fois par panne et par hôte : un
// miroir servi sans rafraîchissement doit se voir, sans une ligne par dépôt.
// La réponse de l'hôte clôt la panne ; la suivante se dit de nouveau.
func TestUneSondeManqueeSeDitUneFoisParPanne(t *testing.T) {
var journal bytes.Buffer
avant := log.Writer()
log.SetOutput(&journal)
t.Cleanup(func() { log.SetOutput(avant) })
var enPanne atomic.Bool
enPanne.Store(true)
g := &GitMirror{
Mandataire: sansMandataire,
Sonder: func(context.Context, string) error {
if enPanne.Load() {
return refusEtablissement()
}
return nil
},
}
const depot = "https://forge.example/o/d.git"
const adresse = "forge.example:443"
ctx := context.Background()
for i := 0; i < 3; i++ {
g.amontJoignable(ctx, depot)
}
if n := strings.Count(journal.String(), adresse); n != 1 {
t.Errorf("%d ligne(s) pour trois sondes manquées, attendu 1 :\n%s",
n, journal.String())
}
enPanne.Store(false)
g.amontJoignable(ctx, depot)
enPanne.Store(true)
g.amontJoignable(ctx, depot)
if n := strings.Count(journal.String(), adresse); n != 2 {
t.Errorf("%d ligne(s) après une seconde panne, attendu 2", n)
}
}

468
script/qemu_cache/main.go Normal file
View file

@ -0,0 +1,468 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
// erplibre_go_qemu_cache — miroir de téléchargement pour les VM QEMU locales.
//
// L'orchestrateur détourne le 80 et le 443 de ses VM vers cet outil, qui
// garde ce qui descend et sert la copie à la VM suivante. Deux VM de la même
// distribution ne tirent donc qu'une fois les mêmes paquets.
//
// Ce que l'outil NE fait pas, et qu'il faut savoir : il n'efface rien. Aucune
// éviction, aucun plafond de disque — « --status » dit ce qu'il occupe, la
// surveillance est manuelle et le cache vit sur le disque de l'orchestrateur.
package main
import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const version = "0.2.4"
func main() {
var (
cacheDir = flag.String("cache-dir", "/var/cache/erplibre_go_qemu_cache",
"répertoire des objets gardés")
caDir = flag.String("ca-dir", "/var/lib/erplibre_go_qemu_cache",
"répertoire de l'autorité de certification")
httpPort = flag.Int("http-port", 8898, "écoute pour le 80 détourné")
tlsPort = flag.Int("tls-port", 8899, "écoute pour le 443 détourné")
bridge = flag.String("bridge", "virbr0", "pont libvirt des VM")
subnet = flag.String("subnet", "192.168.122.0/24", "sous-réseau des VM")
logPath = flag.String("access-log", "", "journal d'accès JSON par ligne")
exclude = flag.String("exclude", "",
"hôtes à ne jamais déchiffrer, séparés par des virgules")
verbose = flag.Bool("verbose", false, "dire chaque requête")
status = flag.Bool("status", false,
"dire ce que le cache occupe, puis sortir")
dryRun = flag.Bool("dry-run", false,
"montrer les gestes privilégiés sans en faire un")
initCA = flag.Bool("init-ca", false,
"créer l'autorité si elle manque, puis sortir")
printNft = flag.Bool("print-nft", false,
"écrire les règles nft seules, à passer à « nft -f - »")
printIptables = flag.Bool("print-iptables", false,
"écrire les commandes iptables seules, une par ligne")
bypassFile = flag.String("bypass-file",
"/etc/erplibre_go_qemu_cache/bypass",
"liste des VM soustraites au détournement, une MAC par ligne")
bypassAdd = flag.String("bypass-add", "",
"soustraire cette adresse MAC au détournement")
bypassName = flag.String("bypass-name", "",
"nom de la VM, écrit à côté de la MAC ajoutée")
bypassDel = flag.String("bypass-del", "",
"rendre cette adresse MAC au détournement")
gitMirrorDir = flag.String("git-mirror-dir", "",
"racine des dépôts git tenus en miroir ; vide, git est"+
" simplement relayé vers l'amont")
gitMirrorFresh = flag.Duration("git-mirror-fresh", 60*time.Second,
"délai en deçà duquel un dépôt n'est pas re-interrogé")
gitPrefetch = flag.String("git-mirror-prefetch", "",
"fichier de dépôts, un par ligne, à tenir en miroir d'avance")
gitPrefetchJobs = flag.Int("git-mirror-jobs", 4,
"dépôts clonés en parallèle par le pré-remplissage")
ageReport = flag.Bool("age-report", false,
"dire ce que le cache occupe, groupé par âge du dernier usage")
agePar = flag.String("age-par", "semaine",
"découpage du relevé par âge : jour, semaine ou mois")
purgeTout = flag.Bool("purge", false,
"effacer TOUT le cache : objets et dépôts en miroir")
purgeAvant = flag.String("purge-older-than", "",
"n'effacer que ce qui n'a pas servi depuis ce délai (ex. 30j, 12h)")
gitList = flag.Bool("git-mirror-list", false,
"dire les dépôts tenus en miroir, du plus lourd au plus léger")
gitRemove = flag.String("git-mirror-remove", "",
"effacer le miroir de ce dépôt ; il se refera au prochain besoin")
bypassList = flag.Bool("bypass-list", false,
"dire les exceptions en place, une « MAC nom » par ligne")
showVersion = flag.Bool("version", false, "dire la version, puis sortir")
detient = flag.Bool("detient", false,
"lire des lignes « MÉTHODE URL » sur l'entrée standard et dire,"+
" pour chacune, ce que le magasin tient : une ligne séparée"+
" par des tabulations « verdict statut stored_at classe"+
" méthode url », verdict garde (corps 200), statut (statut"+
" seul, sans corps), absent ou non-cachable. Lecture seule :"+
" --cache-dir suffit, sans privilège, et l'âge des objets"+
" n'est pas touché")
)
flag.Parse()
if *showVersion {
fmt.Printf("erplibre_go_qemu_cache %s\n", version)
return
}
// Traité AVANT tout ce qui lit une configuration : la question ne
// porte que sur le magasin, et un fichier d'exceptions illisible pour
// l'appelant ne doit pas l'empêcher d'y répondre.
if *detient {
store := &Store{Dir: *cacheDir}
if err := EcrireDetentions(store, os.Stdin, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "entrée illisible : %v\n", err)
}
return
}
bypass := BypassFile{Path: *bypassFile}
// Les exceptions entrent dans les règles dès leur RENDU : le service les
// repose telles quelles à chaque démarrage, et une exception ne survit
// donc pas au seul noyau.
exceptions, err := bypass.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "exceptions illisibles : %v\n", err)
os.Exit(1)
}
rules := RuleSet{
Bridge: *bridge, Subnet: *subnet,
HTTPPort: *httpPort, TLSPort: *tlsPort,
Bypass: MACs(exceptions),
}
store := &Store{Dir: *cacheDir}
// Les règles sortent d'ici et de nulle part ailleurs : le service les
// pose en tubant cette sortie dans nft. Une seconde copie dans un script
// d'installation dériverait de celle que les tests vérifient.
if *printNft {
for _, l := range rules.NftLines() {
fmt.Println(l)
}
return
}
if *printIptables {
for _, l := range rules.IptablesLines() {
fmt.Println(l)
}
return
}
// Les trois gestes de la liste écrivent le fichier et rendent sur la
// SORTIE le geste à chaud correspondant, à tuber dans « nft -f - ». Ce
// paquet ne touche pas au pare-feu : c'est l'invariant qui rend les
// règles vérifiables par un test sans privilège.
if *bypassAdd != "" {
mac, err := bypass.Add(*bypassAdd, *bypassName)
if err != nil {
fmt.Fprintf(os.Stderr, "exception refusée : %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "exception posée : %s %s\n", mac, *bypassName)
fmt.Println(BypassAddElement(mac))
return
}
if *bypassDel != "" {
mac, avait, err := bypass.Del(*bypassDel)
if err != nil {
fmt.Fprintf(os.Stderr, "exception refusée : %v\n", err)
os.Exit(1)
}
if !avait {
fmt.Fprintf(os.Stderr, "aucune exception pour %s\n", mac)
}
// Le geste à chaud est rendu même si le fichier ne l'avait pas :
// l'ensemble du noyau peut porter ce que le fichier a perdu, et le
// retrait doit alors pouvoir le rattraper.
fmt.Println(BypassDelElement(mac))
return
}
if *bypassList {
for _, e := range exceptions {
fmt.Printf("%s %s\n", e.MAC, e.Name)
}
return
}
// Une seule mémoire des amonts muets, partagée par le relais et le
// miroir : un hôte coupé l'est pour les deux, et le premier qui le
// constate en épargne le délai à l'autre.
muets := NouvelleJoignabilite()
// Le témoin que la levée d'une coupure touche. Il vit dans le magasin,
// seul répertoire que le service et la levée connaissent tous deux.
muets.Sentinelle = filepath.Join(*cacheDir, SentinelleAmonts)
miroir := &GitMirror{
Dir: *gitMirrorDir, Frais: *gitMirrorFresh, Muets: muets,
}
if *ageReport {
gran, err := LireGranularite(*agePar)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
printAge(store, miroir, gran)
return
}
if *purgeTout || *purgeAvant != "" {
avant := time.Now()
if *purgeAvant != "" {
d, err := LireDuree(*purgeAvant)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
avant = time.Now().Add(-d)
fmt.Printf("efface ce qui n'a pas servi depuis %s (avant %s)\n",
*purgeAvant, avant.Format("2006-01-02 15:04"))
} else {
fmt.Println("efface TOUT le cache")
}
if *dryRun {
printPurgeABlanc(store, miroir, avant)
return
}
n, oct, err := store.Purger(avant)
if err != nil {
fmt.Fprintf(os.Stderr, "purge des objets : %v\n", err)
}
fmt.Printf("objets effacés : %d, %s rendus\n", n, HumanBytes(oct))
nd, octd, err := miroir.PurgerMiroirs(avant)
if err != nil {
fmt.Fprintf(os.Stderr, "purge des miroirs : %v\n", err)
}
fmt.Printf("dépôts effacés : %d, %s rendus\n", nd, HumanBytes(octd))
return
}
if *gitList {
depots := miroir.Depots()
if len(depots) == 0 {
fmt.Println("aucun dépôt en miroir")
return
}
var total int64
for _, d := range depots {
total += d.Octets
fmt.Printf("%10s %s %s\n",
HumanBytes(d.Octets), d.Maj.Format("2006-01-02"), d.Nom)
}
fmt.Printf("%10s %d dépôts\n", HumanBytes(total), len(depots))
return
}
if *gitRemove != "" {
// Le nom suffit : on ne demande pas à l'opérateur de retrouver le
// chemin d'un répertoire qu'il n'a pas choisi.
cible := *gitRemove
if !strings.HasPrefix(cible, miroir.Dir) {
for _, d := range miroir.Depots() {
if d.Nom == strings.TrimSuffix(cible, ".git") {
cible = d.Chemin
break
}
}
}
if err := miroir.Retirer(cible); err != nil {
fmt.Fprintf(os.Stderr, "effacement : %v\n", err)
os.Exit(1)
}
fmt.Printf("miroir effacé : %s\n", cible)
return
}
if *gitPrefetch != "" {
if !miroir.Actif() {
fmt.Fprintln(os.Stderr,
"miroir git éteint : passer --git-mirror-dir")
os.Exit(1)
}
depots, err := DepotsDuFichier(*gitPrefetch)
if err != nil {
fmt.Fprintf(os.Stderr, "liste illisible : %v\n", err)
os.Exit(1)
}
fmt.Printf("%d dépôts à tenir en miroir sous %s\n",
len(depots), miroir.Dir)
reussis, echoues := miroir.Prefetch(
context.Background(), depots, *gitPrefetchJobs,
func(l string) { fmt.Println(l) },
)
_, octets := miroir.Occupation()
fmt.Printf("%d en miroir, %d en échec, %s occupés\n",
reussis, echoues, HumanBytes(octets))
// Un dépôt mort ne fait pas échouer l'opération : sur une liste de
// trois cents, il y en a toujours un — privé, déplacé, retiré — et
// rendre une erreur ferait passer pour ratée une avance qui a pris.
// Seule une liste dont RIEN n'a été tenu est un échec.
if reussis == 0 && echoues > 0 {
os.Exit(1)
}
return
}
if *status {
if err := printStatus(store, *caDir, rules, miroir); err != nil {
fmt.Fprintf(os.Stderr, "état illisible : %v\n", err)
os.Exit(1)
}
return
}
if *dryRun {
printDryRun(store, *caDir, rules)
return
}
if *initCA {
ca, err := LoadOrCreateCA(*caDir)
if err != nil {
fmt.Fprintf(os.Stderr, "autorité : %v\n", err)
os.Exit(1)
}
fmt.Printf("autorité : %s\n", CertPath(*caDir))
fmt.Printf("empreinte : %s\n", ca.Fingerprint())
return
}
if *gitMirrorDir != "" && !miroir.Actif() {
// Le dire plutôt que de laisser croire à un miroir : sans le
// programme de git, le service marche mais git repart à l'amont à
// chaque VM, ce qui est exactement ce que le miroir devait éviter.
log.Printf(
"miroir git demandé mais « git-http-backend » est introuvable :"+
" git sera relayé vers l'amont (%s)", *gitMirrorDir)
}
if err := serve(
store, *caDir, rules, *logPath, *exclude, *verbose, miroir, muets,
); err != nil {
log.Fatalf("le cache s'arrête : %v", err)
}
}
func serve(
store *Store, caDir string, rules RuleSet,
logPath, exclude string, verbose bool, miroir *GitMirror,
muets *Joignabilite,
) error {
if err := os.MkdirAll(store.Dir, 0o755); err != nil {
return err
}
// Un objet à moitié écrit ne vaut rien et occupe : le démarrage est le
// seul moment où un « .part » n'appartient à aucune écriture vivante.
if n := store.SweepPartials(); n > 0 {
log.Printf("%d écriture(s) interrompue(s) retirée(s)", n)
}
ca, err := LoadOrCreateCA(caDir)
if err != nil {
return err
}
alog, err := OpenAccessLog(logPath)
if err != nil {
return err
}
defer alog.Close()
proxy := NewProxy(store, alog)
proxy.Git = miroir
proxy.Verbose = verbose
proxy.Muets = muets
// Les ports d'écoute sont ceux que la requête d'une boucle viserait.
proxy.Ecoutes = []int{rules.HTTPPort, rules.TLSPort}
refusals := NewRefusals(append(DefaultExclusions, splitList(exclude)...))
front := &TLSFront{CA: ca, Proxy: proxy, Refusals: refusals}
httpLn, err := net.Listen("tcp", fmt.Sprintf(":%d", rules.HTTPPort))
if err != nil {
return fmt.Errorf("écoute HTTP : %w", err)
}
tlsLn, err := net.Listen("tcp", fmt.Sprintf(":%d", rules.TLSPort))
if err != nil {
return fmt.Errorf("écoute TLS : %w", err)
}
log.Printf("cache : %s", store.Dir)
log.Printf("autorité : %s (%s)", CertPath(caDir), ca.Fingerprint())
log.Printf("écoutes : http %d, tls %d", rules.HTTPPort, rules.TLSPort)
errc := make(chan error, 2)
go func() {
srv := &http.Server{
Handler: proxy.handler("http"),
ReadHeaderTimeout: 30 * time.Second,
}
errc <- srv.Serve(httpLn)
}()
go func() { errc <- front.Serve(tlsLn) }()
return <-errc
}
func printStatus(
store *Store, caDir string, rules RuleSet, miroirStatut *GitMirror,
) error {
st, err := store.Stat()
if err != nil {
return err
}
fmt.Printf("répertoire : %s\n", store.Dir)
fmt.Printf("objets : %d\n", st.Objects)
fmt.Printf("occupation : %s\n", HumanBytes(st.Bytes))
if st.Oldest.IsZero() {
fmt.Printf("plus ancien : aucun objet\n")
} else {
fmt.Printf("plus ancien : %s\n", st.Oldest.Format(time.RFC3339))
}
fmt.Printf("autorité : %s\n", CertPath(caDir))
if ca, err := LoadOrCreateCA(caDir); err == nil {
fmt.Printf("empreinte : %s\n", ca.Fingerprint())
} else {
fmt.Printf("empreinte : autorité absente\n")
}
if depots, octets := miroirStatut.Occupation(); depots > 0 {
fmt.Printf("dépôts git : %d en miroir, %s\n",
depots, HumanBytes(octets))
}
if len(rules.Bypass) == 0 {
fmt.Printf("exceptions : aucune\n")
} else {
fmt.Printf("exceptions : %d VM soustraite(s) au détournement\n",
len(rules.Bypass))
for _, m := range rules.Bypass {
fmt.Printf(" %s\n", m)
}
}
fmt.Printf("\nAucune éviction n'est écrite : ce cache ne diminue jamais\n")
fmt.Printf("de lui-même, et il vit sur le disque de l'orchestrateur.\n")
return nil
}
func printDryRun(store *Store, caDir string, rules RuleSet) {
fmt.Printf("À blanc — rien n'est écrit, rien n'est posé.\n\n")
fmt.Printf("Répertoire du cache, créé au démarrage :\n %s\n\n", store.Dir)
fmt.Printf("Autorité, créée si elle manque :\n %s (clé en 0600)\n\n",
CertPath(caDir))
fmt.Printf("Règles nft à poser sur l'hôte :\n")
for _, l := range rules.NftLines() {
fmt.Printf(" %s\n", l)
}
fmt.Printf("\nÀ défaut de nft :\n")
for _, l := range rules.IptablesLines() {
fmt.Printf(" %s\n", l)
}
fmt.Printf("\nRetrait :\n %s\n", rules.NftDeleteLine())
fmt.Printf("\nDans chaque VM qui utilise le cache :\n")
for _, f := range []string{"pacman", "apt", "dnf", "zypper"} {
dir, cmd, bundle, _ := GuestTrustCommand(f)
fmt.Printf(" %-7s %s/erplibre-cache.crt puis %s\n", f, dir, cmd)
for _, l := range GuestEnvLines(bundle) {
fmt.Printf(" %s\n", l)
}
}
}
func splitList(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}

680
script/qemu_cache/mitm.go Normal file
View file

@ -0,0 +1,680 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// CA signe les certificats que le cache présente aux invités.
//
// Déchiffrer suppose que l'invité approuve cette autorité, ce qui est la
// SEULE configuration que l'interception transparente ne peut pas éviter : la
// redirection TCP est invisible, la confiance TLS ne l'est pas. La clé reste
// en 0600 et ne quitte jamais l'orchestrateur ; seul le certificat part dans
// les VM.
type CA struct {
cert *x509.Certificate
key *rsa.PrivateKey
certPEM []byte
mu sync.Mutex
leaves map[string]*tls.Certificate
}
// LoadOrCreateCA lit l'autorité, et la fabrique si elle manque.
//
// Une autorité créée à l'installation et jamais remplacée : la régénérer
// invaliderait les certificats de toutes les VM déjà configurées, qui
// tomberaient sur une erreur de certificat sans rapport apparent avec le
// cache.
func LoadOrCreateCA(dir string) (*CA, error) {
certPath := filepath.Join(dir, "ca.crt")
keyPath := filepath.Join(dir, "ca.key")
if certPEM, err := os.ReadFile(certPath); err == nil {
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("clé de l'autorité illisible : %w", err)
}
cb, _ := pem.Decode(certPEM)
kb, _ := pem.Decode(keyPEM)
if cb == nil || kb == nil {
return nil, errors.New("autorité illisible : PEM invalide")
}
cert, err := x509.ParseCertificate(cb.Bytes)
if err != nil {
return nil, err
}
key, err := x509.ParsePKCS1PrivateKey(kb.Bytes)
if err != nil {
return nil, err
}
return &CA{cert: cert, key: key, certPEM: certPEM,
leaves: map[string]*tls.Certificate{}}, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, err
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "ERPLibre QEMU cache",
Organization: []string{"ERPLibre"},
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(10, 0, 0),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
return nil, err
}
// 0600 : la clé de cette autorité permet de se faire passer pour
// n'importe quel site auprès d'une VM qui l'approuve.
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return nil, err
}
return &CA{cert: cert, key: key, certPEM: certPEM,
leaves: map[string]*tls.Certificate{}}, nil
}
// CertPath rend le chemin du certificat à poser dans les VM.
func CertPath(dir string) string { return filepath.Join(dir, "ca.crt") }
// Fingerprint permet de vérifier de visu que la VM approuve BIEN cette
// autorité et non une autre.
func (c *CA) Fingerprint() string {
sum := sha256.Sum256(c.cert.Raw)
h := hex.EncodeToString(sum[:])
var b strings.Builder
for i := 0; i < len(h); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(strings.ToUpper(h[i : i+2]))
}
return b.String()
}
// leafFor fabrique, et garde en mémoire, le certificat d'un nom d'hôte. Les
// feuilles sont en ECDSA : une VM demande des dizaines d'hôtes pendant une
// installation, et générer une clé RSA à chacun se verrait.
func (c *CA) leafFor(host string) (*tls.Certificate, error) {
c.mu.Lock()
if crt, ok := c.leaves[host]; ok {
c.mu.Unlock()
return crt, nil
}
c.mu.Unlock()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, err
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
if ip := net.ParseIP(host); ip != nil {
tmpl.IPAddresses = []net.IP{ip}
} else {
tmpl.DNSNames = []string{host}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, c.cert, &key.PublicKey, c.key)
if err != nil {
return nil, err
}
crt := &tls.Certificate{Certificate: [][]byte{der, c.cert.Raw}, PrivateKey: key}
c.mu.Lock()
c.leaves[host] = crt
c.mu.Unlock()
return crt, nil
}
// Refusals retient les hôtes dont le client a refusé notre certificat.
//
// Certains clients épinglent leur autorité et n'accepteront jamais la nôtre —
// snapd est le cas connu. Plutôt qu'une liste à tenir à jour, le repli ne se
// déclare pas d'avance : la première poignée de main échoue, l'hôte est
// retenu, et toutes les suivantes passent en tunnel opaque. La première
// requête est perdue, c'est le prix de n'avoir rien à configurer.
// Un refus APPRIS s'oublie, un refus DÉCLARÉ jamais.
//
// Les deux ne disent pas la même chose. Un hôte déclaré épingle son autorité
// pour toujours, et le réessayer coûterait une requête à chaque fois. Un refus
// appris, lui, peut n'avoir rien de définitif : une VM dont le magasin de
// confiance n'est pas encore posé refuse la première poignée de main, et
// l'hôte se retrouvait alors condamné au tunnel POUR LA VIE DU SERVICE — donc
// à ne plus jamais être caché, y compris pour toutes les VM suivantes, qui
// elles font confiance.
//
// L'oubli remet le doute : au bout du délai, une poignée de main est retentée.
// Un vrai épingleur la refuse à nouveau et le refus est réappris, au prix
// d'une requête perdue par délai. Un hôte qui n'avait qu'un magasin en retard
// redevient cachable.
// refus retient QUAND un hôte a été appris, et POURQUOI.
//
// La nature commande la durée. Une alerte est une décision : le client a
// regardé notre certificat et l'a rejeté, il ne se ravisera pas. Une
// répétition de coupures n'est qu'un SOUPÇON — trois erreurs de transport
// ressemblent à un épingleur sans en être un — et un soupçon doit pouvoir se
// rouvrir. Les confondre condamne un miroir de distribution aussi
// définitivement qu'un épingleur, et prive alors le cache de tout ce qu'il
// détient pour lui : un tunnel recopie des octets, il ne consulte jamais le
// magasin.
type refus struct {
quand time.Time
alerte bool
}
type Refusals struct {
mu sync.RWMutex
declares map[string]bool
apprises map[string]refus
echecs map[string]int
// Oubli borne la mémoire d'un refus de TRANSPORT. Nul, il ne s'oublie
// jamais. Une ALERTE ne s'oublie en aucun cas, quel que soit ce réglage.
Oubli time.Duration
// Seuil : combien de poignées de main de suite doivent échouer avant de
// conclure. Nul, la valeur par défaut s'applique.
Seuil int
}
// SeuilParDefaut : trois échecs de suite avant de renoncer à déchiffrer.
//
// Un client qui ne nous fera jamais confiance échoue à CHAQUE fois ; une
// coupure de transport, elle, ne se répète pas. Compter permet de servir les
// deux sans les distinguer à la première vue — ce qui est impossible, npm
// rejetant notre certificat sans envoyer d'alerte : le serveur ne voit qu'un
// EOF, exactement comme sur une VM qui démarre et coupe.
const SeuilParDefaut = 3
// OubliParDefaut : au bout de ce délai, un refus de TRANSPORT est rouvert.
//
// Une ALERTE, elle, ne s'oublie jamais, et ce réglage ne l'atteint pas : le
// client a regardé notre certificat et l'a rejeté. Ceux qui font cela portent
// leur propre magasin de confiance et ne changeront pas d'avis ; les
// ré-intercepter ferait échouer de nouveau une installation qui les traverse.
//
// Un refus appris par SEUIL n'est pas de cette nature. Trois coupures de
// suite — un flux corrompu, une fin de flux — ne disent rien de ce que le
// client pense de nous ; estRefusTLS le sait, mais le seuil conclut quand
// même. Condamner sur ce soupçon prive le cache de TOUT ce qu'il détient pour
// cet hôte, un tunnel ne servant jamais le magasin : un miroir de
// distribution banni sur une rafale renvoie alors tout son trafic à l'amont,
// y compris ce qui est en réserve.
//
// Dix minutes : assez pour ne pas repayer le délai pendant la rafale qui a
// condamné l'hôte, assez peu pour que l'installation suivante le retrouve. Le
// prix du doute est une poignée de main perdue de loin en loin.
const OubliParDefaut = 10 * time.Minute
func NewRefusals(static []string) *Refusals {
r := &Refusals{
declares: map[string]bool{},
apprises: map[string]refus{},
echecs: map[string]int{},
Oubli: OubliParDefaut,
Seuil: SeuilParDefaut,
}
for _, h := range static {
if h = strings.TrimSpace(strings.ToLower(h)); h != "" {
r.declares[h] = true
}
}
return r
}
// DefaultExclusions : les hôtes dont l'épinglage est connu d'avance. Les y
// mettre évite de perdre une requête pour l'apprendre.
var DefaultExclusions = []string{
"api.snapcraft.io",
"dashboard.snapcraft.io",
"login.ubuntu.com",
}
func (r *Refusals) Has(host string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.declares[host] {
return true
}
// Un suffixe couvre un domaine entier : « .snapcraft.io » vaut pour tous
// ses sous-domaines.
for h := range r.declares {
if strings.HasPrefix(h, ".") && strings.HasSuffix(host, h) {
return true
}
}
vu, appris := r.apprises[host]
if !appris {
return false
}
// Une décision ne se révise pas ; un soupçon, si.
if vu.alerte {
return true
}
if r.Oubli > 0 && time.Since(vu.quand) >= r.Oubli {
r.oublier(host)
return false
}
return true
}
// oublier rouvre un soupçon de transport, compte compris. Garder le compte
// ferait condamner l'hôte rouvert dès sa première coupure : le doute rendu
// par l'oubli doit se reconstruire sur un seuil entier. Appelant verrouillé.
func (r *Refusals) oublier(host string) {
delete(r.apprises, host)
delete(r.echecs, host)
}
// Echec note une poignée de main manquée et dit si l'hôte passe en tunnel.
//
// Une ALERTE tranche tout de suite : le client a regardé notre certificat et
// l'a rejeté, il n'y a rien à réessayer. Tout le reste — coupure, fin de flux —
// doit se RÉPÉTER pour compter : un client qui ne nous fera jamais confiance
// échoue à chaque fois, une VM qui démarre coupe une fois puis réussit.
//
// La raison journalisée est celle que la bibliothèque rend, jamais une
// interprétation : « certificat refusé » a longtemps été écrit là où l'erreur
// disait autre chose, et l'on cherchait une autorité manquante alors que la
// poignée de main échouait pour un motif sans rapport.
func (r *Refusals) Echec(host string, raison error) bool {
r.mu.Lock()
defer r.mu.Unlock()
seuil := r.Seuil
if seuil < 1 {
seuil = SeuilParDefaut
}
alerte := estRefusTLS(raison)
r.echecs[host]++
if !alerte && r.echecs[host] < seuil {
return false
}
if _, deja := r.apprises[host]; !deja {
log.Printf("tunnel opaque retenu pour %s (%d échec(s)) : %v",
host, r.echecs[host], raison)
}
// La NATURE est retenue avec l'instant : elle décide si ce refus se
// rouvrira. Un seuil atteint sur des coupures reste un soupçon, même
// répété trois fois.
r.apprises[host] = refus{quand: time.Now(), alerte: alerte}
return true
}
// Reussite efface le compte d'un hôte : la coupure d'avant n'était qu'un
// incident, et deux incidents éloignés ne doivent pas s'additionner jusqu'au
// seuil.
func (r *Refusals) Reussite(host string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.echecs, host)
}
func (r *Refusals) List() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, 0, len(r.declares)+len(r.apprises))
for h := range r.declares {
out = append(out, h)
}
for h, vu := range r.apprises {
if !vu.alerte && r.Oubli > 0 && time.Since(vu.quand) >= r.Oubli {
r.oublier(h)
continue
}
out = append(out, h)
}
return out
}
// estRefusTLS dit si l'échec vient d'une DÉCISION du client.
//
// Le pair qui refuse envoie une alerte, que la bibliothèque rend comme une
// « remote error ». Tout le reste — coupure, fin de flux, délai — est du
// transport, et ne dit rien de ce que le client pense de notre autorité.
func estRefusTLS(err error) bool {
if err == nil {
return false
}
var alerte tls.AlertError
if errors.As(err, &alerte) {
return true
}
return strings.Contains(err.Error(), "remote error: tls:")
}
// TLSFront écoute le port vers lequel le 443 des invités est détourné.
type TLSFront struct {
CA *CA
Proxy *Proxy
Refusals *Refusals
// Origine rend la destination qu'avait la connexion avant le détournement.
// Nulle, originalDst s'applique ; les tests la remplacent pour viser une
// destination sans poser de règle de détournement.
Origine func(net.Conn) (string, error)
}
// Serve accepte et traite chaque connexion détournée.
func (t *TLSFront) Serve(ln net.Listener) error {
for {
c, err := ln.Accept()
if err != nil {
return err
}
go t.handle(c)
}
}
func (t *TLSFront) handle(c net.Conn) {
defer c.Close()
// Le premier enregistrement TLS est lu en entier avant toute décision :
// il porte le SNI, donc le nom d'hôte, donc la réponse à « déchiffrer ou
// laisser passer ». Ses octets sont rejoués ensuite, l'invité ne devant
// pas s'apercevoir qu'on les a regardés.
raw, err := readFirstRecord(c)
if err != nil {
return
}
host := ""
if hello, err := peekSNI(raw); err == nil && hello.ServerName != "" {
host = strings.ToLower(hello.ServerName)
}
peeked := &replayed{Conn: c, buf: bytes.NewBuffer(raw)}
if host == "" || t.Refusals.Has(host) {
// Sans SNI il n'y a pas de nom à certifier ; avec un refus connu il
// n'y a rien à tenter. Les deux passent en tunnel vers la
// destination que le noyau a gardée.
//
// Un tunnel dont l'amont ne répond pas ne peut que couper le client,
// alors que le magasin détient peut-être ce qu'il demande. Rien n'a
// encore été écrit vers le client, et le ClientHello est gardé : la
// même connexion est alors déchiffrée. Un client qui refuse vraiment
// notre autorité échoue de toute façon, l'amont étant coupé.
if !t.tunnel(peeked, host) || host == "" {
return
}
}
cfg := &tls.Config{
GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
name := strings.ToLower(chi.ServerName)
if name == "" {
name = host
}
return t.CA.leafFor(name)
},
MinVersion: tls.VersionTLS12,
}
tc := tls.Server(peeked, cfg)
if err := tc.Handshake(); err != nil {
// Un REFUS et une COUPURE ne disent pas la même chose, et les
// confondre coûte cher. Le client qui rejette notre autorité envoie
// une alerte TLS : c'est une décision, et l'hôte doit passer en
// tunnel. Une connexion coupée en cours de route — « connection reset
// by peer », une fin de flux — n'est qu'un incident de transport, que
// la requête suivante ne reproduira pas.
//
// Les traiter pareil condamnait un miroir de distribution au tunnel
// sur une seule coupure, et tout son trafic repartait à l'amont.
if !t.Refusals.Echec(host, err) {
log.Printf("poignée de main interrompue pour %s : %v", host, err)
}
return
}
t.Refusals.Reussite(host)
defer tc.Close()
// Chaque requête de la connexion est servie comme du HTTPS : le schéma
// compte, l'URL stockée devant être celle que l'invité a demandée.
serveConn(tc, t.Proxy.handler("https"))
}
// serveConn fait traiter une connexion DÉJÀ établie par le serveur HTTP de la
// bibliothèque standard, en la lui présentant comme un écouteur d'une seule
// connexion. Passer par « Serve » plutôt que par une lecture à la main donne
// gratuitement la persistance, le pipelining et les délais.
func serveConn(c net.Conn, h http.Handler) {
srv := &http.Server{
Handler: h,
ReadHeaderTimeout: 30 * time.Second,
}
// « Serve » traite chaque connexion dans une GOROUTINE puis reboucle sur
// « Accept ». Un écouteur qui rendrait la fin de flux tout de suite ferait
// donc rendre la main à « Serve » — et à l'appelant, dont le « defer
// Close » couperait la connexion pendant que le handler y écrit encore :
// le client reçoit « Empty reply from server », sans une ligne au journal.
//
// Le second « Accept » attend donc la fermeture de la connexion servie.
srv.Serve(&oneConn{c: c, fini: make(chan struct{})})
}
// oneConn présente une connexion unique sous la forme d'un écouteur.
type oneConn struct {
c net.Conn
fini chan struct{}
pris bool
mu sync.Mutex
}
func (l *oneConn) Accept() (net.Conn, error) {
l.mu.Lock()
if l.pris {
l.mu.Unlock()
// La connexion est déjà partie : on attend qu'elle soit refermée
// avant d'annoncer la fin, sans quoi « Serve » rendrait la main
// pendant que la réponse s'écrit.
<-l.fini
return nil, io.EOF
}
l.pris = true
l.mu.Unlock()
return &connSignalee{Conn: l.c, fini: l.fini}, nil
}
func (l *oneConn) Close() error { return nil }
func (l *oneConn) Addr() net.Addr { return l.c.LocalAddr() }
// connSignalee prévient l'écouteur de sa fermeture, qui est le seul moment où
// « Serve » peut rendre la main sans couper une réponse.
type connSignalee struct {
net.Conn
une sync.Once
fini chan struct{}
}
func (c *connSignalee) Close() error {
err := c.Conn.Close()
c.une.Do(func() { close(c.fini) })
return err
}
// tunnel relie l'invité à sa destination sans rien comprendre à ce qui passe.
//
// Rend vrai quand l'établissement vers l'amont a échoué — refus, délai, réseau
// injoignable — et que rien n'a été écrit vers le client : la connexion reste
// alors utilisable par l'appelant. Faux dans tous les autres cas, connexion
// relayée ou abandonnée.
func (t *TLSFront) tunnel(c net.Conn, host string) bool {
origine := t.Origine
if origine == nil {
origine = originalDst
}
dst, err := origine(c)
if err != nil {
log.Printf("tunnel impossible pour %q : destination inconnue (%v)", host, err)
return false
}
// Une connexion NON détournée — ouverte directement sur l'écoute — a pour
// destination d'origine l'écoute elle-même. La relayer la renverrait ici,
// où elle serait relayée de nouveau, sans fin : chaque tour ouvre une
// connexion, jusqu'à épuiser les descripteurs et arrêter le service.
if memeAdresse(dst, c.LocalAddr().String()) {
log.Printf("tunnel refusé pour %q : connexion non détournée, sa"+
" destination %s est cette écoute même", host, dst)
t.Proxy.record(accessLine{
Method: "CONNECT", URL: "tcp://" + dst, Class: "tunnel",
Outcome: OutcomeError, Status: http.StatusLoopDetected,
Client: clientDe(c.RemoteAddr().String()),
})
return false
}
up, err := net.DialTimeout("tcp", dst, 10*time.Second)
if err != nil {
injoignable := estEchecDEtablissement(err)
if injoignable && host != "" {
log.Printf("tunnel vers %s : %v ; %s est déchiffré à la place",
dst, err, host)
} else {
log.Printf("tunnel vers %s : %v", dst, err)
}
return injoignable
}
defer up.Close()
t.Proxy.record(accessLine{
Method: "CONNECT", URL: "tcp://" + dst, Class: "tunnel",
Outcome: OutcomePassthrough, Upstream: true,
Client: clientDe(c.RemoteAddr().String()),
})
done := make(chan struct{}, 2)
go func() { io.Copy(up, c); done <- struct{}{} }()
go func() { io.Copy(c, up); done <- struct{}{} }()
<-done
return false
}
// readFirstRecord lit l'en-tête de cinq octets d'un enregistrement TLS puis
// exactement la longueur qu'il annonce. Aucune heuristique : la taille est
// écrite dans le protocole.
func readFirstRecord(c net.Conn) ([]byte, error) {
head := make([]byte, 5)
if _, err := io.ReadFull(c, head); err != nil {
return nil, err
}
if head[0] != 0x16 { // handshake
return nil, fmt.Errorf("ce n'est pas une poignée de main TLS (type %d)", head[0])
}
n := int(head[3])<<8 | int(head[4])
if n <= 0 || n > 1<<16 {
return nil, fmt.Errorf("longueur d'enregistrement invraisemblable : %d", n)
}
body := make([]byte, n)
if _, err := io.ReadFull(c, body); err != nil {
return nil, err
}
return append(head, body...), nil
}
// peekSNI fait analyser le ClientHello par la bibliothèque standard plutôt
// que par un analyseur écrit à la main : le format a des extensions, des
// versions et des pièges, et un analyseur maison les découvrirait un par un.
func peekSNI(raw []byte) (*tls.ClientHelloInfo, error) {
var hello *tls.ClientHelloInfo
err := tls.Server(&readOnly{r: bytes.NewReader(raw)}, &tls.Config{
GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) {
clone := *chi
hello = &clone
return nil, nil
},
}).Handshake()
if hello != nil {
return hello, nil
}
return nil, err
}
// replayed rejoue les octets déjà lus avant de rendre la main à la
// connexion.
type replayed struct {
net.Conn
buf *bytes.Buffer
}
func (c *replayed) Read(p []byte) (int, error) {
if c.buf.Len() > 0 {
return c.buf.Read(p)
}
return c.Conn.Read(p)
}
// readOnly sert le ClientHello à l'analyseur et refuse d'écrire : la poignée
// de main s'arrête donc juste après l'analyse, ce qui est tout ce qu'on veut.
type readOnly struct {
r io.Reader
}
var errNoWrite = errors.New("analyse seule")
func (c *readOnly) Read(p []byte) (int, error) { return c.r.Read(p) }
func (c *readOnly) Write([]byte) (int, error) { return 0, errNoWrite }
func (c *readOnly) Close() error { return nil }
func (c *readOnly) LocalAddr() net.Addr { return dummyAddr{} }
func (c *readOnly) RemoteAddr() net.Addr { return dummyAddr{} }
func (c *readOnly) SetDeadline(time.Time) error { return nil }
func (c *readOnly) SetReadDeadline(time.Time) error { return nil }
func (c *readOnly) SetWriteDeadline(time.Time) error { return nil }
type dummyAddr struct{}
func (dummyAddr) Network() string { return "peek" }
func (dummyAddr) String() string { return "peek" }

View file

@ -0,0 +1,499 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
func TestCreationEtRelectureDeLAutorite(t *testing.T) {
dir := t.TempDir()
ca1, err := LoadOrCreateCA(dir)
if err != nil {
t.Fatalf("création : %v", err)
}
// Relire ne doit PAS régénérer : une autorité neuve invaliderait les
// certificats de toutes les VM déjà configurées.
ca2, err := LoadOrCreateCA(dir)
if err != nil {
t.Fatalf("relecture : %v", err)
}
if ca1.Fingerprint() != ca2.Fingerprint() {
t.Errorf("l'autorité a été régénérée :\n %s\n %s",
ca1.Fingerprint(), ca2.Fingerprint())
}
if !ca1.cert.IsCA {
t.Error("le certificat n'est pas une autorité")
}
}
// La clé permet de se faire passer pour n'importe quel site auprès d'une VM
// qui approuve l'autorité : elle ne doit être lisible que par son
// propriétaire.
func TestCleDeLAutoriteEnMode0600(t *testing.T) {
dir := t.TempDir()
if _, err := LoadOrCreateCA(dir); err != nil {
t.Fatalf("création : %v", err)
}
st, err := os.Stat(filepath.Join(dir, "ca.key"))
if err != nil {
t.Fatalf("clé absente : %v", err)
}
if mode := st.Mode().Perm(); mode != 0o600 {
t.Errorf("clé en %o, attendu 600", mode)
}
// Le certificat, lui, part dans les VM : il doit rester lisible.
stc, err := os.Stat(CertPath(dir))
if err != nil {
t.Fatalf("certificat absent : %v", err)
}
if mode := stc.Mode().Perm(); mode != 0o644 {
t.Errorf("certificat en %o, attendu 644", mode)
}
}
// Une feuille doit se vérifier CONTRE l'autorité, sans quoi l'invité la
// rejette malgré la confiance accordée.
func TestFeuilleVerifieeParLAutorite(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatalf("autorité : %v", err)
}
crt, err := ca.leafFor("miroir.example")
if err != nil {
t.Fatalf("feuille : %v", err)
}
leaf, err := x509.ParseCertificate(crt.Certificate[0])
if err != nil {
t.Fatalf("feuille illisible : %v", err)
}
pool := x509.NewCertPool()
pool.AddCert(ca.cert)
if _, err := leaf.Verify(x509.VerifyOptions{
DNSName: "miroir.example",
Roots: pool,
}); err != nil {
t.Errorf("la feuille ne se vérifie pas : %v", err)
}
// La chaîne envoyée porte l'autorité après la feuille : un invité qui ne
// la connaît pas encore doit pouvoir la voir.
if len(crt.Certificate) != 2 {
t.Errorf("la chaîne porte %d certificats, attendu 2", len(crt.Certificate))
}
}
// Un hôte demandé deux fois ne coûte qu'une génération : une installation
// touche des dizaines d'hôtes.
func TestFeuilleGardeeEnMemoire(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatalf("autorité : %v", err)
}
a, _ := ca.leafFor("miroir.example")
b, _ := ca.leafFor("miroir.example")
if a != b {
t.Error("la feuille est régénérée à chaque demande")
}
}
// Un SNI qui est une adresse IP ne peut pas aller dans un nom DNS.
func TestFeuillePourUneAdresseIP(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatalf("autorité : %v", err)
}
crt, err := ca.leafFor("192.168.122.50")
if err != nil {
t.Fatalf("feuille : %v", err)
}
leaf, _ := x509.ParseCertificate(crt.Certificate[0])
if len(leaf.IPAddresses) != 1 {
t.Errorf("%d adresse(s) dans le certificat, attendu 1", len(leaf.IPAddresses))
}
if len(leaf.DNSNames) != 0 {
t.Errorf("une adresse IP est passée en nom DNS : %v", leaf.DNSNames)
}
}
// errRefus est l'alerte qu'envoie un client qui a REGARDÉ notre certificat.
var errRefus = errors.New("remote error: tls: unknown certificate authority")
// Le repli ne se déclare pas d'avance : un hôte qui refuse le certificat est
// retenu, et la requête suivante vers lui n'essaie plus de le déchiffrer.
func TestRefusRetenu(t *testing.T) {
r := NewRefusals(nil)
if r.Has("api.example") {
t.Fatal("un hôte est exclu avant tout refus")
}
r.Echec("api.example", errRefus)
if !r.Has("api.example") {
t.Error("le refus n'est pas retenu")
}
// Deux fois le même hôte ne double pas l'entrée.
r.Echec("api.example", errRefus)
if n := len(r.List()); n != 1 {
t.Errorf("%d hôtes retenus, attendu 1", n)
}
}
// Un suffixe couvre un domaine entier, sous-domaines compris.
func TestExclusionParSuffixe(t *testing.T) {
r := NewRefusals([]string{".snapcraft.io"})
for _, h := range []string{"api.snapcraft.io", "dashboard.snapcraft.io"} {
if !r.Has(h) {
t.Errorf("%s n'est pas couvert par le suffixe", h)
}
}
if r.Has("snapcraft.io.example.com") {
t.Error("le suffixe attrape un domaine qui ne fait que le contenir")
}
}
// Les hôtes dont l'épinglage est connu d'avance évitent de perdre une requête
// pour l'apprendre.
func TestExclusionsParDefaut(t *testing.T) {
r := NewRefusals(DefaultExclusions)
if !r.Has("api.snapcraft.io") {
t.Error("snapd n'est pas exclu d'avance, alors qu'il épingle")
}
}
// La casse d'un nom d'hôte n'a pas de sens en DNS.
func TestExclusionInsensibleALaCasse(t *testing.T) {
r := NewRefusals([]string{"API.Example"})
if !r.Has("api.example") {
t.Error("l'exclusion dépend de la casse")
}
}
// L'empreinte sert à vérifier de visu qu'une VM approuve BIEN cette autorité.
func TestEmpreinteLisible(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatalf("autorité : %v", err)
}
fp := ca.Fingerprint()
if n := strings.Count(fp, ":"); n != 31 {
t.Errorf("%d séparateurs, attendu 31 pour un SHA-256", n)
}
if fp != strings.ToUpper(fp) {
t.Errorf("empreinte en minuscules : %s", fp)
}
}
// Un premier enregistrement qui n'est pas une poignée de main TLS est refusé
// tôt : le port du 443 détourné ne reçoit rien d'autre.
func TestPremierEnregistrementNonTLS(t *testing.T) {
if _, err := peekSNI([]byte{0x17, 0x03, 0x03, 0x00, 0x01, 0x00}); err == nil {
t.Error("un enregistrement qui n'est pas un handshake est accepté")
}
}
// Une coupure isolée ne condamne pas ; une coupure QUI SE RÉPÈTE, oui.
//
// Les deux se ressemblent au serveur : npm rejette notre certificat sans
// envoyer d'alerte, si bien qu'on ne voit qu'un EOF — exactement ce que
// produit une VM qui démarre et coupe. Seule la répétition les sépare.
func TestUneCoupureIsoleeNeCondamnePas(t *testing.T) {
r := NewRefusals(nil)
coupure := errors.New("read: connection reset by peer")
for i := 1; i < r.Seuil; i++ {
if r.Echec("miroir.example", coupure) {
t.Fatalf("condamné dès l'échec %d, seuil %d", i, r.Seuil)
}
}
if r.Has("miroir.example") {
t.Error("condamné avant le seuil")
}
}
func TestUneCoupureRepeteeFinitParCondamner(t *testing.T) {
r := NewRefusals(nil)
coupure := errors.New("EOF")
for i := 0; i < r.Seuil; i++ {
r.Echec("npm.example", coupure)
}
if !r.Has("npm.example") {
t.Error("un client qui échoue à chaque fois n'est jamais mis en" +
" tunnel : son installation s'arrêtera là")
}
}
// Un soupçon se rouvre ; une décision, jamais.
//
// Trois coupures de suite condamnent l'hôte, et c'est voulu — mais elles ne
// disent rien de ce que le client pense de notre autorité. Le garder banni
// pour toujours prive le cache de tout ce qu'il détient pour lui : un tunnel
// recopie des octets sans consulter le magasin. Un miroir de distribution
// banni sur une rafale de flux corrompus renvoie alors à l'amont jusqu'à ce
// qui est en réserve.
func TestUnRefusDeTransportSeRouvre(t *testing.T) {
r := NewRefusals(nil)
r.Oubli = 50 * time.Millisecond
coupure := errors.New("local error: tls: bad record MAC")
for i := 0; i < r.Seuil; i++ {
r.Echec("miroir.example", coupure)
}
if !r.Has("miroir.example") {
t.Fatal("le seuil ne condamne plus")
}
time.Sleep(60 * time.Millisecond)
if r.Has("miroir.example") {
t.Error("un soupçon de transport ne se rouvre jamais")
}
if slices.Contains(r.List(), "miroir.example") {
t.Error("un refus expiré figure encore dans la liste")
}
}
// L'oubli rouvre le doute ENTIER : un hôte rouvert qui coupe une fois n'est pas
// recondamné sur-le-champ. Le compte d'avant l'oubli, s'il survivait, ferait
// d'une coupure isolée — un processus qui se termine en abandonnant ses
// poignées de main — un nouveau bannissement de toute la durée d'oubli.
func TestLOubliRemetLeCompteAZero(t *testing.T) {
// Has et List constatent l'oubli chacun de leur côté : les deux doivent
// rouvrir le compte.
lectures := map[string]func(r *Refusals, h string) bool{
"Has": func(r *Refusals, h string) bool { return r.Has(h) },
"List": func(r *Refusals, h string) bool { return slices.Contains(r.List(), h) },
}
for nom, lire := range lectures {
t.Run(nom, func(t *testing.T) {
r := NewRefusals(nil)
r.Oubli = 50 * time.Millisecond
for i := 0; i < r.Seuil; i++ {
r.Echec("registre.example", io.EOF)
}
time.Sleep(60 * time.Millisecond)
if lire(r, "registre.example") {
t.Fatal("le soupçon ne s'est pas rouvert")
}
if r.Echec("registre.example", io.EOF) {
t.Error("une seule coupure après l'oubli recondamne l'hôte")
}
})
}
}
// Un hôte en tunnel dont l'amont ne répond pas est DÉCHIFFRÉ sur la même
// connexion : le tunnel ne pourrait que couper le client, alors que le magasin
// détient peut-être ce qu'il demande.
func TestUnTunnelSansAmontSeRabatSurLeDechiffrement(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatal(err)
}
// Une adresse qui refuse : l'écoute est fermée aussitôt ouverte.
ferme, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
coupee := ferme.Addr().String()
ferme.Close()
const hote = "registre.example.invalid"
r := NewRefusals(nil)
for i := 0; i < r.Seuil; i++ {
r.Echec(hote, io.EOF)
}
front := &TLSFront{
CA: ca, Proxy: proxyDeTest(t), Refusals: r,
Origine: func(net.Conn) (string, error) { return coupee, nil },
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go front.Serve(ln)
brut, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
if err != nil {
t.Fatal(err)
}
defer brut.Close()
brut.SetDeadline(time.Now().Add(5 * time.Second))
pool := x509.NewCertPool()
pool.AddCert(ca.cert)
tc := tls.Client(brut, &tls.Config{ServerName: hote, RootCAs: pool})
if err := tc.Handshake(); err != nil {
t.Fatalf("l'amont coupé laisse le client sans poignée de main : %v", err)
}
if !r.Has(hote) {
t.Error("le repli a effacé le soupçon : il ne vaut que pour cette" +
" connexion")
}
}
// Une ALERTE ne se rouvre pas, quel que soit le réglage d'oubli : le client a
// REGARDÉ notre certificat. Le ré-intercepter ferait échouer de nouveau
// l'installation qui le traverse.
func TestUneAlerteNeSeRouvreJamais(t *testing.T) {
r := NewRefusals(nil)
r.Oubli = time.Millisecond
r.Echec("epingleur.example", errRefus)
time.Sleep(10 * time.Millisecond)
if !r.Has("epingleur.example") {
t.Error("une décision du client a été oubliée")
}
if !slices.Contains(r.List(), "epingleur.example") {
t.Error("une alerte a disparu de la liste")
}
}
// Le défaut n'est plus « jamais » : sans lui, une rafale condamne un hôte
// jusqu'au redémarrage du service.
func TestLOubliEstActifParDefaut(t *testing.T) {
if NewRefusals(nil).Oubli <= 0 {
t.Error("un refus de transport ne se rouvre jamais par défaut")
}
}
// Une réussite efface le compte : deux incidents éloignés ne doivent pas
// s'additionner jusqu'au seuil.
func TestUneReussiteEffaceLeCompte(t *testing.T) {
r := NewRefusals(nil)
coupure := errors.New("EOF")
for i := 1; i < r.Seuil; i++ {
r.Echec("h.example", coupure)
}
r.Reussite("h.example")
for i := 1; i < r.Seuil; i++ {
r.Echec("h.example", coupure)
}
if r.Has("h.example") {
t.Error("des incidents éloignés se sont additionnés")
}
}
// Une ALERTE tranche tout de suite : le client a REGARDÉ notre certificat.
func TestUneAlerteCondamneDesLePremierEchec(t *testing.T) {
r := NewRefusals(nil)
if !r.Echec("epingleur.example", errors.New("remote error: tls: bad certificate")) {
t.Error("une alerte n'est pas retenue immédiatement")
}
}
// Un REFUS et une COUPURE ne disent pas la même chose.
//
// Le code les confondait : « connection reset by peer » était journalisé
// « certificat refusé » et faisait passer l'hôte en tunnel opaque. Un miroir
// de distribution se retrouve alors soustrait au cache sur une seule coupure,
// et tout son trafic repart à l'amont.
func TestUneCoupureNestPasUnRefus(t *testing.T) {
coupures := []error{
errors.New("read tcp 10.0.0.1:8899->10.0.0.2:33918: read:" +
" connection reset by peer"),
io.EOF,
errors.New("read tcp: i/o timeout"),
nil,
}
for _, err := range coupures {
if estRefusTLS(err) {
t.Errorf("%v pris pour un refus du client", err)
}
}
}
func TestUneAlerteEstUnRefus(t *testing.T) {
refus := []error{
errors.New("remote error: tls: bad certificate"),
errors.New("remote error: tls: unknown certificate authority"),
tls.AlertError(42),
}
for _, err := range refus {
if !estRefusTLS(err) {
t.Errorf("%v n'est pas reconnu comme un refus", err)
}
}
}
// L'erreur enveloppée compte autant : la bibliothèque en emballe parfois.
func TestUnRefusEnveloppeEstReconnu(t *testing.T) {
err := fmt.Errorf("poignée de main : %w", tls.AlertError(48))
if !estRefusTLS(err) {
t.Error("un refus enveloppé n'est pas reconnu")
}
}
// clientHelloBrut fabrique un ClientHello valide portant ce nom, sans rien
// engager : les octets sont capturés sur un tuyau, puis rejoués à la main.
func clientHelloBrut(t *testing.T, nom string) []byte {
t.Helper()
a, b := net.Pipe()
defer a.Close()
go func() {
tc := tls.Client(a, &tls.Config{ServerName: nom})
_ = tc.Handshake()
}()
b.SetReadDeadline(time.Now().Add(2 * time.Second))
brut, err := readFirstRecord(b)
b.Close()
if err != nil {
t.Fatalf("ClientHello : %v", err)
}
return brut
}
// Le contrôle précédent vérifie la RÈGLE ; celui-ci vérifie qu'elle est
// branchée. Sans lui, remettre les deux cas dans le même sac laisse les tests
// verts et recondamne un miroir au tunnel dès la première coupure.
func TestUneCoupureNeCondamnePasLHote(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatal(err)
}
refus := NewRefusals(nil)
front := &TLSFront{CA: ca, Refusals: refus, Proxy: proxyDeTest(t)}
hello := clientHelloBrut(t, "miroir.example")
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
fini := make(chan struct{})
go func() {
if c, err := ln.Accept(); err == nil {
front.handle(c)
}
close(fini)
}()
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
// Le ClientHello part, puis la connexion est coupée BRUTALEMENT : c'est
// la coupure de transport que le code prenait pour un rejet d'autorité.
// Aucune alerte n'est émise — ce serait l'autre cas.
if _, err := c.Write(hello); err != nil {
t.Fatal(err)
}
c.(*net.TCPConn).SetLinger(0)
c.Close()
select {
case <-fini:
case <-time.After(3 * time.Second):
t.Fatal("la connexion n'a pas été traitée")
}
if refus.Has("miroir.example") {
t.Error("une coupure a condamné l'hôte au tunnel opaque")
}
}

View file

@ -0,0 +1,72 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
//go:build linux
package main
import (
"errors"
"fmt"
"net"
"syscall"
)
// SO_ORIGINAL_DST : l'option que Netfilter renseigne sur une socket
// redirigée. Elle n'est pas dans le paquet « syscall », d'où la constante.
const soOriginalDst = 80
// originalDst rend l'adresse que l'invité voulait joindre.
//
// En interception transparente le client croit parler au serveur amont : la
// socket acceptée porte l'adresse du cache, et la destination d'origine n'est
// connue que du noyau, qui la garde pour cette redirection. Sans elle, un
// tunnel opaque n'a nulle part à aller.
func originalDst(c net.Conn) (string, error) {
// La connexion peut être enveloppée par le rejeu du ClientHello : c'est
// la socket dessous qui porte l'option.
for {
if r, ok := c.(*replayed); ok {
c = r.Conn
continue
}
break
}
tcp, ok := c.(*net.TCPConn)
if !ok {
return "", errors.New("la connexion n'est pas une socket TCP")
}
raw, err := tcp.SyscallConn()
if err != nil {
return "", err
}
var addr string
var inner error
err = raw.Control(func(fd uintptr) {
// GetsockoptIPv6Mreq rend seize octets bruts, ce qui est exactement
// la taille d'un « sockaddr_in » : famille, port, adresse. Le nom de
// l'appel ne correspond pas à l'usage, mais c'est le seul du paquet
// « syscall » qui rende un tampon de cette taille.
mreq, e := syscall.GetsockoptIPv6Mreq(int(fd), syscall.IPPROTO_IP, soOriginalDst)
if e != nil {
inner = e
return
}
port := int(mreq.Multiaddr[2])<<8 | int(mreq.Multiaddr[3])
ip := net.IPv4(
mreq.Multiaddr[4], mreq.Multiaddr[5],
mreq.Multiaddr[6], mreq.Multiaddr[7],
)
addr = fmt.Sprintf("%s:%d", ip.String(), port)
})
if err != nil {
return "", err
}
if inner != nil {
return "", inner
}
if addr == "" || addr == "0.0.0.0:0" {
return "", errors.New("aucune destination d'origine : la connexion n'est pas redirigée")
}
return addr, nil
}

View file

@ -0,0 +1,18 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
//go:build !linux
package main
import (
"errors"
"net"
)
// originalDst n'a de sens que là où Netfilter garde la destination d'une
// connexion redirigée. Ailleurs, le tunnel opaque est impossible et le dire
// vaut mieux que de deviner une adresse.
func originalDst(net.Conn) (string, error) {
return "", errors.New("interception transparente : Linux seulement")
}

777
script/qemu_cache/proxy.go Normal file
View file

@ -0,0 +1,777 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
// Issues d'une requête, telles qu'elles paraissent au journal d'accès. Le
// journal EST la mesure : un test qui veut prouver que la seconde VM n'a rien
// tiré de l'amont compte les lignes, sans avoir à instrumenter l'invité.
const (
OutcomeHit = "hit" // servi du disque
OutcomeStored = "stored" // pris à l'amont, gardé
OutcomeFetched = "fetched" // pris à l'amont, non gardé
OutcomeStale = "stale" // amont muet, copie stockée servie
OutcomeOfflineMiss = "offline-miss" // amont muet, rien en réserve
OutcomeKeep = "keep" // amont muet, le client garde la sienne
OutcomePassthrough = "passthrough" // méthode ou requête non cachable
OutcomeError = "error" // amont joignable, mais en erreur
OutcomeMirror = "mirror" // servi d'un dépôt git tenu sur l'hôte
// Un statut gardé sans corps a ses PROPRES noms : les lecteurs du
// journal tiennent « stored » et « stale » pour la preuve qu'un corps est
// en réserve, et un refus gardé ne l'est pas.
OutcomeStoredStatus = "stored-status" // pris à l'amont, statut seul gardé
OutcomeStaleStatus = "stale-status" // amont muet, statut seul rejoué
)
// AccessLog écrit une ligne JSON par requête. Un format à une ligne par
// requête se lit par « grep » et se compte par « wc », ce qui est exactement
// ce qu'un test de bout en bout a besoin de faire.
type AccessLog struct {
mu sync.Mutex
f *os.File
}
func OpenAccessLog(path string) (*AccessLog, error) {
if path == "" {
return &AccessLog{}, nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, err
}
return &AccessLog{f: f}, nil
}
type accessLine struct {
Time string `json:"time"`
Method string `json:"method"`
URL string `json:"url"`
Class string `json:"class"`
Outcome string `json:"outcome"`
Status int `json:"status"`
Bytes int64 `json:"bytes"`
// Upstream dit si l'octet a traversé le réseau. C'est le champ que la
// mesure regarde.
Upstream bool `json:"upstream"`
// Client est l'adresse de l'invité qui a demandé.
//
// Sans elle, le journal dit ce que le cache a fait mais pas POUR QUI. Un
// doute sur l'accélération reste alors sans réponse : rien ne sépare ce
// qu'une VM a tiré du réseau de ce qu'une autre a été servie du disque.
// Le port est retiré — il change à chaque connexion et empêcherait tout
// regroupement.
Client string `json:"client,omitempty"`
}
// clientDe rend l'adresse de l'invité, sans son port.
func clientDe(adresse string) string {
if h, _, err := net.SplitHostPort(adresse); err == nil {
return h
}
return adresse
}
func (a *AccessLog) Write(l accessLine) {
if a == nil || a.f == nil {
return
}
l.Time = time.Now().UTC().Format(time.RFC3339)
raw, err := json.Marshal(l)
if err != nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
a.f.Write(append(raw, '\n'))
}
func (a *AccessLog) Close() {
if a != nil && a.f != nil {
a.f.Close()
}
}
// Proxy sert les requêtes détournées vers lui.
type Proxy struct {
Store *Store
Log *AccessLog
Client *http.Client
// ClientPatient sert les échanges où l'amont CALCULE avant de répondre.
// Un serveur git énumère ses références à la demande, ce qui demande des
// dizaines de secondes avant le premier octet sur un dépôt chargé, quand
// un miroir de paquets répond en quelques centaines de millisecondes. Le
// délai court,
// qui existe pour que le repli hors ligne arrive avant que le client
// renonce, prenait ce calcul pour un amont injoignable et rendait un 504 :
// « repo sync » échouait alors sur un dépôt parfaitement joignable.
ClientPatient *http.Client
// Git tient les dépôts en miroir sur l'hôte. Nul ou éteint, la
// négociation git est simplement relayée vers l'amont.
Git *GitMirror
// Verbose fait parler chaque requête sur la sortie standard, ce qu'un
// service systemd envoie au journal.
Verbose bool
// Muets retient les amonts dont l'établissement vient d'échouer, et la
// mémoire est partagée avec le miroir git. Nulle, chaque requête retente
// l'amont et repaie le délai d'établissement.
Muets *Joignabilite
// Ecoutes porte les ports où le cache lui-même écoute. Une requête qui
// vise l'un d'eux sur une adresse de cette machine est une boucle. Vide,
// rien n'est refusé.
Ecoutes []int
}
// NewProxy monte le client amont. Aucun délai GLOBAL n'est posé : une image
// qcow2 met des minutes à descendre, et un délai global la couperait au
// milieu. Les délais portent donc sur l'établissement et sur l'attente des
// en-têtes, jamais sur la durée du corps.
func NewProxy(store *Store, alog *AccessLog) *Proxy {
// Les délais sont bornés par la PATIENCE DU CLIENT, pas par la nôtre.
// pacman abandonne un fichier après dix secondes sous un octet par
// seconde ; si notre repli sur la copie stockée arrive plus tard, il
// n'arrive jamais — et un pare-feu qui jette les paquets sans les refuser
// fait justement pendre l'établissement jusqu'au délai.
tr := &http.Transport{
DialContext: (&net.Dialer{Timeout: DelaiEtablissement}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 8 * time.Second,
MaxIdleConnsPerHost: 8,
Proxy: http.ProxyFromEnvironment,
}
// Le même transport, la seule attente des en-têtes allongée : un serveur
// injoignable est toujours détecté à l'établissement, en quatre secondes.
trPatient := tr.Clone()
trPatient.ResponseHeaderTimeout = 120 * time.Second
// Une redirection est RENDUE au client plutôt que suivie : il la
// redemandera au travers du cache, et la copie reste rangée sous l'URL que
// l'invité a réellement demandée.
sansSuivre := func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return &Proxy{
Store: store,
Log: alog,
Client: &http.Client{Transport: tr, CheckRedirect: sansSuivre},
ClientPatient: &http.Client{
Transport: trPatient, CheckRedirect: sansSuivre,
},
}
}
// En-têtes que la norme réserve à un saut : les recopier vers l'amont ou vers
// le client casse la connexion.
var hopByHop = []string{
"Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate",
"Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade",
}
func copyHeader(dst, src http.Header) {
for k, vs := range src {
for _, v := range vs {
dst.Add(k, v)
}
}
for _, h := range hopByHop {
dst.Del(h)
}
}
// absoluteURL reconstruit l'adresse demandée. En interception transparente le
// client parle comme s'il tenait le serveur en face de lui : la ligne de
// requête ne porte qu'un chemin, et l'hôte vient de l'en-tête « Host » pour
// HTTP, du SNI pour TLS.
func absoluteURL(r *http.Request, scheme string) (*url.URL, error) {
if r.URL.IsAbs() {
return r.URL, nil
}
host := r.Host
if host == "" {
return nil, fmt.Errorf("requête sans hôte : ni ligne absolue ni en-tête Host")
}
u := *r.URL
u.Scheme = scheme
u.Host = host
return &u, nil
}
func (p *Proxy) handler(scheme string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p.serve(w, r, scheme)
})
}
func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) {
u, err := absoluteURL(r, scheme)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
class := Classify(u)
key := CleDe(r.Method, u)
cacheable := CacheableMethod(r.Method) && class != ClassNoStore
// Une requête adressée au cache lui-même serait relayée vers sa propre
// écoute, qui la relaierait de nouveau, sans fin : chaque tour ouvre une
// connexion, jusqu'à épuiser les descripteurs et arrêter le service. Elle
// est refusée avant toute autre chose.
if p.viseLeCache(u) {
p.boucle(w, u, class, r.Method, clientDe(r.RemoteAddr))
return
}
// Une requête partielle n'est servie du cache que si le corps ENTIER y
// est ; sinon elle passe et ne se garde pas, un fragment ne valant rien
// pour la requête suivante.
partial := r.Header.Get("Range") != ""
if cacheable && class == ClassImmutable {
if p.serveFromStore(w, r, u, key, class, OutcomeHit) {
return
}
}
// La négociation git n'est pas relayée quand un miroir peut la servir :
// c'est un DÉPÔT que l'on tient, pas des réponses, aucune réponse de ce
// protocole ne se réutilisant d'un client à l'autre.
if p.Git.Actif() && EstGitSmart(u) && !strings.HasSuffix(
u.Path, "/git-receive-pack",
) {
if depot, reste, ok := DepotDeURL(u); ok {
if chemin, pret := p.Git.Assurer(r.Context(), depot); pret {
// Le relevé vient APRÈS : c'est la seule façon de dire ce que
// la réponse a réellement pesé, et ce chemin porte l'essentiel
// du trafic d'une installation.
n := p.Git.Servir(w, r, chemin, reste)
p.record(accessLine{
URL: u.String(), Method: r.Method, Class: class.String(),
Outcome: OutcomeMirror, Status: http.StatusOK, Bytes: n,
Client: clientDe(r.RemoteAddr),
})
return
}
}
}
// Le client détient-il déjà une copie ? Relevé AVANT tout, et jamais
// relu sur la requête d'amont : celle-ci peut avoir perdu sa condition
// juste en dessous, et l'oubli ferait rendre « 504 » à un client qui
// avait de quoi se passer de nous.
conditionnelle := estConditionnelle(r)
// Une requête conditionnelle sur une ressource dont NOUS n'avons pas le
// corps rapporte « 304 », donc rien à garder. Le cache resterait vide
// aussi longtemps que ses clients en détiennent une copie — c'est-à-dire
// toujours, une image cloud livrant déjà l'index de sa suite de base.
// La condition est donc retirée pour ce seul aller : l'amont envoie le
// corps entier, une fois, et toute VM suivante est servie, hors ligne
// comprise. Le corps une fois en réserve, la condition repart et le
// « 304 » économise de nouveau la bande passante.
detient := cacheable && p.Store.Detient(key)
amont := r
if cacheable && r.Method == "GET" && conditionnelle && !detient {
amont = sansCondition(r)
}
// Un statut seul vit sous sa propre clé, que les lecteurs de corps ne
// calculent pas : voir CleStatut.
cleStatut := CleStatut(r.Method, u)
// repli dit si la branche hors ligne a de quoi répondre : un corps 200,
// la copie du client (requête conditionnelle d'ORIGINE) ou un statut
// gardé. Seul un repli autorise à ne pas composer vers un amont connu
// muet ; sans lui, sauter la tentative changerait un aléa passager en
// « 504 » certain, pour une requête que l'amont revenu aurait servie.
repli := cacheable &&
(detient || conditionnelle || p.Store.TientStatut(cleStatut))
resp, upErr := p.fetch(amont, u, repli)
// Une redirection est SUIVIE quand le nom du fichier demandé porte déjà
// son identité, et le contenu est gardé sous l'URL DEMANDÉE.
//
// Sans cela, un paquet publié derrière une redirection n'entre jamais au
// cache : la cible est une URL SIGNÉE qui change à chaque requête, si bien
// que la machine suivante ne retrouve rien et retélécharge. Rendre la
// redirection au client suppose une cible stable, ce qu'une signature à
// péremption n'est pas.
//
// Chaque étape part de la requête d'AMONT : ses conditions sont retirées
// quand le cache ne détient rien, et la requête d'origine ferait répondre
// « 304 » à la cible — il n'y aurait alors rien à garder.
if upErr == nil && class == ClassImmutable && r.Method == "GET" {
resp = p.suivreRedirections(amont, u, resp)
}
if upErr != nil {
// L'amont est injoignable : DNS muet, connexion refusée, délai
// dépassé. C'est ici, et seulement ici, qu'une copie périmée sort —
// y compris un index, ce qui rend le déploiement hors ligne possible.
// Un index plus récent que la signature qui l'annonce n'est PAS
// servi : voir indexIncoherent. Le client tombe alors sur le « 304 »
// qui le laisse garder ses listes, ou sur le refus qui suit.
if cacheable && !p.indexIncoherent(u, key) &&
p.serveFromStore(w, r, u, key, class, OutcomeStale) {
return
}
// Le client a posé une condition : il DÉTIENT déjà une copie, et ne
// demandait qu'à savoir si elle avait changé. Ne pouvant plus le
// vérifier, lui rendre « 304 » le laisse garder la sienne — c'est ce
// que « stale-if-error » veut dire pour une requête conditionnelle.
//
// Un « 504 » à sa place fait échouer toute la suite de dépôt : apt
// ne trouve alors plus un paquet de la suite de base, alors que la
// machine avait chez elle de quoi le nommer.
if conditionnelle {
w.Header().Set("X-ERPLibre-Cache", OutcomeKeep)
w.WriteHeader(http.StatusNotModified)
p.record(accessLine{
Method: r.Method, URL: u.String(), Class: class.String(),
Outcome: OutcomeKeep, Status: http.StatusNotModified,
Client: clientDe(r.RemoteAddr),
})
return
}
// Ni corps en réserve ni copie chez le client : reste le STATUT que
// l'amont a rendu la dernière fois, une redirection ou un refus. Le
// client qui suit la redirection, ou qui s'arrête sur le 404, reçoit
// ce qu'il aurait reçu en ligne, là où un 504 l'arrêterait net.
//
// Rejoué en DERNIER recours : un corps gardé l'emporte toujours, et le
// client qui détient sa copie garde son 304 plutôt qu'un refus.
if cacheable && p.rejouerStatut(w, r, u, cleStatut, class) {
return
}
p.offlineMiss(
w, u, class, r.Method, clientDe(r.RemoteAddr), upErr,
)
return
}
defer resp.Body.Close()
store := cacheable && !partial && resp.StatusCode == http.StatusOK &&
r.Method == "GET"
// Un statut est gardé SEUL, sans corps, quand il porte une réponse
// qu'aucun corps ne remplace : redirection ou refus définitif pour un GET,
// et en plus le 200 d'un HEAD, qui n'a jamais de corps. Il est rangé
// sous CleStatut, jamais sous la clé du corps, et ne ressort que l'amont
// muet. Trois bornes :
// - le volatile seul : l'immuable suit ses redirections, et un 404
// servi du disque y masquerait le fichier publié ensuite ;
// - jamais sous une clé portable : partagée par tous les miroirs, elle
// recevrait le refus d'un miroir en retard à la place de l'index
// qu'un autre a rendu ;
// - jamais quand la clé du CORPS tient un 200 : le rejeu ne passe
// qu'après lui, et le garder ne servirait qu'à le faire mentir le
// jour où ce corps disparaît.
// Un statut passager — 403, 429, 5xx — ne se garde pas : le rejouer
// figerait une panne qui n'a duré qu'un moment.
//
// Detenir, dans « --detient », tient la négation exacte de cette
// condition pour un HEAD : les deux sont à changer ensemble.
statutSeul := !store && cacheable && !partial &&
class == ClassVolatile && !PortableParChemin(u) &&
statutSansCorps(r.Method, resp) && !detient
var cw *Writer
if store || statutSeul {
cle := key
if statutSeul {
cle = cleStatut
}
m := Meta{
URL: u.String(),
Method: r.Method,
Status: resp.StatusCode,
Header: resp.Header.Clone(),
Class: class.String(),
}
if statutSeul {
m.StatusOnly = true
sansLongueurMenteuse(r.Method, m.Header)
// Le témoin de session appartient à la machine qui l'a reçu.
m.Header.Del("Set-Cookie")
}
if cw, err = p.Store.NewWriter(cle, m); err != nil {
log.Printf("cache : écriture impossible pour %s : %v", u, err)
cw = nil
}
}
copyHeader(w.Header(), resp.Header)
w.Header().Set("X-ERPLibre-Cache", "miss")
w.WriteHeader(resp.StatusCode)
// Le corps d'un statut seul va au client tel quel, jamais au disque.
var sink *Writer
if cw != nil && store {
sink = cw
}
var n int64
if sink != nil {
n, err = copyTee(w, sink, resp.Body)
} else {
n, err = copyTee(w, nil, resp.Body)
}
outcome := OutcomeFetched
switch {
case err != nil:
// Le client s'est déconnecté ou l'amont a coupé : rien de partiel
// n'entre au cache.
if cw != nil {
cw.Abort()
}
outcome = OutcomeError
case cw != nil:
// Un statut seul se publie VIDE, quelle que soit la longueur
// annoncée : pour un HEAD, la bibliothèque la tire de l'en-tête alors
// qu'aucun corps ne suit, et la comparer ferait tout refuser.
attendu := resp.ContentLength
if statutSeul {
attendu = 0
}
if cerr := cw.Commit(attendu); cerr != nil {
log.Printf("cache : %s non gardé : %v", u, cerr)
outcome = OutcomeFetched
} else if statutSeul {
outcome = OutcomeStoredStatus
} else {
outcome = OutcomeStored
}
case !cacheable:
outcome = OutcomePassthrough
}
p.record(accessLine{
Method: r.Method, URL: u.String(), Class: class.String(),
Outcome: outcome, Status: resp.StatusCode, Bytes: n, Upstream: true,
Client: clientDe(r.RemoteAddr),
})
}
// serveFromStore rend vrai quand la réponse est partie du disque.
func (p *Proxy) serveFromStore(
w http.ResponseWriter, r *http.Request, u *url.URL, key string,
class Class, outcome string,
) bool {
m, f, err := p.Store.Get(key)
if err != nil {
return false
}
defer f.Close()
// Seul un corps 200 sort par ici. Un statut seul a son propre chemin,
// réservé à l'amont muet : ServeContent en ferait un 200 vide, et sur le
// chemin de l'immuable il sortirait même quand l'amont répond.
if m.StatutSeul() {
return false
}
copyHeader(w.Header(), m.Header)
w.Header().Set("X-ERPLibre-Cache", outcome)
if outcome == OutcomeStale {
// L'opérateur doit pouvoir dire de quand datent les octets sur
// lesquels sa VM se bâtit.
w.Header().Set("X-ERPLibre-Cache-Date", m.StoredAt.Format(time.RFC3339))
}
// ServeContent tient les requêtes partielles et le code 206 : pacman
// reprend un téléchargement interrompu par une plage.
http.ServeContent(w, r, "", m.StoredAt, f)
p.record(accessLine{
Method: r.Method, URL: u.String(), Class: class.String(),
Outcome: outcome, Status: http.StatusOK, Bytes: m.Size, Upstream: false,
Client: clientDe(r.RemoteAddr),
})
if p.Verbose {
log.Printf("%s %s -> %s (%s)", r.Method, u, outcome, HumanBytes(m.Size))
}
return true
}
// rejouerStatut rend, sans corps, le statut gardé sous la clé — une clé de
// CleStatut : ses en-têtes, « Location » compris et tel que l'amont l'a
// écrit, puis le code. Rend faux quand la clé ne porte pas de statut seul.
//
// ServeContent est évité entièrement : il réécrirait le code en 200, et
// ferait d'une redirection un « 304 » ou un « 206 » selon ce que le client
// pose. La longueur d'un GET est retirée une seconde fois, un méta écrit à
// la main pouvant la porter ; celle d'un HEAD ressort telle que l'amont l'a
// annoncée.
func (p *Proxy) rejouerStatut(
w http.ResponseWriter, r *http.Request, u *url.URL, key string,
class Class,
) bool {
m, f, err := p.Store.Get(key)
if err != nil {
return false
}
f.Close()
if !m.StatutSeul() {
return false
}
copyHeader(w.Header(), m.Header)
sansLongueurMenteuse(m.Method, w.Header())
w.Header().Set("X-ERPLibre-Cache", OutcomeStaleStatus)
w.Header().Set("X-ERPLibre-Cache-Date", m.StoredAt.Format(time.RFC3339))
w.WriteHeader(m.Status)
p.record(accessLine{
Method: r.Method, URL: u.String(), Class: class.String(),
Outcome: OutcomeStaleStatus, Status: m.Status, Upstream: false,
Client: clientDe(r.RemoteAddr),
})
if p.Verbose {
log.Printf("%s %s -> %s (%d)", r.Method, u, OutcomeStaleStatus, m.Status)
}
return true
}
// sansLongueurMenteuse retire la longueur d'un statut seul, sauf pour un HEAD.
//
// Pour un GET, la longueur annoncée sans corps mentirait : le client
// attendrait des octets qui ne viendront jamais, et échouerait sur une fin de
// flux inattendue. Pour un HEAD, elle ne ment pas — la norme (RFC 9110) en
// fait la taille du corps que rendrait le GET, et aucun client ne lit de
// corps après un HEAD. C'est même la seule chose qu'il apprend du corps :
// l'installateur qui compare une taille par « curl -I » recevrait sinon hors
// ligne une autre réponse qu'en ligne.
func sansLongueurMenteuse(methode string, h http.Header) {
if methode != http.MethodHead {
h.Del("Content-Length")
}
}
// statutSansCorps dit si la réponse porte un statut qui vaut d'être gardé
// seul : une redirection qui dit où aller, un refus définitif, et pour un
// HEAD aussi le 200 — le HEAD n'a de toute façon jamais de corps.
//
// Une redirection sans « Location » ne mène nulle part : elle n'est pas
// gardée.
func statutSansCorps(methode string, resp *http.Response) bool {
switch resp.StatusCode {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther,
http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
return resp.Header.Get("Location") != ""
case http.StatusNotFound, http.StatusGone:
return true
case http.StatusOK:
return methode == "HEAD"
}
return false
}
// offlineMiss dit CE QUI manque. Un 404 nu ferait accuser le miroir : le
// client n'a aucun moyen de savoir qu'un cache s'est interposé, et le message
// est la seule chance de le lui apprendre.
func (p *Proxy) offlineMiss(
w http.ResponseWriter, u *url.URL, class Class, method, client string,
cause error,
) {
msg := enCommentaire(fmt.Sprintf(
"erplibre_go_qemu_cache : amont injoignable et rien en réserve.\n"+
" demandé : %s\n"+
" classe : %s\n"+
" cause : %v\n"+
"%s"+
"Ce fichier n'a jamais traversé ce cache. Rétablir le réseau, ou\n"+
"déployer une VM identique à celle qui a rempli le cache.\n",
u, class, cause, decrireMuet(cause)))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-ERPLibre-Cache", OutcomeOfflineMiss)
w.WriteHeader(http.StatusGatewayTimeout)
fmt.Fprint(w, msg)
p.record(accessLine{
Method: method, URL: u.String(), Class: class.String(),
Outcome: OutcomeOfflineMiss, Status: http.StatusGatewayTimeout,
Client: client,
})
log.Printf("hors ligne, absent du cache : %s", u)
}
// maxRedirections borne la chaîne : une boucle de redirections tournerait
// jusqu'à épuiser la mémoire, et aucune publication légitime n'en enchaîne
// autant.
const maxRedirections = 5
// suivreRedirections rend la réponse FINALE d'une chaîne de redirections, ou
// la dernière obtenue si quelque chose s'y oppose.
//
// Chaque étape rejoue `r`, la requête d'amont partie vers `depart`. Les
// identifiants — « Authorization », « Cookie », « Proxy-Authorization » — ne
// suivent pas une étape qui quitte l'hôte de départ : ils ont été confiés à
// cet hôte, et une redirection vers un stockage tiers les lui livrerait. Le
// client HTTP de la bibliothèque les retire de même ; un suivi à la main doit
// le faire lui-même.
//
// Une cible qui désigne le cache lui-même arrête la chaîne : la suivre
// renverrait la requête ici.
//
// Le corps de chaque étape est refermé : une redirection en porte un, court,
// que personne ne lira. Une erreur en route rend l'étape courante plutôt que
// rien : le client verra la redirection et se débrouillera, ce qui est le
// comportement d'avant.
func (p *Proxy) suivreRedirections(
r *http.Request, depart *url.URL, resp *http.Response,
) *http.Response {
origine := adresseAmont(depart)
for i := 0; i < maxRedirections; i++ {
if resp.StatusCode < 300 || resp.StatusCode > 399 {
return resp
}
cible, err := resp.Location()
if err != nil || cible == nil || p.viseLeCache(cible) {
return resp
}
etape := r
if adresseAmont(cible) != origine {
etape = sansIdentifiants(r)
}
// Sans repli : l'étape qui échoue rend la redirection au client,
// qui la redemandera au travers du cache — la tenter coûte au plus
// le délai d'établissement, la sauter ne ferait que le déplacer.
suivante, err := p.fetch(etape, cible, false)
if err != nil {
return resp
}
resp.Body.Close()
resp = suivante
}
return resp
}
// enTetesDIdentite : ce par quoi un client s'authentifie auprès d'un hôte.
var enTetesDIdentite = []string{
"Authorization", "Cookie", "Proxy-Authorization",
}
// sansIdentifiants rend une COPIE de la requête, ses identifiants retirés.
func sansIdentifiants(r *http.Request) *http.Request {
out := r.Clone(r.Context())
for _, h := range enTetesDIdentite {
out.Header.Del(h)
}
return out
}
// enCommentaire rend un texte INERTE pour un interpréteur de commandes.
//
// Un corps d'erreur finit régulièrement dans un shell : l'idiome
// « curl … | bash » est celui de la moitié des installateurs, et « curl »
// sans « -f » lui livre le corps d'un 504 comme s'il l'avait demandé. Chaque
// ligne du message devenait alors une commande, et le lecteur recevait une
// cascade de « command not found » à la place de la cause.
//
// Chaque ligne est donc préfixée — y compris celles d'une cause qui en
// porterait plusieurs, sans quoi la première suffirait à sortir du
// commentaire. Le texte reste lisible pour l'humain, et ne fait rien.
func enCommentaire(texte string) string {
lignes := strings.Split(strings.TrimRight(texte, "\n"), "\n")
for i, l := range lignes {
lignes[i] = "# " + l
}
return strings.Join(lignes, "\n") + "\n"
}
// enTetesConditionnels : ce par quoi un client dit « seulement si ça a
// changé ». Le « Range » n'en est pas — il demande un fragment, pas une
// validation, et il est traité ailleurs.
var enTetesConditionnels = []string{
"If-None-Match", "If-Modified-Since", "If-Match", "If-Unmodified-Since",
}
// estConditionnelle dit si le client détient déjà une copie de la ressource.
func estConditionnelle(r *http.Request) bool {
for _, h := range enTetesConditionnels {
if r.Header.Get(h) != "" {
return true
}
}
return false
}
// sansCondition rend une COPIE de la requête, ses conditions retirées.
//
// Une copie : la requête d'origine est celle du serveur HTTP, et la modifier
// changerait ce que voit tout ce qui la lit ensuite — le relevé, notamment.
func sansCondition(r *http.Request) *http.Request {
out := r.Clone(r.Context())
for _, h := range enTetesConditionnels {
out.Header.Del(h)
}
return out
}
// fetch interroge l'amont.
//
// repli dit que l'appelant a de quoi répondre sans l'amont : corps gardé,
// copie du client ou statut gardé. Avec lui, un amont dont l'établissement
// vient d'échouer n'est pas retenté : fetch rend aussitôt une erreur qui
// enveloppe errAmontConnuMuet, et l'appelant sert son repli sans repayer le
// délai d'établissement. Un pare-feu qui jette les paquets fait attendre ce
// délai entier à chaque tentative ; le repli, lui, n'a rien à attendre.
//
// Sans repli, fetch compose toujours, mémoire ou non : un échec
// d'établissement passager — un paquet perdu, un miroir qui redémarre —
// ferait sinon rendre « 504 » à toute requête vers cet hôte pendant la
// fenêtre, alors que l'amont revenu l'aurait servie.
//
// Chaque échec d'établissement est retenu et chaque réponse efface l'hôte,
// avec ou sans repli : la mémoire ne retient que les établissements manqués.
func (p *Proxy) fetch(
r *http.Request, u *url.URL, repli bool,
) (*http.Response, error) {
adresse := adresseAmont(u)
if repli && p.Muets.ConnuMuet(adresse) {
return nil, fmt.Errorf("%w (%s)", errAmontConnuMuet, adresse)
}
out, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), r.Body)
if err != nil {
return nil, err
}
copyHeader(out.Header, r.Header)
// L'identité de l'invité est conservée : certains miroirs répondent
// différemment selon l'agent, et un paquet servi à un agent n'est pas
// forcément celui servi à un autre.
out.Header.Del("Accept-Encoding")
client := p.Client
if EstGitSmart(u) {
client = p.ClientPatient
}
resp, err := client.Do(out)
if err != nil {
p.Muets.Echec(adresse, err)
return nil, err
}
p.Muets.Reussite(adresse)
return resp, nil
}
func (p *Proxy) record(l accessLine) {
p.Log.Write(l)
if p.Verbose && l.Upstream {
log.Printf("%s %s -> %s (%s)", l.Method, l.URL, l.Outcome, HumanBytes(l.Bytes))
}
}
// hostOnly retire le port d'une autorité, le SNI n'en portant pas.
func hostOnly(hostport string) string {
if h, _, err := net.SplitHostPort(hostport); err == nil {
return h
}
return strings.TrimSuffix(hostport, ":")
}

View file

@ -0,0 +1,510 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// amont monte un serveur qui compte ce qu'on lui demande. Le compteur EST la
// mesure de tous ces tests : le cache n'a d'intérêt que s'il fait baisser le
// nombre de requêtes qui sortent.
type amont struct {
srv *httptest.Server
appel int64
corps string
}
func nouvelAmont(t *testing.T, corps string) *amont {
t.Helper()
a := &amont{corps: corps}
a.srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&a.appel, 1)
w.Header().Set("Content-Type", "application/octet-stream")
io.WriteString(w, a.corps)
}))
t.Cleanup(a.srv.Close)
return a
}
func (a *amont) appels() int64 { return atomic.LoadInt64(&a.appel) }
func (a *amont) hote() string {
u, _ := url.Parse(a.srv.URL)
return u.Host
}
func proxyDeTest(t *testing.T) *Proxy {
t.Helper()
alog, err := OpenAccessLog("")
if err != nil {
t.Fatalf("journal : %v", err)
}
return NewProxy(&Store{Dir: t.TempDir()}, alog)
}
// demande joue une requête telle que l'interception transparente la présente :
// une ligne de requête sans hôte, l'hôte venant de l'en-tête.
func demande(t *testing.T, p *Proxy, hote, chemin string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("GET", chemin, nil)
r.Host = hote
w := httptest.NewRecorder()
p.serve(w, r, "http")
return w
}
// Invariant 1 : un index n'est JAMAIS servi du cache tant que l'amont répond.
//
// C'est la règle qui empêche le cache de provoquer le « failed retrieving
// file … 404 » sur un paquet retiré des miroirs : servir un index périmé
// promet des fichiers qui n'existent plus.
func TestIndexJamaisServiQuandAmontRepond(t *testing.T) {
a := nouvelAmont(t, "base de paquets")
p := proxyDeTest(t)
for i := 1; i <= 3; i++ {
w := demande(t, p, a.hote(), "/arch/core/os/x86_64/core.db")
if w.Code != 200 {
t.Fatalf("requête %d : code %d", i, w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" {
t.Errorf("requête %d : servie « %s », attendu « miss »", i, got)
}
}
if n := a.appels(); n != 3 {
t.Errorf("l'amont a reçu %d requêtes, attendu 3 : un index a été servi du cache", n)
}
}
// Invariant 2 : un fichier de paquet est servi du cache dès la seconde
// demande. C'est le gain que l'outil existe pour produire.
func TestPaquetServiDuCache(t *testing.T) {
a := nouvelAmont(t, "contenu du paquet")
p := proxyDeTest(t)
chemin := "/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst"
w1 := demande(t, p, a.hote(), chemin)
if w1.Header().Get("X-ERPLibre-Cache") != "miss" {
t.Fatalf("première demande servie du cache : %s",
w1.Header().Get("X-ERPLibre-Cache"))
}
w2 := demande(t, p, a.hote(), chemin)
if got := w2.Header().Get("X-ERPLibre-Cache"); got != OutcomeHit {
t.Errorf("seconde demande servie « %s », attendu « %s »", got, OutcomeHit)
}
if w2.Body.String() != a.corps {
t.Errorf("corps servi %q, attendu %q", w2.Body.String(), a.corps)
}
if n := a.appels(); n != 1 {
t.Errorf("l'amont a reçu %d requêtes, attendu 1", n)
}
}
// Invariant 3 : l'amont muet fait sortir la copie stockée, index compris.
// C'est ce qui rend un déploiement possible sans réseau du tout.
func TestIndexServiQuandAmontMuet(t *testing.T) {
a := nouvelAmont(t, "base de paquets")
p := proxyDeTest(t)
chemin := "/arch/core/os/x86_64/core.db"
if w := demande(t, p, a.hote(), chemin); w.Code != 200 {
t.Fatalf("remplissage : code %d", w.Code)
}
hote := a.hote()
a.srv.Close() // l'amont devient injoignable
w := demande(t, p, hote, chemin)
if w.Code != 200 {
t.Fatalf("hors ligne : code %d, attendu 200", w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale {
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale)
}
if w.Header().Get("X-ERPLibre-Cache-Date") == "" {
t.Error("la date de l'instantané manque : l'opérateur ne peut pas savoir de quand datent ses octets")
}
if w.Body.String() != a.corps {
t.Errorf("corps servi %q, attendu %q", w.Body.String(), a.corps)
}
}
// Invariant 4 : hors ligne et sans copie, l'erreur NOMME le fichier.
//
// Un 404 nu ferait accuser le miroir : le client n'a aucun moyen de savoir
// qu'un cache s'est interposé.
func TestDefautHorsLigneNommeLeFichier(t *testing.T) {
a := nouvelAmont(t, "peu importe")
hote := a.hote()
a.srv.Close()
p := proxyDeTest(t)
w := demande(t, p, hote, "/arch/core/os/x86_64/jamais-vu.pkg.tar.zst")
if w.Code != http.StatusGatewayTimeout {
t.Fatalf("code %d, attendu %d", w.Code, http.StatusGatewayTimeout)
}
corps := w.Body.String()
for _, attendu := range []string{"jamais-vu.pkg.tar.zst", hote, "erplibre_go_qemu_cache"} {
if !strings.Contains(corps, attendu) {
t.Errorf("le message ne dit pas %q :\n%s", attendu, corps)
}
}
}
// Une requête partielle ne remplit pas le cache : un fragment ne sert à rien
// à la demande suivante, et le garder comme un corps entier servirait un
// paquet tronqué.
func TestRequetePartielleNonGardee(t *testing.T) {
a := nouvelAmont(t, "0123456789")
p := proxyDeTest(t)
chemin := "/x/paquet-1-1-x86_64.pkg.tar.zst"
r := httptest.NewRequest("GET", chemin, nil)
r.Host = a.hote()
r.Header.Set("Range", "bytes=0-4")
p.serve(httptest.NewRecorder(), r, "http")
// Une demande entière ensuite doit ressortir à l'amont.
w := demande(t, p, a.hote(), chemin)
if got := w.Header().Get("X-ERPLibre-Cache"); got == OutcomeHit {
t.Error("un fragment est entré au cache et a été servi comme un corps entier")
}
if n := a.appels(); n != 2 {
t.Errorf("l'amont a reçu %d requêtes, attendu 2", n)
}
}
// Un POST ne se cache pas : il change un état à l'amont.
func TestPostNonCache(t *testing.T) {
a := nouvelAmont(t, "réponse")
p := proxyDeTest(t)
for i := 0; i < 2; i++ {
r := httptest.NewRequest("POST", "/api/quelque-chose.whl", strings.NewReader("x"))
r.Host = a.hote()
p.serve(httptest.NewRecorder(), r, "http")
}
if n := a.appels(); n != 2 {
t.Errorf("l'amont a reçu %d requêtes, attendu 2 : un POST a été caché", n)
}
}
// Un transport qui note le client par lequel la requête est passée.
type transportTemoin struct {
nom string
vues *[]string
}
func (t transportTemoin) RoundTrip(r *http.Request) (*http.Response, error) {
*t.vues = append(*t.vues, t.nom)
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
}
// Le délai court existe pour que le repli hors ligne arrive AVANT que le
// client renonce. Il prenait le calcul d'un serveur git pour un amont
// injoignable — mesuré à seize secondes avant le premier octet — et rendait un
// 504 sur un dépôt parfaitement joignable, faisant échouer « repo sync ».
func TestGitPasseParLeClientPatient(t *testing.T) {
var vues []string
p := &Proxy{
Client: &http.Client{
Transport: transportTemoin{"court", &vues},
},
ClientPatient: &http.Client{
Transport: transportTemoin{"patient", &vues},
},
}
cas := []struct {
brut string
attendu string
}{
{"https://git.example/o/d.git/info/refs?service=git-upload-pack",
"patient"},
{"https://git.example/o/d.git/git-upload-pack", "patient"},
{"https://miroir.example/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst",
"court"},
{"https://miroir.example/core/os/x86_64/core.db", "court"},
}
for _, c := range cas {
vues = nil
u, _ := url.Parse(c.brut)
r, _ := http.NewRequest("GET", c.brut, nil)
if _, err := p.fetch(r, u, false); err != nil {
t.Fatalf("%s : %v", c.brut, err)
}
if len(vues) != 1 || vues[0] != c.attendu {
t.Errorf("%s passé par %v, attendu « %s »",
c.brut, vues, c.attendu)
}
}
}
// Les deux clients ne se distinguent que par leur patience : un amont
// injoignable reste détecté à l'établissement, en quatre secondes, dans les
// deux cas.
func TestLeClientPatientLestUniquementSurLesEnTetes(t *testing.T) {
p := NewProxy(&Store{Dir: t.TempDir()}, nil)
court := p.Client.Transport.(*http.Transport)
patient := p.ClientPatient.Transport.(*http.Transport)
if patient.ResponseHeaderTimeout <= court.ResponseHeaderTimeout {
t.Errorf("le client patient n'attend pas plus : %v contre %v",
patient.ResponseHeaderTimeout, court.ResponseHeaderTimeout)
}
if patient.TLSHandshakeTimeout != court.TLSHandshakeTimeout {
t.Error("le délai de poignée de main ne doit pas changer")
}
}
// Un paquet publié derrière une redirection vers une URL SIGNÉE.
//
// La cible change à chaque requête — c'est ce que fait une signature à
// péremption. Rendre la redirection au client, en comptant qu'il la
// redemandera au travers du cache, suppose une cible stable : ici la machine
// suivante ne retrouve rien et retélécharge le paquet en entier.
func TestPaquetDerriereUneRedirectionSignee(t *testing.T) {
var telechargements, signature int64
contenu := strings.Repeat("charge utile", 64)
stockage := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&telechargements, 1)
io.WriteString(w, contenu)
}))
t.Cleanup(stockage.Close)
publication := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt64(&signature, 1)
http.Redirect(w, r,
stockage.URL+"/objet?signature="+string(rune('a'+n)),
http.StatusFound)
}))
t.Cleanup(publication.Close)
u, _ := url.Parse(publication.URL)
p := proxyDeTest(t)
chemin := "/paquets/releases/download/1.0/outil_1.0_amd64.deb"
for i := 1; i <= 3; i++ {
w := demande(t, p, u.Host, chemin)
if w.Code != 200 {
t.Fatalf("requête %d : code %d, attendu 200", i, w.Code)
}
if w.Body.String() != contenu {
t.Fatalf("requête %d : corps de %d octets", i, w.Body.Len())
}
}
if n := atomic.LoadInt64(&telechargements); n != 1 {
t.Errorf("le paquet est descendu %d fois, attendu 1 :"+
" la redirection empêche le cache de servir", n)
}
}
// Un index, lui, garde l'ancien comportement : la redirection lui est rendue,
// et il la redemandera. Rien n'est gardé d'un index tant que l'amont répond,
// donc suivre la chaîne n'apporterait rien et masquerait au client l'hôte qui
// lui a réellement répondu.
func TestUnIndexRecoitSaRedirection(t *testing.T) {
cible := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "base")
}))
t.Cleanup(cible.Close)
publication := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, cible.URL+"/vraie.db", http.StatusFound)
}))
t.Cleanup(publication.Close)
u, _ := url.Parse(publication.URL)
w := demande(t, proxyDeTest(t), u.Host, "/arch/core/os/x86_64/core.db")
if w.Code != http.StatusFound {
t.Errorf("code %d, attendu 302 : la redirection a été suivie", w.Code)
}
}
// Une boucle de redirections doit rendre la main plutôt que tourner.
func TestUneBoucleDeRedirectionsSarrete(t *testing.T) {
var vues int64
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&vues, 1)
http.Redirect(w, r, srv.URL+"/tourne.deb", http.StatusFound)
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
w := demande(t, proxyDeTest(t), u.Host, "/tourne.deb")
if w.Code != http.StatusFound {
t.Errorf("code %d, attendu 302 au bout de la chaîne", w.Code)
}
if n := atomic.LoadInt64(&vues); n > maxRedirections+1 {
t.Errorf("%d requêtes pour une boucle, borne %d",
n, maxRedirections+1)
}
}
// proxyEtCasier rend un proxy et le répertoire de son casier : les contrôles
// qui suivent ont besoin de constater ce qui a été GARDÉ, pas seulement ce
// qui a été rendu.
func proxyEtCasier(t *testing.T) (*Proxy, string) {
t.Helper()
alog, err := OpenAccessLog("")
if err != nil {
t.Fatalf("journal : %v", err)
}
dir := t.TempDir()
return NewProxy(&Store{Dir: dir}, alog), dir
}
func corpsGardes(t *testing.T, dir string) int {
t.Helper()
n := 0
_ = filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
if err == nil && !fi.IsDir() && strings.HasSuffix(p, ".body") {
n++
}
return nil
})
return n
}
// Une image cloud livre déjà l'index de sa suite de base. Son apt le REVALIDE
// au lieu de le télécharger, l'amont rend « 304 », et le cache n'a donc
// jamais de corps à garder pour cette ressource — aussi longtemps que ses
// clients en détiennent une copie, c'est-à-dire toujours.
//
// Hors ligne, la suite de base est alors la seule à manquer : ses voisines
// « -updates », « -security » et « -backports » sortent périmées du cache, et
// apt ne trouve plus un paquet de la base pendant que le reste marche.
func TestUneConditionnelleNeLaissePasLeCasierVide(t *testing.T) {
var recues []string
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
recues = append(recues, r.Header.Get("If-Modified-Since"))
if r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write([]byte("Origin: Ubuntu\nSuite: base\n"))
}))
defer srv.Close()
hote, _ := url.Parse(srv.URL)
p, dir := proxyEtCasier(t)
joue := func() *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil)
r.Host = hote.Host
r.Header.Set("If-Modified-Since", "Wed, 01 Jan 2020 00:00:00 GMT")
w := httptest.NewRecorder()
p.serve(w, r, "http")
return w
}
if w := joue(); w.Code != http.StatusOK {
t.Fatalf("statut rendu %d, attendu 200", w.Code)
}
if len(recues) != 1 || recues[0] != "" {
t.Fatalf("la condition est partie à l'amont : %q", recues)
}
if corpsGardes(t, dir) == 0 {
t.Fatal("rien n'est gardé : le cache restera vide pour cette suite")
}
// Le corps est là : la condition peut repartir, et un « 304 » ne coûte
// alors plus rien — c'est ce qui économise la bande passante.
recues = nil
joue()
if len(recues) != 1 || recues[0] == "" {
t.Fatalf("la condition a encore été retirée : %q", recues)
}
}
// Amont muet et client qui détient déjà sa copie : lui rendre « 504 » le
// prive d'une suite entière qu'il pouvait lire. « 304 » le laisse garder la
// sienne, ce que « stale-if-error » veut dire pour une conditionnelle.
func TestAmontMuetEtClientQuiDetientDejaSaCopie(t *testing.T) {
p, _ := proxyEtCasier(t)
r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil)
// Rien n'écoute : l'amont est injoignable.
r.Host = "127.0.0.1:1"
r.Header.Set("If-None-Match", `"abc"`)
w := httptest.NewRecorder()
p.serve(w, r, "http")
if w.Code != http.StatusNotModified {
t.Fatalf("statut rendu %d, attendu 304", w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeKeep {
t.Fatalf("issue %q, attendue %q", got, OutcomeKeep)
}
}
// Sans condition, le client n'a rien à garder : le 504 reste la réponse
// juste, et il dit pourquoi.
func TestAmontMuetSansCopieChezLeClient(t *testing.T) {
p, _ := proxyEtCasier(t)
r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil)
r.Host = "127.0.0.1:1"
w := httptest.NewRecorder()
p.serve(w, r, "http")
if w.Code != http.StatusGatewayTimeout {
t.Fatalf("statut rendu %d, attendu 504", w.Code)
}
}
// Un corps d'erreur finit régulièrement dans un shell : « curl … | bash »
// est l'idiome de la moitié des installateurs, et « curl » sans « -f » lui
// livre le corps d'un 504 comme s'il l'avait demandé.
//
// Le message doit donc ne RIEN faire une fois exécuté. Sans cela, l'échec
// se lit « bash: line 1: erplibre_go_qemu_cache: command not found » et la
// cause véritable, elle, ne se lit plus.
func TestLeMessageHorsLigneEstInerteDansUnShell(t *testing.T) {
p, _ := proxyEtCasier(t)
r := httptest.NewRequest("GET", "/pyenv-installer", nil)
r.Host = "127.0.0.1:1"
w := httptest.NewRecorder()
p.serve(w, r, "http")
if w.Code != http.StatusGatewayTimeout {
t.Fatalf("statut rendu %d, attendu 504", w.Code)
}
for i, l := range strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") {
if !strings.HasPrefix(l, "#") {
t.Fatalf("ligne %d exécutable par un shell : %q", i+1, l)
}
}
// Inerte ne veut pas dire muet : la cause reste lisible.
if !strings.Contains(w.Body.String(), "amont injoignable") {
t.Fatal("le message ne dit plus pourquoi")
}
}
// Une cause qui porte plusieurs lignes ne doit pas sortir du commentaire :
// il suffirait de la première pour rendre la suite exécutable.
func TestUneCauseMultiligneResteCommentee(t *testing.T) {
got := enCommentaire("premier\nseconde\ntroisième\n")
for _, l := range strings.Split(strings.TrimRight(got, "\n"), "\n") {
if !strings.HasPrefix(l, "# ") {
t.Fatalf("ligne non commentée : %q", l)
}
}
}

159
script/qemu_cache/rules.go Normal file
View file

@ -0,0 +1,159 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"fmt"
"strings"
)
// RuleSet décrit le détournement à poser sur le pont de l'orchestrateur.
//
// Les règles sont RENDUES sous forme de texte et jamais exécutées depuis ce
// paquet : le script d'installation les applique, et un test peut donc les
// vérifier au caractère près sans toucher au pare-feu de la machine qui
// exécute les tests.
type RuleSet struct {
// Bridge est le pont que libvirt donne à ses VM, « virbr0 » par défaut.
Bridge string
// Subnet est le /24 que ce réseau sert.
Subnet string
// HTTPPort et TLSPort sont les deux écoutes du cache.
HTTPPort int
TLSPort int
// Bypass : les adresses MAC soustraites au détournement, déjà
// canoniques. Vide, l'ensemble existe quand même — il est peuplé à
// chaud, et une règle qui viserait un ensemble absent ferait échouer
// TOUT le jeu de règles, donc le démarrage du service.
Bypass []string
}
// TableName : une table à nous, effaçable d'un geste, plutôt que des règles
// ajoutées dans les chaînes d'autrui — que le retrait devrait alors retrouver
// une par une.
const TableName = "erplibre_qemu_cache"
// NftLines rend les lignes à passer à « nft -f - ».
//
// La clause « ip daddr != <subnet> » est la garde qui compte : sans elle, une
// VM qui joint l'orchestrateur lui-même — ssh, Odoo, un service quelconque
// sur la passerelle du /24 — se retrouve détournée vers le cache, qui ne sait
// rien en faire. Seul ce qui SORT du sous-réseau est intercepté. Une règle
// réseau posée trop large sur le pont d'un hôte est la classe de panne qui
// prive une machine de son propre réseau.
//
// L'exception par MAC passe AVANT les deux détournements, sans quoi elle
// n'aurait aucun effet : la première règle qui rend un verdict l'emporte.
// « accept » clôt cette chaîne-ci et non le hook : les chaînes des autres
// tables — le masquage de libvirt, notamment — sont évaluées ensuite, donc
// une VM exceptée garde son réseau normal.
func (r RuleSet) NftLines() []string {
lignes := []string{
fmt.Sprintf("table ip %s {", TableName),
fmt.Sprintf(" set %s {", BypassSetName),
" type ether_addr",
}
if len(r.Bypass) > 0 {
lignes = append(lignes, fmt.Sprintf(
" elements = { %s }", strings.Join(r.Bypass, ", ")))
}
lignes = append(lignes,
" }",
" chain prerouting {",
" type nat hook prerouting priority dstnat; policy accept;",
fmt.Sprintf(
` iifname "%s" ether saddr @%s accept`,
r.Bridge, BypassSetName),
fmt.Sprintf(
` iifname "%s" ip saddr %s ip daddr != %s tcp dport 80 redirect to :%d`,
r.Bridge, r.Subnet, r.Subnet, r.HTTPPort),
fmt.Sprintf(
` iifname "%s" ip saddr %s ip daddr != %s tcp dport 443 redirect to :%d`,
r.Bridge, r.Subnet, r.Subnet, r.TLSPort),
" }",
"}",
)
return lignes
}
// NftDeleteLine rend le geste de retrait. Une seule table à effacer, donc un
// seul geste : le désarmement ne peut pas être à moitié fait.
func (r RuleSet) NftDeleteLine() string {
return fmt.Sprintf("nft delete table ip %s", TableName)
}
// IptablesLines rend l'équivalent pour un hôte qui n'a pas nft. Le script
// d'installation choisit selon ce qui répond ; les deux jeux disent la même
// chose.
//
// Les exceptions sont émises en tête : iptables n'a pas d'ensemble nommé, la
// chaîne est bâtie par « -A » successifs, et un RETURN posé après les
// redirections ne serait jamais atteint. Le corollaire est qu'ici une
// exception neuve demande de reposer les règles, là où nft en ajoute une à
// chaud.
func (r RuleSet) IptablesLines() []string {
var lignes []string
for _, mac := range r.Bypass {
lignes = append(lignes, fmt.Sprintf(
"iptables -t nat -A PREROUTING -i %s -m mac --mac-source %s "+
"-j RETURN", r.Bridge, mac))
}
return append(lignes,
fmt.Sprintf(
"iptables -t nat -A PREROUTING -i %s -s %s ! -d %s "+
"-p tcp --dport 80 -j REDIRECT --to-ports %d",
r.Bridge, r.Subnet, r.Subnet, r.HTTPPort),
fmt.Sprintf(
"iptables -t nat -A PREROUTING -i %s -s %s ! -d %s "+
"-p tcp --dport 443 -j REDIRECT --to-ports %d",
r.Bridge, r.Subnet, r.Subnet, r.TLSPort),
)
}
// GuestEnvLines rend ce qu'une VM doit poser en plus de l'autorité.
//
// pip embarque son propre jeu de certificats et IGNORE le magasin système ;
// npm fait de même. Poser l'autorité ne suffit donc pas pour eux, alors
// qu'elle suffit pour pacman et pour apt, qui lisent le magasin.
//
// La valeur visée est le FAISCEAU système, que la commande de confiance
// régénère avec notre autorité dedans — et non le seul certificat du cache :
// pointer celui-là ferait perdre à pip toutes les autres autorités, donc
// échouer sur le premier hôte que le cache ne déchiffre pas, et le jour où le
// cache disparaît.
func GuestEnvLines(caPath string) []string {
return []string{
fmt.Sprintf("PIP_CERT=%s", caPath),
fmt.Sprintf("REQUESTS_CA_BUNDLE=%s", caPath),
fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caPath),
}
}
// GuestTrustCommand rend la commande qui fait approuver l'autorité, par
// famille de distribution. Le chemin ET la commande changent, et se tromper
// laisse une VM qui échoue sur chaque téléchargement HTTPS.
//
// Les familles portent le nom de leur gestionnaire de paquets, comme partout
// ailleurs dans le dépôt. La même table existe côté déploiement, qui écrit le
// fichier dans l'invité ; un test les compare, la dérive entre deux copies
// étant le seul risque de cette duplication.
func GuestTrustCommand(family string) (dir, cmd, bundle string, ok bool) {
switch family {
case "pacman":
return "/etc/ca-certificates/trust-source/anchors", "trust extract-compat",
"/etc/ssl/certs/ca-certificates.crt", true
case "apt":
return "/usr/local/share/ca-certificates", "update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt", true
case "dnf":
return "/etc/pki/ca-trust/source/anchors", "update-ca-trust",
"/etc/pki/tls/certs/ca-bundle.crt", true
case "zypper":
// openSUSE range ses ancres ailleurs que la famille RHEL, tout en
// employant la même commande que Debian.
return "/etc/pki/trust/anchors", "update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt", true
}
return "", "", "", false
}

View file

@ -0,0 +1,235 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"strings"
"testing"
)
func jeuDeTest() RuleSet {
return RuleSet{
Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899,
}
}
// La garde qui compte : seul ce qui SORT du sous-réseau est détourné. Sans
// elle, une VM qui joint l'orchestrateur lui-même — ssh, Odoo, un service sur
// la passerelle du /24 — part vers le cache, qui n'en sait rien faire.
func TestNftEpargneLeTraficInterne(t *testing.T) {
lignes := jeuDeTest().NftLines()
texte := strings.Join(lignes, "\n")
if !strings.Contains(texte, "ip daddr != 192.168.122.0/24") {
t.Errorf("l'exclusion du trafic interne manque :\n%s", texte)
}
for _, port := range []string{"tcp dport 80", "tcp dport 443"} {
if !strings.Contains(texte, port) {
t.Errorf("%q absent des règles", port)
}
}
if !strings.Contains(texte, "redirect to :8898") {
t.Error("le 80 ne part pas vers l'écoute HTTP")
}
if !strings.Contains(texte, "redirect to :8899") {
t.Error("le 443 ne part pas vers l'écoute TLS")
}
if !strings.Contains(texte, `iifname "virbr0"`) {
t.Error("les règles ne sont pas bornées au pont des VM")
}
}
// Chaque règle porte l'exclusion : une seule qui l'oublie suffit à couper le
// trafic interne du port qu'elle vise.
func TestChaqueRegleDeRedirectionPorteLExclusion(t *testing.T) {
for _, l := range jeuDeTest().NftLines() {
if !strings.Contains(l, "redirect to") {
continue
}
if !strings.Contains(l, "ip daddr != 192.168.122.0/24") {
t.Errorf("règle sans exclusion : %s", l)
}
}
for _, l := range jeuDeTest().IptablesLines() {
if !strings.Contains(l, "! -d 192.168.122.0/24") {
t.Errorf("règle iptables sans exclusion : %s", l)
}
}
}
// Le retrait est UN geste : un désarmement à moitié fait laisserait une VM
// détournée vers un cache éteint.
func TestRetraitEnUnGeste(t *testing.T) {
l := jeuDeTest().NftDeleteLine()
if !strings.Contains(l, "delete table ip "+TableName) {
t.Errorf("le retrait n'efface pas la table : %s", l)
}
if strings.Count(l, "\n") != 0 {
t.Errorf("le retrait tient sur plusieurs lignes : %q", l)
}
}
// La table porte un nom à nous : des règles ajoutées dans les chaînes
// d'autrui devraient être retrouvées une par une pour être retirées.
func TestTableNommee(t *testing.T) {
if !strings.Contains(jeuDeTest().NftLines()[0], TableName) {
t.Error("les règles ne vivent pas dans une table nommée")
}
}
// pip et npm ignorent le magasin système : poser l'autorité ne suffit pas
// pour eux, alors qu'elle suffit pour pacman et pour apt.
func TestVariablesDeLInvite(t *testing.T) {
lignes := GuestEnvLines("/etc/ssl/certs/ca-certificates.crt")
attendues := []string{"PIP_CERT=", "REQUESTS_CA_BUNDLE=", "NODE_EXTRA_CA_CERTS="}
if len(lignes) != len(attendues) {
t.Fatalf("%d variables, attendu %d", len(lignes), len(attendues))
}
texte := strings.Join(lignes, "\n")
for _, a := range attendues {
if !strings.Contains(texte, a) {
t.Errorf("%q manque :\n%s", a, texte)
}
}
for _, l := range lignes {
if !strings.HasSuffix(l, "/etc/ssl/certs/ca-certificates.crt") {
t.Errorf("la variable ne pointe pas l'autorité : %s", l)
}
}
}
// Le chemin ET la commande changent par famille : se tromper laisse une VM
// qui échoue sur chaque téléchargement HTTPS.
func TestCommandeDeConfiance(t *testing.T) {
cas := map[string][3]string{
"pacman": {
"/etc/ca-certificates/trust-source/anchors", "trust extract-compat",
"/etc/ssl/certs/ca-certificates.crt",
},
"apt": {
"/usr/local/share/ca-certificates", "update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt",
},
"dnf": {
"/etc/pki/ca-trust/source/anchors", "update-ca-trust",
"/etc/pki/tls/certs/ca-bundle.crt",
},
"zypper": {
"/etc/pki/trust/anchors", "update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt",
},
}
for famille, attendu := range cas {
dir, cmd, bundle, ok := GuestTrustCommand(famille)
if !ok {
t.Errorf("%s : famille inconnue", famille)
continue
}
if dir != attendu[0] {
t.Errorf("%s : répertoire %q, attendu %q", famille, dir, attendu[0])
}
if cmd != attendu[1] {
t.Errorf("%s : commande %q, attendu %q", famille, cmd, attendu[1])
}
if bundle != attendu[2] {
t.Errorf("%s : faisceau %q, attendu %q", famille, bundle, attendu[2])
}
}
if _, _, _, ok := GuestTrustCommand("plan9"); ok {
t.Error("une famille inconnue est pourtant acceptée")
}
}
// L'exception doit passer AVANT les détournements : nftables rend le verdict
// de la première règle qui correspond, et une exception placée après ne
// serait jamais atteinte.
func TestExceptionAvantDetournement(t *testing.T) {
r := RuleSet{
Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899,
Bypass: []string{"52:54:00:aa:bb:cc"},
}
lignes := r.NftLines()
iException, iRedirect := -1, -1
for i, l := range lignes {
if strings.Contains(l, "ether saddr @"+BypassSetName) {
iException = i
}
if iRedirect < 0 && strings.Contains(l, "redirect to :") {
iRedirect = i
}
}
if iException < 0 {
t.Fatal("aucune règle d'exception rendue")
}
if iRedirect < 0 {
t.Fatal("aucun détournement rendu")
}
if iException > iRedirect {
t.Errorf("l'exception (ligne %d) suit le détournement (ligne %d)",
iException, iRedirect)
}
}
// L'ensemble existe même vide : une règle qui viserait un ensemble absent
// ferait échouer le jeu ENTIER, donc le démarrage du service — un cache muet
// là où l'on n'a jamais demandé d'exception.
func TestEnsembleRenduMemeVide(t *testing.T) {
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899}
texte := strings.Join(r.NftLines(), "\n")
if !strings.Contains(texte, "set "+BypassSetName+" {") {
t.Error("l'ensemble n'est pas déclaré quand aucune MAC n'est exceptée")
}
if !strings.Contains(texte, "type ether_addr") {
t.Error("l'ensemble n'a pas de type")
}
if strings.Contains(texte, "elements =") {
t.Error("un ensemble vide ne doit pas déclarer d'éléments")
}
}
func TestElementsRendusQuandIlYEnA(t *testing.T) {
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899,
Bypass: []string{"52:54:00:aa:bb:cc", "52:54:00:11:22:33"}}
texte := strings.Join(r.NftLines(), "\n")
if !strings.Contains(texte,
"elements = { 52:54:00:aa:bb:cc, 52:54:00:11:22:33 }") {
t.Errorf("éléments mal rendus :\n%s", texte)
}
}
// iptables n'a pas d'ensemble nommé : la chaîne se bâtit par « -A »
// successifs, et un RETURN posé après les redirections ne serait jamais
// atteint.
func TestIptablesExceptionEnTete(t *testing.T) {
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899,
Bypass: []string{"52:54:00:aa:bb:cc"}}
lignes := r.IptablesLines()
if len(lignes) != 3 {
t.Fatalf("%d lignes rendues, trois attendues : %v", len(lignes), lignes)
}
if !strings.Contains(lignes[0], "--mac-source 52:54:00:aa:bb:cc") ||
!strings.Contains(lignes[0], "-j RETURN") {
t.Errorf("la première ligne n'est pas l'exception : %q", lignes[0])
}
for _, l := range lignes[1:] {
if !strings.Contains(l, "REDIRECT") {
t.Errorf("ligne inattendue : %q", l)
}
}
}
// Sans exception, le jeu iptables est celui d'avant : trois lignes là où deux
// suffisent trahirait une ligne vide rendue pour rien.
func TestIptablesSansExceptionInchange(t *testing.T) {
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899}
if n := len(r.IptablesLines()); n != 2 {
t.Errorf("%d lignes sans exception, deux attendues", n)
}
}

View file

@ -0,0 +1,647 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// amontScripte monte un amont dont la réponse est dictée par le test, et qui
// compte ce qu'on lui demande. La réponse peut changer en cours de test :
// c'est ce qui fait rendre un 200 puis un 404 à la même URL.
type amontScripte struct {
srv *httptest.Server
appel int64
mu sync.Mutex
h http.HandlerFunc
}
func nouvelAmontScripte(t *testing.T, h http.HandlerFunc) *amontScripte {
t.Helper()
a := &amontScripte{h: h}
a.srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&a.appel, 1)
a.mu.Lock()
h := a.h
a.mu.Unlock()
h(w, r)
}))
t.Cleanup(a.srv.Close)
return a
}
func (a *amontScripte) repondre(h http.HandlerFunc) {
a.mu.Lock()
a.h = h
a.mu.Unlock()
}
func (a *amontScripte) appels() int64 { return atomic.LoadInt64(&a.appel) }
func (a *amontScripte) hote() string {
u, _ := url.Parse(a.srv.URL)
return u.Host
}
// couper rend l'amont injoignable et rend son adresse, qui refuse désormais
// toute connexion.
func (a *amontScripte) couper() string {
h := a.hote()
a.srv.Close()
return h
}
func rediriger(code int, cible string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", cible)
w.Header().Set("Set-Cookie", "session=temoin")
w.WriteHeader(code)
io.WriteString(w, "déplacé")
}
}
func refuser(code int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(code)
io.WriteString(w, "introuvable")
}
}
func servir(corps string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
io.WriteString(w, corps)
}
}
// joue fait une requête de méthode et d'en-têtes choisis, telle que
// l'interception transparente la présente. Les en-têtes vont par paires :
// nom, valeur.
func joue(
t *testing.T, p *Proxy, methode, hote, chemin string, entetes ...string,
) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(methode, chemin, nil)
r.Host = hote
for i := 0; i+1 < len(entetes); i += 2 {
r.Header.Set(entetes[i], entetes[i+1])
}
w := httptest.NewRecorder()
p.serve(w, r, "http")
return w
}
// journalDeTest branche un journal d'accès sur fichier et rend son chemin.
func journalDeTest(t *testing.T, p *Proxy) string {
t.Helper()
chemin := filepath.Join(t.TempDir(), "acces.jsonl")
alog, err := OpenAccessLog(chemin)
if err != nil {
t.Fatalf("journal : %v", err)
}
t.Cleanup(alog.Close)
p.Log = alog
return chemin
}
func lignesDuJournal(t *testing.T, chemin string) []accessLine {
t.Helper()
raw, err := os.ReadFile(chemin)
if err != nil {
t.Fatalf("journal illisible : %v", err)
}
var out []accessLine
for _, l := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
if l == "" {
continue
}
var a accessLine
if err := json.Unmarshal([]byte(l), &a); err != nil {
t.Fatalf("ligne illisible %q : %v", l, err)
}
out = append(out, a)
}
return out
}
// Une redirection volatile est gardée, mais ne sort QUE l'amont muet : tant
// qu'il répond, chaque requête lui revient, et le client reçoit ce que
// l'amont dit aujourd'hui.
func TestUneRedirectionVolatileNeSortQueHorsLigne(t *testing.T) {
const cible = "https://ailleurs.example/bootstrap.sh"
a := nouvelAmontScripte(t, rediriger(http.StatusFound, cible))
p := proxyDeTest(t)
journal := journalDeTest(t, p)
for i := 1; i <= 2; i++ {
w := joue(t, p, "GET", a.hote(), "/installer.sh")
if w.Code != http.StatusFound {
t.Fatalf("en ligne %d : code %d, attendu 302", i, w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" {
t.Errorf("en ligne %d : servi « %s », attendu « miss »", i, got)
}
}
if n := a.appels(); n != 2 {
t.Errorf("l'amont a reçu %d requêtes, attendu 2 : un statut gardé"+
" est sorti alors qu'il répondait", n)
}
hote := a.couper()
w := joue(t, p, "GET", hote, "/installer.sh")
if w.Code != http.StatusFound {
t.Fatalf("hors ligne : code %d, attendu 302", w.Code)
}
if got := w.Header().Get("Location"); got != cible {
t.Errorf("Location %q, attendu %q", got, cible)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStaleStatus {
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStaleStatus)
}
if w.Header().Get("X-ERPLibre-Cache-Date") == "" {
t.Error("la date du statut rejoué manque")
}
if w.Body.Len() != 0 {
t.Errorf("un statut rejoué porte un corps de %d octets", w.Body.Len())
}
if got := w.Header().Get("Set-Cookie"); got != "" {
t.Errorf("le témoin d'une autre machine est rejoué : %q", got)
}
if got := w.Header().Get("Content-Length"); got != "" {
t.Errorf("une longueur %q est annoncée sans corps", got)
}
// Le journal dit le VRAI statut, et sous des noms qui ne se confondent
// pas avec ceux d'un corps.
var garde, rejoue bool
for _, l := range lignesDuJournal(t, journal) {
switch l.Outcome {
case OutcomeStoredStatus:
garde = l.Status == http.StatusFound && l.Upstream
case OutcomeStaleStatus:
rejoue = l.Status == http.StatusFound && !l.Upstream
case OutcomeStored, OutcomeStale:
t.Errorf("un statut seul est journalisé « %s »", l.Outcome)
}
}
if !garde || !rejoue {
t.Errorf("journal : gardé=%v rejoué=%v, attendu les deux avec 302",
garde, rejoue)
}
}
// Un refus définitif ressort tel quel : un client qui sonde un fichier
// optionnel s'arrête sur le 404 et poursuit, là où un 504 l'arrête net.
func TestUnRefusDefinitifSeRejoueHorsLigne(t *testing.T) {
for _, code := range []int{http.StatusNotFound, http.StatusGone} {
t.Run(fmt.Sprint(code), func(t *testing.T) {
a := nouvelAmontScripte(t, refuser(code))
p := proxyDeTest(t)
if w := joue(t, p, "GET", a.hote(), "/sonde/2.root.json"); w.Code != code {
t.Fatalf("en ligne : code %d, attendu %d", w.Code, code)
}
hote := a.couper()
w := joue(t, p, "GET", hote, "/sonde/2.root.json")
if w.Code != code {
t.Fatalf("hors ligne : code %d, attendu %d", w.Code, code)
}
if w.Body.Len() != 0 {
t.Errorf("corps de %d octets rejoué", w.Body.Len())
}
})
}
}
// Un refus n'écrase jamais un corps : un amont qui perd un fichier un jour
// ne doit pas priver le hors-ligne de la copie qu'il avait rendue.
func TestUnRefusNeRemplacePasUnCorps(t *testing.T) {
a := nouvelAmontScripte(t, servir("corps publié"))
p := proxyDeTest(t)
chemin := "/metadonnees/etat.json"
joue(t, p, "GET", a.hote(), chemin)
a.repondre(refuser(http.StatusNotFound))
if w := joue(t, p, "GET", a.hote(), chemin); w.Code != http.StatusNotFound {
t.Fatalf("en ligne, le refus de l'amont n'est pas relayé : %d", w.Code)
}
hote := a.couper()
w := joue(t, p, "GET", hote, chemin)
if w.Code != http.StatusOK || w.Body.String() != "corps publié" {
t.Fatalf("hors ligne : %d %q, attendu le corps gardé", w.Code, w.Body.String())
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale {
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale)
}
}
// Un corps, lui, l'emporte sur un statut seul — y compris quand le client
// pose une condition : le statut ne vaut pas détention, la condition est donc
// retirée, et l'amont rend le corps entier au lieu d'un 304.
func TestUnCorpsRemplaceUnStatutSeul(t *testing.T) {
a := nouvelAmontScripte(t, refuser(http.StatusNotFound))
p := proxyDeTest(t)
chemin := "/metadonnees/etat.json"
joue(t, p, "GET", a.hote(), chemin)
a.repondre(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
io.WriteString(w, "corps publié")
})
w := joue(t, p, "GET", a.hote(), chemin, "If-None-Match", `"v1"`)
if w.Code != http.StatusOK {
t.Fatalf("code %d, attendu 200 : la condition est partie à l'amont"+
" alors que le cache n'a qu'un statut", w.Code)
}
hote := a.couper()
w = joue(t, p, "GET", hote, chemin)
if w.Code != http.StatusOK || w.Body.String() != "corps publié" {
t.Fatalf("hors ligne : %d %q, attendu le corps", w.Code, w.Body.String())
}
}
// Un statut seul vit sous sa propre clé. Un lecteur qui ne connaît que les
// corps — qui ne calcule que Key — n'y trouve rien, et ne peut donc pas le
// servir en « 200 » vide. Le rejeu, lui, le trouve ; et un corps gardé plus
// tard sous la clé du corps coexiste avec lui et sort en premier.
func TestUnStatutSeulResteHorsDesClesDeCorps(t *testing.T) {
a := nouvelAmontScripte(t, rediriger(http.StatusFound,
"https://ailleurs.example/bootstrap.sh"))
p := proxyDeTest(t)
p.Muets = NouvelleJoignabilite()
chemin := "/installer.sh"
u, _ := url.Parse("http://" + a.hote() + chemin)
joue(t, p, "GET", a.hote(), chemin)
if _, err := p.Store.LireMeta(Key("GET", u.String())); err == nil {
t.Error("un statut seul est lisible sous la clé d'un corps")
}
if !p.Store.TientStatut(CleStatut("GET", u)) {
t.Fatal("le statut seul n'est pas sous sa propre clé")
}
// L'hôte retenu muet : le repli sort sans composer, et c'est le rejeu.
p.Muets.Echec(a.hote(), refusEtablissement())
w := joue(t, p, "GET", a.hote(), chemin)
if w.Code != http.StatusFound ||
w.Header().Get("X-ERPLibre-Cache") != OutcomeStaleStatus {
t.Fatalf("rejeu : %d « %s », attendu 302 « %s »", w.Code,
w.Header().Get("X-ERPLibre-Cache"), OutcomeStaleStatus)
}
p.Muets.Reussite(a.hote())
a.repondre(servir("echo bonjour"))
if w := joue(t, p, "GET", a.hote(), chemin); w.Code != http.StatusOK {
t.Fatalf("en ligne : code %d, attendu 200", w.Code)
}
if !p.Store.Detient(Key("GET", u.String())) {
t.Fatal("le corps n'est pas gardé sous la clé du corps")
}
if !p.Store.TientStatut(CleStatut("GET", u)) {
t.Error("le statut seul a disparu : il devait coexister avec le corps")
}
hote := a.couper()
w = joue(t, p, "GET", hote, chemin)
if w.Code != http.StatusOK || w.Body.String() != "echo bonjour" {
t.Fatalf("hors ligne : %d %q, attendu le corps", w.Code, w.Body.String())
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale {
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale)
}
}
// Un statut passager ne se garde pas : le rejouer figerait une panne d'un
// moment, et le hors-ligne dirait 403 ou 503 pour toujours.
func TestUnStatutPassagerNestPasGarde(t *testing.T) {
for _, code := range []int{403, 429, 500, 503} {
t.Run(fmt.Sprint(code), func(t *testing.T) {
a := nouvelAmontScripte(t, refuser(code))
p, dir := proxyEtCasier(t)
joue(t, p, "GET", a.hote(), "/api/etat")
hote := a.couper()
if w := joue(t, p, "GET", hote, "/api/etat"); w.Code != http.StatusGatewayTimeout {
t.Errorf("hors ligne : code %d, attendu 504", w.Code)
}
if n := corpsGardes(t, dir); n != 0 {
t.Errorf("%d objet(s) gardé(s) pour un %d", n, code)
}
})
}
}
// Une clé portable est partagée par tous les miroirs : le refus d'un miroir
// en retard y prendrait la place de l'index qu'un autre a rendu.
func TestUnCheminPortableNeGardeNiRedirectionNiRefus(t *testing.T) {
for nom, h := range map[string]http.HandlerFunc{
"redirection": rediriger(http.StatusFound, "https://ailleurs.example/core.db"),
"refus": refuser(http.StatusNotFound),
} {
t.Run(nom, func(t *testing.T) {
a := nouvelAmontScripte(t, h)
p, dir := proxyEtCasier(t)
joue(t, p, "GET", a.hote(), "/arch/core/os/x86_64/core.db")
hote := a.couper()
w := joue(t, p, "GET", hote, "/arch/core/os/x86_64/core.db")
if w.Code != http.StatusGatewayTimeout {
t.Errorf("hors ligne : code %d, attendu 504", w.Code)
}
if n := corpsGardes(t, dir); n != 0 {
t.Errorf("%d objet(s) gardé(s) sous une clé portable", n)
}
})
}
}
// L'immuable ne garde pas son refus : servi du disque sans jamais
// redemander, un 404 masquerait le fichier publié ensuite.
//
// L'index par empreinte est le cas qui isole la règle : immuable mais
// attaché à son hôte, la borne des clés portables ne l'écarte pas.
func TestLImmuableNeGardePasSonRefus(t *testing.T) {
for _, chemin := range []string{
"/outils/outil-2.0.tar.gz",
"/debian/dists/trixie/by-hash/SHA256/" + strings.Repeat("ab", 32),
} {
a := nouvelAmontScripte(t, refuser(http.StatusNotFound))
p, dir := proxyEtCasier(t)
joue(t, p, "GET", a.hote(), chemin)
hote := a.couper()
if w := joue(t, p, "GET", hote, chemin); w.Code != http.StatusGatewayTimeout {
t.Errorf("%s hors ligne : code %d, attendu 504", chemin, w.Code)
}
if n := corpsGardes(t, dir); n != 0 {
t.Errorf("%s : %d objet(s) gardé(s) pour un 404 immuable", chemin, n)
}
}
}
// Un HEAD garde son statut : un installateur lit la version publiée dans le
// « Location » d'un HEAD, et n'a besoin de rien d'autre.
//
// L'amont annonce une longueur sans corps — c'est ce que fait un HEAD. La
// comparer au corps écrit ferait refuser l'objet comme tronqué.
func TestUnHeadGardeSonStatut(t *testing.T) {
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/o/d/releases/latest" {
rediriger(http.StatusFound, "/o/d/releases/tag/v1.2.3")(w, r)
return
}
w.Header().Set("Content-Length", "1234")
w.WriteHeader(http.StatusOK)
})
p := proxyDeTest(t)
joue(t, p, "HEAD", a.hote(), "/o/d/releases/latest")
joue(t, p, "HEAD", a.hote(), "/api/bonjour")
hote := a.couper()
w := joue(t, p, "HEAD", hote, "/o/d/releases/latest")
if w.Code != http.StatusFound {
t.Fatalf("HEAD hors ligne : code %d, attendu 302", w.Code)
}
if got := w.Header().Get("Location"); got != "/o/d/releases/tag/v1.2.3" {
t.Errorf("Location %q : une cible relative doit ressortir telle quelle", got)
}
w = joue(t, p, "HEAD", hote, "/api/bonjour")
if w.Code != http.StatusOK {
t.Fatalf("HEAD 200 hors ligne : code %d, attendu 200", w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStaleStatus {
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStaleStatus)
}
// La longueur d'un HEAD est celle du corps que rendrait le GET : elle
// ressort telle que l'amont l'a annoncée, comme en ligne.
if got := w.Header().Get("Content-Length"); got != "1234" {
t.Errorf("HEAD rejoué avec la longueur %q, attendu « 1234 »", got)
}
// Le HEAD n'a rien donné à garder pour un GET.
if w := joue(t, p, "GET", hote, "/api/bonjour"); w.Code != http.StatusGatewayTimeout {
t.Errorf("GET hors ligne : code %d, attendu 504", w.Code)
}
}
// Un client qui détient sa copie garde son 304, même quand un statut seul est
// en réserve : le lui remplacer par un refus lui ferait jeter une copie
// valide.
func TestUneConditionnelleGardeSaCopieMalgreUnStatutSeul(t *testing.T) {
for nom, h := range map[string]http.HandlerFunc{
"refus": refuser(http.StatusNotFound),
"redirection": rediriger(http.StatusFound, "https://ailleurs.example/x"),
} {
t.Run(nom, func(t *testing.T) {
a := nouvelAmontScripte(t, h)
p := proxyDeTest(t)
joue(t, p, "GET", a.hote(), "/depot/etat")
hote := a.couper()
w := joue(t, p, "GET", hote, "/depot/etat", "If-None-Match", `"v1"`)
if w.Code != http.StatusNotModified {
t.Fatalf("code %d, attendu 304", w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeKeep {
t.Errorf("servi « %s », attendu « %s »", got, OutcomeKeep)
}
// Sans condition, le statut ressort.
if w := joue(t, p, "GET", hote, "/depot/etat"); w.Code == http.StatusGatewayTimeout {
t.Error("sans condition, le statut gardé ne ressort pas")
}
})
}
}
// Le chemin de l'immuable ne sert qu'un corps 200 : un méta de statut sous
// une clé immuable sortirait sinon en « 200 » vide, même l'amont joignable.
func TestLeCheminDeLImmuableRefuseUnStatut(t *testing.T) {
a := nouvelAmontScripte(t, servir("vrai corps"))
p, _ := proxyEtCasier(t)
chemin := "/outils/outil-1.0.tar.gz"
u, _ := url.Parse("http://" + a.hote() + chemin)
cw, err := p.Store.NewWriter(CleDe("GET", u), Meta{
URL: u.String(), Method: "GET", Status: http.StatusFound,
Header: http.Header{"Location": []string{"https://ailleurs.example/"}},
Class: ClassImmutable.String(),
})
if err != nil {
t.Fatal(err)
}
if err := cw.Commit(0); err != nil {
t.Fatal(err)
}
w := joue(t, p, "GET", a.hote(), chemin)
if w.Code != http.StatusOK || w.Body.String() != "vrai corps" {
t.Fatalf("code %d, corps %q : le statut a été servi comme un corps",
w.Code, w.Body.String())
}
if a.appels() != 1 {
t.Errorf("l'amont a reçu %d requêtes, attendu 1", a.appels())
}
}
// clientIntercepte rend un client HTTP dont toute connexion aboutit au cache,
// quelle que soit l'adresse demandée : c'est ce que fait le détournement du
// 80 pour une VM.
func clientIntercepte(t *testing.T, p *Proxy) *http.Client {
t.Helper()
front := httptest.NewServer(p.handler("http"))
t.Cleanup(front.Close)
cache := front.Listener.Addr().String()
return &http.Client{
Transport: &http.Transport{
DialContext: func(
ctx context.Context, reseau, _ string,
) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, reseau, cache)
},
DisableKeepAlives: true,
},
Timeout: 10 * time.Second,
}
}
// Sur le fil, un refus rejoué est une réponse BIEN FORMÉE : une longueur
// annoncée sans corps laisse le client attendre des octets qui ne viennent
// pas, et il échoue sur une fin de flux inattendue.
func TestLeRejeuDUnRefusEstBienFormeSurLeFil(t *testing.T) {
a := nouvelAmontScripte(t, refuser(http.StatusNotFound))
p := proxyDeTest(t)
client := clientIntercepte(t, p)
adresse := a.srv.URL + "/sonde/2.root.json"
rep, err := client.Get(adresse)
if err != nil {
t.Fatalf("en ligne : %v", err)
}
io.Copy(io.Discard, rep.Body)
rep.Body.Close()
a.couper()
rep, err = client.Get(adresse)
if err != nil {
t.Fatalf("hors ligne : %v", err)
}
defer rep.Body.Close()
corps, err := io.ReadAll(rep.Body)
if err != nil {
t.Fatalf("lecture du corps : %v", err)
}
if rep.StatusCode != http.StatusNotFound || len(corps) != 0 {
t.Errorf("reçu %d et %d octets, attendu 404 sans corps",
rep.StatusCode, len(corps))
}
}
// Hors ligne, un vrai client suit la redirection rejouée jusqu'au corps
// gardé de sa cible : c'est la chaîne entière d'un installateur publié
// derrière une redirection qui redevient servable.
func TestHorsLigneLeClientSuitLaRedirectionJusquAuCorps(t *testing.T) {
cible := nouvelAmontScripte(t, servir("echo bonjour"))
pub := nouvelAmontScripte(t, rediriger(http.StatusFound, cible.srv.URL+"/bootstrap.sh"))
p := proxyDeTest(t)
client := clientIntercepte(t, p)
lire := func(quand string) string {
t.Helper()
rep, err := client.Get(pub.srv.URL + "/installer.sh")
if err != nil {
t.Fatalf("%s : %v", quand, err)
}
defer rep.Body.Close()
corps, _ := io.ReadAll(rep.Body)
if rep.StatusCode != http.StatusOK {
t.Fatalf("%s : code %d", quand, rep.StatusCode)
}
return string(corps)
}
if got := lire("en ligne"); got != "echo bonjour" {
t.Fatalf("en ligne : corps %q", got)
}
cible.couper()
pub.couper()
if got := lire("hors ligne"); got != "echo bonjour" {
t.Errorf("hors ligne : corps %q, attendu celui de la cible", got)
}
}
// Une redirection suivie par le cache repart SANS la condition du client :
// sinon la cible répond « 304 » et il n'y a rien à garder. Et les
// identifiants confiés à l'hôte demandé ne suivent pas vers un autre hôte.
func TestLesRedirectionsSuiviesPartentSansConditionNiIdentifiants(t *testing.T) {
contenu := strings.Repeat("charge utile", 32)
var mu sync.Mutex
var conditions, identites []string
stockage := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
conditions = append(conditions, r.Header.Get("If-None-Match"))
identites = append(identites, r.Header.Get("Authorization"))
mu.Unlock()
if r.Header.Get("If-None-Match") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
io.WriteString(w, contenu)
})
pub := nouvelAmontScripte(t,
rediriger(http.StatusFound, stockage.srv.URL+"/objet?signature=a"))
p, dir := proxyEtCasier(t)
w := joue(t, p, "GET", pub.hote(), "/paquets/outil_1.0_amd64.deb",
"If-None-Match", `"v0"`, "Authorization", "Bearer jeton-de-test")
if w.Code != http.StatusOK || w.Body.String() != contenu {
t.Fatalf("code %d, %d octets : le corps n'est pas venu", w.Code, w.Body.Len())
}
mu.Lock()
defer mu.Unlock()
if len(conditions) != 1 || conditions[0] != "" {
t.Errorf("la cible a reçu la condition : %q", conditions)
}
if len(identites) != 1 || identites[0] != "" {
t.Errorf("l'identifiant a suivi vers un autre hôte : %q", identites)
}
if corpsGardes(t, dir) != 1 {
t.Error("rien n'est gardé derrière la redirection")
}
}
// Sur le MÊME hôte, l'identifiant suit : c'est à lui qu'il a été confié.
func TestUneRedirectionSurLeMemeHoteGardeLIdentifiant(t *testing.T) {
var recu atomic.Value
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/a/outil_1.0_amd64.deb" {
rediriger(http.StatusFound, "/b/outil_1.0_amd64.deb")(w, r)
return
}
recu.Store(r.Header.Get("Authorization"))
io.WriteString(w, "paquet")
})
p := proxyDeTest(t)
w := joue(t, p, "GET", a.hote(), "/a/outil_1.0_amd64.deb",
"Authorization", "Bearer jeton-de-test")
if w.Code != http.StatusOK {
t.Fatalf("code %d", w.Code)
}
if got, _ := recu.Load().(string); got != "Bearer jeton-de-test" {
t.Errorf("identifiant reçu %q sur le même hôte", got)
}
}

430
script/qemu_cache/store.go Normal file
View file

@ -0,0 +1,430 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// Store garde les réponses sur disque, un objet valant deux fichiers : les
// métadonnées en JSON et le corps brut.
//
// Le corps ne passe jamais en mémoire : une image qcow2 pèse des gigaoctets,
// et un cache qui la charge pour la servir tue l'orchestrateur. Écriture par
// fichier temporaire puis renommage, si bien qu'un objet visible est toujours
// un objet complet — une interruption ne laisse qu'un temporaire, que le
// démarrage suivant balaie.
type Store struct {
Dir string
}
// Meta accompagne chaque corps stocké.
type Meta struct {
URL string `json:"url"`
Method string `json:"method"`
Status int `json:"status"`
Header http.Header `json:"header"`
Size int64 `json:"size"`
StoredAt time.Time `json:"stored_at"`
Class string `json:"class"`
// StatusOnly marque un objet gardé SANS corps, qui ne rejoue que son
// statut. Le statut seul ne suffit pas à le dire : le 200 d'un HEAD est
// un statut seul, et se lirait sinon comme un corps vide.
//
// Le marqueur décrit l'objet, il ne le protège pas : ce qui tient un
// lecteur plus ancien à l'écart d'un statut seul, c'est la clé sous
// laquelle il est rangé (voir CleStatut), pas un champ qu'un tel lecteur
// ignore.
StatusOnly bool `json:"status_only,omitempty"`
}
// StatutReel rend le statut gardé, 200 quand le méta n'en porte pas : un méta
// écrit avant que le magasin garde autre chose que des corps n'a pas de
// statut, et il décrivait toujours un 200.
func (m *Meta) StatutReel() int {
if m.Status == 0 {
return http.StatusOK
}
return m.Status
}
// StatutSeul dit si l'objet ne porte qu'un statut : marqué comme tel, ou de
// statut autre que 200 — seul un 200 a jamais été gardé avec son corps.
func (m *Meta) StatutSeul() bool {
return m.StatusOnly || m.StatutReel() != http.StatusOK
}
// Stats répond au besoin de surveillance manuelle : aucune éviction n'est
// écrite, donc l'outil doit au moins dire ce qu'il occupe.
type Stats struct {
Objects int
Bytes int64
Oldest time.Time
}
var errMiss = errors.New("cache: absent")
// Key mêle la méthode et l'URL : un HEAD et un GET sur la même adresse ne
// portent pas le même corps.
func Key(method, rawURL string) string {
sum := sha256.Sum256([]byte(method + " " + rawURL))
return hex.EncodeToString(sum[:])
}
// KeySansHote range un objet sous son CHEMIN, l'hôte écarté.
//
// Une liste de miroirs tourne : pacman tire un fichier de « fastly », le
// suivant de « geo », et une clé qui porte l'hôte réduit alors le cache à
// néant — même fichier, autre nom, défaut de cache. Le chemin, lui, identifie
// le fichier sur TOUS les miroirs d'une même distribution :
// « /core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst » nomme le même octet
// partout, sa version et son architecture étant dans son nom.
//
// Réservé aux fichiers dont le NOM porte l'identité — paquets, index de
// dépôt. L'appliquer à tout ferait entrer en collision les « /index.html » de
// deux sites sans rapport.
func KeySansHote(method string, u *url.URL) string {
chemin := u.Path
if u.RawQuery != "" {
chemin += "?" + u.RawQuery
}
sum := sha256.Sum256([]byte(method + " path " + chemin))
return hex.EncodeToString(sum[:])
}
// CleDe rend la clé sous laquelle une réponse est rangée et cherchée.
//
// La clé écarte l'hôte quand le NOM du fichier l'identifie partout : une
// liste de miroirs tourne, et une clé qui porte l'hôte ferait manquer le
// cache au fichier déjà gardé sous un autre nom de miroir. Le service et la
// lecture « --detient » passent tous deux par ici : deux calculs de la clé
// finiraient par diverger, et le relevé dirait absent ce que le service sert.
func CleDe(method string, u *url.URL) string {
if PortableParChemin(u) {
return KeySansHote(method, u)
}
return Key(method, u.String())
}
// CleStatut rend la clé sous laquelle un STATUT SEUL est rangé et cherché.
//
// Un espace de clés à part, et non la clé du corps : un lecteur qui ne
// connaît que les corps ne lit que Key et KeySansHote, et ne tombe donc
// jamais sur un statut seul. Rangé sous la clé du corps, un tel objet — un
// méta 302 et un corps de zéro octet de la bonne taille — ressortirait chez
// lui en « 200 » vide, que « curl … | bash » exécuterait comme un script
// vide qui réussit.
//
// Aucune clé de corps ne peut la rejoindre : une méthode HTTP ne porte pas
// d'espace, si bien que « STATUT GET … » n'est la méthode d'aucune requête.
// L'URL entière, hôte compris : un statut seul ne se garde jamais sous une
// clé portable.
func CleStatut(method string, u *url.URL) string {
return Key("STATUT "+method, u.String())
}
// TientStatut dit si la clé porte un statut seul que le rejeu servirait : un
// méta lisible, marqué statut seul, dont le corps a la taille annoncée — les
// conditions mêmes auxquelles Get le rend.
func (s *Store) TientStatut(key string) bool {
m, err := s.LireMeta(key)
if err != nil || !m.StatutSeul() {
return false
}
_, bodyPath := s.paths(key)
fi, err := os.Stat(bodyPath)
return err == nil && fi.Size() == m.Size
}
func (s *Store) paths(key string) (metaPath, bodyPath string) {
// Deux niveaux de répertoires : un seul répertoire de cent mille entrées
// ralentit chaque ouverture sur la plupart des systèmes de fichiers.
dir := filepath.Join(s.Dir, key[0:2], key[2:4])
return filepath.Join(dir, key+".meta"), filepath.Join(dir, key+".body")
}
// LireMeta rend les métadonnées d'une clé sans toucher au corps.
//
// Get, lui, remet la date du corps à maintenant : un relevé qui passerait par
// lui rajeunirait tout ce qu'il regarde, et l'âge du dernier usage ne
// voudrait plus rien dire. Un méta illisible vaut absent.
func (s *Store) LireMeta(key string) (*Meta, error) {
metaPath, _ := s.paths(key)
raw, err := os.ReadFile(metaPath)
if err != nil {
return nil, errMiss
}
var m Meta
if err := json.Unmarshal(raw, &m); err != nil {
return nil, errMiss
}
return &m, nil
}
// Get rend les métadonnées et un lecteur positionné sur le corps. Le lecteur
// est à refermer par l'appelant.
func (s *Store) Get(key string) (*Meta, *os.File, error) {
metaPath, bodyPath := s.paths(key)
raw, err := os.ReadFile(metaPath)
if err != nil {
return nil, nil, errMiss
}
var m Meta
if err := json.Unmarshal(raw, &m); err != nil {
// Métadonnées illisibles : l'objet vaut absent plutôt que faux.
return nil, nil, errMiss
}
f, err := os.Open(bodyPath)
if err != nil {
return nil, nil, errMiss
}
// La date du corps est remise à MAINTENANT parce qu'on va le servir.
//
// C'est ce qui permet à un nettoyage par âge de vouloir dire « ce qui ne
// sert plus » et non « ce qui est entré il y a longtemps ». Un paquet
// servi tous les jours depuis un an n'est pas vieux : l'effacer
// obligerait à le retélécharger le lendemain, ce qui est exactement le
// contraire de ce qu'un cache est là pour faire.
//
// L'échec est ignoré : un magasin en lecture seule doit servir, pas
// refuser parce qu'il n'a pas pu noter une date.
maintenant := time.Now()
_ = os.Chtimes(bodyPath, maintenant, maintenant)
st, err := f.Stat()
if err != nil || st.Size() != m.Size {
// Un corps dont la taille ne correspond plus est une écriture
// interrompue par un moyen qui a contourné le renommage.
f.Close()
return nil, nil, errMiss
}
return &m, f, nil
}
// Writer accumule un corps dans un temporaire et ne le publie qu'à la
// fermeture réussie.
type Writer struct {
store *Store
key string
meta Meta
tmp *os.File
written int64
finished bool
}
// Detient dit si une clé porte le corps d'une réponse 200, sans l'ouvrir.
//
// Sert à décider si une requête conditionnelle peut partir telle quelle :
// sans corps en réserve, un « 304 » de l'amont ne laisserait rien à garder,
// et le cache resterait vide pour cette ressource aussi longtemps que ses
// clients en détiennent une copie — c'est-à-dire toujours.
//
// Un objet de statut seul ne compte pas : il vit sous sa propre clé
// (CleStatut), et un méta de statut qui se trouverait sous une clé de corps
// n'en fait pas un corps. La condition est alors retirée, pour que le premier
// 200 que l'amont rendra vienne entier. C'est aussi le test qui interdit de
// garder un refus quand un corps est en réserve : un statut seul ne s'écrit
// que là où Detient est faux pour la clé du corps.
func (s *Store) Detient(key string) bool {
m, err := s.LireMeta(key)
if err != nil || m.StatutSeul() {
return false
}
_, bodyPath := s.paths(key)
fi, err := os.Stat(bodyPath)
return err == nil && fi.Size() > 0
}
// NewWriter ouvre un temporaire dans le répertoire de destination : un
// renommage n'est atomique qu'au sein d'un même système de fichiers.
func (s *Store) NewWriter(key string, m Meta) (*Writer, error) {
metaPath, _ := s.paths(key)
if err := os.MkdirAll(filepath.Dir(metaPath), 0o755); err != nil {
return nil, err
}
tmp, err := os.CreateTemp(filepath.Dir(metaPath), key+".part-*")
if err != nil {
return nil, err
}
return &Writer{store: s, key: key, meta: m, tmp: tmp}, nil
}
func (w *Writer) Write(p []byte) (int, error) {
n, err := w.tmp.Write(p)
w.written += int64(n)
return n, err
}
// Commit publie l'objet. La taille annoncée par l'amont, quand il l'annonce,
// est vérifiée : une réponse tronquée par une coupure réseau ressemble à une
// réponse complète pour tout le reste du code.
func (w *Writer) Commit(expected int64) error {
defer w.cleanup()
if expected >= 0 && w.written != expected {
return fmt.Errorf("corps tronqué : %d octets sur %d", w.written, expected)
}
if err := w.tmp.Sync(); err != nil {
return err
}
if err := w.tmp.Close(); err != nil {
return err
}
w.meta.Size = w.written
w.meta.StoredAt = time.Now().UTC()
raw, err := json.Marshal(w.meta)
if err != nil {
return err
}
metaPath, bodyPath := w.store.paths(w.key)
if err := os.Rename(w.tmp.Name(), bodyPath); err != nil {
return err
}
// Les métadonnées en DERNIER : leur présence est ce qui rend l'objet
// visible, et un corps sans métadonnées est simplement ignoré.
if err := os.WriteFile(metaPath+".part", raw, 0o644); err != nil {
return err
}
w.finished = true
return os.Rename(metaPath+".part", metaPath)
}
// Abort jette le temporaire. Appelé quand le client se déconnecte ou que
// l'amont coupe : rien de partiel n'entre au cache.
func (w *Writer) Abort() {
w.cleanup()
}
func (w *Writer) cleanup() {
if w.finished {
return
}
name := w.tmp.Name()
w.tmp.Close()
os.Remove(name)
}
// Stat parcourt le cache. Coûteux sur un grand cache, donc appelé à la
// demande et non à chaque requête.
// horsCasier écarte, à la racine du magasin, tout répertoire qui n'est pas un
// casier à lui.
//
// Les objets sont rangés sous deux niveaux de deux caractères hexadécimaux.
// Ce qui vit à côté — les dépôts git tenus en miroir, par exemple — n'a rien à
// faire dans un parcours du magasin : le traverser coûterait un appel système
// par fichier de chaque dépôt, à chaque relevé et à chaque démarrage.
func horsCasier(racine, chemin string, info os.FileInfo) bool {
if !info.IsDir() || filepath.Dir(chemin) != filepath.Clean(racine) {
return false
}
nom := filepath.Base(chemin)
if len(nom) != 2 {
return true
}
for _, c := range nom {
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
return true
}
}
return false
}
func (s *Store) Stat() (Stats, error) {
var st Stats
err := filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if horsCasier(s.Dir, p, info) {
return filepath.SkipDir
}
if info.IsDir() {
return nil
}
if filepath.Ext(p) != ".body" {
return nil
}
st.Objects++
st.Bytes += info.Size()
if st.Oldest.IsZero() || info.ModTime().Before(st.Oldest) {
st.Oldest = info.ModTime()
}
return nil
})
if os.IsNotExist(err) {
return st, nil
}
return st, err
}
// SweepPartials retire ce qu'une interruption a laissé. Lancé au démarrage,
// jamais pendant le service : un « .part » y appartient à une écriture vivante.
func (s *Store) SweepPartials() int {
n := 0
filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if horsCasier(s.Dir, p, info) {
return filepath.SkipDir
}
if info.IsDir() {
return nil
}
name := filepath.Base(p)
if filepath.Ext(p) == ".part" || containsPart(name) {
if os.Remove(p) == nil {
n++
}
}
return nil
})
return n
}
func containsPart(name string) bool {
for i := 0; i+5 <= len(name); i++ {
if name[i:i+5] == ".part" {
return true
}
}
return false
}
// HumanBytes rend une taille lisible par un opérateur, la surveillance du
// disque étant manuelle.
func HumanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d o", n)
}
units := []string{"Kio", "Mio", "Gio", "Tio"}
v := float64(n)
for _, u := range units {
v /= unit
if v < unit {
return fmt.Sprintf("%.1f %s", v, u)
}
}
return fmt.Sprintf("%.1f Pio", v/unit)
}
// copyTee écrit dans le cache ET vers le client en une seule lecture de
// l'amont : lire deux fois doublerait le trafic que l'outil existe pour
// supprimer.
func copyTee(dst io.Writer, cache io.Writer, src io.Reader) (int64, error) {
if cache == nil {
return io.Copy(dst, src)
}
return io.Copy(io.MultiWriter(dst, cache), src)
}

View file

@ -0,0 +1,232 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
func ecrire(t *testing.T, s *Store, key, corps string, taille int64) error {
t.Helper()
w, err := s.NewWriter(key, Meta{
URL: "https://exemple.example/x", Method: "GET", Status: 200,
Header: http.Header{"Content-Type": []string{"application/octet-stream"}},
Class: ClassImmutable.String(),
})
if err != nil {
t.Fatalf("ouverture : %v", err)
}
io.WriteString(w, corps)
return w.Commit(taille)
}
func TestStorePuisGet(t *testing.T) {
s := &Store{Dir: t.TempDir()}
key := Key("GET", "https://exemple.example/x")
if err := ecrire(t, s, key, "bonjour", 7); err != nil {
t.Fatalf("commit : %v", err)
}
m, f, err := s.Get(key)
if err != nil {
t.Fatalf("get : %v", err)
}
defer f.Close()
if m.Size != 7 {
t.Errorf("taille %d, attendu 7", m.Size)
}
raw, _ := io.ReadAll(f)
if string(raw) != "bonjour" {
t.Errorf("corps %q, attendu %q", raw, "bonjour")
}
if m.StoredAt.IsZero() {
t.Error("la date de stockage manque")
}
}
// Une réponse tronquée par une coupure réseau ressemble à une réponse
// complète pour tout le reste du code : la taille annoncée est donc vérifiée.
func TestCorpsTronqueRefuse(t *testing.T) {
s := &Store{Dir: t.TempDir()}
key := Key("GET", "https://exemple.example/tronque")
err := ecrire(t, s, key, "court", 4096)
if err == nil {
t.Fatal("un corps tronqué a été accepté")
}
if !strings.Contains(err.Error(), "tronqué") {
t.Errorf("erreur %q, attendu qu'elle dise « tronqué »", err)
}
if _, _, err := s.Get(key); err == nil {
t.Error("l'objet refusé est pourtant lisible")
}
}
// Une taille inconnue à l'amont — pas de Content-Length — vaut -1 et ne
// déclenche aucune vérification.
func TestTailleInconnueAcceptee(t *testing.T) {
s := &Store{Dir: t.TempDir()}
key := Key("GET", "https://exemple.example/inconnue")
if err := ecrire(t, s, key, "quoi que ce soit", -1); err != nil {
t.Fatalf("commit : %v", err)
}
if _, f, err := s.Get(key); err != nil {
t.Errorf("get : %v", err)
} else {
f.Close()
}
}
// Un objet dont le corps ne correspond plus aux métadonnées vaut absent
// plutôt que faux.
func TestCorpsAlteréVautAbsent(t *testing.T) {
s := &Store{Dir: t.TempDir()}
key := Key("GET", "https://exemple.example/altere")
if err := ecrire(t, s, key, "bonjour", 7); err != nil {
t.Fatalf("commit : %v", err)
}
_, body := s.paths(key)
if err := os.WriteFile(body, []byte("plus court"), 0o644); err != nil {
t.Fatalf("altération : %v", err)
}
if _, _, err := s.Get(key); err == nil {
t.Error("un corps de taille inattendue a été servi")
}
}
func TestAbortNeLaisseRien(t *testing.T) {
dir := t.TempDir()
s := &Store{Dir: dir}
key := Key("GET", "https://exemple.example/abandon")
w, err := s.NewWriter(key, Meta{URL: "x", Method: "GET", Status: 200})
if err != nil {
t.Fatalf("ouverture : %v", err)
}
io.WriteString(w, "à moitié")
w.Abort()
if _, _, err := s.Get(key); err == nil {
t.Error("un objet abandonné est lisible")
}
var restes []string
filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
restes = append(restes, p)
}
return nil
})
if len(restes) != 0 {
t.Errorf("l'abandon laisse %v", restes)
}
}
func TestSweepPartials(t *testing.T) {
dir := t.TempDir()
s := &Store{Dir: dir}
sub := filepath.Join(dir, "ab", "cd")
os.MkdirAll(sub, 0o755)
os.WriteFile(filepath.Join(sub, "clef.part-123"), []byte("x"), 0o644)
os.WriteFile(filepath.Join(sub, "clef.body"), []byte("x"), 0o644)
if n := s.SweepPartials(); n != 1 {
t.Errorf("%d fichier(s) balayé(s), attendu 1", n)
}
if _, err := os.Stat(filepath.Join(sub, "clef.body")); err != nil {
t.Error("le balayage a emporté un corps complet")
}
}
func TestStat(t *testing.T) {
s := &Store{Dir: t.TempDir()}
for _, u := range []string{"a", "b", "c"} {
if err := ecrire(t, s, Key("GET", u), "12345", 5); err != nil {
t.Fatalf("commit %s : %v", u, err)
}
}
st, err := s.Stat()
if err != nil {
t.Fatalf("stat : %v", err)
}
if st.Objects != 3 {
t.Errorf("%d objets, attendu 3", st.Objects)
}
if st.Bytes != 15 {
t.Errorf("%d octets, attendu 15", st.Bytes)
}
if st.Oldest.IsZero() {
t.Error("aucune date pour le plus ancien objet")
}
}
// Un répertoire de cache qui n'existe pas encore rend un état vide et non une
// erreur : « --status » doit répondre avant le premier démarrage.
func TestStatSansRepertoire(t *testing.T) {
s := &Store{Dir: filepath.Join(t.TempDir(), "jamais-cree")}
st, err := s.Stat()
if err != nil {
t.Fatalf("stat : %v", err)
}
if st.Objects != 0 || st.Bytes != 0 {
t.Errorf("état %+v, attendu vide", st)
}
}
func TestKeyDistingueMethode(t *testing.T) {
if Key("GET", "https://x/y") == Key("HEAD", "https://x/y") {
t.Error("GET et HEAD partagent une clé : un HEAD servirait un corps")
}
}
// Detient veut dire « tient un corps 200 ». Un statut seul n'en est pas un,
// quel que soit ce que son fichier de corps contient ; un méta sans statut,
// écrit avant que le magasin garde autre chose que des corps, en est un.
func TestDetientNeCompteQueLesCorps200(t *testing.T) {
s := &Store{Dir: t.TempDir()}
cas := []struct {
statut int
seul bool
attendu bool
}{
{http.StatusFound, false, false},
{http.StatusNotFound, false, false},
// Le 200 d'un HEAD : un statut seul malgré son code.
{http.StatusOK, true, false},
{http.StatusOK, false, true},
{0, false, true},
}
for i, c := range cas {
key := Key("GET", fmt.Sprintf("https://exemple.example/%d", i))
w, err := s.NewWriter(key, Meta{
URL: "x", Method: "GET", Status: c.statut, StatusOnly: c.seul,
})
if err != nil {
t.Fatal(err)
}
io.WriteString(w, "corps")
if err := w.Commit(5); err != nil {
t.Fatal(err)
}
if got := s.Detient(key); got != c.attendu {
t.Errorf("statut %d : Detient %v, attendu %v", c.statut, got, c.attendu)
}
}
}
func TestHumanBytes(t *testing.T) {
cas := map[int64]string{
512: "512 o",
2048: "2.0 Kio",
5 * 1024 * 1024: "5.0 Mio",
3 * 1024 * 1024 * 1024: "3.0 Gio",
}
for n, attendu := range cas {
if got := HumanBytes(n); got != attendu {
t.Errorf("%d octets rendus %q, attendu %q", n, got, attendu)
}
}
}

View file

@ -363,6 +363,169 @@ def disk_note(plan_gb, free_gb, total_gb=0) -> str:
return f"~{plan_gb} G / {free_gb} G {t('free of')} {total_gb} G"
def _depots_declares(version=""):
"""Les dépôts git que les manifestes déclarent, ou [] si on ne sait pas.
La lecture vit dans le menu du cache, qui la porte déjà ; l'import est
DIFFÉRÉ pour que le socle des formulaires ne traîne pas ce menu entier
quand personne ne coupe le réseau. Toute défaillance rend une liste vide :
le verdict se tait plutôt que d'annoncer des miroirs manquants sur une
lecture qui a échoué.
La lecture est BORNÉE à une version d'Odoo : un déploiement n'en installe
qu'une, et compter les dépôts des autres versions — les dépréciées
comprises — nomme comme manquants des dépôts que personne ne clonera.
Vide, celle que le dépôt porte dans « .odoo-version », qui est ce qu'un
déploiement pose par défaut.
"""
import os
try:
from script.todo.qemu_cache_menu import depots_des_manifestes
except Exception:
return []
racine = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
if not version:
try:
with open(
os.path.join(racine, ".odoo-version"), encoding="utf-8"
) as fh:
version = fh.read().strip()
except OSError:
version = ""
try:
return depots_des_manifestes(racine, version)
except Exception:
return []
def motifs_hors_ligne(vms):
"""Ce qu'une coupure fera échouer, en phrases, ou [] si rien ne manque.
Les deux formulaires la partagent : une VM privée de réseau échoue de la
même façon quel que soit l'hyperviseur qui la porte, et deux copies de ce
texte divergeraient au premier ajustement.
Trois verdicts se complètent, et un seul avertissement les porte — deux
confirmations d'affilée apprendraient à les enchaîner sans les lire.
L'appelant ajoute sa propre invite à confirmer.
"""
from script.qemu import cache_offline
absentes = cache_offline.suites_absentes(vms)
# Le verdict par suite se tait dès qu'UNE url est en réserve : il ne voit
# pas « restricted » ou « -security » absents, qui font pourtant échouer
# l'installation sur « Unable to locate package », vingt minutes plus tard.
composants = cache_offline.composants_absents(vms)
# Ce que les derniers déploiements hors ligne des MÊMES VM ont manqué,
# moins ce que le cache détient depuis.
manques = cache_offline.manques_hors_ligne(vms)
# Un paquet posé hors du fil observé : son absence ne remonte nulle part,
# et l'index de sa suite peut être en réserve sans lui.
paquets = cache_offline.paquets_absents(vms)
# Les dépôts git déclarés qui n'ont pas de miroir. Une négociation git ne
# se garde pas : sans miroir, le clone échoue une fois le réseau coupé.
depots = cache_offline.miroirs_absents(_depots_declares())
if not (absentes or composants or manques or paquets or depots):
return []
motifs = []
if absentes:
quoi = ", ".join(f"{d} {v}" for d, v in absentes)
motifs.append(
t("cache holds nothing for")
+ f" {quoi} — "
+ t("an offline VM will fail")
)
for distro, version, manque in composants:
# Trois au plus : la liste entière tiendrait douze entrées et
# personne ne lirait la douzième.
exemples = ", ".join(manque[:3])
if len(manque) > 3:
exemples += f" (+{len(manque) - 3})"
motifs.append(
t("cache holds no index for")
+ f" {distro} {version} : {exemples} — "
+ t("those packages will not be found")
)
if paquets:
motifs.append(
t("the cache has no package named")
+ " "
+ ", ".join(paquets)
+ " — "
+ t("an offline VM will not install it")
)
if depots:
# Trois au plus, comme pour les composants : une liste de cinquante
# dépôts ne se lit pas, et le geste est le même pour tous.
exemples = ", ".join(depots[:3])
if len(depots) > 3:
exemples += f" (+{len(depots) - 3})"
motifs.append(
t("these git repositories are not mirrored:")
+ f" {exemples} — "
+ t("an offline VM cannot clone them")
)
motifs.append(t("fill them from Cache › Git mirrors"))
for b in manques:
heures = int(b["age"] // 3600)
if heures < 1:
age = f"{int(b['age'] // 60)} min"
elif heures < 72:
age = f"{heures} h"
else:
age = f"{heures // 24} " + t("days")
# « au moins » : l'installation s'arrête au premier manque fatal, ce
# qui suivait n'a pas été demandé.
motif = (
t("the last offline run of")
+ f" {b['nom']} ("
+ t("age:")
+ f" {age}) "
+ t("lacked at least")
+ f" {len(b['manquants'])} "
+ t("addresses")
)
# Nommés à part : ils ne se comblent pas par l'entrée « Combler ».
# Une négociation git se remplit par le miroir, dépôt par dépôt ; le
# reste est nommé par sa méthode, un GET que le cache ne garde pas
# n'étant pas un POST.
extras = []
if b.get("git"):
extras.append(
f"+{len(b['git'])} "
+ t(
"git repositories not mirrored: fill"
" them from entry 5 of the cache menu"
)
)
if b["jamais"]:
methodes = sorted({m.upper() for m, _u in b["jamais"]})
extras.append(
f"+{len(b['jamais'])} "
+ t("requests the cache never keeps:")
+ " "
+ ", ".join(methodes)
)
if extras:
motif += " (" + "; ".join(extras) + ")"
if b["selon_journal"]:
motif += ", " + t(
"according to the log: a purge can make it wrong"
)
motifs.append(motif)
if manques:
exemples = [url for b in manques for _m, url in b["manquants"]][:3]
motifs.append(t("e.g.") + " " + ", ".join(exemples))
motifs.append(
t("fill them from Cache › Fill what offline runs lacked")
)
return motifs
def plan_totals(rows):
"""Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de
neuf) : (nb, vcpus, ram_mo, disque_go)."""
@ -401,6 +564,12 @@ def build_spec(vms, domains, form):
"ai_agent": form.get("ai_agent", ""),
"git_name": form.get("git_name", ""),
"git_email": form.get("git_email", ""),
# Au niveau du déploiement : soustraire une VM au cache se décide
# avant sa création, l'exception portant sur son adresse MAC.
"cache_bypass": form.get("cache_bypass", False),
# Au niveau du déploiement : la coupure vise le service du cache et
# dure le temps de la spec entière, installation comprise.
"offline": form.get("offline", False),
"add_ssh_config": form["add_ssh_config"],
"parallelism": form["parallelism"],
}

View file

@ -62,8 +62,23 @@ class LongTestMenuMixin:
)
},
{"prompt_description": t("Nested QEMU depth: run it")},
{"prompt_description": t("Download cache: plan only (dry-run)")},
{"prompt_description": t("Download cache: two VMs, measure")},
{
"prompt_description": t(
"Download cache: measure, then cut the upstream"
)
},
{"prompt_description": t("Undo what the descent created")},
]
# Le cache n'est pas une descente : ni profondeur, ni hôte de départ.
# Ses entrées sont donc traitées à part plutôt que pliées dans la
# table des piles imbriquées.
cache = {
"5": "--dry-run",
"6": "",
"7": "--hors-ligne",
}
# Chaque choix : le script, et s'il faut demander d'où l'on part.
scripts = {
"1": ("deep_proxmox.py", True),
@ -77,6 +92,9 @@ class LongTestMenuMixin:
print()
if status == "0":
return False
if status in cache:
self._longtest_run("qemu_cache.py", cache[status])
continue
if status in scripts:
script, demander = scripts[status]
# La profondeur est DEMANDÉE : c'est le réglage qui décide de
@ -88,7 +106,7 @@ class LongTestMenuMixin:
if status in ("1", "3"):
args += " --dry-run"
self._longtest_run(script, args)
elif status == "5":
elif status == "8":
self._longtest_defaire()
else:
print(t("Command not found !"))
@ -104,7 +122,7 @@ class LongTestMenuMixin:
on lui fait faire cette liste à blanc pour qu'un choix d'une touche ne
mène pas directement à un « qm destroy --purge ».
"""
for script in ("deep_proxmox.py", "deep_qemu.py"):
for script in ("deep_proxmox.py", "deep_qemu.py", "qemu_cache.py"):
self._longtest_run(script, "--detruire --dry-run")
if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")):
self._longtest_run(script, "--detruire")

View file

@ -34,6 +34,7 @@ from script.todo.deploy_form_lib import (
disk_note,
entry_key,
gib,
motifs_hors_ligne,
plan_rows,
plan_totals,
res_row_widgets,
@ -111,6 +112,9 @@ def build_spec(vms, existants, form):
"prod": bool((form.get("install") or {}).get("prod")),
"monitor": form["monitor"],
"parallelism": form["parallelism"],
# La coupure d'amont demandée pour TOUT le déploiement, installation
# comprise. Absente de la spec, le déploiement part en ligne.
"offline": bool(form.get("offline")),
}
@ -150,6 +154,10 @@ def run_proxmox_form(ctx, run_app: bool = True):
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
stockages = ctx.get("storages") or []
ponts = ctx.get("bridges") or []
# La case « Sans connexion internet » ne s'offre que là où la coupure a
# un effet : l'hôte Proxmox est alors une VM de notre pont, et le menu
# l'a établi avant d'ouvrir cet écran.
cache_offert = bool(ctx.get("cache_offert"))
# {système: (libellé, commande)} — ce qu'un système impose d'installer.
distro_profiles = ctx.get("distro_profiles") or {}
# Les commandes qui ne posent PAS ERPLibre : sa marge disque ne les suit
@ -390,6 +398,57 @@ def run_proxmox_form(ctx, run_app: bool = True):
disabled=True,
id="f_par",
)
# La coupure est posée ICI, sur le pont local, et non sur
# l'hôte Proxmox : ses invités sortent derrière son
# adresse, donc couper ce pont les coupe aussi. Offerte
# seulement là où cela vaut — le contexte le dit.
if cache_offert:
yield Static(
t("Network"),
id="t_network",
classes="grouptitle",
)
yield Checkbox(
t("No internet connection"),
value=False,
id="f_offline",
)
yield Static(
f" {t('Cuts internet for the cache and the VMs: proves')}"
)
yield Static(
f" {t('the install builds from what the cache holds.')}"
)
# Découvert par la case : ce qui suit ne concerne que
# celui qui vient de la cocher.
yield Static(
f" ⚠ {t('The cut hits every user of the cache:')}",
id="t_offline_w1",
)
yield Static(
f" {t('a deployment run from another terminal')}",
id="t_offline_w2",
)
yield Static(
f" {t('goes offline too, without asking for it.')}",
id="t_offline_w3",
)
yield Static(
f" {t('Nothing is cut before F5: the upstream falls')}",
id="t_offline_w4",
)
yield Static(
f" {t('at launch and comes back when the last install')}",
id="t_offline_w5",
)
yield Static(
f" {t('ends (12 h at most), even with the monitor closed.')}",
id="t_offline_w6",
)
yield Static(
f" {t('The monitor stays ticked: it is what arms that return.')}",
id="t_offline_w7",
)
with Vertical(id="right"):
yield VerticalScroll(id="plan")
yield Static("", id="totals")
@ -450,9 +509,40 @@ def run_proxmox_form(ctx, run_app: bool = True):
self.notify(f"✓ {nom}")
self._refresh_after()
# L'avertissement que la case « Sans connexion internet » découvre.
_OFFLINE_WIDGETS = tuple(f"#t_offline_w{n}" for n in range(1, 8))
def _sync_offline(self) -> None:
"""Montre l'avertissement quand la coupure est demandée, et y
force le suivi.
Il dit ce qu'on ne devine pas : la coupure vaut pour TOUS les
usagers du cache, elle ne tombe qu'au lancement, et elle ne se
lève qu'à la fin de la dernière installation.
Cette dernière promesse n'est tenue que par le déploiement suivi,
le seul qui confie la levée à une unité systemd. Le suivi est donc
coché et grisé tant que la case l'est ; la décocher le rend
modifiable, avec la valeur qu'il avait avant.
"""
case = self.query("#f_offline")
vu = bool(case) and bool(case.first(Checkbox).value)
for sel in self._OFFLINE_WIDGETS:
for widget in self.query(sel):
widget.display = vu
suivi = self.query_one("#f_monitor", Checkbox)
if vu and not suivi.disabled:
self._suivi_avant = suivi.value
suivi.value = True
suivi.disabled = True
elif not vu and suivi.disabled:
suivi.disabled = False
suivi.value = getattr(self, "_suivi_avant", True)
def on_mount(self) -> None:
self._reload_catalog()
self._sync_install_deps()
self._sync_offline()
# ---------------------------------------------------------------- #
# Le plan
@ -715,6 +805,8 @@ def run_proxmox_form(ctx, run_app: bool = True):
self._refresh_after()
elif event.checkbox.id == "f_par_all":
self.query_one("#f_par", Select).disabled = event.value
elif event.checkbox.id == "f_offline":
self._sync_offline()
elif str(event.checkbox.id or "").startswith("f_tool_"):
# Un IDE de plus, c'est un disque plus grand : le plan doit
# le montrer AVANT de déployer, pas après une heure.
@ -852,6 +944,12 @@ def run_proxmox_form(ctx, run_app: bool = True):
def _form_values(self):
cle = self.query_one("#f_key", Input).value.strip()
# La case n'existe que là où le cache tourne : la chercher sans
# la trouver vaut « en ligne ».
offline = bool(
self.query("#f_offline")
and self.query_one("#f_offline", Checkbox).value
)
return {
"host": ctx["host"],
"storage": self._storage(),
@ -865,7 +963,12 @@ def run_proxmox_form(ctx, run_app: bool = True):
**self.extras_values(),
# Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans
# ERPLibre se suit aussi (cloud-init, puis relevé système).
"monitor": self.query_one("#f_monitor", Checkbox).value,
"offline": offline,
# Hors ligne, le suivi est d'office : seule sa voie confie la
# levée de la coupure au guet, qui la tient jusqu'à la fin de
# la dernière installation.
"monitor": offline
or self.query_one("#f_monitor", Checkbox).value,
# Une exécution par installation : le nombre de VM retenues
# fait foi. Le déploiement le borne ensuite à ce même nombre,
# donc une valeur haute ne crée aucun travailleur inutile.
@ -881,6 +984,23 @@ def run_proxmox_form(ctx, run_app: bool = True):
if not spec["vms"]:
self.notify(t("Nothing to deploy."), severity="warning")
return
# Hors ligne : ce que le cache ne détient pas, aucune VM ne
# pourra le lire, et une VM Proxmox échoue aussi loin de son
# lancement qu'une VM libvirt — à la pose du bureau, une heure
# plus tard. F5 à nouveau vaut passage outre : le journal peut
# avoir tourné, ou le magasin avoir été rempli autrement.
if spec.get("offline") and not getattr(
self, "_offline_ack", False
):
motifs = motifs_hors_ligne(spec["vms"])
if motifs:
self._offline_ack = True
self.notify(
" — ".join(motifs + [t("press F5 again to confirm")]),
severity="error",
timeout=20,
)
return
if not spec["storage"]:
self.notify(
t("No storage able to hold a VM disk."), severity="error"

View file

@ -1065,9 +1065,31 @@ class ProxmoxMenuMixin:
print(t("Cancelled."))
return
if spec:
return self._pve_deploy_spec(host, spec, mod, dry_run)
return self._pve_run_spec(host, spec, mod, dry_run)
return self._pve_deploy_prompts(dry_run)
def _pve_run_spec(self, host, spec, mod, dry_run=False):
"""Enveloppe le déploiement de la coupure d'amont qu'il demande.
La coupure est la MÊME qu'en QEMU/KVM, et elle est posée ICI, pas sur
l'hôte distant : un hôte Proxmox qui porte l'autorité du cache est
une VM de ce pont, et ses invités sortent derrière son adresse. Les
règles qui coupent ce pont les couvrent donc tous.
Le formulaire n'offre la case que dans ce cas ; une spec qui la porte
quand même — invites textuelles, spec écrite à la main — coupe ce
pont-ci, ce qui reste vrai pour les VM qui le traversent.
"""
from script.todo.qemu_deploy import _SansInternetImpossible
try:
with self._qemu_sans_internet(bool(spec.get("offline"))) as coupee:
return self._pve_deploy_spec(
host, spec, mod, dry_run, coupee=coupee
)
except _SansInternetImpossible:
return
def _pve_form_context(self, mod, host):
"""Tout ce que l'écran doit savoir, LU AVANT de l'ouvrir.
@ -1206,6 +1228,13 @@ class ProxmoxMenuMixin:
"host_cpu": cpu,
"free_ram": ram_libre,
"extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB,
# La case « Sans connexion internet » ne s'offre que là où la
# coupure a un effet. Un hôte Proxmox qui reçoit l'autorité du
# cache est une VM de CE pont : ses invités sortent derrière son
# adresse, donc la coupure de ce pont les couvre. Un hôte qui ne
# vit pas ici ne traverse rien qu'on sache couper, et la case y
# promettrait un hors-ligne que personne ne tient.
"cache_offert": bool(self._pve_cache_ca(host)),
}
def _pve_capacity(self):
@ -1298,9 +1327,14 @@ class ProxmoxMenuMixin:
vm["vmid"], detail
)
def _pve_deploy_spec(self, host, spec, mod, dry_run=False):
def _pve_deploy_spec(self, host, spec, mod, dry_run=False, coupee=False):
"""Exécute la spec rendue par l'écran.
`coupee` : l'amont du cache est coupé autour de cet appel. La levée
est alors confiée au guet, au lancement des installations, comme sur
la voie QEMU/KVM — sans quoi elle tomberait avec ce processus, avant
la fin de ce qui télécharge.
Les images D'ABORD, une par une : deux téléchargements simultanés du
même fichier se marcheraient dessus. Les VM ensuite, en parallèle si
on l'a demandé — chacune est une suite « qm » indépendante.
@ -1308,6 +1342,11 @@ class ProxmoxMenuMixin:
from script.proxmox import proxmox_deploy as pve
from script.todo.deploy_form_lib import run_deploy_progress
# L'instant où CE déploiement commence : le manifeste le porte, et le
# bilan hors ligne s'en sert pour ne relire que ce qui s'est passé
# depuis. Pris avant la première commande, création comprise.
debut = time.time()
# Le stockage et le pont AVANT tout : l'écran les vérifie déjà, mais
# cette méthode s'appelle aussi d'ailleurs. Sans ce garde-fou, on
# téléchargeait 350 Mio d'image pour finir sur « net0: invalid format
@ -1413,7 +1452,9 @@ class ProxmoxMenuMixin:
print(f" {ligne}")
if not reussies:
return
joignables = self._pve_after_create(host, spec, reussies, cle_locale)
joignables = self._pve_after_create(
host, spec, reussies, cle_locale, coupee=coupee, debut=debut
)
self._pve_print_summary(spec, joignables or [], session)
@staticmethod
@ -1534,6 +1575,143 @@ class ProxmoxMenuMixin:
print(f" ✓ {t('Timezone')} : {fuseau}")
return True
def _pve_cache_ca(self, host):
"""L'autorité du cache à poser dans les VM de cet hôte, ou ''.
Le cache détourne tout ce qui sort de SON pont. Un hôte Proxmox qui
est lui-même une VM d'ici y est branché, et les machines qu'il porte
sortent derrière son adresse : elles sont donc interceptées, sans que
rien à l'intérieur ne l'annonce. Un hôte Proxmox qui ne vit pas ici ne
traverse pas ce pont, et son invité n'a que faire de cette autorité.
Le déséquilibre décide du doute : une autorité approuvée en trop ne
signe jamais rien, tandis qu'un détournement sans autorité fait
échouer chaque téléchargement HTTPS sur « self-signed certificate in
certificate chain ». En cas d'hésitation, on la pose.
"""
nom = (host.get("target") or "").split("@")[-1]
if not nom or nom not in set(self._qemu_list_domains()):
return ""
return self._qemu_cache_ca_path()
def _pve_set_cache_ca(self, cible, vm, ca):
"""Pose l'autorité du cache DANS la VM, par ssh.
Même source que la voie libvirt — `cache_files` et `cache_commands` de
deploy_qemu — livrée autrement : « qm set » ne sait écrire aucun
fichier, comme pour le guide et pour le fuseau.
AVANT l'installation : c'est elle qui télécharge. Un magasin de
confiance relu ensuite ne rattrape rien de ce qui a déjà échoué.
"""
import types
try:
mod = self._qemu_import_module()
except Exception: # pragma: no cover - dépend du module
return False
args = types.SimpleNamespace(
distro=vm.get("distro") or "", cache_ca=ca, cache_bypass=False
)
fichiers = mod.cache_files(args)
if not fichiers:
# Distribution hors table, ou autorité illisible : la VM
# télécharge en direct, ce qui marche tant qu'aucune règle ne la
# vise. Poser le fichier au mauvais endroit ne marcherait pas et
# ne dirait rien.
return False
morceaux = []
for chemin, mode, contenu, _proprio in fichiers:
q = shlex.quote(chemin)
morceaux.append(
f"printf '%s' {shlex.quote(contenu)} | sudo tee {q} "
f">/dev/null && sudo chmod {mode} {q}"
)
morceaux += [
f"sudo sh -c {shlex.quote(c)}" for c in mod.cache_commands(args)
]
code, _o = self._pve_ssh(cible, " && ".join(morceaux), timeout=120)
if code:
print(
f" ⚠ {t('download cache authority not installed')} ({code})"
)
return False
print(f" ✓ {t('download cache authority installed')}")
return True
def _pve_attendre_ssh(self, cible, delai=300, pas=10):
"""Attend que la VM réponde en ssh. Rend False si elle ne répond pas.
Une VM tout juste créée a une adresse bien avant d'avoir un sshd :
cloud-init pose les comptes et les clés, et cela prend des minutes
sur une machine émulée. Les étapes qui suivent passent toutes par
ssh, et les lancer trop tôt les fait échouer ENSEMBLE, chacune avec
son propre message — la panne ressemble alors à quatre pannes.
Bornée par le TEMPS : un essai coûte le délai de connexion de ssh,
que rien ici ne borne à l'avance.
"""
fin = time.time() + delai
premier = True
while time.time() < fin:
code, _o = self._pve_ssh(cible, "true", timeout=20)
if code == 0:
return True
if premier:
print(f" … {t('waiting for the VM to answer ssh')}")
premier = False
time.sleep(pas)
return False
def _pve_set_apt_mirror(self, cible, vm, mod=None):
"""Fixe le miroir apt de la VM sur celui que le cache a rempli.
Le magasin range ses index sous l'HÔTE demandé : une VM qui réclame
« archive.ubuntu.com » ne retrouve rien de ce qu'une autre a gardé
depuis un miroir, et hors ligne chacun de ces index manque — la suite
échoue alors sur des dépendances introuvables, ce qui accuse le dépôt
et non le miroir. La voie libvirt écrit le miroir dans le
cloud-config ; « qm set » ne sait écrire aucun fichier, d'où ce
passage par ssh.
Ubuntu seulement : Debian, Fedora et Arch ont leurs propres dépôts, et
y réécrire une URI ubuntu ne viserait rien. Les deux formats sont
couverts — le « .sources » deb822 des images récentes et le
« sources.list » des anciennes — et « security » suit le même miroir,
que les miroirs répliquent sous le même chemin.
"""
if (vm.get("distro") or "") != "ubuntu":
return False
ports = vm.get("arch") in (getattr(mod, "PORTS_ARCHES", ()) or ())
miroirs = (
getattr(mod, "APT_MIRRORS_PORTS", ())
if ports
else getattr(mod, "APT_MIRRORS_MAIN", ())
) or ()
if not miroirs:
return False
miroir = miroirs[0]
# Les arches « ports » ne sont pas sur archive.ubuntu.com, et amd64
# n'est pas sur ports.ubuntu.com : le motif suit l'architecture.
motif = (
r"https?://ports\.ubuntu\.com/ubuntu-ports"
if ports
else r"https?://(archive|security)\.ubuntu\.com/ubuntu"
)
# « # » comme séparateur de sed : une URL en est dépourvue, alors
# qu'elle porte des « / » en quantité.
geste = (
f"sudo sed -i -E 's#{motif}#{miroir}#g'"
" /etc/apt/sources.list /etc/apt/sources.list.d/*.sources"
" /etc/apt/sources.list.d/*.list 2>/dev/null; true"
)
code, _o = self._pve_ssh(cible, geste, timeout=60)
if code:
print(f" ⚠ {t('apt mirror not pinned')} ({code})")
return False
print(f" ✓ {t('apt mirror pinned')} : {miroir}")
return True
def _pve_write_guide(self, cible, vm, spec, mod):
"""Pose le guide de connexion et l'identité git DANS la VM.
@ -1708,9 +1886,15 @@ class ProxmoxMenuMixin:
input(f"\n{t('Deploy this VM now? (Y/n): ')}")
)
def _pve_after_create(self, host, spec, reussies, cle_locale):
def _pve_after_create(
self, host, spec, reussies, cle_locale, coupee=False, debut=None
):
"""Ce qui suit la création : l'adresse, ~/.ssh/config, l'installation.
`coupee` et `debut` suivent jusqu'à l'installateur : le premier lui
fait confier la levée de la coupure au guet, le second date le
déploiement dans le manifeste, où le bilan hors ligne le lit.
L'alias et non l'IP dans les étapes suivantes : ssh y lit le rebond
par l'hôte Proxmox, et le suivi d'installation en a besoin pour
entrer dans une VM qui n'est pas sur notre réseau."""
@ -1736,6 +1920,7 @@ class ProxmoxMenuMixin:
# qu'on a RÉELLEMENT écrit, pas par le nom.
alias = {}
joignables = []
ca_cache = self._pve_cache_ca(host)
for vm in spec["vms"]:
if vm["name"] not in reussies:
continue
@ -1752,8 +1937,8 @@ class ProxmoxMenuMixin:
# Un nom qui existe DÉJÀ comme domaine local est un piège : l'alias
# ~/.ssh/config serait volé à la VM locale, et le suivi
# d'installation — qui ré-résout par virsh — irait installer
# ERPLibre sur ELLE. Vécu : « erplibre-ubuntu-2604 » déployée sur
# Proxmox, installation partie sur la VM locale du même nom.
# ERPLibre sur ELLE : une VM Proxmox homonyme d'un domaine
# local lui prend son alias, et l'installation part sur elle.
noms_alias, vole = self._pve_alias_names(
vm["name"],
alias_chaine(vm["name"]),
@ -1789,12 +1974,29 @@ class ProxmoxMenuMixin:
print(f" ✓ ~/.ssh/config : ssh {noms_alias[0]}")
vm["adresse"] = ip
vm["alias"] = alias.get(vm["name"], vm["name"])
# Une adresse n'est pas une machine prête : cloud-init tourne
# encore, et sshd n'écoute pas toujours. Les quatre étapes qui
# suivent passent TOUTES par ssh — sans cette attente, elles
# échouaient ensemble sur une VM qui n'avait pas fini de naître,
# et la machine partait sans guide, en UTC, sans autorité et sur
# le miroir de son image.
if vm["alias"] and not self._pve_attendre_ssh(vm["alias"]):
print(f" ⚠ {t('No ssh answer: guest left as created.')}")
vm["alias"] = ""
# Le guide AVANT l'installation : il doit être là même si rien ne
# s'installe, et l'installation ne le touche pas.
if vm["alias"] and mod_qemu:
self._pve_write_guide(vm["alias"], vm, spec, mod_qemu)
if vm["alias"]:
self._pve_set_timezone(vm["alias"], spec)
# Après le fuseau et avant l'installation : c'est
# l'installation qui télécharge.
if ca_cache:
# Le miroir AVANT l'autorité et l'installation : le cache
# range ses index sous l'hôte demandé, et une VM qui en
# réclame un autre ne retrouve rien de ce qui est gardé.
self._pve_set_apt_mirror(vm["alias"], vm, mod_qemu)
self._pve_set_cache_ca(vm["alias"], vm, ca_cache)
joignables.append(vm)
install = spec.get("install")
# Rendu à l'appelant pour son sommaire : lui seul sait ce qui a été
@ -1870,6 +2072,11 @@ class ProxmoxMenuMixin:
app_store=spec.get("app_store") or "deb",
vm_tools=spec.get("vm_tools") or (),
pve=cartes_pve,
guet_hors_ligne=coupee,
deploy_started=debut,
# La coupure TENUE, et non la case de la spec : c'est elle
# qui fait qu'une réussite prouve le hors ligne.
hors_ligne=bool(coupee),
# Ce que sont ces VM, pris de la SPEC. Le suivi le demandait
# à virsh, qui ne connaît que les domaines d'ici.
meta={

File diff suppressed because it is too large Load diff

View file

@ -3,13 +3,17 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Menu QEMU/KVM : d\u00e9cider et lancer un d\u00e9ploiement.\n\nLe chemin complet d'une cr\u00e9ation : les ressources (pr\u00e9r\u00e9glages vCPU/RAM/disque\net saisie libre), le plan et son r\u00e9capitulatif, les v\u00e9rifications de l'h\u00f4te\n(groupe libvirt, KVM), le contexte du formulaire TUI, la collecte en ligne, et\nl'ex\u00e9cution d'une spec \u2014 la M\u00caME structure quelle que soit l'interface, ce qui\npermet aux invites et au formulaire de partager tout le reste.\n\nFronti\u00e8re claire : ici on d\u00e9cide ; dans qemu_install.py on \u00e9crit ce qui sera\nex\u00e9cut\u00e9 dans l'invit\u00e9."""
import contextlib
import fcntl
import getpass
import grp
import json
import os
import re
import shlex
import signal
import subprocess
import threading
import time
from script.todo import todo_prefs
@ -17,6 +21,15 @@ from script.todo.qemu_privilege import sudo_prefix
from script.todo.todo_i18n import get_lang, t
class _SansInternetImpossible(Exception):
"""La coupure demandée n'a pas pu être posée : rien n'est déployé.
Une exception et non un code de retour : le déploiement est enveloppé
d'un gestionnaire de contexte, et seule une exception l'empêche d'entrer
dans le bloc.
"""
class QemuDeployMixin:
"""Menu QEMU/KVM : d\u00e9cider et lancer un d\u00e9ploiement.\n\nLe chemin complet d'une cr\u00e9ation : les ressources (pr\u00e9r\u00e9glages vCPU/RAM/disque\net saisie libre), le plan et son r\u00e9capitulatif, les v\u00e9rifications de l'h\u00f4te\n(groupe libvirt, KVM), le contexte du formulaire TUI, la collecte en ligne, et\nl'ex\u00e9cution d'une spec \u2014 la M\u00caME structure quelle que soit l'interface, ce qui\npermet aux invites et au formulaire de partager tout le reste.\n\nFronti\u00e8re claire : ici on d\u00e9cide ; dans qemu_install.py on \u00e9crit ce qui sera\nex\u00e9cut\u00e9 dans l'invit\u00e9."""
@ -181,9 +194,14 @@ class QemuDeployMixin:
# (« Could not get lock … lists/lock ») -> lists vides -> « Unable
# to locate package git ». On RÉESSAIE donc update jusqu'à ce que
# le verrou se libère (et les lists soient peuplées), borné à ~5 min.
"n=0; until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do "
"n=$((n+1)); [ $n -ge 30 ] && break; "
'echo "apt verrouille (tentative $n), attente 10s..."; sleep 10; '
# Bornée par le TEMPS : trente essais valent cinq minutes quand
# chacun échoue en une seconde sur un verrou, mais des heures
# quand le cache répond 504 sur chaque index et qu'un essai dure
# des minutes.
"fin=$(( $(date +%s) + 300 )); "
"until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do "
'[ "$(date +%s)" -ge "$fin" ] && break; '
'echo "apt indisponible, nouvel essai dans 10s..."; sleep 10; '
"done; "
"sudo apt-get -o DPkg::Lock::Timeout=600 install -y $PKGS; "
"elif command -v dnf >/dev/null 2>&1; then "
@ -252,26 +270,55 @@ class QemuDeployMixin:
+ tools_cmd
# Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur
# pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev.
# Un dépôt déjà là est gardé tel quel, et le journal le dit : la
# branche choisie n'y est alors PAS rechargée.
+ (
(
"sudo mkdir -p /opt; "
"if [ ! -d /opt/erplibre/.git ]; then "
f"sudo git clone --branch {shlex.quote(branch)} "
f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; "
"sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; "
f"cd /opt/erplibre && {install_chain}"
"sudo chown -R $(id -un):$(id -gn) /opt/erplibre; "
f"else {self._qemu_checkout_garde('/opt/erplibre')}; fi; "
+ self._qemu_commit_line("/opt/erplibre")
+ f"cd /opt/erplibre && {install_chain}"
)
if prod
else (
"mkdir -p ~/git; "
"if [ ! -d ~/git/erplibre/.git ]; then "
f"git clone --branch {shlex.quote(branch)} "
f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; "
f"cd ~/git/erplibre && {install_chain}"
f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; "
f"else {self._qemu_checkout_garde('~/git/erplibre')}; fi; "
+ self._qemu_commit_line("~/git/erplibre")
+ f"cd ~/git/erplibre && {install_chain}"
)
)
)
@staticmethod
def _qemu_checkout_garde(depot):
"""Ligne du journal quand le clone est sauté : le dépôt existant est
gardé, sans mise à jour."""
note = f" {t('Existing checkout kept, not updated:')} {depot}"
return f"echo {shlex.quote(note)}"
@staticmethod
def _qemu_commit_line(depot):
"""Ligne du journal qui nomme le commit que la VM exécute vraiment.
Lu DANS la VM, après le clone : c'est la seule source sûre. Hors
ligne, le clone vient du miroir du cache, qui peut retarder sur le
dépôt distant comme sur le checkout de l'hôte. `depot` n'est pas
cité : « ~ » doit s'y développer. « || true » : sous « set -e », une
ligne d'information ne doit jamais faire échouer l'installation.
"""
fmt = shlex.quote(" Commit : %h %s")
return (
f"git -C {depot} log -1 --abbrev=12 --format={fmt} "
"2>/dev/null || true; "
)
def _qemu_install_erplibre_monitored(
self,
names,
@ -286,9 +333,19 @@ class QemuDeployMixin:
pve=None,
meta=None,
ai_agent="",
guet_hors_ligne=False,
deploy_started=None,
hors_ligne=None,
):
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre
le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
`guet_hors_ligne` : l'amont du cache est coupé ; sa levée est confiée
au guet entre le lancement et l'ouverture du tableau de bord.
`deploy_started` : début du déploiement (epoch), recopié dans le
manifeste ; None n'y écrit rien.
`hors_ligne` : l'amont du cache était-il coupé pour ce déploiement ?
Recopié dans le manifeste, où le bilan hors ligne le lit ; None, pour
un appelant qui ne le sait pas, n'y écrit rien.
`ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE).
`final_cmd` : commande d'install selon le profil choisi.
`prod` : install /opt/erplibre + service SELinux confiné.
@ -387,9 +444,21 @@ class QemuDeployMixin:
if not vms:
print(t("No VM to install."))
return
# Le mot-clé ne part que s'il porte une valeur : un lanceur de
# remplacement à trois arguments reste alors appelable.
debut = {}
if deploy_started is not None:
debut["deploy_started"] = deploy_started
if hors_ligne is not None:
debut["hors_ligne"] = bool(hors_ligne)
manifest = launch_installs(
vms, branch_def or next(iter(branch_map.values()), ""), remote
vms,
branch_def or next(iter(branch_map.values()), ""),
remote,
**debut,
)
if guet_hors_ligne:
self._qemu_confier_la_levee(manifest)
print(f"\n🖥 {t('Opening the interactive monitor...')}")
# Affiche tous les chemins de log (pour les consulter/partager même si
# on quitte le dashboard avant la fin).
@ -926,6 +995,9 @@ class QemuDeployMixin:
for vm in spec["vms"]
}
varies = t("varies, see each line")
# L'écart se mesure sur les branches RÉELLES : « varie » n'est
# qu'un libellé, et le miroir n'a aucune branche de ce nom.
ecart_de = sorted(used_br) if len(used_br) > 1 else None
br_txt = used_br.pop() if len(used_br) == 1 else varies
lb_txt = used_lb.pop() if len(used_lb) == 1 else varies
print(
@ -933,10 +1005,13 @@ class QemuDeployMixin:
f"{t('profile')} {lb_txt}, {env}"
)
# La VM ne reçoit pas CE checkout : elle CLONE la branche depuis
# le dépôt distant. Un correctif commité ici et non poussé n'y est
# donc pas, et le défaut « revient » alors qu'il est corrigé —
# vécu deux fois de suite sur install_proxmox.sh.
for ligne in self._qemu_branch_gap_lines(br_txt):
# le dépôt distant, ou hors ligne depuis le miroir du cache. Un
# correctif commité ici et non poussé n'y est donc pas, et le
# défaut « revient » alors qu'il est corrigé.
for ligne in self._qemu_branch_gap_lines(
br_txt if ecart_de is None else ecart_de,
hors_ligne=bool(spec.get("offline")),
):
print(f" {ligne}")
else:
print(f" {t('Install:')} {t('no')}")
@ -1037,6 +1112,8 @@ class QemuDeployMixin:
gpu3d=False,
git_name="",
git_email="",
cache_ca="",
cache_bypass=False,
):
"""Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu
dry-run ET le déploiement réel)."""
@ -1076,6 +1153,16 @@ class QemuDeployMixin:
# « on » et non « auto » : auto s'abstient sur une VM sans écran,
# or c'est précisément ce que la case permet de demander.
parts += ["--gpu", "on"]
if cache_bypass:
# L'exception l'emporte sur l'autorité : la VM ne rencontrera
# jamais le cache, lui faire approuver cette signature ne servirait
# à rien. deploy_qemu.py pose l'exception AVANT de créer la VM.
parts.append("--cache-bypass")
elif cache_ca:
# L'autorité du cache de téléchargement de l'hôte. La VM
# l'approuve dès son premier démarrage, sans quoi le détournement
# lui présente un certificat qu'elle rejette.
parts += ["--cache-ca", cache_ca]
# L'identité git de la VM. Sans ces options, deploy_qemu recopie celle
# de l'HÔTE : le formulaire la montre et permet de la changer, il ne
# la remplace pas par du vide.
@ -1157,8 +1244,69 @@ class QemuDeployMixin:
gpu3d=bool(spec.get("gpu3d")),
git_name=spec.get("git_name") or "",
git_email=spec.get("git_email") or "",
# L'autorité est posée dès que le service TOURNE, sans égard à
# la case : l'interception est transparente et vaut pour tout le
# pont, si bien qu'une VM privée de l'autorité est quand même
# détournée et échoue sur « self-signed certificate in
# certificate chain » à chaque téléchargement HTTPS. Le seul
# contournement vrai est d'arrêter le service, qui emporte ses
# règles avec lui.
#
# Le chemin est relu à chaque commande : désinstaller le cache
# entre deux déploiements ne doit pas laisser une VM approuver une
# autorité disparue.
cache_ca=(
self._qemu_cache_ca_path() if self._qemu_cache_active() else ""
),
cache_bypass=bool(spec.get("cache_bypass")),
)
# Où l'installateur du cache pose son autorité. Un test compare cette
# valeur au défaut du script d'installation : les deux séparées, la case
# s'offrirait sans que la VM reçoive rien.
QEMU_CACHE_CA = "/var/lib/erplibre_go_qemu_cache/ca.crt"
QEMU_CACHE_SERVICE = "erplibre-go-qemu-cache.service"
@classmethod
def _qemu_cache_ca_path(cls):
"""Chemin de l'autorité du cache, ou '' si le cache n'est pas posé.
L'existence du FICHIER suffit à décider : une autorité approuvée alors
que le cache est arrêté ne coûte rien à la VM, qui télécharge en
direct. C'est l'inverse qui casse — un détournement actif sans
autorité dans l'invité.
"""
return cls.QEMU_CACHE_CA if os.path.isfile(cls.QEMU_CACHE_CA) else ""
@classmethod
def _qemu_cache_active(cls):
"""Le service du cache tourne-t-il ? Sert au DÉFAUT de la case.
Jamais à l'offrir : c'est le fichier d'autorité qui décide de son
existence, et l'état d'un service change entre l'affichage du
formulaire et le déploiement.
"""
if not cls._qemu_cache_ca_path():
return False
try:
return (
subprocess.run(
[
"systemctl",
"is-active",
"--quiet",
cls.QEMU_CACHE_SERVICE,
],
check=False,
timeout=5,
).returncode
== 0
)
except (OSError, subprocess.SubprocessError):
# Hôte sans systemd, ou systemctl injoignable : on ne prétend pas
# savoir, et la case s'offre décochée.
return False
def _qemu_arches_for(self, distro, arch):
"""Architectures à déployer pour cette distro selon le choix global.
« all » = uniquement celles que la distro publie réellement."""
@ -1392,6 +1540,15 @@ class QemuDeployMixin:
if self._qemu_distro_profile(d)
},
"ssh_key": self._qemu_default_ssh_key(),
# Le cache de téléchargement de CET hôte. Lu ici, comme le reste
# des mesures : l'écran ne doit rien interroger pendant qu'il
# affiche. Absent, le formulaire n'offre pas la case.
"cache_ca": self._qemu_cache_ca_path(),
"cache_active": self._qemu_cache_active(),
# La case « hors cache » ne s'offre que là où elle a un effet.
# Sans service actif rien n'intercepte, et une case sans effet
# est ce que ce menu a déjà eu, à tort.
"cache_offert": self._qemu_cache_active(),
"host_cpu": os.cpu_count() or 2,
"free_ram": self._host_free_ram_mb(),
# La place du système de fichiers qui portera les qcow2. Mesurée
@ -1958,6 +2115,14 @@ class QemuDeployMixin:
)
)
# Posée seulement quand un cache tourne : ailleurs, la réponse ne
# changerait rien et la question ferait croire le contraire.
cache_bypass = False
if self._qemu_cache_active():
cache_bypass = self._is_yes(
input(t("Keep this VM out of the download cache? (y/N): "))
)
# Ces trois réponses n'ont d'objet que si l'outil est coché : les
# poser toujours ferait trois questions de plus à qui n'en veut pas.
ai_agent, git_name, git_email = self._qemu_ask_ai_tools(vm_tools)
@ -2025,6 +2190,7 @@ class QemuDeployMixin:
# décochée (voir _qemu_run_spec).
"monitor": monitor,
"gpu3d": gpu3d,
"cache_bypass": cache_bypass,
"ai_agent": ai_agent,
"git_name": git_name,
"git_email": git_email,
@ -2122,12 +2288,406 @@ class QemuDeployMixin:
except ImportError:
return None
@contextlib.contextmanager
def _qemu_sans_internet(self, actif):
"""Coupe l'amont du cache le temps du bloc, et le rebranche toujours.
Deux sorties tombent : celle du service du cache, et celle que l'hôte
relaie pour les VM, et la résolution des noms par l'internet. Ce
qu'une VM demande à l'hôte lui-même reste joignable : le cache, et
les noms, auxquels l'hôte répond seul une adresse que le cache
intercepte. Tout ce qui arrive encore dans une VM vient donc du
disque du cache, et un pas qui prendrait un autre chemin échoue.
La coupure vaut pour la spec ENTIÈRE, installation comprise : c'est
l'installation qui télécharge, et une coupure levée avant elle ne
mesurerait plus rien.
Elle vaut aussi pour les AUTRES usagers du cache pendant ce temps —
un déploiement mené en parallèle depuis un autre terminal se
retrouvera hors ligne sans l'avoir demandé.
La table est UNIQUE et partagée : deux coupures n'en font qu'une, et
la première levée ôte les deux. D'où le refus quand un guet tourne
encore — sa fin lèverait la coupure de ce déploiement-ci en cours de
route — et la question quand une table est posée sans guet.
Sur la voie suivie, la levée est confiée au guet dès le lancement des
installations (`_qemu_confier_la_levee`) ; le « finally » ne la fait
alors pas, il dit jusqu'à quand l'amont reste coupé. Ailleurs, il lève
et VÉRIFIE : « rebranché » ne s'affiche que constaté.
Hors ligne, un verrou de fichier tient le bloc ENTIER, relevés
compris : deux déploiements lancés ensemble passeraient sinon tous
deux les relevés avant que l'un ait posé son guet. Tant que la
coupure est tenue, SIGHUP et SIGTERM déroulent le « finally » au lieu
de tuer le processus sur place. La commande de coupure elle-même
tourne DANS le « try » qui lève : un signal reçu pendant qu'elle
s'exécute, la table déjà posée, trouve la levée sur son chemin. Seule
une coupure qui rend un échec n'est pas levée — le sudo qui l'a
refusée refuserait le retrait aussi, et redemanderait un mot de passe.
En ligne, rien n'est coupé, mais une coupure tenue par un autre
déploiement ferait tourner celui-ci hors ligne : on le dit et on
demande (`_qemu_en_ligne_malgre_la_coupure`).
"""
if not actif:
if not self._qemu_en_ligne_malgre_la_coupure():
raise _SansInternetImpossible()
yield False
return
from script.qemu import cache_offline
# Sans dnsmasq, la coupure des noms ne peut pas se poser, et
# `cut_cmd` échouerait sans dire pourquoi : on le dit avant de rien
# toucher.
if not cache_offline.dnsmasq():
print(f"\n ✗ {t('dnsmasq is missing on the host: names cannot')}")
print(
f" {t('be cut. Install the dnsmasq package, then F5.')} "
f"{t('Nothing deployed.')}"
)
raise _SansInternetImpossible()
with self._qemu_verrou_hors_ligne() as libre:
if not libre:
print(
f"\n ✗ {t('Another offline deployment is starting or')}"
)
print(
f" {t('running in another terminal.')} "
f"{t('Nothing deployed.')}"
)
raise _SansInternetImpossible()
if not self._qemu_coupure_libre():
raise _SansInternetImpossible()
anciens = self._qemu_signaux_de_sortie()
lever = True
try:
try:
if self._qemu_shell(cache_offline.cut_cmd()):
lever = False
# Refuser plutôt que déployer quand même : une VM
# bâtie avec l'amont debout se bâtit toujours, et son
# succès se lirait comme une preuve hors ligne qu'elle
# n'est pas.
print(
f"\n ✗ {t('Upstream not cut: nothing deployed.')}"
)
faux = t(
"The result would look offline without being so."
)
print(f" {faux}")
raise _SansInternetImpossible()
print(
f"\n ✂ {t('Cache upstream cut for this deployment.')}"
)
yield True
finally:
if lever:
self._qemu_rebrancher()
finally:
self._qemu_signaux_rendus(anciens)
@staticmethod
def _qemu_verrou_hors_ligne_chemin():
"""Fichier du verrou des déploiements hors ligne, dans le répertoire
des sessions d'installation."""
from script.todo.qemu_install_monitor import session_dir
return session_dir() / ".coupure-hors-ligne.lock"
@contextlib.contextmanager
def _qemu_verrou_hors_ligne(self):
"""Tient le verrou exclusif des déploiements hors ligne le temps du
bloc. Rend False s'il est déjà tenu ailleurs, True sinon.
`flock` non bloquant : un second terminal est refusé sur-le-champ
plutôt que mis en attente d'un déploiement qui dure des heures. Le
verrou tombe avec le descripteur, donc aussi à la mort du processus,
même par SIGKILL : aucun reste à nettoyer. Le descripteur n'est pas
hérité (PEP 446) : les installations détachées ne le gardent pas.
Un fichier impossible à ouvrir rend True : le verrou ne ferme que la
course entre deux terminaux, les relevés qui suivent tiennent encore.
"""
try:
fd = os.open(
self._qemu_verrou_hors_ligne_chemin(),
os.O_RDWR | os.O_CREAT,
0o600,
)
except OSError:
yield True
return
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
libre = True
except BlockingIOError:
libre = False
except OSError:
# Système de fichiers sans flock : même règle qu'un fichier
# impossible à ouvrir.
libre = True
try:
yield libre
finally:
os.close(fd)
def _qemu_verrou_tenu_ailleurs(self):
"""Un déploiement hors ligne tient-il le verrou ? Lecture seule.
Le fichier n'est pas créé : absent, personne ne le tient. Le verrou
n'est pris qu'un instant pour le sonder, puis rendu.
"""
try:
fd = os.open(self._qemu_verrou_hors_ligne_chemin(), os.O_RDWR)
except OSError:
return False
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return True
except OSError:
return False
finally:
os.close(fd)
return False
def _qemu_en_ligne_malgre_la_coupure(self):
"""Un déploiement EN LIGNE peut-il partir ? True si oui.
La table est partagée par tout le cache : tant qu'un autre
déploiement la tient — son guet attend ses installations, ou il est
encore en train de se lancer —, celui-ci tournerait hors ligne sans
l'avoir demandé, et ses échecs de téléchargement se liraient comme
des pannes. On le dit et on demande, « non » par défaut.
Aucun sudo ici : `guet_actif_cmd` lit l'état d'une unité, et le
verrou est un fichier de l'utilisateur. Sans guet ni verrou tenu,
rien n'est demandé.
"""
from script.qemu import cache_offline
if self._qemu_shell(cache_offline.guet_actif_cmd()) == 0:
heures = cache_offline.DUREE_MAX_GUET // 3600
print(f"\n ⚠ {t('An offline deployment is still installing.')}")
print(f" {t('The cache upstream stays cut until the last')}")
print(f" {t('installation ends, at most')} {heures} h.")
print(f" {t('This deployment would therefore run offline.')}")
print(
f" {t('Lift it now with:')} "
f"{cache_offline.lever_maintenant_cmd()}"
)
elif self._qemu_verrou_tenu_ailleurs():
print(f"\n ⚠ {t('An offline deployment is starting in another')}")
print(
f" {t('terminal: the cache upstream is cut until it ends.')}"
)
print(f" {t('This deployment would therefore run offline.')}")
else:
return True
if not self._is_yes(
input(f" {t('Deploy anyway, offline? (y/N): ')}")
):
print(f" {t('Nothing deployed.')}")
return False
return True
@staticmethod
def _qemu_signaux_de_sortie():
"""Fait de SIGHUP et SIGTERM une sortie qui déroule les « finally ».
Leur action par défaut tue le processus sur place : un terminal fermé
ou une session ssh perdue laisse alors la coupure posée sans fin.
Ici, le premier signal lève SystemExit(128 + signal) ; les suivants
sont sans effet, pour qu'un second signal n'interrompe pas la levée en
cours. Un signal déjà ignoré le reste : lancé sous « nohup », le
déploiement doit survivre à son terminal.
Rend {signal: ancien gestionnaire}, à passer à `_qemu_signaux_rendus`.
Hors du fil principal, `signal.signal` lève ValueError : rien n'est
posé et le dictionnaire est vide.
"""
if threading.current_thread() is not threading.main_thread():
return {}
deja = []
def sortir(signum, _frame):
if deja:
return
deja.append(signum)
raise SystemExit(128 + signum)
anciens = {}
for nom in ("SIGHUP", "SIGTERM"):
num = getattr(signal, nom, None)
if num is None or signal.getsignal(num) == signal.SIG_IGN:
continue
try:
anciens[num] = signal.signal(num, sortir)
except (ValueError, OSError):
continue
return anciens
@staticmethod
def _qemu_signaux_rendus(anciens):
"""Remet les gestionnaires que rend `_qemu_signaux_de_sortie`.
Un ancien gestionnaire posé hors de Python se lit None : il redevient
l'action par défaut, la seule qu'on sache rétablir.
"""
for num, ancien in anciens.items():
try:
signal.signal(
num, signal.SIG_DFL if ancien is None else ancien
)
except (ValueError, OSError, TypeError):
continue
def _qemu_coupure_libre(self):
"""Peut-on poser une coupure ? True si oui, False si rien ne doit
être déployé — l'appelant n'a plus rien à dire, tout est affiché.
Un guet actif refuse d'office, que la table soit posée ou non : il la
retirera à la fin des installations qu'il attend, et celle de ce
déploiement avec. Une table posée sans guet est un reste — processus
tué, installation synchrone menée depuis un autre terminal — que
seul l'utilisateur peut juger : on demande, « non » par défaut.
"""
from script.qemu import cache_offline
if self._qemu_shell(cache_offline.guet_actif_cmd()) == 0:
print(
f"\n ✗ {t('An offline deployment is still running: the cut')}"
)
print(
f" {t('is shared, and its end would lift yours.')} "
f"{t('Nothing deployed.')}"
)
print(
f" {t('Lift it now with:')} "
f"{cache_offline.lever_maintenant_cmd()}"
)
return False
if self._qemu_shell(cache_offline.table_posee_cmd()) != 0:
return True
print(f"\n ⚠ {t('The cache upstream is already cut, and nothing')}")
print(f" {t('will lift it: an interrupted deployment, or one')}")
print(f" {t('still installing without the monitor.')}")
if not self._is_yes(input(f" {t('Lift it and continue? (y/N): ')}")):
print(f" {t('Nothing deployed.')}")
return False
self._qemu_shell(cache_offline.restore_cmd())
return True
def _qemu_rebrancher(self):
"""Le « finally » de la coupure : lever, ou dire qui lèvera.
Guet actif : les installations tournent encore, détachées, et c'est
lui qui lèvera à la dernière. Lever ici les ferait finir en ligne.
Sinon, TOUJOURS lever — une coupure laissée en place prive le cache de
réseau bien après, et la panne se découvre ailleurs — puis constater :
`restore_cmd` rend 0 même quand sudo refuse, et seul un relevé de la
table dit si l'amont est revenu.
"""
from script.qemu import cache_offline
if self._qemu_shell(cache_offline.guet_actif_cmd()) == 0:
heures = cache_offline.DUREE_MAX_GUET // 3600
print(f"\n ✂ {t('The cache upstream stays cut until the last')}")
print(
f" {t('installation ends, at most')} {heures} h."
f" {t('Lift it now with:')}"
)
print(f" {cache_offline.lever_maintenant_cmd()}")
return
self._qemu_shell(cache_offline.restore_cmd())
if self._qemu_shell(cache_offline.table_posee_cmd()) == 1:
print(f" {t('Cache upstream restored.')}")
return
print(f"\n ✗ {t('The cache upstream may still be cut.')}")
print(f" {t('Lift it with:')} {cache_offline.restore_cmd()}")
def _qemu_confier_la_levee(self, manifest):
"""Confie la levée de la coupure à root, le temps des installations.
Appelée juste après leur lancement et AVANT le tableau de bord : le
terminal est encore libre pour une invite de mot de passe. L'unité
attend le marqueur de sortie de chaque journal du manifeste, puis
lève ; elle survit à la fermeture du tableau de bord comme à la mort
de ce processus, et le tableau de bord reste détachable.
Rend True si le guet est posé. En cas d'échec, la coupure reste au
« finally », c'est-à-dire à la fermeture du tableau de bord : le dire,
puisque le fermer avant la fin fait finir les installations en ligne.
"""
from script.qemu import cache_offline
from script.todo.qemu_install_monitor import EXIT_MARKER
try:
with open(manifest, encoding="utf-8") as fh:
journaux = [vm["log"] for vm in json.load(fh)["vms"]]
except (OSError, ValueError, KeyError, TypeError):
journaux = None
heures = cache_offline.DUREE_MAX_GUET // 3600
if (
journaux
and cache_offline.chemins_surs(journaux)
and not self._qemu_shell(
cache_offline.guet_cmd(journaux, EXIT_MARKER)
)
):
print(f"\n ✂ {t('The cache upstream comes back when the last')}")
print(
f" {t('installation ends, at most')} {heures} h,"
f" {t('even if the monitor is closed.')}"
)
return True
print(f"\n ⚠ {t('Could not hand the lift over to systemd-run:')}")
print(f" {t('the cache upstream comes back when the monitor')}")
print(
f" {t('closes; closing it early finishes the installs online.')}"
)
return False
@staticmethod
def _qemu_shell(cmd, timeout=60):
"""Code de retour d'une commande shell locale, 255 si elle n'a pas
pu être lancée du tout."""
try:
return subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
).returncode
except (OSError, subprocess.SubprocessError):
return 255
def _qemu_run_spec(self, spec):
"""Enveloppe le déploiement de la coupure d'amont qu'il demande.
Séparée du déploiement lui-même : la spec entière doit tenir dans le
bloc, et un « with » autour de deux cents lignes déjà indentées se
relit mal.
"""
try:
with self._qemu_sans_internet(bool(spec.get("offline"))) as coupee:
return self._qemu_deploie_spec(spec, coupee=coupee)
except _SansInternetImpossible:
return
def _qemu_deploie_spec(self, spec, coupee=False):
"""Exécute une spec de déploiement : création des VM en parallèle,
résolution des IP, ~/.ssh/config, installation ERPLibre.
Ne pose AUCUNE question — tous les choix sont dans la spec, d'où
qu'elle vienne (invites en ligne ou formulaire TUI)."""
qu'elle vienne (invites en ligne ou formulaire TUI).
`coupee` : l'amont du cache est coupé autour de cet appel. La voie
suivie en confie alors la levée au guet, dès le lancement."""
pending = spec["vms"]
deployed = list(spec.get("existing") or [])
install = spec.get("install")
@ -2235,6 +2795,13 @@ class QemuDeployMixin:
# de bord — rapporté, et c'est ce qui donnait « le suivi ne fonctionne
# plus ». Le choix vient du déploiement, pas de l'installation.
monitor = install["monitor"] if install else spec.get("monitor", True)
# Hors ligne, le suivi est d'office : seule sa voie confie la levée de
# la coupure au guet, qui la tient jusqu'à la fin de la dernière
# installation. Sans lui, la voie synchrone n'a aucun guet, et une spec
# sans rien à installer lèverait la coupure dès les IP connues, avant
# que cloud-init et l'agent invité aient fini de télécharger.
if spec.get("offline"):
monitor = True
if install or desktop or monitor:
if monitor:
# Installs détachées en parallèle + dashboard Textual.
@ -2249,6 +2816,11 @@ class QemuDeployMixin:
app_store=app_store,
vm_tools=vm_tools,
ai_agent=ai_agent,
guet_hors_ligne=coupee,
deploy_started=deploy_start,
# La coupure TENUE, et non la case de la spec : c'est
# elle qui fait qu'une réussite prouve le hors ligne.
hors_ligne=bool(coupee),
)
elif install:
print(

View file

@ -31,6 +31,9 @@ except Exception: # pragma: no cover - repli si i18n indisponible
return key
# Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté
# tel quel : les appelants historiques importent encore ces noms ICI.
from script.todo.deploy_form_extras import SERVER, ExtrasMixin
from script.todo.deploy_form_lib import ( # noqa: F401
CLIP_LIMIT,
CSS_BASE,
@ -54,6 +57,7 @@ from script.todo.deploy_form_lib import ( # noqa: F401
expand_copies,
fmt_dur,
gib,
motifs_hors_ligne,
parse_disk,
parse_ram,
plan_rows,
@ -67,10 +71,6 @@ from script.todo.deploy_form_lib import ( # noqa: F401
vm_name,
vm_status,
)
# Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté
# tel quel : les appelants historiques importent encore ces noms ICI.
from script.todo.deploy_form_extras import SERVER, ExtrasMixin
from script.todo.deploy_form_plan import ( # noqa: F401
PlanMixin,
preview_screen,
@ -133,6 +133,11 @@ def run_deploy_form(ctx, run_app: bool = True):
# saveurs — on ne le redéfinit pas ici.
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
defaults = ctx.get("defaults") or {}
# Le cache est-il en marche sur cet hôte ? Lu du CONTEXTE, comme les
# autres mesures de la machine, et non de « defaults » : celui-là ne porte
# que ce qu'on veut PRÉ-COCHER, et il est vide au premier affichage — la
# case ne paraissait donc jamais, quel que soit l'état du cache.
cache_offert = bool(ctx.get("cache_offert"))
result = {"spec": None}
AUTO = "__auto__"
@ -318,6 +323,12 @@ def run_deploy_form(ctx, run_app: bool = True):
id="f_profile_install",
)
yield from self.compose_install_extras()
# Aucune case pour le cache : l'interception est
# transparente et vaut pour tout le pont, donc une VM ne
# peut pas s'y soustraire. Décocher aurait produit une VM
# détournée SANS l'autorité, cassée sur chaque
# téléchargement HTTPS. Le seul contournement est
# d'arrêter le service, ce que le guide dit.
yield from self.compose_timezone()
yield Static("SSH", classes="grouptitle")
yield Input(
@ -355,6 +366,15 @@ def run_deploy_form(ctx, run_app: bool = True):
value=defaults.get("gpu3d", False),
id="f_gpu3d",
)
# Offerte seulement là où elle a un effet : sans cache
# actif, rien n'intercepte, et une case qui ne change
# rien apprend au lecteur une chose fausse.
if cache_offert:
yield Checkbox(
t("Keep this VM out of the download cache"),
value=defaults.get("cache_bypass", False),
id="f_cache_bypass",
)
# Révélés par la case « AI coding tools » du bloc des
# outils : sans elle, ni l'agent ni l'identité git n'ont
# d'objet, et trois widgets de plus encombrent un écran
@ -402,6 +422,68 @@ def run_deploy_form(ctx, run_app: bool = True):
disabled=True,
id="f_par",
)
# Offerte seulement là où le cache tourne : sans lui il
# n'y a pas d'amont à couper, et la case ne ferait rien.
# Elle coupe l'internet du cache, la sortie directe des VM
# et la résolution des noms par l'internet, le temps du
# déploiement ; les VM gardent l'hôte : le cache, et des
# noms que l'hôte leur répond seul.
if cache_offert:
yield Static(
t("Network"),
id="t_network",
classes="grouptitle",
)
yield Checkbox(
t("No internet connection"),
value=defaults.get("offline", False),
id="f_offline",
)
yield Static(
f" {t('Cuts internet for the cache and the VMs: proves')}"
)
yield Static(
f" {t('the install builds from what the cache holds.')}"
)
# Découvert par la case, comme le bloc IA : ce qui
# suit ne concerne que celui qui vient de la cocher.
yield Static(
f" ⚠ {t('The cut hits every user of the cache:')}",
id="t_offline_w1",
)
yield Static(
f" {t('a deployment run from another terminal')}",
id="t_offline_w2",
)
yield Static(
f" {t('goes offline too, without asking for it.')}",
id="t_offline_w3",
)
# La fin de la coupure suit celle des installations,
# pas celle du tableau de bord : c'est ce qu'on ne
# devine pas, le tableau de bord étant détachable.
yield Static(
f" {t('Nothing is cut before F5: the upstream falls')}",
id="t_offline_w4",
)
yield Static(
f" {t('at launch and comes back when the last install')}",
id="t_offline_w5",
)
yield Static(
f" {t('ends (12 h at most), even with the monitor closed.')}",
id="t_offline_w6",
)
# Dit pourquoi le suivi est grisé : seul le chemin
# suivi confie ce retour à une unité systemd. Sans
# lui, l'amont revient dès que les VM ont une adresse
# quand rien ne s'installe, ou reste coupé pour de
# bon si le terminal se ferme pendant une
# installation synchrone.
yield Static(
f" {t('The monitor stays ticked: it is what arms that return.')}",
id="t_offline_w7",
)
with Vertical(id="right"):
# Une liste de widgets, pas un tableau : chaque VM porte
# SES listes déroulantes, modifiables sur place. Un
@ -413,6 +495,37 @@ def run_deploy_form(ctx, run_app: bool = True):
# Les widgets que la case « AI coding tools » découvre.
_AI_WIDGETS = ("#t_ai", "#f_ai_agent", "#f_git_name", "#f_git_email")
# L'avertissement que la case « Sans connexion internet » découvre.
_OFFLINE_WIDGETS = tuple(f"#t_offline_w{n}" for n in range(1, 8))
def _sync_offline(self) -> None:
"""Montre l'avertissement quand la coupure est demandée, et y
force le suivi.
Il dit ce qu'on ne devine pas : la coupure vaut pour TOUS les
usagers du cache ; elle ne tombe qu'au lancement — cocher la case
ne coupe rien, l'écran reste utilisable — et ne se lève qu'à la
fin de la dernière installation, tableau de bord fermé ou non.
Cette dernière promesse n'est tenue que par le déploiement suivi,
le seul qui confie la levée à une unité systemd. Le suivi est donc
coché et grisé tant que la case l'est ; la décocher le rend
modifiable, avec la valeur qu'il avait avant.
"""
case = self.query("#f_offline")
vu = bool(case) and bool(case.first(Checkbox).value)
for sel in self._OFFLINE_WIDGETS:
for widget in self.query(sel):
widget.display = vu
suivi = self.query_one("#f_monitor", Checkbox)
if vu and not suivi.disabled:
self._suivi_avant = suivi.value
suivi.value = True
suivi.disabled = True
elif not vu and suivi.disabled:
suivi.disabled = False
suivi.value = getattr(self, "_suivi_avant", True)
def _sync_ai(self) -> None:
"""Montre ou cache le bloc IA selon la case des outils.
@ -429,6 +542,7 @@ def run_deploy_form(ctx, run_app: bool = True):
self._reload_catalog(first_load=True)
self._sync_install_deps()
self._sync_ai()
self._sync_offline()
# -- catalogue et recalcul ------------------------------------- #
def _entries(self):
@ -932,6 +1046,8 @@ def run_deploy_form(ctx, run_app: bool = True):
elif event.checkbox.id == "f_tool_aidev":
self._sync_ai()
self._recompute()
elif event.checkbox.id == "f_offline":
self._sync_offline()
elif str(event.checkbox.id or "").startswith("f_tool_"):
# Un IDE de plus, c'est un disque plus grand : le plan doit le
# montrer AVANT de déployer, pas après une heure d'installation.
@ -955,6 +1071,18 @@ def run_deploy_form(ctx, run_app: bool = True):
self._mount_rows()
def _form_values(self):
# La case n'existe que si le cache tourne : la chercher toujours
# ferait lever le formulaire là où il n'y a pas de cache,
# c'est-à-dire sur la plupart des hôtes.
offline = bool(
self.query("#f_offline") # type: ignore[union-attr]
and self.query_one("#f_offline", Checkbox).value
)
# Hors ligne, le suivi est exigé : seul son chemin confie la levée
# à une unité systemd. L'écran force déjà la case ; la valeur
# l'est aussi ici, pour qu'aucun état du widget ne mène à une
# coupure sans levée bornée.
monitor = offline or self.query_one("#f_monitor", Checkbox).value
install = None
if self.query_one("#f_install", Checkbox).value and profiles:
index = self.query_one("#f_profile_install", Select).value
@ -964,15 +1092,23 @@ def run_deploy_form(ctx, run_app: bool = True):
"prod": self.query_one("#f_prod", Checkbox).value,
"label": label,
"cmd": cmd,
"monitor": self.query_one("#f_monitor", Checkbox).value,
"monitor": monitor,
}
key = self.query_one("#f_key", Input).value.strip()
return {
# Le suivi est demandé au NIVEAU DU DÉPLOIEMENT, pas de
# l'installation : décocher ERPLibre emportait la case avec
# elle, et le tableau de bord ne s'ouvrait plus du tout.
"monitor": self.query_one("#f_monitor", Checkbox).value,
"monitor": monitor,
"gpu3d": self.query_one("#f_gpu3d", Checkbox).value,
# La case n'existe que si le cache tourne : la chercher
# toujours ferait lever le formulaire là où il n'y a pas de
# cache, c'est-à-dire sur la plupart des hôtes.
"cache_bypass": bool(
self.query("#f_cache_bypass") # type: ignore[union-attr]
and self.query_one("#f_cache_bypass", Checkbox).value
),
"offline": offline,
"ai_agent": self.query_one("#f_ai_agent", Select).value,
"git_name": self.query_one("#f_git_name", Input).value.strip(),
"git_email": self.query_one(
@ -1073,6 +1209,23 @@ def run_deploy_form(ctx, run_app: bool = True):
severity="warning",
)
return
# Hors ligne : ce que le cache ne détient pas, aucune VM ne
# pourra le lire. Dit MAINTENANT, et non après une heure
# d'installation qui échoue sur « Impossible de trouver le
# paquet ». F5 à nouveau vaut passage outre — le journal peut
# avoir tourné, ou le cache avoir été rempli autrement.
if spec.get("offline") and not getattr(
self, "_offline_ack", False
):
motifs = motifs_hors_ligne(spec["vms"])
if motifs:
self._offline_ack = True
self.notify(
" — ".join(motifs + [t("press F5 again to confirm")]),
severity="error",
timeout=20,
)
return
orphans = [r for r in self.rows if r["state"] == "orphan"]
if orphans and not getattr(self, "_orphan_ack", False):
# Un qcow2 orphelin fait échouer deploy_qemu : on prévient une

View file

@ -425,6 +425,8 @@ class QemuInstallMixin:
suivante trouvait le verrou pris, échouait jusqu'à sa borne, puis
installait sur un index jamais rafraîchi : « Impossible de trouver le
paquet », un message qui n'accuse personne."""
from script.qemu.deploy_qemu import cache_env_reload
return (
"if command -v cloud-init >/dev/null 2>&1; then "
'echo "== '
@ -443,6 +445,9 @@ class QemuInstallMixin:
"n=0; while systemctl is-active --quiet erplibre-qga 2>/dev/null; "
"do n=$((n+1)); [ $n -ge 150 ] && break; sleep 2; done; "
"fi; "
# Les variables du cache sont écrites par cloud-init PENDANT
# l'attente : cette session, ouverte avant, ne les a pas reçues.
+ cache_env_reload() + "; "
)
@staticmethod
@ -699,12 +704,18 @@ class QemuInstallMixin:
# tient, et dormir dix secondes entre deux essais coûte des
# minutes à ne rien faire. On repasse plus souvent, et on rend la
# main dès que le verrou se libère.
"n=0; until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do "
"n=$((n+1)); [ $n -ge 60 ] && "
# Bornée par le TEMPS, et non par un nombre d'essais. Un essai
# coûte moins d'une seconde quand le verrou est tenu, mais des
# MINUTES quand le cache répond 504 sur chaque index : soixante
# essais valaient alors des heures d'attente muette, là où on
# voulait cinq minutes.
"fin=$(( $(date +%s) + 300 )); "
"until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do "
'[ "$(date +%s)" -ge "$fin" ] && '
# Le dire ICI. Sans cette ligne, l'installation continue sur un
# index jamais rafraîchi et échoue plus bas sur « Impossible de
# trouver le paquet », qui accuse le dépôt et non le verrou.
f'{{ echo " ⚠ {t("apt-get update never succeeded: the lock stayed held")}"; '
f'{{ echo " ⚠ {t("apt-get update never succeeded in 5 min (lock held, or nothing served)")}"; '
"break; }; sleep 2; done; "
"sudo DEBIAN_FRONTEND=noninteractive "
"apt-get -o DPkg::Lock::Timeout=600 install -y "

View file

@ -22,14 +22,11 @@ import shutil
import socket
import subprocess
import time
from script.todo.qemu_privilege import (
LIBVIRT_URI as URI,
sudo_prefix,
virsh_argv,
)
from pathlib import Path
from script.todo.qemu_privilege import LIBVIRT_URI as URI
from script.todo.qemu_privilege import sudo_prefix, virsh_argv
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
@ -368,10 +365,27 @@ def _log_header(vm: dict, branch: str, when: str) -> str:
)
def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
def launch_installs(
vms: list[dict],
branch: str,
remote_cmd: str,
deploy_started: float | None = None,
hors_ligne: bool | None = None,
) -> str:
"""vms : [{name, ip, distro?, version?, arch?}]. Lance chaque install
détachée, écrit un manifeste et retourne son chemin. remote_cmd : script
exécuté dans chaque VM."""
exécuté dans chaque VM.
`deploy_started` : instant (epoch, secondes) où le déploiement a commencé,
AVANT la création des VM. Écrit tel quel sous « deploy_started », et
seulement s'il est donné : « started » n'est posé qu'ici, après le
premier démarrage des VM, et une fenêtre ouverte à « started » laisse
dehors ce que cloud-init et l'agent invité ont déjà demandé au cache.
`hors_ligne` : l'amont du cache était coupé pendant ce déploiement.
Écrit sous « offline », true ou false, et seulement s'il est donné : un
manifeste sans la clé ne dit pas s'il a tourné coupé, et le bilan hors
ligne le traite comme tel."""
sdir = session_dir()
stamp = time.strftime("%Y%m%d-%H%M%S")
when = time.strftime("%Y-%m-%d %H:%M:%S")
@ -423,6 +437,10 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
"started": time.time(),
"vms": entries,
}
if deploy_started is not None:
manifest["deploy_started"] = float(deploy_started)
if hors_ligne is not None:
manifest["offline"] = bool(hors_ligne)
manifest_path = str(logdir / "session.json")
Path(manifest_path).write_text(json.dumps(manifest, indent=2))
return manifest_path

View file

@ -13,6 +13,7 @@ import subprocess
import time
from script.todo import ssh_config, todo_install
from script.todo.qemu_cache_menu import bypass_menage
from script.todo.qemu_privilege import (
LIBVIRT_URI as URI,
sudo_prefix,
@ -2569,6 +2570,14 @@ class QemuManageMixin:
print(f" ⚠ {name} : {t('no disk file found for this VM')}")
print(f"\n▶ {name}: {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
# Une exception du cache survit à la VM qu'elle nommait, et une MAC
# libérée se réattribue : l'exception soustrairait alors au cache une
# machine neuve, sans que personne l'ait demandé et sans que rien ne le
# dise. Le ménage est fait ICI parce que c'est le seul endroit qui SAIT
# que la VM vient de disparaître.
retirees = bypass_menage(self.execute)
if retirees:
print(f" {t('Cache exceptions removed:')} {retirees}")
print(f"\n✅ {t('Deletion done.')}")
@staticmethod
@ -3311,8 +3320,163 @@ class QemuManageMixin:
]
return len(sujets), sujets
def _qemu_branch_gap_lines(self, branche, limite=3):
"""Les lignes à dire avant de déployer, ou []."""
def _qemu_miroir_erplibre(self):
"""Le miroir du cache qui sert le clone d'ERPLibre, ou "" s'il manque.
Même chemin que celui que le cache calcule : <racine>/<hôte>/<chemin
sans « .git »>.git. Absent sur un hôte sans cache, ou tant qu'aucune
VM n'a cloné ERPLibre à travers lui.
"""
from urllib.parse import urlparse
from script.todo.qemu_cache_menu import CACHE_MIROIR_GIT
url = urlparse(self.ERPLIBRE_GIT_URL)
chemin = url.path.strip("/")
if chemin.endswith(".git"):
chemin = chemin[: -len(".git")]
if not url.netloc or not chemin:
return ""
miroir = os.path.join(CACHE_MIROIR_GIT, url.netloc, chemin + ".git")
return miroir if os.path.isdir(miroir) else ""
def _qemu_ecart_miroir(self, branche):
"""Ce qu'une VM hors ligne recevra, face à ce que l'hôte connaît.
Hors ligne, le clone est servi par le miroir du cache TEL QUEL : son
rafraîchissement échoue, l'amont étant coupé. Ce que le miroir porte
est donc ce que la VM exécute, poussé ou non depuis.
Rend None quand on ne sait pas — pas de miroir, miroir illisible —
et l'appelant retombe alors sur l'écart avec origin. Sinon
(sha_miroir, sha_ici, nombre) : `sha_miroir` vide si le miroir n'a
pas la branche ; `sha_ici` celui de la branche locale, à défaut
d'origin/<branche>, vide si l'hôte n'a ni l'une ni l'autre ;
`nombre` les commits de `sha_ici` absents du miroir, -1 si
incalculable. Tout est lu, rien n'est écrit : le miroir appartient
au compte du service.
« ls-remote --exit-code » rend 2 quand aucune référence ne répond au
motif, et seul ce code vaut absence : une requête qui échoue autrement
rend None. Le motif se compare à la FIN des références, d'où la
recherche de la référence exacte dans ce qui revient.
"""
miroir = self._qemu_miroir_erplibre() if branche else ""
if not miroir:
return None
def lancer(*args):
try:
return subprocess.run(
["git", *args], capture_output=True, text=True, timeout=15
)
except (OSError, subprocess.SubprocessError):
return None
def git(*args):
res = lancer(*args)
if res is None or res.returncode:
return None
return (res.stdout or "").strip()
ref = f"refs/heads/{branche}"
vu = lancer("ls-remote", "--exit-code", miroir, ref)
if vu is None or vu.returncode not in (0, 2):
return None
sha_miroir = ""
if vu.returncode == 0:
for ligne in (vu.stdout or "").splitlines():
champs = ligne.split()
if len(champs) == 2 and champs[1] == ref:
sha_miroir = champs[0]
break
sha_ici = ""
for ref in (f"refs/heads/{branche}", f"refs/remotes/origin/{branche}"):
sha_ici = git("rev-parse", "--verify", "-q", f"{ref}^{{commit}}")
if sha_ici:
break
sha_ici = sha_ici or ""
nombre = -1
if sha_miroir and sha_ici and sha_miroir != sha_ici:
compte = git("rev-list", "--count", f"{sha_miroir}..{sha_ici}")
nombre = int(compte) if (compte or "").isdigit() else -1
return sha_miroir, sha_ici, nombre
@staticmethod
def _qemu_lignes_miroir(ecarts):
"""Les lignes à dire face au miroir, pour [(branche, écart)].
`écart` est ce que rend `_qemu_ecart_miroir`, None compris. Chaque
ligne nomme sa branche. Une branche absente du miroir a sa ligne ;
une branche à jour, ou dont on ne sait rien, n'en a aucune. Le
conseil sur « git push » ne vient qu'une fois, et seulement si le
miroir porte, pour une branche au moins, un autre commit que l'hôte.
"""
lignes, en_retard = [], False
for branche, ecart in ecarts:
if ecart is None:
continue
sha_miroir, sha_ici, nombre = ecart
if not sha_miroir:
lignes.append(
f"⚠ {t('The cache mirror has no branch')} {branche}: "
f"{t('an offline clone will fail.')}"
)
continue
if sha_miroir == sha_ici:
continue
en_retard = True
lignes.append(
f"⚠ {t('Offline, the VM clones the cache mirror of')} "
f"{branche}: {sha_miroir[:12]}"
)
if sha_ici:
lignes.append(
f" {t('this checkout has')} {sha_ici[:12]}"
+ (
f", {nombre} {t('commit(s) missing from the mirror')}"
if nombre > 0
else ""
)
)
if en_retard:
lignes.append(
f" → {t('A push alone changes nothing: the mirror is')}"
)
lignes.append(
f" {t('refreshed only when a VM clones it online.')}"
)
return lignes
def _qemu_branch_gap_lines(self, branche, limite=3, hors_ligne=False):
"""Les lignes à dire avant de déployer, ou [].
`branche` : un nom, ou la collection des branches que les VM
clonent réellement. Plusieurs branches ne sont comparées qu'au
miroir, une par une, chaque ligne nommant la sienne : l'écart avec
origin se mesure contre HEAD, qui n'est qu'une seule branche, et lui
attribuerait les commits d'une autre. Une branche dont le miroir ne
dit rien reste alors muette.
`hors_ligne` : la VM clonera depuis le miroir du cache. L'écart qui
compte est alors celui du miroir, et « git push » seul n'y change
rien : le miroir ne se rafraîchit que quand une VM le clone amont
branché.
"""
if isinstance(branche, str):
branches = [branche]
else:
branches = sorted({b for b in branche if b})
if len(branches) != 1:
if not hors_ligne:
return []
return self._qemu_lignes_miroir(
[(b, self._qemu_ecart_miroir(b)) for b in branches]
)
branche = branches[0]
ecart = self._qemu_ecart_miroir(branche) if hors_ligne else None
if ecart is not None:
return self._qemu_lignes_miroir([(branche, ecart)])
nombre, sujets = self._qemu_branch_gap(branche)
if not nombre:
return []

View file

@ -33,6 +33,7 @@ from script.todo.kdbx_manager import KdbxManager
from script.todo.longtest_menu import LongTestMenuMixin
from script.todo.proxmox_menu import ProxmoxMenuMixin
from script.todo.qemu_access import QemuAccessMixin
from script.todo.qemu_cache_menu import QemuCacheMenuMixin
from script.todo.qemu_deploy import QemuDeployMixin
from script.todo.qemu_install import QemuInstallMixin
from script.todo.qemu_manage import QemuManageMixin
@ -96,6 +97,7 @@ class TODO(
# L'ordre est celui de la lecture, pas de la résolution : aucun nom n'est
# défini deux fois (une classe unique jusqu'ici), donc aucune priorité à
# arbitrer. Chaque fichier porte un sujet, et son en-tête dit sa frontière.
QemuCacheMenuMixin,
QemuMenuMixin,
QemuDeployMixin,
QemuInstallMixin,
@ -606,6 +608,13 @@ class TODO(
"prompt_execute_update": "Update",
"prompt_execute_deploy": "Deploy",
"prompt_execute_deploy_ssh": "SSH",
"prompt_execute_qemu_cache": "QEMU cache",
"_cache_service": "Service",
"_cache_tests": "Tests",
"_cache_age": "Age and cleanup",
"_cache_exceptions": "Exceptions",
"_cache_journaux": "Logs",
"_cache_miroir_git": "Git mirrors",
"prompt_execute_qemu": "QEMU/KVM",
"prompt_execute_proxmox": "Proxmox VE",
"prompt_execute_vpn": "VPN",
@ -971,6 +980,11 @@ class TODO(
"Deploy - Install NTFY notification server"
)
},
{
"prompt_description": t(
"QEMU cache - Download mirror for local VMs"
)
},
{"section": t("VPN & tunnels")},
{
"prompt_description": t(
@ -1000,6 +1014,8 @@ class TODO(
elif status == "7":
self._deploy_ntfy_server()
elif status == "8":
self.prompt_execute_qemu_cache()
elif status == "9":
self.prompt_execute_vpn()
else:
print(t("Command not found !"))
@ -1756,6 +1772,92 @@ class TODO(
except Exception as e:
print(f"{t('Error installing NTFY server: ')}{e}")
def _deploy_qemu_cache(self):
"""Pose le miroir de téléchargement partagé par les VM QEMU de l'hôte.
Idempotente : l'installateur recompile, réécrit l'unité et redémarre le
service, qu'il existe déjà ou non.
Ce que l'entrée ANNONCE avant de demander sudo, et pourquoi : elle fait
écrire des règles sur le pont de l'hôte. Une règle trop large y prive la
machine de son propre réseau, et l'invite de sudo tombe entre deux
lignes de journal sans dire ce qu'elle sert à faire."""
print(
f"\n{t('Install the download cache shared by the QEMU VMs of this host')}"
)
http_port = (
input(t("HTTP port of the cache (default: 8898): ")).strip()
or "8898"
)
tls_port = (
input(t("TLS port of the cache (default: 8899): ")).strip()
or "8899"
)
cache_dir = (
input(
t(
"Cache directory (default: /var/cache/erplibre_go_qemu_cache): "
)
).strip()
or "/var/cache/erplibre_go_qemu_cache"
)
script_path = os.path.realpath(
os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"install",
"install_qemu_cache.sh",
)
)
if not os.path.isfile(script_path):
print(f"{t('QEMU cache install script not found: ')}{script_path}")
return
print(f"\n{t('Will write network rules on the host bridge:')}")
print(f" {t('Only what leaves the VM subnet is redirected')}")
print(f" {t('The rules exist only while the service runs')}")
if not shutil.which("go"):
print(f" {t('Go is absent; the installer lays it down')}")
print(
f" {t('No eviction is written: this cache never shrinks by itself')}"
)
cmd = (
f"sudo EL_HTTP_PORT={http_port}"
f" EL_TLS_PORT={tls_port}"
f" EL_CACHE_DIR={cache_dir}"
f" bash {script_path}"
)
print(f"\n{t('Will execute:')} {cmd}\n")
if not click.confirm(t("Install the QEMU download cache?")):
return
print(
f"\n{t('Installing the QEMU download cache (requires sudo)...')}"
)
try:
code = self.execute.exec_command_live(cmd, source_erplibre=False)
except Exception as e:
print(f"{t('The cache install failed, nothing is started')} : {e}")
return
# Le CODE de sortie, et pas seulement l'absence d'exception. Un
# installateur qui meurt — réseau libvirt absent, compilation qui
# cède — rend un code non nul sans rien lever, et l'entrée annonçait
# « installé et démarré » au-dessus de son propre message d'erreur.
# Rien n'est plus coûteux qu'un succès annoncé à tort : on cherche
# ensuite la panne partout sauf là où elle est.
if code:
print(f"\n ✗ {t('The cache install failed, nothing is started')}")
print(f" {t('Read the error above, fix it, and run entry 1.')}")
return
print(f"\n{t('QEMU download cache installed and started')}")
print(
f"{t('Certificate authority a VM must trust: ')}"
"/var/lib/erplibre_go_qemu_cache/ca.crt"
)
@staticmethod
def _ssh_config_hosts():
"""Noms d'hôtes déclarés dans ~/.ssh/config, dans l'ordre du fichier.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Une clé de traduction en double écrase la précédente, sans un mot.
Python construit un dictionnaire littéral de haut en bas : la dernière
occurrence gagne. Une clé posée deux fois avec des valeurs différentes fait
donc qu'un menu affiche la traduction d'un autre, et rien ne le signale — ni
au chargement, ni à l'exécution, ni au test qui exerce le premier menu.
C'est arrivé : « running » et « stopped » existaient déjà, et des clés
homonymes ajoutées pour un service ont été avalées par elles. Le symptôme
était un « Service : en cours » là où le fichier disait « actif ».
Le contrôle lit le fichier par son ARBRE et non par une expression
régulière : la mienne n'attrapait que la forme multiligne, et la collision
était écrite sur une seule ligne.
"""
import ast
import collections
import sys
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
I18N = RACINE / "script" / "todo" / "todo_i18n.py"
sys.path.insert(0, str(RACINE / "script" / "todo"))
def cles_litterales():
"""Les clés du dictionnaire TRANSLATIONS, dans l'ordre du fichier.
Par l'arbre syntaxique : une clé écrite sur une ligne et une clé écrite
sur plusieurs sont le même nœud, alors qu'elles n'ont pas la même forme
dans le texte.
"""
arbre = ast.parse(I18N.read_text(encoding="utf-8"))
for noeud in ast.walk(arbre):
if not isinstance(noeud, ast.Assign):
continue
cibles = [c.id for c in noeud.targets if isinstance(c, ast.Name)]
if "TRANSLATIONS" not in cibles:
continue
if not isinstance(noeud.value, ast.Dict):
continue
return [
k.value
for k in noeud.value.keys
if isinstance(k, ast.Constant) and isinstance(k.value, str)
]
raise AssertionError("TRANSLATIONS introuvable")
class TestClesUniques(unittest.TestCase):
def test_aucune_cle_nest_ecrite_deux_fois(self):
compte = collections.Counter(cles_litterales())
doubles = sorted(k for k, n in compte.items() if n > 1)
self.assertEqual(
doubles,
[],
"clés en double — la dernière écrase les précédentes, sans un "
f"mot : {doubles}",
)
def test_le_controle_lit_bien_quelque_chose(self):
"""Un test qui ne trouve aucune clé passerait toujours."""
self.assertGreater(len(cles_litterales()), 1000)
def test_chaque_cle_porte_les_deux_langues(self):
import todo_i18n
manques = [
k
for k, v in todo_i18n.TRANSLATIONS.items()
if not isinstance(v, dict) or "fr" not in v or "en" not in v
]
self.assertEqual(manques, [], f"traductions incomplètes : {manques}")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce qui est téléchargé puis EXÉCUTÉ doit d'abord avoir été obtenu.
« curl … | bash » est l'idiome de la moitié des installateurs. Sans « -f »,
curl rend 0 sur une erreur HTTP et livre le CORPS de l'erreur à bash, qui
l'exécute : une page de miroir en panne, un portail captif ou le 504 d'un
cache hors ligne devient une suite de commandes. Le lecteur reçoit alors
« command not found » et la cause véritable ne se lit plus nulle part.
Le contrôle porte sur la PROPRIÉTÉ et non sur un fichier : tout script du
dépôt qui tube un téléchargement dans un interpréteur doit demander à curl
d'échouer.
"""
import re
import sys
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE))
# « curl » suivi de ce qui n'est pas une nouvelle commande, jusqu'à un tube
# vers un interpréteur. Le « \\ » de continuation de ligne est traversé.
TUBE = re.compile(r"curl\s+((?:[^\n|;&]|\\\n)*?)\|\s*(?:sudo\s+)?(?:ba)?sh\b")
def demande_lechec(options: str) -> bool:
"""« -f » y est-il demandé, sous l'une de ses formes ?
Les options courtes se GROUPENT : « -sSf » vaut « -s -S -f », et
chercher le seul jeton « -f » manquerait la moitié des appels du dépôt.
"""
for mot in options.split():
if mot == "--fail" or mot.startswith("--fail-"):
return True
if mot.startswith("-") and not mot.startswith("--") and "f" in mot[1:]:
return True
return False
def scripts():
for chemin in sorted(RACINE.glob("script/**/*.sh")):
yield chemin, chemin.read_text(encoding="utf-8", errors="replace")
class TestUnTelechargementTubeDansUnShell(unittest.TestCase):
def test_curl_doit_echouer_sur_une_erreur_http(self):
fautifs = []
for chemin, src in scripts():
for options in TUBE.findall(src):
if not demande_lechec(options):
fautifs.append(
f"{chemin.relative_to(RACINE)} : curl {options.strip()}"
)
self.assertEqual(
fautifs,
[],
"le corps d'une erreur HTTP y serait exécuté :\n "
+ "\n ".join(fautifs),
)
def test_les_options_groupees_sont_reconnues(self):
"""« -sSf » vaut « -s -S -f » : les traiter comme un seul jeton
signalerait à tort la moitié des appels du dépôt."""
for bon in ("-fsSL", "-sSf", "-f", "--fail", "--fail-with-body"):
self.assertTrue(demande_lechec(f"{bon} https://x"), bon)
for mauvais in ("-L", "-sSL", "", "--silent"):
self.assertFalse(demande_lechec(f"{mauvais} https://x"), mauvais)
def test_le_motif_trouve_bien_les_tubes_existants(self):
"""Une expression qui ne trouve rien passerait tous les contrôles.
Le dépôt en porte plusieurs : si ce compte tombe à zéro, c'est
l'expression qu'il faut relire, pas le dépôt qu'il faut féliciter.
"""
trouves = sum(len(TUBE.findall(src)) for _c, src in scripts())
self.assertGreaterEqual(trouves, 3, f"seulement {trouves} trouvés")
if __name__ == "__main__":
unittest.main()

View file

@ -132,6 +132,23 @@ class TestSpec(unittest.TestCase):
self.assertNotEqual(res_label("custom"), "xcustom")
def setUpModule():
"""L'attente ssh ne part JAMAIS pour de vrai depuis les tests.
Le déploiement attend qu'une VM fraîche réponde en ssh avant de lui poser
le guide, le fuseau, le miroir et l'autorité — une adresse n'est pas une
machine prête. Les harnais d'ici remplacent chaque geste distant mais
appellent le vrai `_pve_ssh` : sans ce remplacement, chacun tenterait une
connexion vers un alias inventé et la suite unitaire y perdrait des
minutes. Les tests de l'attente elle-même la rappellent sur place.
"""
from script.todo.todo import TODO
patch = mock.patch.object(TODO, "_pve_attendre_ssh", lambda *a, **k: True)
patch.start()
unittest.addModuleCleanup(patch.stop)
def contexte():
def entree(distro, version, arch="amd64"):
return {
@ -172,6 +189,9 @@ def contexte():
},
"bridges": ["vmbr0"],
"bridge": "vmbr0",
# L'hôte d'essai est une VM de notre pont : la case « Sans connexion
# internet » est donc offerte, comme sur un Proxmox imbriqué.
"cache_offert": True,
"ipconfig": lambda pont, vmid: f"ip=10.10.10.{50 + vmid % 200}/24",
"build_command": lambda vm, spec: [f"qm create {vm['vmid']}"],
"branches": ["develop", "master"],
@ -598,6 +618,477 @@ class TestDeuxVmDuMemeNom(unittest.TestCase):
)
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestLePreVolDuCacheSurProxmox(unittest.TestCase):
"""Ce que le cache ne détient pas, aucune VM coupée ne le lira.
Le formulaire libvirt le dit depuis toujours ; celui de Proxmox partait
sans rien vérifier, et l'échec tombait une heure plus tard, à la pose du
bureau — un message qui accuse le dépôt, jamais le cache. F5 à nouveau
vaut passage outre : le journal peut avoir tourné, ou le magasin avoir
été rempli autrement.
"""
def _deployer(self, absentes=(), hors_ligne=True):
"""Deux F5 d'affilée. Rend ce que la spec valait après chacun."""
import asyncio
from unittest import mock
from script.qemu import cache_offline
ctx = contexte()
vu = {}
async def scenario():
from textual.widgets import Checkbox, SelectionList
app = run_proxmox_form(ctx, run_app=False)
async with app.run_test(size=(200, 50)) as pilote:
await pilote.pause()
liste = app.query_one(SelectionList)
liste.select(liste.get_option_at_index(0).value)
await pilote.pause()
if hors_ligne:
app.query_one("#f_offline", Checkbox).value = True
await pilote.pause()
app.action_deploy()
vu["premier"] = getattr(app, "result", None)
app.action_deploy()
vu["second"] = getattr(app, "result", None)
with mock.patch.object(
cache_offline, "suites_absentes", return_value=list(absentes)
), mock.patch.object(
cache_offline, "composants_absents", return_value=[]
), mock.patch.object(
cache_offline, "manques_hors_ligne", return_value=[]
), mock.patch.object(
cache_offline, "paquets_absents", return_value=[]
), mock.patch.object(
cache_offline, "miroirs_absents", return_value=[]
):
asyncio.run(scenario())
return vu
def test_le_premier_f5_avertit_au_lieu_de_partir(self):
vu = self._deployer(absentes=[("ubuntu", "26.04")])
self.assertFalse(vu["premier"], "parti sans rien dire du manque")
self.assertTrue(vu["second"], "le second F5 ne passe pas outre")
def test_un_magasin_complet_ne_retarde_personne(self):
"""Un avertissement qui tombe quand rien ne manque s'apprend par
cœur, et c'est ainsi qu'on cesse de le lire."""
vu = self._deployer(absentes=[])
self.assertTrue(vu["premier"], "avertissement sans manque")
def test_en_ligne_le_pre_vol_ne_se_pose_pas(self):
"""Le cache n'est qu'un raccourci tant que l'amont répond : ce qui
lui manque se télécharge, et rien n'échoue."""
vu = self._deployer(absentes=[("ubuntu", "26.04")], hors_ligne=False)
self.assertTrue(vu["premier"], "le pré-vol s'est posé hors coupure")
class TestLeHorsLigneSurProxmox(unittest.TestCase):
"""La coupure d'amont, offerte sur Proxmox VE comme sur QEMU/KVM.
Elle est posée ICI, sur le pont local, et jamais sur l'hôte distant : un
hôte Proxmox qui reçoit l'autorité du cache est une VM de ce pont, et ses
invités sortent derrière son adresse. Un hôte qui ne vit pas ici ne doit
donc PAS se voir offrir la case — rien ici ne sait couper sa sortie, et
une VM qui s'y bâtirait réussirait en ligne sous une promesse de
hors-ligne.
"""
def _todo(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
todo._write_ssh_config_entry = lambda *a, **k: None
todo._ssh_private_key = lambda k: None
todo._ssh_config_block = lambda nom: {}
todo._pve_guest_ip = lambda vmid, attente=120: ""
todo._pve_write_guide = lambda *a, **k: True
todo._pve_set_timezone = lambda *a, **k: True
todo._qemu_import_module = lambda: None
return todo
def test_la_case_ne_sort_que_pour_un_hote_qui_vit_ici(self):
"""C'est le même verdict que celui de l'autorité du cache : là où
elle est posée, le trafic traverse notre pont."""
todo = self._todo()
todo._qemu_cache_ca_path = lambda: "/var/lib/cache/ca.crt"
todo._qemu_list_domains = lambda: ["pve-imbrique"]
self.assertTrue(
todo._pve_cache_ca({"target": "erplibre@pve-imbrique"})
)
self.assertFalse(todo._pve_cache_ca({"target": "erplibre@ailleurs"}))
def test_le_contexte_offre_la_case_selon_ce_verdict(self):
from pathlib import Path
racine = Path(__file__).resolve().parent.parent
src = (racine / "script" / "todo" / "proxmox_menu.py").read_text(
encoding="utf-8"
)
self.assertIn('"cache_offert": bool(self._pve_cache_ca(host))', src)
def _capture(self, **kw):
"""Ce que l'installateur reçoit, la coupure tenue ou non."""
import contextlib
import io
todo = self._todo()
vu = {}
todo._qemu_install_erplibre_monitored = lambda *a, **k: vu.update(k)
spec = {
"host": {"target": "pve1"},
"vms": [
{
"name": "vm-a",
"vmid": 100,
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
"install_cmd": "",
}
],
"user": "erplibre",
"add_ssh_config": True,
"install": {"branch": "develop", "cmd": "make", "label": "X"},
"monitor": True,
}
todo._qemu_list_domains = lambda: []
with contextlib.redirect_stdout(io.StringIO()):
todo._pve_after_create(spec["host"], spec, ["vm-a"], "", **kw)
return vu
def test_la_coupure_tenue_atteint_linstallateur(self):
"""Le guet reçoit la levée, et le manifeste porte la coupure : sans
cela, la coupure tomberait avec ce processus — avant la fin de ce qui
télécharge — et le bilan hors ligne ne saurait pas quoi relire."""
vu = self._capture(coupee=True, debut=1789000000.0)
self.assertTrue(vu["guet_hors_ligne"])
self.assertTrue(vu["hors_ligne"])
self.assertEqual(vu["deploy_started"], 1789000000.0)
def test_sans_coupure_rien_nest_promis(self):
vu = self._capture()
self.assertFalse(vu["guet_hors_ligne"])
self.assertFalse(vu["hors_ligne"])
def test_le_deploiement_passe_par_lenveloppe(self):
"""La spec ENTIÈRE tient dans le bloc coupé, création comprise : une
coupure levée avant l'installation ne prouverait rien."""
import contextlib
todo = self._todo()
vu = {}
@contextlib.contextmanager
def fausse_coupure(actif):
vu["demandee"] = actif
yield actif
todo._qemu_sans_internet = fausse_coupure
todo._pve_deploy_spec = lambda *a, **k: vu.setdefault(
"coupee", k.get("coupee")
)
todo._pve_run_spec({"target": "pve1"}, {"offline": True}, None)
self.assertTrue(vu["demandee"], "la coupure n'a pas été demandée")
self.assertTrue(vu["coupee"], "le déploiement ignore la coupure")
def test_une_coupure_impossible_ne_deploie_rien(self):
"""Le refus vient de la coupure elle-même — amont debout, verrou pris,
dnsmasq absent. Déployer quand même bâtirait une VM en ligne sous une
promesse de hors-ligne."""
import contextlib
from script.todo.qemu_deploy import _SansInternetImpossible
todo = self._todo()
vu = {}
@contextlib.contextmanager
def refus(actif):
raise _SansInternetImpossible()
yield # pragma: no cover - jamais atteint
todo._qemu_sans_internet = refus
todo._pve_deploy_spec = lambda *a, **k: vu.setdefault("parti", True)
self.assertIsNone(
todo._pve_run_spec({"target": "pve1"}, {"offline": True}, None)
)
self.assertNotIn("parti", vu)
def test_la_case_atteint_la_spec(self):
form = {
"host": {"target": "pve1"},
"storage": "local-lvm",
"bridge": "vmbr0",
"res_label": "x1",
"ssh_key": "",
"start": True,
"add_ssh_config": True,
"install": None,
"monitor": True,
"parallelism": 1,
"offline": True,
}
self.assertTrue(build_spec([{"name": "a"}], [], form)["offline"])
form["offline"] = False
self.assertFalse(build_spec([{"name": "a"}], [], form)["offline"])
def _ecran(self, cache_offert, cocher=False):
"""Monte l'écran avec — ou sans — le cache offert, et relève l'état
DANS le contexte : `run_test` démonte les widgets en sortant."""
from textual.widgets import Checkbox
ctx = contexte()
ctx["cache_offert"] = cache_offert
vu = {}
async def scenario():
app = run_proxmox_form(ctx, run_app=False)
async with app.run_test(size=(200, 50)) as pilote:
await pilote.pause()
cases = app.query("#f_offline")
vu["offerte"] = bool(cases)
if cocher and cases:
cases.first(Checkbox).value = True
await pilote.pause()
suivi = app.query_one("#f_monitor", Checkbox)
vu["suivi"] = suivi.value
vu["suivi_fige"] = suivi.disabled
vu["avertissement"] = [
w.display for w in app.query("#t_offline_w1")
]
asyncio.run(scenario())
return vu
def test_sans_cache_aucune_case(self):
"""Une case sans effet est pire que pas de case : elle promet."""
self.assertFalse(self._ecran(False)["offerte"])
def test_avec_le_cache_la_case_est_la_et_muette_tant_quon_ny_touche_pas(
self,
):
vu = self._ecran(True)
self.assertTrue(vu["offerte"])
self.assertEqual(vu["avertissement"], [False])
self.assertFalse(vu["suivi_fige"])
def test_cocher_decouvre_lavertissement_et_fige_le_suivi(self):
"""Seule la voie suivie confie la levée au guet : sans suivi, la
coupure tomberait avec le tableau de bord."""
vu = self._ecran(True, cocher=True)
self.assertEqual(vu["avertissement"], [True])
self.assertTrue(vu["suivi"])
self.assertTrue(vu["suivi_fige"])
class TestLeMiroirAptDesVmProxmox(unittest.TestCase):
"""Une VM Proxmox tire du miroir que le cache a rempli.
Le magasin range ses index sous l'HÔTE demandé. Une VM qui réclame
« archive.ubuntu.com » ne retrouve donc rien de ce qu'une autre a gardé
depuis un miroir : hors ligne, chacun de ces index manque, et
l'installation échoue plus bas sur des dépendances introuvables — un
message qui accuse le dépôt, jamais le miroir.
"""
def _todo(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)
class _Mod:
APT_MIRRORS_MAIN = [
"http://miroir.invalid/ubuntu",
"http://second.invalid/ubuntu",
]
APT_MIRRORS_PORTS = ["http://miroir.invalid/ubuntu-ports"]
PORTS_ARCHES = ("arm64", "s390x")
def _poser(self, vm, code=0):
import contextlib
import io
todo = self._todo()
vu = {}
def faux_ssh(cible, cmd, timeout=120):
vu["cible"], vu["cmd"] = cible, cmd
return code, ""
todo._pve_ssh = faux_ssh
with contextlib.redirect_stdout(io.StringIO()) as sortie:
vu["rendu"] = todo._pve_set_apt_mirror("pve+vm-a", vm, self._Mod)
vu["ecrit"] = sortie.getvalue()
return vu
def test_le_premier_miroir_remplace_les_depots_officiels(self):
vu = self._poser({"distro": "ubuntu", "arch": "amd64"})
self.assertTrue(vu["rendu"])
self.assertIn("miroir.invalid/ubuntu", vu["cmd"])
self.assertIn("archive|security", vu["cmd"])
self.assertNotIn("second.invalid", vu["cmd"])
def test_les_deux_formats_de_sources_sont_couverts(self):
"""Le « .sources » deb822 des images récentes, et le
« sources.list » des anciennes : n'en réécrire qu'un laisse l'autre
pointer ailleurs."""
cmd = self._poser({"distro": "ubuntu", "arch": "amd64"})["cmd"]
self.assertIn("/etc/apt/sources.list ", cmd)
self.assertIn("sources.list.d/*.sources", cmd)
self.assertIn("sources.list.d/*.list", cmd)
def test_une_arche_ports_prend_son_propre_miroir(self):
"""Les arches « ports » ne sont pas sur archive.ubuntu.com, et amd64
n'est pas sur ports.ubuntu.com."""
cmd = self._poser({"distro": "ubuntu", "arch": "arm64"})["cmd"]
# Le motif est une EXPRESSION : ses points sont échappés, sans quoi
# ils vaudraient « n'importe quel caractère ».
self.assertIn(r"ports\.ubuntu\.com/ubuntu-ports", cmd)
self.assertIn("miroir.invalid/ubuntu-ports", cmd)
def test_les_autres_distributions_sont_laissees_tranquilles(self):
"""Debian, Fedora et Arch ont leurs propres dépôts : y réécrire une
URI ubuntu ne viserait rien."""
todo = self._todo()
todo._pve_ssh = lambda *a, **k: self.fail("ssh lancé pour rien")
self.assertFalse(
todo._pve_set_apt_mirror(
"pve+vm-a", {"distro": "debian", "arch": "amd64"}, self._Mod
)
)
def test_un_echec_est_dit(self):
vu = self._poser({"distro": "ubuntu", "arch": "amd64"}, code=255)
self.assertFalse(vu["rendu"])
self.assertIn("255", vu["ecrit"])
def test_le_miroir_est_pose_avant_lautorite_du_cache(self):
"""L'ordre est le sujet : l'autorité sert aux téléchargements, et le
miroir décide OÙ ils vont. Posé après, il ne vaudrait que pour ce qui
reste à venir."""
from pathlib import Path
racine = Path(__file__).resolve().parent.parent
src = (racine / "script" / "todo" / "proxmox_menu.py").read_text(
encoding="utf-8"
)
self.assertLess(
src.index('self._pve_set_apt_mirror(vm["alias"]'),
src.index('self._pve_set_cache_ca(vm["alias"]'),
)
class TestLAttenteAvantLesGestesDansLInvite(unittest.TestCase):
"""Une adresse n'est pas une machine prête.
Vécu : une VM Proxmox est née sans guide, en UTC, sans l'autorité du
cache et sur le miroir de son image. Les quatre gestes passent tous par
ssh et partaient dès l'adresse connue, pendant que cloud-init posait
encore les comptes et les clés. Ils échouaient donc ENSEMBLE, et la panne
ressemblait à quatre pannes sans lien.
"""
def _todo(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)
@property
def attendre(self):
"""La VRAIE attente : le module de test la remplace partout
ailleurs, et c'est elle qu'on éprouve ici."""
from script.todo.proxmox_menu import ProxmoxMenuMixin
return ProxmoxMenuMixin._pve_attendre_ssh
def test_elle_rend_vrai_des_que_le_ssh_repond(self):
todo = self._todo()
essais = []
todo._pve_ssh = lambda c, cmd, timeout=120: (
essais.append(cmd),
(0, ""),
)[1]
self.assertTrue(self.attendre(todo, "pve+vm-a"))
self.assertEqual(essais, ["true"], "une seule sonde suffit")
def test_elle_rend_faux_au_bout_du_delai(self):
"""Bornée par le TEMPS : un essai coûte le délai de connexion de ssh,
que rien ici ne borne à l'avance."""
import contextlib
import io
from script.todo import proxmox_menu
todo = self._todo()
todo._pve_ssh = lambda c, cmd, timeout=120: (255, "")
horloge = iter([0, 0, 5, 10, 15, 20, 25, 30, 35, 40])
with mock.patch.object(proxmox_menu.time, "sleep", lambda _s: None):
with mock.patch.object(
proxmox_menu.time, "time", lambda: next(horloge)
):
with contextlib.redirect_stdout(io.StringIO()) as sortie:
rendu = self.attendre(todo, "pve+vm-a", delai=20)
self.assertFalse(rendu)
self.assertIn("ssh", sortie.getvalue())
def test_sans_reponse_les_gestes_sont_sautes_et_dits(self):
"""Quatre échecs silencieux valent moins qu'un refus qui se nomme."""
import contextlib
import io
todo = self._todo()
faits = []
todo._write_ssh_config_entry = lambda *a, **k: None
todo._ssh_private_key = lambda k: None
todo._qemu_list_domains = lambda: []
todo._pve_guest_ip = lambda vmid, attente=120: ""
todo._qemu_import_module = lambda: None
todo._pve_attendre_ssh = lambda *a, **k: False
for nom in (
"_pve_write_guide",
"_pve_set_timezone",
"_pve_set_apt_mirror",
"_pve_set_cache_ca",
):
setattr(
todo, nom, (lambda n: lambda *a, **k: faits.append(n))(nom)
)
todo._qemu_install_erplibre_monitored = lambda *a, **k: None
spec = {
"host": {"target": "pve1"},
"vms": [
{
"name": "vm-a",
"vmid": 100,
"ipconfig": "ip=10.0.0.2/24,gw=10.0.0.1",
"distro": "ubuntu",
"arch": "amd64",
}
],
"user": "erplibre",
"add_ssh_config": True,
"install": None,
"monitor": False,
}
with contextlib.redirect_stdout(io.StringIO()) as sortie:
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
self.assertEqual(faits, [], f"des gestes sont partis : {faits}")
self.assertIn("ssh", sortie.getvalue())
class TestUnParcMixte(unittest.TestCase):
"""Le plan porte branche, profil et type PAR RANGÉE — le déploiement
lisait encore la seule valeur commune.
@ -1524,5 +2015,223 @@ class TestLeSuivi(unittest.TestCase):
self.assertIn("tableau", vus)
class TestLAutoriteDuCacheDansUneVmImbriquee(unittest.TestCase):
"""Une VM née sur un Proxmox imbriqué est interceptée sans le savoir.
Le cache détourne tout ce qui sort de son pont. Un hôte Proxmox qui est
lui-même une VM d'ici y est branché, et les machines qu'il porte sortent
derrière son adresse : elles traversent donc le cache, alors que rien à
l'intérieur ne leur a donné son autorité. Le mode de défaillance est
trompeur — un dépôt apt en clair passe, si bien que l'installation
démarre, et seuls les téléchargements HTTPS échouent, sur « self-signed
certificate in certificate chain ».
« qm set » ne sait écrire aucun fichier : l'autorité part par ssh, depuis
la MÊME source que la voie libvirt (`cache_files`, `cache_commands`).
"""
def _todo(self, domaines=("pve-local",), ca="/tmp/ca.crt"):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
todo._qemu_list_domains = lambda: list(domaines)
todo._qemu_cache_ca_path = classmethod(lambda cls: ca).__get__(
todo, type(todo)
)
return todo
# -- Qui est concerné ----------------------------------------------
def test_a_nested_proxmox_host_gets_the_authority(self):
todo = self._todo()
self.assertEqual(
todo._pve_cache_ca({"target": "root@pve-local"}),
"/tmp/ca.crt",
)
def test_a_proxmox_host_that_lives_elsewhere_gets_nothing(self):
"""Son trafic ne traverse pas ce pont : l'autorité n'y servirait à
rien, et le cache doit s'installer sur cet hôte-là."""
todo = self._todo()
self.assertEqual(todo._pve_cache_ca({"target": "root@10.0.0.5"}), "")
def test_no_cache_installed_here_means_no_authority(self):
todo = self._todo(ca="")
self.assertEqual(todo._pve_cache_ca({"target": "pve-local"}), "")
# -- Ce qui est réellement posé ------------------------------------
def _pose(self, distro="ubuntu", cache_files=None):
import contextlib
import io
import tempfile
todo = self._todo()
vus = {}
todo._pve_ssh = lambda cible, remote, timeout=60: (
vus.update(cible=cible, remote=remote, timeout=timeout) or (0, "")
)
mod = todo._qemu_import_module()
with tempfile.NamedTemporaryFile(
"w", suffix=".crt", delete=False
) as fh:
fh.write("-----BEGIN CERTIFICATE-----\nZm F1eA==\n")
fh.write("-----END CERTIFICATE-----\n")
ca = fh.name
patch = (
mock.patch.object(mod, "cache_files", cache_files)
if cache_files
else contextlib.nullcontext()
)
with contextlib.redirect_stdout(io.StringIO()), patch:
vus["ok"] = todo._pve_set_cache_ca(
"hote+vm-a", {"name": "vm-a", "distro": distro}, ca
)
return vus
def test_the_authority_goes_where_the_family_reads_it(self):
vus = self._pose()
self.assertTrue(vus["ok"])
# Par l'ALIAS : lui seul porte le rebond vers le réseau interne.
self.assertEqual(vus["cible"], "hote+vm-a")
self.assertIn(
"/usr/local/share/ca-certificates/erplibre-cache.crt",
vus["remote"],
)
def test_arch_does_not_get_the_debian_path(self):
vus = self._pose(distro="arch")
self.assertIn(
"/etc/ca-certificates/trust-source/anchors", vus["remote"]
)
self.assertNotIn("/usr/local/share/ca-certificates", vus["remote"])
def test_the_store_is_reread_then_the_variables_are_written(self):
"""Dans cet ordre : les variables visent le faisceau que la commande
de confiance vient de régénérer."""
vus = self._pose()
confiance = vus["remote"].index("update-ca-certificates")
for var in ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"):
self.assertLess(confiance, vus["remote"].index(var), var)
def test_the_content_comes_from_cache_files_and_is_not_rebuilt_here(self):
"""Une seule source pour les deux voies de livraison : ce que
cloud-init écrirait est ce que ssh pose."""
vus = self._pose(
cache_files=lambda args: [
("/etc/anchors/temoin.crt", "0644", "PEM-TEMOIN", "")
]
)
self.assertIn("/etc/anchors/temoin.crt", vus["remote"])
self.assertIn("PEM-TEMOIN", vus["remote"])
def test_a_distro_out_of_the_table_poses_nothing(self):
"""Le fichier au mauvais endroit ne servirait à rien sans rien
dire ; la VM télécharge en direct, ce qui marche."""
vus = self._pose(distro="plan9")
self.assertFalse(vus["ok"])
self.assertNotIn("remote", vus)
# -- Le câblage ----------------------------------------------------
def test_the_authority_is_posed_before_the_install(self):
"""Le contrôle porte sur l'ORDRE : c'est l'installation qui
télécharge, et un magasin relu ensuite ne rattrape rien."""
import contextlib
import io
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
ordre = []
todo = TODO.__new__(TODO)
todo._write_ssh_config_entry = lambda *a, **k: None
todo._ssh_private_key = lambda k: None
todo._pve_alias_perime = lambda *a, **k: []
todo._qemu_list_domains = lambda: ["pve-local"]
todo._qemu_cache_ca_path = classmethod(
lambda cls: "/tmp/ca.crt"
).__get__(todo, type(todo))
todo._pve_guest_ip = lambda vmid, attente=120: ""
todo._pve_write_guide = lambda *a, **k: True
todo._pve_set_timezone = lambda *a, **k: True
todo._qemu_import_module = lambda: None
todo._pve_set_cache_ca = lambda cible, vm, ca: ordre.append(
("autorité", cible, ca)
)
todo._qemu_install_erplibre_monitored = lambda *a, **k: ordre.append(
("installation",)
)
spec = {
"host": {"target": "root@pve-local"},
"vms": [
{
"name": "vm-a",
"vmid": 100,
"distro": "ubuntu",
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
"install_cmd": "",
}
],
"user": "erplibre",
"add_ssh_config": True,
"install": {"branch": "develop", "cmd": "make x", "label": "X"},
"monitor": True,
"desktop": "",
}
with contextlib.redirect_stdout(io.StringIO()):
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
self.assertEqual([e[0] for e in ordre], ["autorité", "installation"])
self.assertEqual(ordre[0][2], "/tmp/ca.crt")
def test_a_remote_host_does_not_get_the_step_at_all(self):
import contextlib
import io
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
appels = []
todo = TODO.__new__(TODO)
todo._write_ssh_config_entry = lambda *a, **k: None
todo._ssh_private_key = lambda k: None
todo._pve_alias_perime = lambda *a, **k: []
todo._qemu_list_domains = lambda: []
todo._qemu_cache_ca_path = classmethod(
lambda cls: "/tmp/ca.crt"
).__get__(todo, type(todo))
todo._pve_guest_ip = lambda vmid, attente=120: ""
todo._pve_write_guide = lambda *a, **k: True
todo._pve_set_timezone = lambda *a, **k: True
todo._qemu_import_module = lambda: None
todo._pve_set_cache_ca = lambda *a: appels.append(a)
todo._qemu_install_erplibre_monitored = lambda *a, **k: None
spec = {
"host": {"target": "root@10.0.0.5"},
"vms": [
{
"name": "vm-a",
"vmid": 100,
"distro": "ubuntu",
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
"install_cmd": "",
}
],
"user": "erplibre",
"add_ssh_config": True,
"install": None,
"monitor": False,
"desktop": "",
}
with contextlib.redirect_stdout(io.StringIO()):
todo._pve_after_create(spec["host"], spec, ["vm-a"], "")
self.assertEqual(appels, [])
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -0,0 +1,67 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le miroir apt d'une VM Ubuntu, tel que son cloud-config l'écrit.
Derrière le cache, le miroir est fixe (« uri: ») : la recherche de
cloud-init (« search: ») écarte tout miroir derrière un résolveur qui répond
à tout nom, et une VM qui tire d'un autre miroir que les précédentes ne
retrouve rien de ce que le cache a gardé.
"""
import sys
import tempfile
import unittest
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE))
from script.qemu import deploy_qemu as DQ # noqa: E402
class TestLeMiroirApt(unittest.TestCase):
def _apt(self, *extra):
args = DQ.build_parser().parse_args(
["--distro", "ubuntu", "--hostname", "vm", *extra]
)
return yaml.safe_load(DQ.build_cloud_config(args, None, []))["apt"]
def _ca(self):
f = tempfile.NamedTemporaryFile(
"w", suffix=".pem", delete=False, encoding="utf-8"
)
self.addCleanup(Path(f.name).unlink)
f.write(
"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"
)
f.close()
return f.name
def test_sans_cache_cloud_init_cherche(self):
apt = self._apt()
for bloc in ("primary", "security"):
self.assertEqual(apt[bloc][0]["search"], DQ.APT_MIRRORS_MAIN)
self.assertNotIn("uri", apt[bloc][0])
def test_derriere_le_cache_le_premier_miroir_est_fixe(self):
apt = self._apt("--cache-ca", self._ca())
for bloc in ("primary", "security"):
self.assertEqual(apt[bloc][0]["uri"], DQ.APT_MIRRORS_MAIN[0])
self.assertNotIn("search", apt[bloc][0])
def test_un_miroir_impose_est_fixe(self):
miroir = "http://miroir.invalid/ubuntu"
apt = self._apt("--apt-mirror", miroir)
self.assertEqual(apt["primary"][0]["uri"], miroir)
self.assertNotIn("search", apt["primary"][0])
def test_les_arches_ports_fixent_leur_propre_miroir(self):
lignes = DQ.apt_mirror_lines("arm64", fixe=True)
self.assertIn(f" uri: {DQ.APT_MIRRORS_PORTS[0]}", lignes)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,303 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Soustraire UNE VM au cache : l'exception par adresse MAC.
Le détournement est transparent et vaut pour tout le pont. Une VM ne peut pas
s'y soustraire de l'intérieur, et lui retirer l'autorité ne la dispense de
rien : elle est interceptée quand même et échoue sur un certificat qu'elle ne
reconnaît pas. L'exception se pose donc sur l'HÔTE, et elle a besoin d'un
identifiant que la VM porte de façon stable.
D'où ce que ces tests tiennent. L'adresse MAC est choisie AVANT la création :
la lire après laisserait la fenêtre où cloud-init télécharge déjà. Une MAC
posée à la main l'emporte sur le tirage. Et l'exception l'emporte sur
l'autorité, qu'il serait absurde de faire approuver à une VM qui ne
rencontrera jamais le cache.
"""
import argparse
import sys
import unittest
from pathlib import Path
from unittest import mock
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE))
from script.qemu import deploy_qemu # noqa: E402
class FauxRunner:
"""Retient les commandes. Rend ce qu'on lui a dit de rendre."""
def __init__(self, reponses=None):
self.commandes = []
self.reponses = reponses or {}
self.use_sudo = True
self.dry_run = False
def run(self, cmd, *, privileged=False, check=True, capture=False):
self.commandes.append(list(cmd))
for motif, reponse in self.reponses.items():
if motif in " ".join(cmd):
return reponse
return (0, "") if capture else None
def args_neufs(**kw):
base = dict(
name="vm-essai",
network="network=default,model=virtio",
cache_bypass=True,
cache_ca="",
distro="arch",
dry_run=False,
)
base.update(kw)
return argparse.Namespace(**base)
class TestLaMACChoisie(unittest.TestCase):
def test_une_mac_deja_demandee_lemporte(self):
"""Une MAC posée à la main sert une réservation DHCP : la remplacer
casserait ce que l'appelant a réglé ailleurs."""
reseau = "network=default,model=virtio,mac=52:54:00:de:ad:be"
self.assertEqual(
deploy_qemu.mac_du_network(reseau), "52:54:00:de:ad:be"
)
self.assertEqual(
deploy_qemu.network_avec_mac(reseau, "52:54:00:11:11:11"), reseau
)
def test_sans_mac_elle_est_ajoutee(self):
reseau = deploy_qemu.network_avec_mac(
"network=default,model=virtio", "52:54:00:11:22:33"
)
self.assertEqual(
reseau, "network=default,model=virtio,mac=52:54:00:11:22:33"
)
self.assertEqual(
deploy_qemu.mac_du_network(reseau), "52:54:00:11:22:33"
)
def test_la_mac_tiree_porte_le_prefixe_qemu(self):
mac = deploy_qemu.mac_neuve(set())
self.assertTrue(
mac.startswith(deploy_qemu.MAC_PREFIXE),
f"{mac} n'est pas une adresse QEMU",
)
self.assertEqual(len(mac.split(":")), 6)
def test_une_mac_prise_nest_pas_retiree(self):
"""Libvirt refuse un doublon, mais à la création — après le
téléchargement de l'image. Le refus doit venir avant."""
prises = set()
for _ in range(30):
mac = deploy_qemu.mac_neuve(prises)
self.assertNotIn(mac, prises)
prises.add(mac)
class TestLExceptionEstPoseeAvant(unittest.TestCase):
def test_la_mac_entre_dans_le_reseau_avant_la_creation(self):
args = args_neufs()
runner = FauxRunner({"is-active": (0, "")})
with mock.patch.object(
deploy_qemu.os.path, "isfile", return_value=True
), mock.patch.object(
deploy_qemu.shutil, "which", return_value="/usr/bin/nft"
), mock.patch.object(
deploy_qemu, "macs_deja_prises", return_value=set()
):
mac = deploy_qemu.cache_bypass_apply(args, runner)
self.assertTrue(mac, "aucune MAC retenue")
self.assertIn(f"mac={mac}", args.network)
pose = [c for c in runner.commandes if "--bypass-add" in " ".join(c)]
self.assertTrue(pose, "l'exception n'a pas été posée")
self.assertIn("nft -f -", " ".join(pose[0]))
def test_sans_cache_installe_rien_nest_pose(self):
"""Exiger un cache pour pouvoir s'en passer n'aurait aucun sens :
sans lui, rien n'intercepte."""
args = args_neufs()
runner = FauxRunner()
with mock.patch.object(
deploy_qemu.os.path, "isfile", return_value=False
):
self.assertEqual(deploy_qemu.cache_bypass_apply(args, runner), "")
self.assertEqual(runner.commandes, [])
self.assertNotIn("mac=", args.network)
def test_service_arrete_rien_nest_pose(self):
args = args_neufs()
runner = FauxRunner({"is-active": (3, "")})
with mock.patch.object(
deploy_qemu.os.path, "isfile", return_value=True
):
self.assertEqual(deploy_qemu.cache_bypass_apply(args, runner), "")
self.assertNotIn("mac=", args.network)
def test_sans_la_demande_rien_ne_bouge(self):
args = args_neufs(cache_bypass=False)
runner = FauxRunner()
self.assertEqual(deploy_qemu.cache_bypass_apply(args, runner), "")
self.assertEqual(runner.commandes, [])
def test_sans_nft_le_service_est_repose(self):
"""iptables n'a pas d'ensemble nommé : l'exception n'entre qu'en
reposant la chaîne, et seul un redémarrage la repose."""
args = args_neufs()
runner = FauxRunner({"is-active": (0, "")})
with mock.patch.object(
deploy_qemu.os.path, "isfile", return_value=True
), mock.patch.object(
deploy_qemu.shutil, "which", return_value=None
), mock.patch.object(
deploy_qemu, "macs_deja_prises", return_value=set()
):
deploy_qemu.cache_bypass_apply(args, runner)
dit = [" ".join(c) for c in runner.commandes]
self.assertTrue(
any("systemctl" in c and "restart" in c for c in dit),
f"le service n'est pas reposé : {dit}",
)
class TestLExceptionLemporteSurLAutorite(unittest.TestCase):
def test_pas_dautorite_dans_une_vm_exceptee(self):
"""Elle ne rencontrera jamais le cache : poser cette signature dans
son magasin n'aurait aucun objet."""
args = args_neufs(cache_ca="/var/lib/erplibre_go_qemu_cache/ca.crt")
self.assertEqual(deploy_qemu.cache_files(args), [])
def test_la_ligne_de_commande_ne_porte_pas_les_deux(self):
from script.todo.qemu_deploy import QemuDeployMixin
parts = QemuDeployMixin._qemu_build_deploy_parts(
mock.MagicMock(_qemu_script_path=lambda: "deploy_qemu.py"),
"arch",
"latest",
"x86_64",
"vm",
4096,
2,
"20G",
"",
"master",
True,
cache_ca="/var/lib/erplibre_go_qemu_cache/ca.crt",
cache_bypass=True,
)
self.assertIn("--cache-bypass", parts)
self.assertNotIn("--cache-ca", parts)
def test_sans_exception_lautorite_passe(self):
"""Le cas courant : la VM approuve l'autorité et traverse le cache."""
from script.todo.qemu_deploy import QemuDeployMixin
parts = QemuDeployMixin._qemu_build_deploy_parts(
mock.MagicMock(_qemu_script_path=lambda: "deploy_qemu.py"),
"arch",
"latest",
"x86_64",
"vm",
4096,
2,
"20G",
"",
"master",
True,
cache_ca="/var/lib/erplibre_go_qemu_cache/ca.crt",
)
self.assertIn("--cache-ca", parts)
self.assertNotIn("--cache-bypass", parts)
class TestLeMenageDesExceptions(unittest.TestCase):
"""Une exception qui survit à sa VM est le danger de cette liste.
L'adresse MAC est libérée par la destruction et se réattribue : l'entrée
restée derrière soustrairait au cache une machine neuve que personne n'a
exceptée. Ni la VM ni le cache ne le diraient — la VM télécharge
normalement, le journal du cache reste seulement muet à son sujet.
"""
def test_une_vm_disparue_rend_son_entree_orpheline(self):
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
entrees = [
("52:54:00:00:00:01", "vm-vivante"),
("52:54:00:00:00:02", "vm-detruite"),
]
with mock.patch.object(
M, "_cache_domaines", return_value={"vm-vivante"}
):
orphelines = M._cache_bypass_orphelines(entrees)
self.assertEqual(orphelines, [("52:54:00:00:00:02", "vm-detruite")])
def test_une_entree_sans_nom_nest_jamais_orpheline(self):
"""Posée à la main : rien ne dit à quelle VM elle se rapporte, et la
retirer d'office déferait le travail de quelqu'un."""
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
with mock.patch.object(M, "_cache_domaines", return_value=set()):
self.assertEqual(
M._cache_bypass_orphelines([("52:54:00:00:00:03", "")]), []
)
def test_le_menage_retire_du_fichier_et_du_noyau(self):
"""Les deux : le fichier est ce que le service reposera, l'ensemble
est ce qui s'applique en ce moment."""
from script.todo import qemu_cache_menu as menu
execute = mock.MagicMock()
with mock.patch.object(
menu.os.path, "isfile", return_value=True
), mock.patch.object(
menu.QemuCacheMenuMixin,
"_cache_bypass_orphelines",
return_value=[("52:54:00:00:00:04", "partie")],
):
self.assertEqual(menu.bypass_menage(execute), 1)
cmd = execute.exec_command_live.call_args.args[0]
self.assertIn("--bypass-del 52:54:00:00:00:04", cmd)
self.assertIn("nft -f -", cmd)
def test_sans_cache_pose_le_menage_ne_fait_rien(self):
from script.todo import qemu_cache_menu as menu
execute = mock.MagicMock()
with mock.patch.object(menu.os.path, "isfile", return_value=False):
self.assertEqual(menu.bypass_menage(execute), 0)
execute.exec_command_live.assert_not_called()
def test_la_liste_est_lue_du_binaire(self):
"""Lui seul normalise une adresse et saute une ligne fautive : une
seconde lecture écrite ici dériverait de la sienne."""
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
sortie = "52:54:00:aa:bb:cc vm-une\n52:54:00:11:22:33\n\n"
with mock.patch.object(
M, "_cache_lire", return_value=sortie
), mock.patch("os.path.isfile", return_value=True):
self.assertEqual(
M._cache_bypass_lire(),
[("52:54:00:aa:bb:cc", "vm-une"), ("52:54:00:11:22:33", "")],
)
class TestLaSuppressionFaitLeMenage(unittest.TestCase):
def test_la_suppression_de_vm_appelle_le_menage(self):
"""Le seul endroit qui SAIT que la VM vient de disparaître."""
src = (RACINE / "script" / "todo" / "qemu_manage.py").read_text(
encoding="utf-8"
)
bloc = src[src.index("def _qemu_delete_vm(") :]
bloc = bloc[: bloc.index("\n def ")]
self.assertIn("bypass_menage(self.execute)", bloc)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce que le test long télécharge, et sur quel système.
Trois axes désormais : l'essai (cache, contre-épreuve, témoin), la charge
(un lot de paquets ou l'installation réelle) et la distribution. Chacun a sa
façon de se tromper en silence.
La charge doit être IDENTIQUE d'une VM à l'autre, sans quoi la comparaison ne
compare rien. Elle doit aussi correspondre à la famille de la distribution :
un nom de paquet d'une autre famille fait échouer l'installation loin de sa
cause, après le déploiement d'une machine entière.
Et le catalogue des systèmes n'est PAS recopié : il vient du déploiement. Une
seconde table proposerait un système que le déploiement ne sait pas installer,
ce que l'on ne découvrirait qu'en le lançant.
"""
import importlib.util
import sys
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE))
def charger():
chemin = RACINE / "long_test" / "qemu_cache.py"
spec = importlib.util.spec_from_file_location("qemu_cache_long", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
QC = charger()
class TestLeCatalogueVientDuDeploiement(unittest.TestCase):
def test_aucun_systeme_inconnu_du_deploiement(self):
from script.qemu.deploy_qemu import DISTROS
for d in QC.systemes_mesurables():
self.assertIn(
d, DISTROS, f"« {d} » n'est pas au catalogue du déploiement"
)
def test_chaque_systeme_a_une_famille_servie(self):
"""Proposer un système dont on ne sait pas installer les paquets, c'est
déployer une VM entière pour échouer à la dernière étape."""
for d in QC.systemes_mesurables():
self.assertIn(
QC.famille_de(d),
QC.PAQUETS_MINIMUM,
f"« {d} » n'a pas de lot de paquets",
)
def test_proxmox_est_ecarte(self):
"""Un hyperviseur : on n'y installe ni ERPLibre ni un lot de
développement, et le déploiement lui impose déjà son profil."""
self.assertNotIn("proxmox", QC.systemes_mesurables())
def test_les_quatre_familles_sont_couvertes(self):
self.assertEqual(
set(QC.PAQUETS_MINIMUM), {"pacman", "apt", "dnf", "zypper"}
)
class TestLaCharge(unittest.TestCase):
def test_chaque_famille_rend_une_commande(self):
for d in sorted(QC.systemes_mesurables()):
self.assertTrue(
QC.commande_de_charge(d, "minimum"),
f"aucune charge minimale pour « {d} »",
)
def test_un_systeme_hors_catalogue_ne_rend_rien(self):
"""Rendre une commande vide plutôt qu'une commande fausse : le test
s'arrête en le disant, au lieu de lancer un shell vide dans la VM."""
self.assertEqual(QC.commande_de_charge("haiku", "minimum"), "")
def test_la_charge_reelle_contient_la_minimale(self):
"""Elles partagent leur début, ce qui rend leurs mesures comparables
sur cette portion — et « make » n'existe pas avant que git l'ait
cloné."""
for d in sorted(QC.systemes_mesurables()):
minimum = QC.commande_de_charge(d, "minimum")
reelle = QC.commande_de_charge(d, "erplibre")
self.assertTrue(
reelle.startswith(minimum),
f"« {d} » : la charge réelle ne part pas de la minimale",
)
def test_la_charge_reelle_clone_puis_installe(self):
cmd = QC.commande_de_charge("arch", "erplibre")
self.assertIn("git clone", cmd)
self.assertIn("install_odoo_18", cmd)
self.assertLess(
cmd.index("git clone"),
cmd.index("install_odoo_18"),
"la cible make est lancée avant que le dépôt existe",
)
def test_chaque_charge_a_son_delai(self):
"""Un délai unique ferait échouer la courte ou laisserait la longue
pendre : ERPLibre se compte en heures, le lot en minutes."""
self.assertEqual(set(QC.DELAI_CHARGE), {"minimum", "erplibre"})
self.assertGreater(
QC.DELAI_CHARGE["erplibre"], QC.DELAI_CHARGE["minimum"]
)
def test_la_charge_attend_cloud_init_avant_tout(self):
"""cloud-init réécrit la liste des dépôts à son premier démarrage.
Une mise à jour lancée pendant ce remplacement récupère une partie des
index et s'arrête là SANS échouer : l'installation qui suit ne trouve
plus les paquets de « main », et le message accuse le paquet plutôt que
le moment. sshd répond bien avant que cloud-init ait fini.
"""
for d in sorted(QC.systemes_mesurables()):
cmd = QC.commande_de_charge(d, "minimum")
self.assertTrue(
cmd.startswith(QC.ATTENDRE_CLOUD_INIT),
f"« {d} » touche au gestionnaire de paquets sans attendre",
)
def test_lattente_tolere_un_cloud_init_en_erreur(self):
"""Il sort en erreur pour un module accessoire — un fuseau que
l'invité ne connaît pas — et ce n'est pas une raison de renoncer."""
self.assertIn("|| true", QC.ATTENDRE_CLOUD_INIT)
def test_lattente_relit_les_variables_du_cache(self):
"""La session de la charge s'ouvre avant que cloud-init n'écrive les
variables du cache : « sudo npm » les relit, pas un npm sans sudo."""
from script.qemu.deploy_qemu import cache_env_reload
i = QC.ATTENDRE_CLOUD_INIT.index("status --wait")
self.assertIn(cache_env_reload(), QC.ATTENDRE_CLOUD_INIT[i:])
def test_la_charge_est_du_shell_valide(self):
"""Une instruction collée sans séparateur casse la commande entière,
et la VM ne dit alors pas pourquoi elle n'a rien installé."""
import subprocess
for d in sorted(QC.systemes_mesurables()):
for charge in ("minimum", "erplibre"):
cmd = QC.commande_de_charge(d, charge)
res = subprocess.run(
["bash", "-n", "-c", cmd], capture_output=True, text=True
)
self.assertEqual(
res.returncode, 0, f"« {d} », {charge} : {res.stderr}"
)
def test_apt_ne_cache_plus_ses_echecs(self):
"""« apt-get update » rend ZÉRO même quand un index n'a pas pu être
récupéré : il n'émet qu'un avertissement, que « -qq » cachait."""
rafraichir = QC.PAQUETS_MINIMUM["apt"][0]
self.assertIn("APT::Update::Error-Mode=any", rafraichir)
self.assertNotIn("-qq", rafraichir)
def test_le_plan_a_blanc_montre_la_commande_exacte(self):
"""Un plan qui montre autre chose que ce qui sera lancé n'est pas un
plan."""
from unittest import mock
with mock.patch.object(QC, "dire") as dit:
QC.poser_les_paquets(
"10.0.0.1",
None,
dry_run=True,
distro="debian",
charge="erplibre",
)
annonce = " ".join(str(a) for c in dit.call_args_list for a in c.args)
self.assertIn(QC.commande_de_charge("debian", "erplibre"), annonce)
class TestLesOptions(unittest.TestCase):
def test_la_version_vide_vient_du_catalogue(self):
from script.qemu.deploy_qemu import DISTROS
for d in sorted(QC.systemes_mesurables()):
defaut = DISTROS[d][1]
self.assertTrue(
defaut, f"« {d} » n'a pas de version par défaut au catalogue"
)
class TestLeRapportSeClotSurUnEchec(unittest.TestCase):
"""Une étape qui échoue arrête la boucle : le rapport doit le dire.
Sans « fin » ni « verdict », un rapport laissé par une VM qui n'a pas
installé se lit comme une exécution encore en cours.
"""
def boucler(self, echoue):
import argparse
import json
import tempfile
from unittest import mock
args = argparse.Namespace(
dry_run=False,
sans_cache=False,
hors_ligne=False,
distro="debian",
version="12",
charge="minimum",
)
with tempfile.TemporaryDirectory() as rep:
fichier = str(Path(rep) / "rapport.json")
rapport = {"_fichier": fichier, "vms": []}
with mock.patch.object(QC, "dire"), mock.patch.object(
QC, "noter_uuid"
), mock.patch.object(
QC,
"deployer",
return_value="" if echoue == "deployer" else "10.0.0.1",
), mock.patch.object(
QC, "attendre_ssh", return_value=echoue != "attendre_ssh"
), mock.patch.object(
QC,
"poser_les_paquets",
return_value=echoue != "poser_les_paquets",
):
code = QC._boucle(args, rapport, None, "", 0)
with open(fichier, encoding="utf-8") as fh:
return code, json.load(fh)
def test_chaque_etape_en_echec_ecrit_fin_et_verdict(self):
for etape, mot in (
("deployer", "déploiement"),
("attendre_ssh", "ssh"),
("poser_les_paquets", "paquets"),
):
with self.subTest(etape=etape):
code, ecrit = self.boucler(etape)
self.assertEqual(code, 1)
self.assertEqual(ecrit.get("verdict"), "échec")
self.assertTrue(ecrit.get("fin"))
self.assertIn(mot, ecrit.get("etape_en_echec", ""))
def test_un_echec_n_est_pas_un_succes(self):
"""Le verdict « ok » reste réservé à la boucle menée à son terme."""
code, ecrit = self.boucler("deployer")
self.assertNotEqual(ecrit.get("verdict"), "ok")
self.assertEqual(len(ecrit["vms"]), 1)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,820 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La case « cache » du formulaire QEMU, et ce qu'elle change à la commande.
Trois propriétés, chacune pour une panne :
1. la case n'est offerte que là où elle a un effet — une case qui ne change
rien apprend au lecteur une chose fausse ;
2. cochée, la commande porte « --cache-bypass » et JAMAIS l'autorité en
même temps : une VM exceptée ne rencontrera pas le cache, et cette
signature n'aurait rien à faire dans son magasin ;
3. le chemin de l'autorité est le MÊME que celui où l'installateur la pose.
Les deux séparés, la VM approuverait un fichier qui n'existe pas — et
rien ne le dirait.
L'écran Proxmox n'a pas cette case, et c'est voulu : sa VM naît sur un hôte
distant, que le cache local ne sert pas.
"""
import os
import re
import subprocess
import sys
import unittest
from pathlib import Path
from unittest import mock
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE))
QEMU_FORM = RACINE / "script" / "todo" / "qemu_deploy_form.py"
PROXMOX_FORM = RACINE / "script" / "todo" / "proxmox_deploy_form.py"
QEMU_DEPLOY = RACINE / "script" / "todo" / "qemu_deploy.py"
INSTALLATEUR = RACINE / "script" / "install" / "install_qemu_cache.sh"
from script.todo.qemu_deploy import QemuDeployMixin # noqa: E402
from script.todo.todo import TODO # noqa: E402
class TestLaCaseNePrometQueCeQuElleTient(unittest.TestCase):
"""Une case a existé qui ne tenait pas sa promesse, et c'est l'histoire
de celle-ci.
Décocher n'omettait que l'AUTORITÉ. L'interception étant transparente et
couvrant tout le pont, la VM était détournée quand même et échouait sur
« self-signed certificate in certificate chain » à chaque téléchargement
HTTPS : la case fabriquait une machine cassée.
La case revient parce qu'elle a désormais de quoi tenir : une exception
par adresse MAC, posée sur l'hôte avant la création. Ce qui se vérifie
ici, c'est donc le lien — cochée, la commande porte « --cache-bypass »,
et jamais l'autorité en même temps.
"""
def test_la_case_mene_a_lexception_et_non_au_seul_retrait(self):
src = QEMU_FORM.read_text(encoding="utf-8")
self.assertIn("f_cache_bypass", src, "la case a disparu du formulaire")
self.assertIn(
"cache_bypass",
QEMU_DEPLOY.read_text(encoding="utf-8"),
"la case ne mène à rien dans la commande",
)
def test_lecran_proxmox_ne_loffre_pas(self):
"""Sa VM naît sur un hôte distant, que le cache local ne sert pas."""
self.assertNotIn(
"cache_bypass", PROXMOX_FORM.read_text(encoding="utf-8")
)
def test_la_case_est_gardee_par_letat_du_cache(self):
"""Sans service actif rien n'intercepte : une case qui ne change rien
apprend au lecteur une chose fausse.
Le drapeau est lu du CONTEXTE. La version d'avant le cherchait dans
« defaults », qui ne porte que ce qu'on veut pré-cocher et reste vide
au premier affichage : la case ne s'affichait jamais, et ce contrôle —
qui ne lisait que le source — l'attestait quand même.
"""
src = QEMU_FORM.read_text(encoding="utf-8")
self.assertIn('cache_offert = bool(ctx.get("cache_offert"))', src)
bloc = src[: src.index("f_cache_bypass")]
self.assertIn(
"if cache_offert:",
bloc[-500:],
"la case s'affiche sans égard à l'état du cache",
)
def test_lautorite_suit_le_service_et_non_une_case(self):
src = QEMU_DEPLOY.read_text(encoding="utf-8")
self.assertIn("_qemu_cache_active()", src)
self.assertNotIn(
'spec.get("use_cache")',
src,
"l'autorité dépend encore d'un choix qui ne peut pas être tenu",
)
class TestCommandeProduite(unittest.TestCase):
"""Le drapeau passe par le POINT DE PASSAGE UNIQUE des deux interfaces."""
def vm(self):
return {
"distro": "arch",
"version": "latest",
"arch": "amd64",
"name": "essai",
"ram": 4096,
"vcpus": 2,
"disk": "20G",
}
def parts(self, spec, ca="", actif=True):
# « TODO.__new__ » sans __init__ : l'idiome des tests du dépôt, qui
# donne toutes les méthodes du menu sans ouvrir d'écran.
todo = TODO.__new__(TODO)
# Ni l'autorité ni le service ne sont ceux de la machine de test : la
# détection est remplacée, le reste du chemin reste intact.
todo._qemu_cache_ca_path = lambda: ca
todo._qemu_cache_active = lambda: actif and bool(ca)
return todo._qemu_deploy_parts_for(self.vm(), spec, dry_run=True)
def test_service_arrete_aucun_drapeau(self):
"""Sans interception, l'autorité n'a rien à faire dans la VM."""
parts = self.parts({"install": None}, ca="/tmp/ca.crt", actif=False)
self.assertNotIn("--cache-ca", parts)
def test_service_actif_le_drapeau_et_le_chemin(self):
"""Le service tourne : la VM SERA détournée, donc elle doit approuver
l'autorité, quoi qu'on ait coché."""
parts = self.parts({"install": None}, ca="/tmp/essai-ca.crt")
self.assertIn("--cache-ca", parts)
self.assertEqual(
parts[parts.index("--cache-ca") + 1],
"/tmp/essai-ca.crt",
"le chemin de l'autorité ne suit pas le drapeau",
)
def test_cache_disparu_entre_temps(self):
"""Le chemin est relu à CHAQUE commande : un cache désinstallé entre
le formulaire et le déploiement ne doit pas faire approuver une
autorité qui n'existe plus."""
parts = self.parts({"install": None}, ca="")
self.assertNotIn("--cache-ca", parts)
class TestLInstallationNeSannonceQueSiElleAEuLieu(unittest.TestCase):
"""Un code de sortie non nul n'est pas une réussite.
Vécu : l'installateur est mort sur « réseau libvirt default introuvable »,
a rendu 1, et l'entrée a imprimé « Cache de téléchargement QEMU installé
et démarré » juste en dessous, avec le chemin d'une autorité qui n'existe
pas. Un succès annoncé à tort coûte plus qu'une panne : on cherche
ensuite partout sauf là où elle est.
"""
def _installer(self, code):
"""Joue l'entrée 1 avec un installateur qui rend `code`."""
import contextlib
import io
import click
todo = TODO.__new__(TODO)
todo.execute = mock.Mock()
todo.execute.exec_command_live = mock.Mock(return_value=code)
with mock.patch("builtins.input", return_value=""), mock.patch.object(
click, "confirm", return_value=True
):
with contextlib.redirect_stdout(io.StringIO()) as sortie:
todo._deploy_qemu_cache()
return sortie.getvalue()
def test_un_echec_ne_sannonce_pas_comme_une_reussite(self):
dit = self._installer(1)
self.assertNotIn("installed and started", dit)
self.assertNotIn("installé et démarré", dit)
self.assertNotIn("ca.crt", dit, "une autorité inexistante est nommée")
def test_un_echec_dit_quoi_faire(self):
self.assertIn("1", self._installer(1))
def test_une_reussite_sannonce_et_nomme_lautorite(self):
dit = self._installer(0)
self.assertIn("ca.crt", dit)
class TestLeReseauDonneALaMain(unittest.TestCase):
"""Un pont nommé à la main suffit : l'installateur ne sonde plus libvirt.
Vécu ailleurs : sur une machine dont le réseau libvirt « default » n'est
pas démarré, l'installation mourait sur « réseau introuvable » — et le
contournement annoncé en tête du fichier, EL_BRIDGE et EL_SUBNET, ne
servait à rien, la sonde tombant AVANT que ces variables soient lues.
"""
def _jouer(self, env):
"""Exécute la VRAIE fonction, extraite du script, avec un « virsh »
qui échoue et des « log »/« die » de doublure."""
source = INSTALLATEUR.read_text(encoding="utf-8")
corps = re.search(
r"^detecter_reseau\(\) \{.*?^\}", source, re.S | re.M
)
self.assertIsNotNone(corps, "detecter_reseau introuvable")
script = (
'log() { echo "LOG: $*"; }\n'
'die() { echo "DIE: $*" >&2; exit 1; }\n'
"virsh() { return 1; }\n"
f"{corps.group(0)}\n"
"detecter_reseau\n"
)
return subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
env=dict(os.environ, EL_NET="default", **env),
timeout=30,
)
def test_les_deux_donnes_la_sonde_est_sautee(self):
res = self._jouer({"EL_BRIDGE": "virbr9", "EL_SUBNET": "192.0.2.0/24"})
self.assertEqual(0, res.returncode, res.stderr)
self.assertIn("virbr9", res.stdout)
self.assertIn("192.0.2.0/24", res.stdout)
def test_sans_eux_la_mort_nomme_les_deux_issues(self):
"""Mourir est juste ; mourir sans dire quoi faire ne l'est pas."""
res = self._jouer({"EL_BRIDGE": "", "EL_SUBNET": ""})
self.assertNotEqual(0, res.returncode)
for issue in ("net-start", "EL_BRIDGE", "EL_SUBNET"):
self.assertIn(issue, res.stderr, res.stderr)
def test_un_seul_des_deux_ne_suffit_pas(self):
"""Le pont sans le sous-réseau laisserait des règles sans préfixe."""
res = self._jouer({"EL_BRIDGE": "virbr9", "EL_SUBNET": ""})
self.assertNotEqual(0, res.returncode)
def test_la_garde_precede_la_sonde(self):
"""L'ordre EST le correctif : lue après, la garde ne sauverait rien."""
source = INSTALLATEUR.read_text(encoding="utf-8")
self.assertLess(
source.index('if [ -n "$EL_BRIDGE" ] && [ -n "$EL_SUBNET" ]'),
source.index("net-dumpxml"),
)
class TestAccordAvecLInstallateur(unittest.TestCase):
def test_meme_chemin_dautorite(self):
"""`EL_CA_DIR` du script et `QEMU_CACHE_CA` du menu doivent désigner
le même fichier."""
texte = INSTALLATEUR.read_text(encoding="utf-8")
m = re.search(r'EL_CA_DIR="\$\{EL_CA_DIR:-([^}]+)\}"', texte)
self.assertIsNotNone(m, "EL_CA_DIR introuvable dans l'installateur")
attendu = f"{m.group(1)}/ca.crt"
self.assertEqual(
QemuDeployMixin.QEMU_CACHE_CA,
attendu,
"le menu cherche l'autorité là où l'installateur ne la pose pas",
)
def test_meme_nom_de_service(self):
texte = INSTALLATEUR.read_text(encoding="utf-8")
m = re.search(r"UNIT=\"/etc/systemd/system/([^\"]+)\"", texte)
self.assertIsNotNone(m, "le nom de l'unité est introuvable")
self.assertEqual(
QemuDeployMixin.QEMU_CACHE_SERVICE,
m.group(1),
"le menu interroge un service que l'installateur ne pose pas",
)
class TestDetectionHote(unittest.TestCase):
def test_pas_dautorite_pas_de_chemin(self):
class Absent(QemuDeployMixin):
QEMU_CACHE_CA = "/inexistant/ca.crt"
self.assertEqual(Absent._qemu_cache_ca_path(), "")
self.assertFalse(
Absent._qemu_cache_active(),
"un service est déclaré actif alors qu'aucune autorité n'existe",
)
try:
import textual # noqa: F401
TEXTUAL = True
except Exception: # pragma: no cover - Textual absent
TEXTUAL = False
def contexte_du_formulaire():
todo = TODO.__new__(TODO)
mod = todo._qemu_import_module()
todo._qemu_list_domains = lambda: []
todo._qemu_branch_list = lambda: ["develop", "master"]
return todo._qemu_form_context(mod)
def choisir_une_vm(app):
"""Coche la première entrée du catalogue et refait le plan.
Sans sélection, « action_deploy » sort sur « Rien de sélectionné » et
n'atteint aucun des contrôles qu'on veut éprouver.
"""
from textual.widgets import SelectionList
liste = app.query_one("#f_catalog", SelectionList)
liste.select(liste.options[0].value)
app._recompute()
return app.vms
def champs_affiches(ctx):
"""Les identifiants réellement montés dans le panneau, sans écran."""
import asyncio
from script.todo.qemu_deploy_form import run_deploy_form
vu = []
async def scenario():
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 60)) as pilote:
await pilote.pause()
vu.extend(w.id for w in app.query("#fields *") if w.id)
asyncio.run(scenario())
return vu
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestLaCaseEstVraimentLa(unittest.TestCase):
"""La case a existé sans jamais s'afficher, et personne ne le voyait.
Elle était gardée par « defaults.get('cache_offert') ». Or « defaults » ne
porte que ce qu'on veut PRÉ-COCHER et il est vide au premier affichage : la
condition valait toujours faux. Les contrôles de source, eux, la trouvaient
bien dans le fichier — d'où un écran qui invitait à cocher une case
introuvable.
Ces contrôles-ci MONTENT le formulaire et regardent ce qu'il affiche.
"""
@classmethod
def setUpClass(cls):
cls.ctx = contexte_du_formulaire()
def test_elle_saffiche_quand_le_cache_tourne(self):
champs = champs_affiches(dict(self.ctx, cache_offert=True))
self.assertIn(
"f_cache_bypass",
champs,
"la case n'est pas montée alors que le cache tourne",
)
def test_elle_disparait_quand_le_cache_est_eteint(self):
"""Une case sans effet apprend au lecteur une chose fausse."""
champs = champs_affiches(dict(self.ctx, cache_offert=False))
self.assertNotIn("f_cache_bypass", champs)
def test_cochee_elle_arrive_dans_la_spec(self):
"""Le dernier maillon : c'est la spec qui décide de la commande.
Une case affichée mais dont la valeur n'est pas relue serait aussi
inutile qu'une case absente, et la panne se lirait au déploiement.
"""
import asyncio
from textual.widgets import Checkbox
from script.todo.qemu_deploy_form import run_deploy_form
vu = {}
ctx = dict(self.ctx, cache_offert=True)
async def scenario():
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 60)) as pilote:
await pilote.pause()
case = app.query_one("#f_cache_bypass", Checkbox)
vu["defaut"] = case.value
case.value = True
await pilote.pause()
vu["spec"] = app._form_values().get("cache_bypass")
asyncio.run(scenario())
self.assertIs(
vu["defaut"],
False,
"la case est cochée d'avance : le cache"
" serait contourné sans qu'on l'ait demandé",
)
self.assertIs(vu["spec"], True)
def test_le_contexte_porte_bien_la_cle(self):
"""L'autre moitié de la dérive : le menu doit la fournir là où le
formulaire la lit, c'est-à-dire au niveau du contexte."""
self.assertIn("cache_offert", self.ctx)
self.assertNotIn(
"cache_offert",
self.ctx.get("defaults") or {},
"la clé est repartie dans « defaults », où elle est ignorée",
)
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestRienDeCollecteNeSePerd(unittest.TestCase):
"""Une liste blanche perd ce qu'on oublie d'y écrire, et sans un mot.
Le formulaire collecte ses champs, puis « build_spec » assemble la spec —
clé par clé, nommées à la main. Un champ ajouté au formulaire et pas à
cette assemblée est réglé par l'opérateur, affiché, relu… et jeté. La
trace en porte déjà deux : le suivi et la 3D, chacun réparé après coup.
Le contrôle ne vérifie plus une clé mais la PROPRIÉTÉ : tout ce que le
formulaire collecte doit se retrouver dans la spec.
"""
def test_toute_cle_collectee_arrive_dans_la_spec(self):
import asyncio
from script.todo.deploy_form_lib import build_spec
from script.todo.qemu_deploy_form import run_deploy_form
vu = {}
ctx = dict(contexte_du_formulaire(), cache_offert=True)
async def scenario():
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 60)) as pilote:
await pilote.pause()
valeurs = app._form_values()
vu["perdues"] = sorted(
set(valeurs) - set(build_spec([], set(), valeurs))
)
vu["collectees"] = len(valeurs)
asyncio.run(scenario())
self.assertGreater(
vu["collectees"], 10, "le relevé n'a presque rien trouvé"
)
self.assertEqual(
vu["perdues"],
[],
"des réglages du formulaire n'atteignent jamais le déploiement :"
f" {vu['perdues']}",
)
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestLaSectionReseau(unittest.TestCase):
"""« Sans connexion internet » : la même chose que « --hors-ligne ».
Ce n'est pas le réseau de la VM qui tombe — elle en a besoin pour joindre
le cache — mais l'amont du service, le temps du déploiement.
"""
@classmethod
def setUpClass(cls):
cls.ctx = contexte_du_formulaire()
def setUp(self):
"""Aucun test de cette classe ne pose de règle de pare-feu.
Ils cochent tous la case, et le jour où le formulaire régresse en
coupant au clic, la coupure resterait sur la machine de test — aucun
« finally » ne court sur un test qui vient d'échouer. Le cache
rendrait 504 à toute VM déployée ensuite, et le message ne parlerait
pas d'une règle oubliée.
La garde est ici, sur la CLASSE, et non dans le seul test qui
surveille : c'est celui qui ne surveillait pas qui a posé la règle.
L'espion ne relaie RIEN à la vraie commande : ce qu'il n'attend pas
reçoit un échec vide. Le pré-vol de F5 lit le suivi des déploiements
et le journal du cache, puis interroge le binaire du service ; un
ajout futur au pré-vol ne peut donc pas atteindre la machine. Le
pré-vol par les essais précédents est remplacé pour toute la classe :
il lirait le vrai ~/.erplibre et le vrai journal, et le résultat
dépendrait de la machine qui lance les tests.
"""
from script.qemu import cache_offline
self.lancees = []
def espion(cmd, *a, **kw):
texte = cmd if isinstance(cmd, str) else " ".join(map(str, cmd))
self.lancees.append(texte)
if "nft" in texte:
return subprocess.CompletedProcess(cmd, 0, "", "")
return subprocess.CompletedProcess(cmd, 127, "", "")
for patch in (
mock.patch("subprocess.run", espion),
mock.patch.object(
cache_offline, "manques_hors_ligne", lambda vms: []
),
):
patch.start()
self.addCleanup(patch.stop)
def test_elle_saffiche_quand_le_cache_tourne(self):
champs = champs_affiches(dict(self.ctx, cache_offert=True))
self.assertIn("f_offline", champs)
self.assertIn("t_network", champs, "la section n'a pas de titre")
def test_elle_disparait_sans_cache(self):
"""Sans cache il n'y a pas d'amont à couper : la case ne ferait
rien, et une case sans effet apprend une chose fausse."""
champs = champs_affiches(dict(self.ctx, cache_offert=False))
self.assertNotIn("f_offline", champs)
def test_elle_vient_apres_le_parallelisme(self):
"""« à la toute fin » : la section est la dernière du panneau."""
champs = champs_affiches(dict(self.ctx, cache_offert=True))
self.assertLess(champs.index("f_par"), champs.index("t_network"))
def test_cochee_elle_arrive_dans_la_spec(self):
import asyncio
from textual.widgets import Checkbox
from script.todo.deploy_form_lib import build_spec
from script.todo.qemu_deploy_form import run_deploy_form
vu = {}
ctx = dict(self.ctx, cache_offert=True)
async def scenario():
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 60)) as pilote:
await pilote.pause()
case = app.query_one("#f_offline", Checkbox)
vu["defaut"] = case.value
case.value = True
await pilote.pause()
valeurs = app._form_values()
vu["spec"] = build_spec([], set(), valeurs)["offline"]
asyncio.run(scenario())
self.assertIs(
vu["defaut"],
False,
"cochée d'avance, elle couperait l'amont sans qu'on l'ait"
" demandé",
)
self.assertIs(vu["spec"], True)
def test_lavertissement_ne_parait_que_cochee(self):
"""Il dit deux choses qu'on ne devine pas : la coupure vaut pour
TOUS les usagers du cache, et elle ne tombe qu'au lancement."""
import asyncio
from textual.widgets import Checkbox
from script.todo.qemu_deploy_form import run_deploy_form
vu = {}
ctx = dict(self.ctx, cache_offert=True)
def visibles(app):
return [
w.id
for w in app.query("#fields *")
if str(w.id or "").startswith("t_offline_w") and w.display
]
async def scenario():
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 70)) as pilote:
await pilote.pause()
vu["decochee"] = visibles(app)
app.query_one("#f_offline", Checkbox).value = True
await pilote.pause()
vu["cochee"] = visibles(app)
app.query_one("#f_offline", Checkbox).value = False
await pilote.pause()
vu["redecochee"] = visibles(app)
asyncio.run(scenario())
self.assertEqual(
vu["decochee"], [], "l'avertissement s'affiche sans être demandé"
)
self.assertEqual(len(vu["cochee"]), 7, f"vu : {vu['cochee']}")
self.assertEqual(
vu["redecochee"], [], "il reste affiché après décochage"
)
def suivi_selon_la_case(self, suivi_avant):
"""Coche puis décoche la case, le suivi réglé d'abord à
`suivi_avant` ; rend ce que le suivi et la spec disent à chaque
étape."""
import asyncio
from textual.widgets import Checkbox
from script.todo.qemu_deploy_form import run_deploy_form
vu = {}
ctx = dict(self.ctx, cache_offert=True)
def etat(app):
case = app.query_one("#f_monitor", Checkbox)
return (case.value, case.disabled, app._form_values()["monitor"])
async def scenario():
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 70)) as pilote:
await pilote.pause()
app.query_one("#f_monitor", Checkbox).value = suivi_avant
await pilote.pause()
vu["avant"] = etat(app)
app.query_one("#f_offline", Checkbox).value = True
await pilote.pause()
vu["cochee"] = etat(app)
app.query_one("#f_offline", Checkbox).value = False
await pilote.pause()
vu["decochee"] = etat(app)
asyncio.run(scenario())
return vu
def test_hors_ligne_le_suivi_est_force_et_grise(self):
"""Seul le déploiement suivi confie la levée à une unité systemd :
sans lui, la promesse de l'avertissement — l'amont revient à la fin
de la dernière installation, 12 h au plus — ne tient pas."""
vu = self.suivi_selon_la_case(False)
self.assertEqual(vu["avant"], (False, False, False))
self.assertEqual(
vu["cochee"],
(True, True, True),
"hors ligne, le suivi reste décochable ou n'est pas forcé",
)
def test_decocher_rend_le_suivi_tel_quil_etait(self):
vu = self.suivi_selon_la_case(False)
self.assertEqual(
vu["decochee"],
(False, False, False),
"décocher la case ne rend pas au suivi sa valeur ni sa main",
)
vu = self.suivi_selon_la_case(True)
self.assertEqual(vu["decochee"], (True, False, True))
def test_la_spec_exige_le_suivi_hors_ligne(self):
"""La défense derrière l'écran : un suivi décoché par un chemin qui
contourne la case grisée part quand même suivi."""
import asyncio
from textual.widgets import Checkbox
from script.todo.qemu_deploy_form import run_deploy_form
vu = {}
ctx = dict(self.ctx, cache_offert=True)
async def scenario():
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 70)) as pilote:
await pilote.pause()
app.query_one("#f_offline", Checkbox).value = True
await pilote.pause()
app.query_one("#f_monitor", Checkbox).value = False
await pilote.pause()
vu["valeurs"] = app._form_values()
asyncio.run(scenario())
self.assertIs(vu["valeurs"]["monitor"], True)
if vu["valeurs"]["install"]:
self.assertIs(vu["valeurs"]["install"]["monitor"], True)
def test_cocher_la_case_ne_coupe_rien(self):
"""La coupure tombe à F5, pas au clic.
Couper depuis le formulaire priverait le cache de réseau pendant
qu'on remplit l'écran — et pour de bon si l'écran est annulé, aucun
« finally » ne courant sur une case cochée.
"""
import asyncio
from textual.widgets import Checkbox
from script.todo.qemu_deploy_form import run_deploy_form
ctx = dict(self.ctx, cache_offert=True)
async def scenario():
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 70)) as pilote:
await pilote.pause()
app.query_one("#f_offline", Checkbox).value = True
await pilote.pause()
app._form_values()
asyncio.run(scenario())
coupures = [c for c in self.lancees if "nft" in c]
self.assertEqual(
coupures,
[],
f"le formulaire a coupé l'amont tout seul : {coupures}",
)
def test_f5_previent_avant_de_couper_pour_rien(self):
"""Une suite que le cache n'a jamais servie fait échouer la VM une
heure plus tard, sur « Impossible de trouver le paquet » — un message
qui ne parle ni du cache ni du hors ligne.
Même idiome que les disques orphelins : on prévient une fois, F5 à
nouveau vaut passage outre. Passer outre reste possible — le journal
peut avoir tourné, ou le cache avoir été rempli autrement.
"""
import asyncio
from textual.widgets import Checkbox
from script.qemu import cache_offline
from script.todo.qemu_deploy_form import run_deploy_form
vu = {}
ctx = dict(self.ctx, cache_offert=True)
async def scenario():
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 70)) as pilote:
await pilote.pause()
choisir_une_vm(app)
await pilote.pause()
app.query_one("#f_offline", Checkbox).value = True
await pilote.pause()
app.action_deploy()
vu["premier"] = app._result.get("spec")
app.action_deploy()
vu["second"] = app._result.get("spec")
# Les quatre autres verdicts sont truqués à vide : cette épreuve
# nomme la suite absente, et elle seule. Un verdict laissé libre lit
# la machine qui exécute les tests — son magasin, ses miroirs — et
# l'épreuve passerait alors pour une raison qui n'est pas la sienne.
with mock.patch.object(
cache_offline, "suites_absentes", lambda vms: [("ubuntu", "26.04")]
), mock.patch.object(
cache_offline, "composants_absents", lambda *a, **k: []
), mock.patch.object(
cache_offline, "manques_hors_ligne", lambda *a, **k: []
), mock.patch.object(
cache_offline, "paquets_absents", lambda *a, **k: []
), mock.patch.object(
cache_offline, "miroirs_absents", lambda *a, **k: []
):
asyncio.run(scenario())
self.assertIsNone(
vu["premier"], "le déploiement est parti sans prévenir"
)
self.assertIsNotNone(
vu["second"], "un second F5 ne passe pas outre l'avertissement"
)
def test_f5_ne_previent_pas_quand_le_cache_a_de_quoi(self):
import asyncio
from textual.widgets import Checkbox
from script.qemu import cache_offline
from script.todo.qemu_deploy_form import run_deploy_form
vu = {}
ctx = dict(self.ctx, cache_offert=True)
async def scenario():
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 70)) as pilote:
await pilote.pause()
choisir_une_vm(app)
await pilote.pause()
app.query_one("#f_offline", Checkbox).value = True
await pilote.pause()
app.action_deploy()
vu["premier"] = app._result.get("spec")
# Les CINQ verdicts se taisent : c'est la seule façon d'éprouver
# qu'un cache pourvu ne retarde personne. En laisser un libre le fait
# lire la machine qui exécute les tests — son magasin, ses miroirs,
# son journal — et l'épreuve échoue selon le poste.
with mock.patch.object(
cache_offline, "suites_absentes", lambda vms: []
), mock.patch.object(
cache_offline, "composants_absents", lambda *a, **k: []
), mock.patch.object(
cache_offline, "manques_hors_ligne", lambda *a, **k: []
), mock.patch.object(
cache_offline, "paquets_absents", lambda *a, **k: []
), mock.patch.object(
cache_offline, "miroirs_absents", lambda *a, **k: []
):
asyncio.run(scenario())
self.assertIsNotNone(
vu["premier"], "un avertissement sans motif apprend à passer outre"
)
def test_le_formulaire_ne_sait_pas_couper(self):
"""La garde structurelle : le formulaire LIT ce que le cache détient
— il en a besoin pour prévenir avant le lancement — mais il n'a
aucun moyen de poser ni de lever la coupure, quoi qu'on y ajoute.
La frontière est là et non sur le module entier : c'est le geste qui
est interdit à cet écran, pas la connaissance.
"""
src = QEMU_FORM.read_text(encoding="utf-8")
for interdit in ("cut_cmd", "restore_cmd", "nft"):
self.assertNotIn(
interdit,
src,
f"le formulaire peut couper l'amont ({interdit})",
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,312 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce que le cache pose DANS la VM, et l'ordre dans lequel il le pose.
L'interception est transparente au niveau TCP mais pas au niveau TLS : un
invité qui n'approuve pas l'autorité du cache rejette son certificat, et tout
téléchargement HTTPS échoue. Trois choses doivent donc arriver dans la VM, et
la troisième est un piège que la table seule ne dit pas :
1. le certificat, à l'endroit où la famille de distribution range ses ancres ;
2. la commande qui fait relire ce magasin, AVANT tout téléchargement ;
3. les variables que pip et npm exigent, parce qu'ils embarquent leur propre
jeu de certificats et ignorent le magasin système.
La table des chemins existe deux fois — ici en Python, et dans
script/qemu_cache/rules.go côté cache. La duplication est assumée : deploy
n'a pas à dépendre du binaire pour générer un user-data. Le dernier test la
rend sûre en comparant les deux.
"""
import argparse
import re
import sys
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE))
from script.qemu.deploy_qemu import ( # noqa: E402
CACHE_CERT_NAME,
CACHE_ENV_VARS,
CACHE_TRUST,
cache_env_reload,
cache_family,
cache_files,
cache_runcmd,
)
RULES_GO = RACINE / "script" / "qemu_cache" / "rules.go"
# Une autorité inventée : un certificat réel dans un test le figerait pour
# toujours, et celui d'un parc n'a rien à faire dans le dépôt.
PEM_DE_TEST = (
"-----BEGIN CERTIFICATE-----\n"
"MIIBkTCB+wIJAOk0000000000MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMTCXRl\n"
"c3QtY2FjaGUwHhcNMjYwMTAxMDAwMDAwWhcNMzYwMTAxMDAwMDAwWjAUMRIwEAYD\n"
"-----END CERTIFICATE-----\n"
)
def faux_args(tmp, distro="arch", ca=True):
"""Le strict nécessaire : ces fonctions ne lisent que deux champs."""
chemin = ""
if ca:
chemin = str(tmp / "ca.crt")
(tmp / "ca.crt").write_text(PEM_DE_TEST, encoding="utf-8")
return argparse.Namespace(distro=distro, cache_ca=chemin)
class TestFamilles(unittest.TestCase):
"""Le catalogue est vérifié par TestAucunSystemeNestOublie, plus bas.
La liste qui était écrite ici nommait « linuxmint », qui n'est pas un
système déployable, et taisait Proxmox, qui l'est : recopier le catalogue
dans un test le fige au jour où on l'a recopié.
"""
def test_distribution_inconnue_ne_pose_rien(self):
self.assertEqual(cache_family("plan9"), "")
class TestCertificatPose(unittest.TestCase):
def test_pose_au_bon_endroit_par_famille(self):
import tempfile
for distro, attendu in (
("arch", "/etc/ca-certificates/trust-source/anchors"),
("debian", "/usr/local/share/ca-certificates"),
("fedora", "/etc/pki/ca-trust/source/anchors"),
("opensuse", "/etc/pki/trust/anchors"),
):
with tempfile.TemporaryDirectory() as d:
fichiers = cache_files(faux_args(Path(d), distro))
self.assertEqual(len(fichiers), 1, f"{distro} : rien de posé")
chemin, mode, contenu, _ = fichiers[0]
self.assertEqual(chemin, f"{attendu}/{CACHE_CERT_NAME}")
self.assertEqual(
mode,
"0644",
"le mode doit être une CHAÎNE : cloud-init lit un entier "
"non quoté en décimal et pose des droits absurdes",
)
self.assertIn("BEGIN CERTIFICATE", contenu)
def test_rien_sans_autorite_demandee(self):
import tempfile
with tempfile.TemporaryDirectory() as d:
self.assertEqual(cache_files(faux_args(Path(d), ca=False)), [])
self.assertEqual(cache_runcmd(faux_args(Path(d), ca=False)), [])
def test_autorite_illisible_ne_casse_pas_le_deploiement(self):
"""Sans autorité la VM télécharge en direct, ce qui marche : un
chemin fautif ne doit pas empêcher de créer la machine."""
args = argparse.Namespace(distro="arch", cache_ca="/inexistant/ca.crt")
self.assertEqual(cache_files(args), [])
def test_fichier_qui_nest_pas_un_certificat_refuse(self):
import tempfile
with tempfile.TemporaryDirectory() as d:
faux = Path(d) / "ca.crt"
faux.write_text("ceci n'est pas un certificat\n", encoding="utf-8")
args = argparse.Namespace(distro="arch", cache_ca=str(faux))
self.assertEqual(cache_files(args), [])
def test_distribution_hors_table_ne_pose_rien(self):
"""Poser le fichier au hasard le rendrait inopérant sans le dire."""
import tempfile
with tempfile.TemporaryDirectory() as d:
self.assertEqual(cache_files(faux_args(Path(d), "plan9")), [])
class TestRuncmd(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.lignes = cache_runcmd(faux_args(Path(self.tmp.name), "arch"))
def tearDown(self):
self.tmp.cleanup()
def test_la_confiance_est_relue_en_premier(self):
"""Tout ce qui suit peut télécharger : la commande doit précéder."""
self.assertTrue(self.lignes, "aucune commande générée")
self.assertIn("trust extract-compat", self.lignes[0])
def test_les_trois_variables_sont_ecrites(self):
texte = "\n".join(self.lignes)
for var in CACHE_ENV_VARS:
self.assertIn(var, texte, f"{var} manque")
def test_les_variables_visent_le_faisceau_pas_notre_ancre(self):
"""Viser le seul certificat du cache ferait perdre à pip toutes les
autres autorités, et le casserait le jour où le cache disparaît."""
_, _, faisceau = CACHE_TRUST["pacman"]
texte = "\n".join(self.lignes[1:])
self.assertIn(faisceau, texte)
self.assertNotIn(CACHE_CERT_NAME, texte)
def test_les_variables_vont_dans_etc_environment(self):
"""PAM lit /etc/environment pour TOUTE session ssh, non interactive
comprise : c'est la seule voie qui atteint une commande distante."""
for ligne in self.lignes[1:]:
self.assertIn("/etc/environment", ligne)
def test_ecriture_idempotente(self):
"""runcmd ne tourne qu'une fois par instance, mais un opérateur peut
rejouer la commande : elle ne doit pas empiler les doublons."""
for ligne in self.lignes[1:]:
self.assertIn("grep -q", ligne)
def test_aucune_commande_ne_peut_faire_echouer_le_boot(self):
"""Une VM qui ne démarre pas pour un confort est un mauvais échange."""
self.assertIn("|| true", self.lignes[0])
class TestLesVariablesRelues(unittest.TestCase):
"""Une session ouverte avant cloud-init ne reçoit pas ce qu'il écrit.
PAM lit /etc/environment à l'ouverture, et la commande distante qui attend
cloud-init s'ouvre avant runcmd. La relecture est jouée ici dans un vrai
shell, sur un fichier de test : c'est son EFFET qui compte, pas son texte.
"""
def relire(self, contenu):
"""Lance la relecture sous « set -e » et rend (code, variables vues
par un processus ENFANT, PATH de la session)."""
import subprocess
import tempfile
with tempfile.TemporaryDirectory() as rep:
fichier = Path(rep) / "environment"
if contenu is not None:
fichier.write_text(contenu, encoding="utf-8")
script = (
f"set -e; {cache_env_reload(str(fichier))};"
' env | grep -E "^(PIP_CERT|REQUESTS_CA_BUNDLE|NODE_EXTRA_CA_CERTS)="'
' || true; echo "PATH=$PATH"'
)
res = subprocess.run(
["sh", "-c", script],
capture_output=True,
text=True,
env={"PATH": "/usr/bin:/bin"},
)
return res.returncode, res.stdout, res.stderr
def test_les_variables_atteignent_un_processus_enfant(self):
"""npm est un ENFANT du shell : une variable posée sans « export »
ne l'atteindrait pas."""
faisceau = "/etc/ssl/certs/ca-certificates.crt"
contenu = "".join(f"{v}={faisceau}\n" for v in CACHE_ENV_VARS)
code, sortie, err = self.relire(contenu)
self.assertEqual(code, 0, err)
for var in CACHE_ENV_VARS:
self.assertIn(f"{var}={faisceau}", sortie)
def test_le_path_de_la_session_est_garde(self):
"""Relire le fichier entier remplacerait le PATH de la session."""
code, sortie, err = self.relire(
'PATH="/nulle/part"\nNODE_EXTRA_CA_CERTS=/x.crt\n'
)
self.assertEqual(code, 0, err)
self.assertIn("PATH=/usr/bin:/bin", sortie)
def test_sans_fichier_ni_variable_rien_n_echoue(self):
"""Une VM sans cache n'a ni les variables ni, parfois, le fichier :
sous « set -e », l'installation ne doit pas s'arrêter là."""
for contenu in (None, "LANG=C\n"):
with self.subTest(contenu=contenu):
code, _, err = self.relire(contenu)
self.assertEqual(code, 0, err)
class TestAccordAvecLeGo(unittest.TestCase):
"""La table Python et la table Go doivent dire la même chose.
Sans ce test, la duplication dérive : un chemin corrigé d'un seul côté
laisse soit un cache qui annonce le mauvais répertoire, soit une VM dont
le certificat atterrit là où personne ne le lit.
"""
def test_memes_chemins_et_memes_commandes(self):
go = RULES_GO.read_text(encoding="utf-8")
bloc = go[go.index("func GuestTrustCommand") :]
trouve = dict(
(famille, (dossier, commande, faisceau))
for famille, dossier, commande, faisceau in re.findall(
r'case "([a-z]+)":\s*\n(?:\s*//[^\n]*\n)*'
r'\s*return "([^"]+)", "([^"]+)",\s*\n?\s*"([^"]+)", true',
bloc,
)
)
self.assertTrue(trouve, "aucune famille lue dans rules.go")
self.assertEqual(
trouve,
{k: tuple(v) for k, v in CACHE_TRUST.items()},
"les tables Python et Go ont divergé",
)
def test_memes_variables(self):
go = RULES_GO.read_text(encoding="utf-8")
for var in CACHE_ENV_VARS:
self.assertIn(
var, go, f"{var} est écrite côté VM mais absente du Go"
)
class TestAucunSystemeNestOublie(unittest.TestCase):
"""Tout système déployable doit pouvoir recevoir l'autorité du cache.
Le détournement s'applique à TOUT le pont : un invité qui ne reçoit pas
l'autorité est intercepté quand même et échoue sur « self-signed
certificate in certificate chain » à chaque téléchargement HTTPS. Le
message ne dit rien d'une table incomplète, et c'est ainsi que Proxmox est
resté sans autorité — la famille de paquets était recopiée à côté du
catalogue, et la copie l'avait oublié.
Le contrôle porte donc sur la PROPRIÉTÉ : le catalogue et la table des
familles doivent couvrir les mêmes systèmes.
"""
def test_chaque_systeme_du_catalogue_a_une_famille(self):
from script.qemu.deploy_qemu import DISTROS, cache_family
sans = sorted(d for d in DISTROS if not cache_family(d))
self.assertEqual(
sans,
[],
"ces systèmes seraient déployés SANS l'autorité du cache, et"
f" chaque téléchargement HTTPS y échouerait : {sans}",
)
def test_chaque_famille_sait_poser_lautorite(self):
from script.qemu.deploy_qemu import CACHE_TRUST, DISTROS, cache_family
manquantes = sorted(
{cache_family(d) for d in DISTROS} - set(CACHE_TRUST)
)
self.assertEqual(
manquantes,
[],
f"familles sans commande de confiance : {manquantes}",
)
def test_la_famille_vient_du_catalogue_et_nest_pas_recopiee(self):
"""Deux tables qui disent la même chose dérivent : c'est ce qui a
laissé Proxmox de côté."""
from script.qemu.deploy_qemu import DISTRO_PKG, cache_family
for d, attendue in DISTRO_PKG.items():
self.assertEqual(cache_family(d), attendue, d)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,232 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'installateur du cache QEMU pose-t-il ce qu'il faut, et rien de plus ?
Ce script écrit des règles de pare-feu sur le pont de l'hôte. Une règle trop
large y prive la machine de son propre réseau, et une règle laissée derrière
un service arrêté envoie les VM vers un cache éteint. Les propriétés qui
empêchent ces deux pannes se lisent dans le texte du script et de l'unité, ce
qui se vérifie en secondes et sans toucher au pare-feu de la machine qui
exécute les tests.
Le jeu de règles lui-même est vérifié en Go, où il vit : voir
script/qemu_cache/rules_test.go. Ce test contrôle qu'il n'en existe pas de
seconde copie ici.
"""
import re
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
SCRIPT = RACINE / "script" / "install" / "install_qemu_cache.sh"
SOURCES = RACINE / "script" / "qemu_cache"
class TestScriptDInstallation(unittest.TestCase):
def setUp(self):
self.assertTrue(SCRIPT.is_file(), f"script absent : {SCRIPT}")
self.texte = SCRIPT.read_text(encoding="utf-8")
def test_syntaxe_bash(self):
r = subprocess.run(
["bash", "-n", str(SCRIPT)], capture_output=True, text=True
)
self.assertEqual(r.returncode, 0, r.stderr)
def test_executable(self):
self.assertTrue(
SCRIPT.stat().st_mode & 0o111,
"le script n'est pas exécutable : le menu l'appelle par bash, mais "
"un opérateur le lance directement",
)
def test_refuse_de_tourner_sans_root(self):
self.assertIn("check_root", self.texte)
self.assertRegex(
self.texte,
r"EUID.*-ne 0",
"rien ne vérifie que le script tourne en root",
)
def test_les_regles_ne_sont_pas_recopiees(self):
"""Les règles viennent du binaire, seule source que les tests Go
vérifient. Une copie littérale ici dériverait en silence."""
for motif in ("redirect to :", "REDIRECT --to-ports", "ip daddr !="):
self.assertNotIn(
motif,
self.texte,
f"le script porte une copie des règles ({motif!r}) : "
"elles doivent sortir de --print-nft / --print-iptables",
)
self.assertIn("--print-nft", self.texte)
self.assertIn("--print-iptables", self.texte)
def test_go_verifie_avant_de_compiler(self):
self.assertIn("GO_MIN_MAJOR", self.texte)
self.assertIn("GO_MIN_MINOR", self.texte)
self.assertIn(
"go_assez_recent",
self.texte,
"la version de Go n'est pas contrôlée : un golang trop ancien "
"échoue à la compilation avec un message qui ne le dit pas",
)
def test_compile_avant_de_toucher_au_systeme(self):
"""L'ordre compte : une compilation échouée ne doit laisser ni unité,
ni règle, ni service à moitié posé. « set -e » y suffit, à condition
que la compilation précède."""
self.assertIn("set -e", self.texte)
ordre = [
self.texte.index(" compiler\n"),
self.texte.index(" ecrire_unite\n"),
]
self.assertLess(
ordre[0], ordre[1], "l'unité est écrite avant la compilation"
)
def test_sources_verifiees(self):
self.assertIn(
"go.mod",
self.texte,
"le script ne vérifie pas que les sources sont là",
)
class TestUniteSystemd(unittest.TestCase):
"""L'unité telle qu'elle est ÉCRITE, et non telle qu'elle est écrite.
Le heredoc est un gabarit : les règles y arrivent par substitution de
commande, et le compte de service par une variable. Lire le gabarit
reviendrait à vérifier autre chose que ce que systemd lira. La fonction
est donc appelée pour de vrai, vers un fichier temporaire, avec les
commandes du système neutralisées.
"""
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp()
unit = Path(cls.tmp) / "erplibre-go-qemu-cache.service"
# Le script se termine par « main "$@" » : la ligne est retirée pour
# pouvoir appeler UNE fonction sans rien installer. systemctl est
# neutralisé, la machine de test n'ayant pas à recharger systemd.
prelude = (
# EL_SRC_DIR évite que la déduction du chemin des sources échoue :
# « source <(...) » fait pointer BASH_SOURCE sur /dev/fd.
f'export EL_SRC_DIR="{SOURCES}"\n'
f'source <(sed "/^main \\"\\$@\\"/d" {SCRIPT})\n'
"systemctl() { :; }\n"
f'UNIT="{unit}"\n'
"ecrire_unite\n"
)
r = subprocess.run(
["bash", "-c", prelude], capture_output=True, text=True
)
if r.returncode != 0 or not unit.is_file():
raise AssertionError(
f"l'unité n'a pas pu être générée : {r.stdout}{r.stderr}"
)
cls.unite_texte = unit.read_text(encoding="utf-8")
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp, ignore_errors=True)
def setUp(self):
self.unite = self.unite_texte
def test_les_regles_partent_a_larret(self):
"""Sans cela, arrêter le cache laisse les VM détournées vers un port
que plus personne n'écoute : elles perdent tout téléchargement."""
self.assertIn("ExecStopPost=", self.unite)
self.assertRegex(
self.unite,
r"ExecStopPost=\+",
"le retrait des règles ne demande pas le privilège root",
)
self.assertIn("delete table ip erplibre_qemu_cache", self.unite)
def test_les_regles_sont_posees_en_root_seulement(self):
"""« + » fait tourner CETTE commande en root, alors que le service
sert les fichiers sous un compte sans privilège."""
self.assertRegex(self.unite, r"ExecStartPre=\+")
self.assertRegex(self.unite, re.compile(r"^User=\S+$", re.M))
self.assertNotRegex(
self.unite,
re.compile(r"^User=root$", re.M),
"le service sert des fichiers en root alors qu'il n'en a pas besoin",
)
def test_durcissement(self):
for directive in (
"NoNewPrivileges=true",
"ProtectSystem=strict",
"ProtectHome=true",
):
self.assertIn(directive, self.unite, f"{directive} manque")
def test_les_repertoires_ecrivables_sont_declares(self):
"""ProtectSystem=strict rend tout le système en lecture seule : sans
ReadWritePaths, le cache ne peut rien écrire et le service tourne en
échouant sur chaque objet."""
m = re.search(r"ReadWritePaths=(.+)", self.unite)
self.assertIsNotNone(m, "ReadWritePaths manque")
chemins = m.group(1)
for attendu in (
"/var/cache/erplibre_go_qemu_cache",
"/var/lib/erplibre_go_qemu_cache",
"/var/log/erplibre_go_qemu_cache.jsonl",
):
self.assertIn(attendu, chemins, f"{attendu} n'est pas écrivable")
def test_redemarre_apres_le_reseau(self):
self.assertIn("After=network.target", self.unite)
def test_aucun_chemin_de_compte_dans_lunite(self):
"""L'unité est un fichier système : y figer le checkout de celui qui a
installé la casse dès que le dépôt bouge, et fait entrer un nom de
compte dans /etc."""
self.assertNotIn(
"/home/",
self.unite,
"l'unité porte un chemin de compte",
)
self.assertNotIn("/root/", self.unite, "l'unité porte le compte root")
class TestSourcesGo(unittest.TestCase):
def test_module_present(self):
self.assertTrue(
(SOURCES / "go.mod").is_file(), "le module Go du cache est absent"
)
def test_binaire_non_versionne(self):
"""Un « go build » dans les sources y laisse un binaire de plusieurs
mégaoctets : il ne doit pas pouvoir entrer dans un commit."""
ignore = SOURCES / ".gitignore"
self.assertTrue(ignore.is_file(), ".gitignore absent des sources Go")
self.assertIn(
"erplibre_go_qemu_cache", ignore.read_text(encoding="utf-8")
)
@unittest.skipIf(
shutil.which("go") is None,
"Go absent : la suite unitaire doit rester lançable sans lui",
)
def test_go_test(self):
r = subprocess.run(
["go", "test", "./..."],
cwd=str(SOURCES),
capture_output=True,
text=True,
)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,112 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le lecteur du journal d'accès : ce qu'il montre, et ce qu'il tait.
La flèche est ce qui se lit sous une coupure : une requête sortie vers
l'internet est la seule chose qu'un déploiement hors ligne ne doit jamais
produire.
"""
import io
import json
import sys
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE))
from script.qemu import cache_journal # noqa: E402
def _ligne(**kw):
d = {
"time": "2026-09-12T04:47:09Z",
"method": "GET",
"url": "http://miroir.invalid/ubuntu/pool/main/x/xz_5.6_amd64.deb",
"class": "immutable",
"outcome": "hit",
"status": 200,
"bytes": 2048,
"upstream": False,
"client": "192.168.0.2",
}
d.update(kw)
return json.dumps(d)
class TestLaMiseEnForme(unittest.TestCase):
def test_une_requete_servie_du_disque_ne_porte_pas_la_fleche(self):
ligne = cache_journal.ligne_lisible(_ligne())
self.assertIn("·", ligne)
self.assertNotIn("↑", ligne)
self.assertIn("04:47:09", ligne)
self.assertIn("192.168.0.2", ligne)
self.assertIn("xz_5.6_amd64.deb", ligne)
def test_une_requete_sortie_porte_la_fleche(self):
"""La seule chose qu'un déploiement hors ligne ne doit pas produire."""
self.assertIn("↑", cache_journal.ligne_lisible(_ligne(upstream=True)))
def test_une_ligne_illisible_est_passee(self):
"""Le journal s'écrit pendant qu'on le lit : sa dernière ligne est
parfois tronquée."""
for brut in ('{"time": "2026-09', "", "\n", "[]", None):
self.assertEqual(cache_journal.ligne_lisible(brut), "")
def test_sans_corps_la_taille_est_un_tiret(self):
ligne = cache_journal.ligne_lisible(_ligne(bytes=0, status=504))
self.assertIn("504", ligne)
self.assertIn("-", ligne)
def test_une_ligne_sans_client_reste_lisible(self):
d = json.loads(_ligne())
del d["client"]
self.assertIn("—", cache_journal.ligne_lisible(json.dumps(d)))
class TestLeFiltreDeLAmont(unittest.TestCase):
def test_amont_ne_garde_que_ce_qui_est_sorti(self):
self.assertEqual(
cache_journal.ligne_lisible(_ligne(), amont_seul=True), ""
)
self.assertNotEqual(
cache_journal.ligne_lisible(
_ligne(upstream=True), amont_seul=True
),
"",
)
def test_le_filtre_se_demande_en_ligne_de_commande(self):
entree = io.StringIO(
_ligne() + "\n" + _ligne(upstream=True) + "\n" + _ligne() + "\n"
)
sortie = io.StringIO()
self.assertEqual(
cache_journal.main(["--amont"], entree, sortie),
0,
)
self.assertEqual(len(sortie.getvalue().splitlines()), 1)
class TestLeFluxSansFin(unittest.TestCase):
"""« tail -f » ne rend jamais la main : ce qui est lu doit sortir tout de
suite, sans quoi l'écran reste vide pendant des minutes."""
def test_chaque_ligne_est_vidangee_aussitot(self):
vidanges = []
class Sortie(io.StringIO):
def flush(self):
vidanges.append(self.getvalue().count("\n"))
sortie = Sortie()
cache_journal.main(
[], io.StringIO(_ligne() + "\n" + _ligne() + "\n"), sortie
)
self.assertEqual(vidanges, [1, 2])
if __name__ == "__main__":
unittest.main()

1054
test/test_qemu_cache_menu.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,594 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le verdict du test long dit-il la vérité sur ce qu'il a mesuré ?
Le test long crée deux VM et dure des dizaines de minutes ; son CRITÈRE, lui,
est une fonction pure sur des lignes de journal. La tester ici coûte des
millisecondes et évite la seule chose qu'un test de quarante minutes ne
pardonne pas : un verdict faux au bout de la course.
Le piège que ces cas verrouillent : Arch est une publication continue. Entre
les deux déploiements, un miroir publie des versions neuves que la seconde VM
tirera légitimement — le cache ne sert jamais un index tant que l'amont
répond, donc elle les VERRA. Un critère fondé sur « zéro octet d'amont »
déclarerait le cache en panne alors qu'il fonctionne.
"""
import argparse
import importlib.util
import json
import re
import sys
import tempfile
import unittest
import unittest.mock
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE / "long_test"))
def charger():
"""Le script long chargé comme module : il n'est pas un paquet."""
chemin = RACINE / "long_test" / "qemu_cache.py"
spec = importlib.util.spec_from_file_location("qemu_cache_long", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
QC = charger()
def ligne(url, upstream, octets=1000, issue="stored", classe="immutable"):
"""Une ligne du journal d'accès du cache.
La CLASSE est ce que le filtre lit désormais : c'est le cache qui décide
si un fichier est figé, et la tenir ici en dur revenait à laisser un index
se faire passer pour un paquet.
"""
return {
"url": url,
"upstream": upstream,
"bytes": octets,
"outcome": issue,
"class": classe,
}
PAQUET_A = (
"https://miroir.example/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst"
)
PAQUET_B = (
"https://miroir.example/arch/core/os/x86_64/git-2.51-1-x86_64.pkg.tar.zst"
)
PAQUET_NEUF = (
"https://miroir.example/arch/core/os/x86_64/rust-1.91-1-x86_64.pkg.tar.zst"
)
INDEX = "https://miroir.example/arch/core/os/x86_64/core.db"
class TestFiltre(unittest.TestCase):
def test_les_index_sont_ecartes(self):
"""Un index n'est JAMAIS servi du cache quand l'amont répond : le
compter ferait échouer un test qui mesure autre chose."""
lignes = [ligne(INDEX, True, classe="volatile"), ligne(PAQUET_A, True)]
gardees = QC.paquets_seulement(lignes)
self.assertEqual(len(gardees), 1)
self.assertEqual(gardees[0]["url"], PAQUET_A)
def test_toutes_les_familles_de_paquets(self):
"""Le filtre ne connaissait que les extensions d'Arch : sur Ubuntu ou
Fedora, la mesure ne trouvait AUCUN fichier et déclarait que rien
n'avait traversé le cache, alors qu'il venait de servir une
installation entière. Il lit maintenant la classe que le cache pose."""
for url in (
PAQUET_A.replace(".pkg.tar.zst", ".pkg.tar.xz"),
"http://miroir.example/pool/main/b/bash/bash_5.2-1_amd64.deb",
"http://miroir.example/Packages/b/bash-5.2-1.fc43.x86_64.rpm",
"https://pypi.example/ab/cd/requests-2.33.0-py3-none-any.whl",
):
self.assertEqual(
len(QC.paquets_seulement([ligne(url, True)])),
1,
f"« {url} » n'est pas compté comme fichier figé",
)
def test_le_filtre_ne_lit_pas_lextension(self):
"""Un fichier que le cache dit volatile est écarté quel que soit son
nom : la décision lui appartient, et une seconde table dériverait."""
self.assertEqual(
QC.paquets_seulement([ligne(PAQUET_A, True, classe="volatile")]),
[],
)
def test_une_url_absente_ne_casse_pas(self):
self.assertEqual(QC.paquets_seulement([{"upstream": True}]), [])
class TestVerdict(unittest.TestCase):
def test_le_cache_a_servi(self):
premier = [ligne(PAQUET_A, True), ligne(PAQUET_B, True)]
second = [
ligne(PAQUET_A, False, issue="hit"),
ligne(PAQUET_B, False, issue="hit"),
]
self.assertTrue(QC.verdict(premier, second, None))
def test_un_paquet_deja_vu_ressorti_est_un_echec(self):
premier = [ligne(PAQUET_A, True)]
second = [ligne(PAQUET_A, True)]
self.assertFalse(
QC.verdict(premier, second, None),
"un fichier déjà tiré est ressorti sur le réseau sans que le "
"verdict s'en plaigne",
)
def test_un_paquet_neuf_ne_fait_pas_echouer(self):
"""LE cas qui justifie le critère : le miroir a publié entre les deux
déploiements. La seconde VM tire légitimement du neuf, et le cache a
pourtant parfaitement servi ce qu'il avait."""
premier = [ligne(PAQUET_A, True)]
second = [
ligne(PAQUET_A, False, issue="hit"),
ligne(PAQUET_NEUF, True, octets=90_000_000),
]
self.assertTrue(
QC.verdict(premier, second, None),
"un paquet publié entre les deux VM a été pris pour une panne",
)
def test_une_mesure_vide_est_un_echec(self):
"""Le cas qui a coûté vingt minutes : un détournement posé sur le
mauvais sous-réseau laisse les VM sortir en direct. Le cache ne voit
rien, le verdict n'a « aucune faute » à signaler — et rendre vrai
déclarerait un succès qu'il n'a jamais mesuré."""
self.assertFalse(
QC.verdict([], [], None),
"un cache que personne ne traverse est déclaré bon",
)
def test_une_seconde_vm_muette_est_un_echec(self):
"""La première a rempli, la seconde n'a rien demandé : il n'y a pas
de mesure, donc pas de succès."""
self.assertFalse(QC.verdict([ligne(PAQUET_A, True)], [], None))
def test_le_melange(self):
premier = [ligne(PAQUET_A, True), ligne(PAQUET_B, True)]
second = [
ligne(PAQUET_A, False, issue="hit"),
ligne(PAQUET_B, True),
ligne(PAQUET_NEUF, True),
]
self.assertFalse(
QC.verdict(premier, second, None),
"un seul fichier déjà vu ressorti suffit à faire échouer",
)
class TestLectureDuJournal(unittest.TestCase):
def test_ne_lit_que_ce_qui_suit_le_decalage(self):
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "acces.jsonl"
p.write_text(
json.dumps(ligne(PAQUET_A, True)) + "\n", encoding="utf-8"
)
decalage = p.stat().st_size
with p.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(ligne(PAQUET_B, False)) + "\n")
lues, neuf = QC.lignes_depuis(str(p), decalage)
self.assertEqual([x["url"] for x in lues], [PAQUET_B])
self.assertEqual(neuf, p.stat().st_size)
def test_journal_tourne_repart_du_debut(self):
"""Un journal plus court que le décalage a été remplacé : lire à
l'ancienne position rendrait n'importe quoi, ou rien."""
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "acces.jsonl"
p.write_text(
json.dumps(ligne(PAQUET_A, True)) + "\n", encoding="utf-8"
)
lues, _ = QC.lignes_depuis(str(p), 10_000_000)
self.assertEqual(len(lues), 1)
def test_derniere_ligne_incomplete_sautee(self):
"""Le service écrit pendant qu'on lit : la dernière ligne peut être
tronquée, et une exception ici perdrait toute la mesure."""
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "acces.jsonl"
p.write_text(
json.dumps(ligne(PAQUET_A, True)) + '\n{"url": "tron',
encoding="utf-8",
)
lues, _ = QC.lignes_depuis(str(p), 0)
self.assertEqual(len(lues), 1)
def test_journal_absent(self):
lues, decalage = QC.lignes_depuis("/inexistant/acces.jsonl", 42)
self.assertEqual(lues, [])
self.assertEqual(decalage, 42)
class TestConventionsDuTestLong(unittest.TestCase):
"""Ce que « long_test/ » exige de ses scripts."""
def setUp(self):
self.source = (RACINE / "long_test" / "qemu_cache.py").read_text(
encoding="utf-8"
)
def test_defaire_et_a_blanc(self):
for drapeau in ("--dry-run", "--detruire"):
self.assertIn(drapeau, self.source, f"{drapeau} manque")
def test_destruction_par_uuid(self):
"""Un nom se réutilise, et « --remove-all-storage » efface un disque
pour de bon : le dépôt identifie ses machines par UUID."""
self.assertIn("detruire_etage1", self.source)
self.assertIn("attendu=", self.source)
self.assertIn("uuid_libvirt", self.source)
def test_la_coupure_est_deleguee_et_non_recopiee(self):
"""Les règles vivent dans `script/qemu/cache_offline.py`, que le
formulaire de déploiement emploie aussi : la case « Sans connexion
internet » coupe donc exactement ce que cette mesure mesure.
Leur contenu — le compte visé plutôt que le port, sans quoi la
session ssh de l'opérateur tomberait — est éprouvé chez elles, dans
`test_qemu_cache_offline.py`.
"""
self.assertIn("cache_offline.cut_cmd()", self.source)
self.assertIn("cache_offline.restore_cmd()", self.source)
self.assertNotIn("meta skuid", self.source)
def test_la_coupure_est_toujours_retiree(self):
self.assertIn("finally:", self.source)
self.assertIn("rebrancher_lamont", self.source)
def test_hors_du_lanceur_unitaire(self):
"""Le lanceur balaie « test/test_*.py » : ce script ne doit pas y
être, ni porter un nom qu'il ramasserait."""
self.assertFalse(
(RACINE / "test" / "test_qemu_cache_long.py").exists(),
"un test qui crée de vraies machines est entré dans test/",
)
class TestCeQuIlFautDefaire(unittest.TestCase):
"""Un échec ne doit pas faire perdre le moyen de défaire ce qui existe.
Le nom d'une machine est noté AVANT sa création, pour qu'une création
interrompue à mi-chemin laisse une trace. Une exécution qui échoue tout de
suite écrit donc un rapport qui NOMME une machine sans la connaître, et
s'en tenir au dernier rapport ferait retomber la destruction sur le nom —
ce que ce dépôt a appris à ne plus faire, « --remove-all-storage »
effaçant un disque pour de bon.
"""
def rapports(self, *contenus):
"""Écrit des rapports datés dans un faux dépôt de rapports.
Le balayage des machines VIVANTES est neutralisé ici : sans cela ces
contrôles liraient le libvirt de la machine qui les exécute, et
passeraient ou tomberaient selon ce qui y tourne. Le balayage a ses
propres contrôles, où il est la chose mesurée.
"""
import contextlib
import json as _json
import tempfile
from unittest import mock
d = tempfile.mkdtemp()
for i, c in enumerate(contenus):
nom = f"qemu_cache-2026090{i + 1}-000000.json"
(Path(d) / nom).write_text(_json.dumps(c), encoding="utf-8")
@contextlib.contextmanager
def isole():
with mock.patch.object(
QC.os.path,
"expanduser",
lambda p: d if "longtest" in p else p,
), mock.patch.object(QC, "machines_vivantes", return_value=[]):
yield
return d, isole()
def test_un_uuid_connu_survit_a_un_rapport_muet(self):
d, patch = self.rapports(
{"vms": ["vm-1"], "uuids": {"vm-1": "UUID-1"}},
{"vms": ["vm-1"]}, # l'échec qui suit, sans UUID
)
with patch:
machines, lus = QC.machines_a_defaire()
self.assertEqual(len(lus), 2)
self.assertEqual(
machines.get("vm-1"),
"UUID-1",
"l'échec le plus récent a fait perdre l'UUID qui permet de"
" détruire sans risque",
)
def test_les_machines_de_plusieurs_essais_sont_reunies(self):
d, patch = self.rapports(
{"vms": ["vm-1", "vm-2"], "uuids": {"vm-1": "U1", "vm-2": "U2"}},
{"vms": ["vm-3"], "uuids": {"vm-3": "U3"}},
)
with patch:
machines, _ = QC.machines_a_defaire()
self.assertEqual(
sorted(machines),
["vm-1", "vm-2", "vm-3"],
"s'en tenir au dernier rapport laisserait vivantes les machines"
" d'un essai antérieur",
)
def test_un_rapport_sans_machine_est_saute(self):
d, patch = self.rapports({"vms": []}, {"vms": ["vm-1"]})
with patch:
machines, lus = QC.machines_a_defaire()
self.assertEqual(len(lus), 1)
self.assertEqual(sorted(machines), ["vm-1"])
def test_aucun_rapport_et_aucune_machine(self):
"""Rien à lire, rien qui vit : il n'y a rien à défaire."""
d, patch = self.rapports()
with patch:
machines, lus = QC.machines_a_defaire()
self.assertEqual((machines, lus), ({}, []))
class TestUnPlanNestPasUneMesure(unittest.TestCase):
"""Un essai à blanc écrit un rapport, et le comparatif le comptait.
Le plan ne crée aucune VM et n'installe aucun paquet : ses durées valent
zéro. Comptées comme des mesures, elles montrent dans le tableau des
exécutions qui n'ont jamais eu lieu et tirent la moyenne vers le bas — un
témoin à blanc faisait ainsi croire à une comparaison qui n'existait pas.
"""
def test_un_rapport_marque_a_blanc_est_ecarte(self):
self.assertFalse(
QC.mesure_reelle({"dry_run": True, "durees": {"vm-1": 42.0}})
)
def test_un_rapport_ancien_se_trahit_par_ses_zeros(self):
"""Les rapports d'avant la marque : une installation de paquets qui
prend zéro seconde n'a pas eu lieu."""
self.assertFalse(
QC.mesure_reelle({"durees": {"vm-1": 0.0, "vm-2": 0.0}})
)
def test_une_vraie_mesure_passe(self):
self.assertTrue(
QC.mesure_reelle({"durees": {"vm-1": 19.6, "vm-2": 20.6}})
)
def test_un_rapport_sans_duree_ne_mesure_rien(self):
self.assertFalse(QC.mesure_reelle({}))
def test_le_comparatif_applique_bien_le_filtre(self):
"""Le contrôle précédent vérifie la RÈGLE ; celui-ci vérifie qu'elle
est branchée. Sans lui, retirer l'appel laisse les tests verts et
remet les lignes fantômes dans le tableau."""
with tempfile.TemporaryDirectory() as rep:
rapports = {
"qemu_cache-20260101-000001.json": {
"outil": "qemu_cache",
"debut": "2026-01-01T00:00:01",
"dry_run": True,
"durees": {"vm-1": 0.0},
},
"qemu_cache-20260101-000002.json": {
"outil": "qemu_cache",
"debut": "2026-01-01T00:00:02",
"durees": {"vm-1": 12.5},
},
}
for nom, contenu in rapports.items():
with open(Path(rep) / nom, "w", encoding="utf-8") as fh:
json.dump(contenu, fh)
with unittest.mock.patch.object(
QC.os.path, "expanduser", return_value=rep
):
vus = QC.rapports_recents()
self.assertEqual(
[r["durees"] for r in vus],
[{"vm-1": 12.5}],
"un plan à blanc revient dans le rapport de performance",
)
class TestUnPrefixeParMode(unittest.TestCase):
"""Les trois modes ne doivent plus se disputer les mêmes machines.
Ils créent tous une « première » et une « seconde » VM. Sous un préfixe
unique, lancer le témoin après la mesure butait sur « machine(s) d'un
essai précédent encore là » alors qu'il s'agissait d'une AUTRE expérience,
et il fallait tout défaire pour comparer — ce que la comparaison exige
justement de ne pas faire.
"""
def mode(self, **kw):
base = {
"sans_cache": False,
"hors_ligne": False,
"distro": "arch",
"version": "latest",
"charge": "minimum",
}
base.update(kw)
return argparse.Namespace(**base)
def test_les_trois_preflxes_different(self):
noms = {
QC.base_des_noms(self.mode()),
QC.base_des_noms(self.mode(sans_cache=True)),
QC.base_des_noms(self.mode(hors_ligne=True)),
}
self.assertEqual(
len(noms), 3, f"des modes partagent un préfixe : {noms}"
)
def test_le_temoin_se_nomme_sans_cache(self):
self.assertTrue(
QC.base_des_noms(self.mode(sans_cache=True)).startswith(
"el-no-cache-"
)
)
def test_le_nom_porte_le_systeme_et_la_charge(self):
"""Lire « virsh list » doit suffire à savoir d'où vient une machine,
et deux essais qui ne portent pas sur la même chose ne doivent plus se
disputer les mêmes noms."""
self.assertEqual(
QC.base_des_noms(
self.mode(distro="ubuntu", version="24.04", charge="erplibre")
),
"el-cache-ubuntu_2404-erplibre_odoo_18",
)
def test_latest_ne_figure_pas_dans_le_nom(self):
"""Une distribution en publication continue n'a qu'une version : le
segment ne distinguerait aucune machine d'une autre."""
self.assertEqual(
QC.base_des_noms(self.mode(distro="arch", version="latest")),
"el-cache-arch-minimum",
)
def test_un_essai_ne_bloque_pas_un_essai_different(self):
"""Le grief exact : mesurer Ubuntu butait sur des machines Arch."""
arch = QC.base_des_noms(self.mode())
ubuntu = QC.base_des_noms(
self.mode(distro="ubuntu", version="24.04", charge="erplibre")
)
self.assertFalse(ubuntu.startswith(arch))
self.assertFalse(arch.startswith(ubuntu))
def test_le_prealable_ne_regarde_que_son_propre_prefixe(self):
"""Une machine d'une autre expérience n'entre en conflit avec rien."""
vivantes = "el-cache-test-1\nel-no-cache-test-1\nautre-vm\n"
with unittest.mock.patch.object(
QC, "executer", return_value=(0, vivantes)
):
self.assertEqual(
QC.machines_vivantes("el-no-cache-test"),
["el-no-cache-test-1"],
)
def test_la_destruction_balaie_les_trois(self):
"""Les rapports sont bornés : une machine plus ancienne que la fenêtre
ne serait jamais défaite et bloquerait tous les essais suivants."""
vivantes = "el-cache-test-9\nel-offline-test-9\nel-no-cache-test-9\n"
with unittest.mock.patch.object(
QC, "executer", return_value=(0, vivantes)
), unittest.mock.patch.object(
QC.os.path, "expanduser", return_value="/inexistant"
):
machines, _ = QC.machines_a_defaire()
self.assertEqual(
sorted(machines),
["el-cache-test-9", "el-no-cache-test-9", "el-offline-test-9"],
)
class TestLeMenuNeRefabriquePasLesNoms(unittest.TestCase):
"""Le menu annonce les machines qui vont naître : il les DEMANDE.
Deux fabriques de noms dériveraient en silence, et le menu annoncerait
alors des VM qui ne sont pas celles qui apparaissent dans « virsh list » —
pire que de ne rien annoncer. Ce contrôle interdit donc au menu d'écrire un
préfixe en dur.
"""
def menu_source(self):
return (
Path(__file__).resolve().parent.parent
/ "script"
/ "todo"
/ "qemu_cache_menu.py"
).read_text(encoding="utf-8")
def test_aucun_prefixe_ecrit_en_dur(self):
trouves = set(re.findall(r'"(el-[a-z-]+)"', self.menu_source()))
self.assertEqual(
trouves,
set(),
f"le menu écrit des noms de machine en dur : {trouves}",
)
def test_le_menu_appelle_la_fabrique_du_script(self):
self.assertIn("module.nom_de_base(", self.menu_source())
class TestLeGainSeCalculeParCondition(unittest.TestCase):
"""Comparer une installation d'ERPLibre à un lot de paquets ne mesure que
le mélange.
Le rapport annonçait 46 % de gain en moyennant une seconde VM d'Ubuntu qui
installe ERPLibre en six minutes avec une seconde VM d'Arch qui pose cinq
paquets en vingt secondes. À condition égale, le même essai en donne 21 %.
"""
def rapport(self, distro, charge, cache, duree):
return {
"debut": "2026-01-01T00:00:00",
"distro": distro,
"charge": charge,
"cache": cache,
"durees": {f"vm-1": duree, f"vm-2": duree},
"octets": {},
}
def sortie(self, rapports):
import contextlib
import io as _io
tampon = _io.StringIO()
with unittest.mock.patch.object(
QC, "rapports_recents", return_value=rapports
), contextlib.redirect_stdout(tampon):
QC.rapport_comparatif()
return tampon.getvalue()
def test_deux_conditions_ne_se_melangent_pas(self):
texte = self.sortie(
[
self.rapport("ubuntu", "erplibre", True, 360),
self.rapport("ubuntu", "erplibre", False, 450),
self.rapport("arch", "minimum", True, 20),
self.rapport("arch", "minimum", False, 30),
]
)
self.assertIn("ubuntu · erplibre", texte)
self.assertIn("arch · minimum", texte)
# 450 → 360 : vingt pour cent. 30 → 20 : trente-trois.
self.assertIn("20 %", texte)
self.assertIn("33 %", texte)
def test_une_condition_sans_temoin_ne_produit_pas_de_gain(self):
texte = self.sortie(
[
self.rapport("ubuntu", "erplibre", True, 360),
self.rapport("arch", "minimum", False, 30),
]
)
self.assertNotIn("gain", texte)
self.assertIn("MÊME système", texte)
def test_le_temoin_dune_autre_condition_ne_sert_pas(self):
"""Le piège exact : un témoin Arch faisait croire à une comparaison
pour une exécution Ubuntu."""
texte = self.sortie(
[
self.rapport("ubuntu", "erplibre", True, 360),
self.rapport("arch", "minimum", False, 450),
]
)
self.assertNotIn("gain", texte)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,270 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les dépôts git que le miroir doit tenir, lus des manifestes du dépôt.
Le protocole git est une négociation : rien ne s'y cache, et c'est un MIROIR
qu'il faut tenir. À la demande, il 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 supprimé mais déplacé, sur la machine qui attend.
La liste vient donc des manifestes, seule source qui dise ce que le dépôt clone
vraiment. Un manifeste Google Repo sépare la forge (« remote ») du projet
(« project ») : l'URL est la concaténation des deux, et un même projet figure
dans plusieurs manifestes, un par version d'Odoo.
"""
import sys
import tempfile
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE))
from script.todo.qemu_cache_menu import ( # noqa: E402
depots_des_manifestes,
manifeste_extra,
manifestes_de_version,
)
def faux_depot(manifestes, listes=None):
"""Un dépôt de manifestes en dur, pour ne pas dépendre des vrais.
`listes` pose les fichiers de conf que la fusion lit — {nom: [chemins]} —
faute de quoi une version n'aurait aucun manifeste commun.
"""
d = tempfile.mkdtemp()
(Path(d) / "manifest").mkdir()
for nom, contenu in manifestes.items():
(Path(d) / "manifest" / nom).write_text(contenu, encoding="utf-8")
if listes:
(Path(d) / "conf").mkdir()
for nom, chemins in listes.items():
(Path(d) / "conf" / nom).write_text(
'"filepath"\n' + "".join(f"{c}\n" for c in chemins),
encoding="utf-8",
)
return d
def manifeste_de(projet):
"""Un manifeste minimal déclarant un seul projet."""
return (
"<manifest>"
'<remote name="F" fetch="https://f.example/" />'
f'<project name="{projet}.git" remote="F" />'
"</manifest>"
)
class TestLaBorneParVersion(unittest.TestCase):
"""Ce qu'un déploiement clone, et rien d'autre.
La règle est celle de la fusion des manifestes : les communs que listent
ses fichiers de conf, la version et son « _dev ». L'extra ne s'installe
que sur demande, le mobile seulement si son répertoire existe — ce qui
n'est pas le cas d'une VM fraîchement clonée. Les compter fait annoncer
comme manquants des dépôts qu'aucun déploiement par défaut ne clonera, et
un avertissement qui crie pour rien cesse d'être lu.
Sans version, rien n'est soustrait : le remplissage complet prend de
l'avance pour toutes les versions à la fois.
"""
MANIFESTES = {
"git_manifest_odoo18.0.xml": manifeste_de("dix-huit"),
"git_manifest_odoo18.0_dev.xml": manifeste_de("dix-huit-dev"),
"git_manifest_odoo12.0.xml": manifeste_de("douze"),
"git_manifest_erplibre_odoo.xml": manifeste_de("commun"),
"git_manifest_extra_odoo18.0.xml": manifeste_de("extra"),
"git_manifest_mobile_home.xml": manifeste_de("mobile"),
"default.staged.deprecated.xml": manifeste_de("deprecie"),
}
LISTES = {
"git_manifest_odoo.csv": ["manifest/git_manifest_erplibre_odoo.xml"],
"git_manifest.csv": [],
}
def depot(self, listes=None):
return faux_depot(self.MANIFESTES, listes or self.LISTES)
def test_la_base_retient_la_version_son_dev_et_les_communs(self):
urls = depots_des_manifestes(self.depot(), "18.0")
for nom in ("dix-huit", "dix-huit-dev", "commun"):
self.assertIn(f"https://f.example/{nom}.git", urls)
self.assertNotIn("https://f.example/douze.git", urls)
def test_ni_extra_ni_mobile_ni_deprecie_dans_la_base(self):
urls = depots_des_manifestes(self.depot(), "18.0")
for nom in ("extra", "mobile", "deprecie"):
self.assertNotIn(f"https://f.example/{nom}.git", urls)
def test_les_communs_viennent_des_listes_de_la_fusion(self):
"""Un manifeste sans numéro que les listes ne nomment pas reste
dehors : c'est la liste qui fait foi, pas le nom du fichier."""
d = self.depot({"git_manifest_odoo.csv": [], "git_manifest.csv": []})
self.assertNotIn(
"https://f.example/commun.git", depots_des_manifestes(d, "18.0")
)
def test_lextra_ne_vient_que_sur_demande(self):
d = self.depot()
extra = manifeste_extra("18.0")
self.assertNotIn(extra, manifestes_de_version(d, "18.0"))
self.assertIn(extra, manifestes_de_version(d, "18.0", extra=True))
self.assertEqual(
depots_des_manifestes(d, fichiers=[extra]),
["https://f.example/extra.git"],
)
def test_sans_version_rien_nest_soustrait(self):
self.assertEqual(len(depots_des_manifestes(self.depot())), 7)
class TestExtraction(unittest.TestCase):
def test_lurl_est_la_forge_plus_le_projet(self):
d = faux_depot(
{
"a.xml": "<manifest>"
'<remote name="F" fetch="https://forge.example/org/" />'
'<project name="outil.git" remote="F" />'
"</manifest>"
}
)
self.assertEqual(
depots_des_manifestes(d), ["https://forge.example/org/outil.git"]
)
def test_un_projet_dans_deux_manifestes_ne_compte_quune_fois(self):
"""Un même projet figure dans un manifeste par version d'Odoo : le
cloner deux fois ne ferait que perdre du temps."""
commun = (
'<manifest><remote name="F" fetch="https://f.example/" />'
'<project name="p.git" remote="F" /></manifest>'
)
d = faux_depot({"odoo17.xml": commun, "odoo18.xml": commun})
self.assertEqual(depots_des_manifestes(d), ["https://f.example/p.git"])
def test_les_barres_obliques_ne_se_doublent_pas(self):
d = faux_depot(
{
"a.xml": "<manifest>"
'<remote name="F" fetch="https://f.example/org/" />'
'<project name="/p.git" remote="F" /></manifest>'
}
)
self.assertEqual(
depots_des_manifestes(d), ["https://f.example/org/p.git"]
)
def test_un_projet_sans_forge_connue_est_saute(self):
"""Sinon l'URL serait un nom de projet nu, que git ne sait pas
cloner et dont l'échec ne se lirait qu'à l'exécution."""
d = faux_depot(
{
"a.xml": "<manifest>"
'<remote name="F" fetch="https://f.example/" />'
'<project name="orphelin.git" remote="INCONNUE" />'
'<project name="bon.git" remote="F" /></manifest>'
}
)
self.assertEqual(
depots_des_manifestes(d), ["https://f.example/bon.git"]
)
def test_un_manifeste_illisible_est_saute(self):
"""La liste sert à prendre de l'avance : en perdre une part vaut
mieux que de ne rien prendre."""
d = faux_depot(
{
"casse.xml": "<manifest><project",
"bon.xml": "<manifest>"
'<remote name="F" fetch="https://f.example/" />'
'<project name="p.git" remote="F" /></manifest>',
}
)
self.assertEqual(depots_des_manifestes(d), ["https://f.example/p.git"])
def test_aucun_manifeste_rend_une_liste_vide(self):
self.assertEqual(depots_des_manifestes(tempfile.mkdtemp()), [])
class TestLeRepliSurLaVersionDuDepot(unittest.TestCase):
"""Sans version donnée, le verdict prend celle que le checkout porte.
C'est ce qu'un déploiement pose par défaut. Sans ce repli, la lecture
redeviendrait celle de TOUS les manifestes, et l'avertissement se
remettrait à nommer des dépôts qu'aucun déploiement ne clonera — ce qui
apprend à ne plus le lire.
"""
def test_le_verdict_est_borne_par_defaut(self):
from script.todo.deploy_form_lib import _depots_declares
tous = depots_des_manifestes(str(RACINE))
bornes = _depots_declares()
self.assertTrue(tous, "aucun manifeste lu")
self.assertTrue(bornes, "la borne a tout supprimé")
self.assertLess(
len(bornes),
len(tous),
"la lecture par défaut n'est pas bornée à une version",
)
def test_la_version_lue_est_celle_du_fichier(self):
"""« .odoo-version » est la source, et non une constante figée :
changer de version d'Odoo doit changer le verdict."""
from script.todo.deploy_form_lib import _depots_declares
version = (
(RACINE / ".odoo-version").read_text(encoding="utf-8").strip()
)
self.assertEqual(
sorted(_depots_declares()),
sorted(depots_des_manifestes(str(RACINE), version)),
)
class TestLaBaseReelle(unittest.TestCase):
"""Sur les vrais manifestes, la base ne porte pas l'extra de la version
active. Sinon le pré-vol annonce des miroirs manquants pour des modules
qu'une installation par défaut ne clone jamais."""
def test_la_base_ne_porte_pas_lextra(self):
version = (
(RACINE / ".odoo-version").read_text(encoding="utf-8").strip()
)
base = set(depots_des_manifestes(str(RACINE), version))
extra = set(
depots_des_manifestes(
str(RACINE), fichiers=[manifeste_extra(version)]
)
)
if not extra:
self.skipTest(f"aucun manifeste extra pour {version}")
self.assertTrue(base, "la base de la version active est vide")
self.assertTrue(extra.isdisjoint(base), extra & base)
class TestLesVraisManifestes(unittest.TestCase):
"""Le dépôt lui-même : la liste ne doit pas se vider en silence."""
def test_le_depot_declare_bien_des_projets(self):
depots = depots_des_manifestes(str(RACINE))
self.assertGreater(
len(depots), 100, "les manifestes ne déclarent presque rien"
)
def test_toutes_les_urls_sont_absolues(self):
for u in depots_des_manifestes(str(RACINE)):
self.assertTrue(
u.startswith(("https://", "http://", "git@", "ssh://")),
f"« {u} » n'est pas une URL que git sait cloner",
)
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -128,6 +128,16 @@ class TestLaDecisionDuDeploiement(unittest.TestCase):
)
todo._qemu_install_erplibre_vm = lambda *a, **k: appels.append("muet")
todo._qemu_resolve_ips = lambda names, labels=None: {}
# Même en ligne, le déploiement relit l'état de la coupure : un hôte
# simulé sans guet et un verrou introuvable tiennent la machine de
# test hors du jeu.
import tempfile
from pathlib import Path
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
todo._qemu_shell = lambda cmd, timeout=60: 1
todo._qemu_verrou_hors_ligne_chemin = lambda: Path(tmp.name) / "absent"
base = {
"vms": [],
"existing": ["vm-a"],

View file

@ -1447,6 +1447,20 @@ class TestLeVerrouAptNeCoutePasDesMinutes(unittest.TestCase):
self.assertIn("erplibre-qga", attente)
self.assertIn("is-active", attente)
def test_lattente_relit_les_variables_du_cache(self):
"""La session distante s'ouvre avant que cloud-init n'écrive les
variables du cache : sans les relire, un npm lancé sans sudo rejette
l'autorité du cache, alors que « sudo npm » l'accepte."""
from script.qemu.deploy_qemu import cache_env_reload
attente = self.todo._qemu_cloud_init_wait()
self.assertIn(cache_env_reload(), attente)
self.assertGreater(
attente.index(cache_env_reload()),
attente.index("status --wait"),
"les variables sont relues avant que cloud-init les ait écrites",
)
def test_le_nom_du_service_est_celui_que_le_deploiement_donne(self):
"""Deux noms qui divergent et l'attente ne trouve jamais rien."""
from script.qemu import deploy_qemu
@ -1493,13 +1507,22 @@ class TestLeVerrouAptNeCoutePasDesMinutes(unittest.TestCase):
)
self.assertEqual(res.returncode, 0, res.stderr[:400])
def test_la_boucle_reste_bornee(self):
"""Sans borne, un verrou jamais rendu tiendrait l'installation pour
toujours."""
def test_la_boucle_est_bornee_par_le_temps(self):
"""La borne est une ÉCHÉANCE, pas un nombre d'essais.
Un essai coûte moins d'une seconde quand le verrou est tenu, et des
minutes quand le cache rend 504 sur chaque index : compter les essais
promettait cinq minutes et en valait des heures. Une échéance tient la
promesse quelle que soit la durée d'un essai.
"""
i = self.cmd.index("until sudo apt-get")
boucle = self.cmd[i : self.cmd.index("done;", i)]
self.assertRegex(boucle, r"-ge \d+ \]")
self.assertIn("date +%s", boucle)
self.assertIn("-ge", boucle)
self.assertIn("break", boucle)
self.assertNotRegex(boucle, r"n=\$\(\(n\+1\)\)")
# L'échéance est POSÉE avant la boucle, sans quoi elle vaudrait zéro.
self.assertIn("fin=$(( $(date +%s) + 300 ))", self.cmd[:i])
if __name__ == "__main__":