[ADD] cache qemu : nettoyage par âge et plafond de taille

Le cache ne diminuait jamais de lui-même et vit sur le disque de
l'orchestrateur : un parc qui déploie chaque jour finissait par le remplir,
sans que rien ne le signale. Deux règles existent désormais, éteintes par
défaut et réglées depuis le menu : l'âge du dernier usage, et un plafond de
taille qui efface le moins récemment servi jusqu'à tenir dessous, objets et
dépôts git confondus. Un minuteur systemd quotidien les applique, et une
réinstallation garde les valeurs choisies. Le menu montre d'abord ce qui
partirait.

--- EN ---

The cache never shrank by itself and lives on the orchestrator's disk: a site
deploying every day filled it up with nothing to say so. Two rules now exist,
off by default and set from the menu: the age of last use, and a size ceiling
that erases the least recently served until the cache fits under it, objects
and git mirrors alike. A daily systemd timer applies them, and a reinstall
keeps the chosen values. The menu first shows what would go.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-16 03:00:17 -04:00
parent cb57a79da0
commit f20e21b7d2
14 changed files with 742 additions and 10 deletions

View file

@ -71,6 +71,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- F5 also reads the previous offline runs of the same VM and warns « at least N addresses were missing », minus what the store holds now (`erplibre_go_qemu_cache --detient`, read-only, no root). **Deployment › QEMU cache › Fill what offline runs lacked** replays them online, through the cache
- The install log names the commit the VM runs; offline, the recap says, branch by branch, which commit the cache's mirror will give
- `long_test/qemu_cache.py --distro tous` (or a comma list) chains one campaign per catalogue system, destroys each system's VMs before the next, and ends on a table of verdict, durations and upstream bytes. A failure does not stop the series
- The QEMU download cache can clean itself up every day: by age (`EL_PURGE_AGE`, e.g. `90j`) and by size ceiling (`EL_MAX_SIZE`, e.g. `50G`, the least recently served going first, objects and git mirrors alike). Both are off by default and set from **Deployment › QEMU cache › Automatic cleanup**, which previews what would go; `--purge-to-size` runs the ceiling by hand. A reinstall keeps the values chosen
<!-- [fr] -->
@ -114,6 +115,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- F5 lit aussi les essais hors ligne précédents de la même VM et prévient « au moins N adresses ont manqué », moins ce que le magasin détient désormais (`erplibre_go_qemu_cache --detient`, en lecture seule, sans root). **Déploiement › Cache QEMU › Combler ce qui a manqué hors ligne** les rejoue en ligne, à travers le cache
- Le journal d'installation nomme le commit que la VM exécute ; hors ligne, le récapitulatif dit, branche par branche, quel commit le miroir du cache donnera
- `long_test/qemu_cache.py --distro tous` (ou une liste séparée par des virgules) enchaîne une campagne par système du catalogue, défait les VM de chaque système avant le suivant, et finit sur un tableau des verdicts, durées et octets d'amont. Un échec n'arrête pas la série
- Le cache de téléchargement QEMU peut se nettoyer chaque jour : par âge (`EL_PURGE_AGE`, ex. `90j`) et par plafond de taille (`EL_MAX_SIZE`, ex. `50G`, le moins récemment servi partant d'abord, objets et miroirs git confondus). Les deux sont désactivés par défaut et se règlent depuis **Déploiement › Cache QEMU › Nettoyage automatique**, qui montre ce qui partirait ; `--purge-to-size` applique le plafond à la main. Une réinstallation garde les valeurs choisies
<!-- [en] -->
## Changed

View file

@ -51,6 +51,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- F5 lit aussi les essais hors ligne précédents de la même VM et prévient « au moins N adresses ont manqué », moins ce que le magasin détient désormais (`erplibre_go_qemu_cache --detient`, en lecture seule, sans root). **Déploiement › Cache QEMU › Combler ce qui a manqué hors ligne** les rejoue en ligne, à travers le cache
- Le journal d'installation nomme le commit que la VM exécute ; hors ligne, le récapitulatif dit, branche par branche, quel commit le miroir du cache donnera
- `long_test/qemu_cache.py --distro tous` (ou une liste séparée par des virgules) enchaîne une campagne par système du catalogue, défait les VM de chaque système avant le suivant, et finit sur un tableau des verdicts, durées et octets d'amont. Un échec n'arrête pas la série
- Le cache de téléchargement QEMU peut se nettoyer chaque jour : par âge (`EL_PURGE_AGE`, ex. `90j`) et par plafond de taille (`EL_MAX_SIZE`, ex. `50G`, le moins récemment servi partant d'abord, objets et miroirs git confondus). Les deux sont désactivés par défaut et se règlent depuis **Déploiement › Cache QEMU › Nettoyage automatique**, qui montre ce qui partirait ; `--purge-to-size` applique le plafond à la main. Une réinstallation garde les valeurs choisies
## Modifié

View file

@ -51,6 +51,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- F5 also reads the previous offline runs of the same VM and warns « at least N addresses were missing », minus what the store holds now (`erplibre_go_qemu_cache --detient`, read-only, no root). **Deployment › QEMU cache › Fill what offline runs lacked** replays them online, through the cache
- The install log names the commit the VM runs; offline, the recap says, branch by branch, which commit the cache's mirror will give
- `long_test/qemu_cache.py --distro tous` (or a comma list) chains one campaign per catalogue system, destroys each system's VMs before the next, and ends on a table of verdict, durations and upstream bytes. A failure does not stop the series
- The QEMU download cache can clean itself up every day: by age (`EL_PURGE_AGE`, e.g. `90j`) and by size ceiling (`EL_MAX_SIZE`, e.g. `50G`, the least recently served going first, objects and git mirrors alike). Both are off by default and set from **Deployment › QEMU cache › Automatic cleanup**, which previews what would go; `--purge-to-size` runs the ceiling by hand. A reinstall keeps the values chosen
## Changed

View file

@ -40,7 +40,19 @@ EL_BYPASS_FILE="${EL_BYPASS_FILE:-/etc/erplibre_go_qemu_cache/bypass}"
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"
CONF_DIR="${EL_CONF_DIR:-/etc/erplibre_go_qemu_cache}"
PURGE_UNIT="/etc/systemd/system/erplibre-go-qemu-cache-purge.service"
PURGE_TIMER="/etc/systemd/system/erplibre-go-qemu-cache-purge.timer"
# Les réglages de nettoyage choisis depuis le menu survivent à une
# réinstallation : le fichier env est réécrit en entier, et les retrouver vides
# changerait en silence le comportement choisi. Une valeur passée dans
# l'environnement, même vide, l'emporte — c'est ainsi qu'on les désactive.
reglage_existant() {
sed -n "s/^$1=//p" "${CONF_DIR}/env" 2>/dev/null | head -n 1
}
EL_PURGE_AGE="${EL_PURGE_AGE-$(reglage_existant EL_PURGE_AGE)}"
EL_MAX_SIZE="${EL_MAX_SIZE-$(reglage_existant EL_MAX_SIZE)}"
UNIT="/etc/systemd/system/erplibre-go-qemu-cache.service"
SERVICE_USER="elqcache"
GO_MIN_MAJOR=1
@ -267,6 +279,12 @@ EL_EXCLUDE=${EL_EXCLUDE}
EL_BYPASS_FILE=${EL_BYPASS_FILE}
EL_GIT_MIRROR_DIR=${EL_GIT_MIRROR_DIR}
EL_LANG=${EL_LANG}
# Nettoyage automatique, chaque jour ; vide = désactivé. Réglable depuis le
# menu du cache de todo.py, sans réinstaller.
# EL_PURGE_AGE : ce qui n'a pas servi depuis ce délai part (ex. 90j).
# EL_MAX_SIZE : au-delà, le moins récemment servi part (ex. 50G).
EL_PURGE_AGE=${EL_PURGE_AGE}
EL_MAX_SIZE=${EL_MAX_SIZE}
CONF
chmod 0644 "${CONF_DIR}/env"
}
@ -325,6 +343,46 @@ UNITE
systemctl daemon-reload
}
ecrire_purge() {
# Un minuteur quotidien qui ne fait RIEN tant que les deux réglages sont
# vides : le nettoyage reste manuel par défaut, et le régler suffit à
# l'activer, sans réinstaller. Sous le compte du service, propriétaire du
# magasin : un effacement en root laisserait des répertoires que le service
# ne pourrait plus écrire. « $$ » rend un « $ » au shell : la variable vient
# de l'EnvironmentFile au moment de l'exécution, jamais de ce script.
cat >"$PURGE_UNIT" <<UNITE
[Unit]
Description=Nettoyage automatique du cache de téléchargement ERPLibre
After=erplibre-go-qemu-cache.service
[Service]
Type=oneshot
EnvironmentFile=${CONF_DIR}/env
User=${SERVICE_USER}
Group=${SERVICE_USER}
ExecStart=/bin/sh -c 'if [ -n "\$\$EL_PURGE_AGE" ]; then ${BIN} --cache-dir "\$\$EL_CACHE_DIR" --git-mirror-dir "\$\$EL_GIT_MIRROR_DIR" --lang "\$\$EL_LANG" --purge-older-than "\$\$EL_PURGE_AGE"; fi; if [ -n "\$\$EL_MAX_SIZE" ]; then ${BIN} --cache-dir "\$\$EL_CACHE_DIR" --git-mirror-dir "\$\$EL_GIT_MIRROR_DIR" --lang "\$\$EL_LANG" --purge-to-size "\$\$EL_MAX_SIZE"; fi'
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=${EL_CACHE_DIR} ${EL_GIT_MIRROR_DIR}
UNITE
cat >"$PURGE_TIMER" <<MINUTEUR
[Unit]
Description=Nettoyage quotidien du cache de téléchargement ERPLibre
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1h
[Install]
WantedBy=timers.target
MINUTEUR
chmod 0644 "$PURGE_UNIT" "$PURGE_TIMER"
systemctl daemon-reload
}
# ---------------------------------------------------------------------------
main() {
@ -337,6 +395,8 @@ main() {
creer_autorite
ecrire_config
ecrire_unite
ecrire_purge
systemctl enable --now erplibre-go-qemu-cache-purge.timer >/dev/null 2>&1 || true
systemctl enable erplibre-go-qemu-cache.service >/dev/null 2>&1 || true
systemctl restart erplibre-go-qemu-cache.service

View file

@ -9,9 +9,16 @@ package main
// qu'aucun T(...) n'emploie, et une traduction qui ne garde pas les mêmes
// verbes de format que son original.
var anglais = map[string]string{
"cache : variante de %s non gardée : %v": "cache: variant of %s not kept: %v",
"langue des messages : fr ou en ; à défaut, EL_LANG": "language of messages: fr or en; otherwise EL_LANG",
"granularité inconnue %q : jour, semaine ou mois": "unknown granularity %q: day, week or month",
"taille vide": "empty size",
"taille illisible %q : essayer 50G, 500M, 2T": "unreadable size %q: try 50G, 500M, 2T",
"effacer le moins récemment servi jusqu'à tenir sous cette taille (ex. 50G)": "erase the least recently served until the cache fits under this size (e.g. 50G)",
"purge au plafond : %v\n": "size purge: %v\n",
"[à blanc] ": "[dry run] ",
"plafond %s : le cache occupe %s\n": "ceiling %s: the cache occupies %s\n",
"%sobjets : %d, %s ; dépôts : %d, %s ; reste %s\n": "%sobjects: %d, %s; repositories: %d, %s; %s left\n",
"cache : variante de %s non gardée : %v": "cache: variant of %s not kept: %v",
"langue des messages : fr ou en ; à défaut, EL_LANG": "language of messages: fr or en; otherwise EL_LANG",
"granularité inconnue %q : jour, semaine ou mois": "unknown granularity %q: day, week or month",
"durée vide": "empty duration",
"durée illisible %q": "unreadable duration %q",
"durée illisible %q : essayer 30j, 12h, 90m": "unreadable duration %q: try 30d, 12h, 90m",

View file

@ -25,7 +25,7 @@ import (
"time"
)
const version = "0.2.8"
const version = "0.2.9"
func main() {
var (
@ -77,6 +77,8 @@ func main() {
T("effacer TOUT le cache : objets et dépôts en miroir"))
purgeAvant = flag.String("purge-older-than", "",
T("n'effacer que ce qui n'a pas servi depuis ce délai (ex. 30j, 12h)"))
purgeTaille = flag.String("purge-to-size", "",
T("effacer le moins récemment servi jusqu'à tenir sous cette taille (ex. 50G)"))
gitList = flag.Bool("git-mirror-list", false,
T("dire les dépôts tenus en miroir, du plus lourd au plus léger"))
gitRemove = flag.String("git-mirror-remove", "",
@ -234,6 +236,26 @@ func main() {
fmt.Printf(T("dépôts effacés : %d, %s rendus\n"), nd, HumanBytes(octd))
return
}
if *purgeTaille != "" {
plafond, err := LireTaille(*purgeTaille)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
b, err := PurgerJusqua(store, miroir, plafond, *dryRun)
if err != nil {
fmt.Fprintf(os.Stderr, T("purge au plafond : %v\n"), err)
}
prefixe := ""
if *dryRun {
prefixe = T("[à blanc] ")
}
fmt.Printf(T("plafond %s : le cache occupe %s\n"), HumanBytes(plafond), HumanBytes(b.Avant))
fmt.Printf(T("%sobjets : %d, %s ; dépôts : %d, %s ; reste %s\n"), prefixe,
b.Objets, HumanBytes(b.OctetsObjets), b.Depots, HumanBytes(b.OctetsDepots),
HumanBytes(b.Apres))
return
}
if *gitList {
depots := miroir.Depots()
if len(depots) == 0 {

View file

@ -0,0 +1,142 @@
// © 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"
"strconv"
"strings"
"time"
)
// LireTaille lit une taille : un nombre, suivi ou non d'une unité binaire.
//
// « 50G », « 50Gio », « 50GiB », « 1.5 G », « 500M », « 2T » ou un nombre
// d'octets. Les unités valent des puissances de 1024 : c'est ce qu'affiche
// HumanBytes, et un plafond lu autrement que la taille affichée ne se
// comparerait pas à elle.
func LireTaille(s string) (int64, error) {
brut := strings.TrimSpace(s)
if brut == "" {
return 0, fmt.Errorf("%s", T("taille vide"))
}
t := strings.ToUpper(strings.ReplaceAll(brut, " ", ""))
for _, suffixe := range []string{"IB", "IO", "B", "O"} {
if strings.HasSuffix(t, suffixe) && len(t) > len(suffixe) {
t = strings.TrimSuffix(t, suffixe)
break
}
}
facteur := int64(1)
if n := len(t); n > 0 {
switch t[n-1] {
case 'K':
facteur = 1 << 10
case 'M':
facteur = 1 << 20
case 'G':
facteur = 1 << 30
case 'T':
facteur = 1 << 40
}
if facteur > 1 {
t = t[:n-1]
}
}
v, err := strconv.ParseFloat(t, 64)
if err != nil || v <= 0 {
return 0, fmt.Errorf(T("taille illisible %q : essayer 50G, 500M, 2T"), brut)
}
return int64(v * float64(facteur)), nil
}
// BilanPlafond dit ce qu'une purge au plafond a effacé, ou effacerait.
type BilanPlafond struct {
Objets int
OctetsObjets int64
Depots int
OctetsDepots int64
Avant int64
Apres int64
}
// candidat est un objet du magasin ou un dépôt en miroir, avec son dernier
// usage : ce qui décide de l'ordre d'éviction.
type candidat struct {
chemin string
octets int64
usage time.Time
depot bool
}
// PurgerJusqua efface le moins récemment servi, objets et dépôts confondus,
// jusqu'à ce que l'ensemble tienne sous le plafond. aBlanc compte sans rien
// effacer.
//
// Confondus, parce que le plafond porte sur le disque et que les deux s'y
// disputent la place : un dépôt oublié depuis des mois pèse plus que mille
// paquets servis récemment. L'usage d'un objet est la date de son corps, remise à
// jour à chaque service ; celle d'un dépôt, son dernier rafraîchissement.
// Rien ne part quand le cache tient déjà sous le plafond.
func PurgerJusqua(store *Store, miroir *GitMirror, plafond int64, aBlanc bool) (BilanPlafond, error) {
var b BilanPlafond
var tous []candidat
if store != nil && store.Dir != "" {
filepath.Walk(store.Dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if horsCasier(store.Dir, p, info) {
return filepath.SkipDir
}
if info.IsDir() || filepath.Ext(p) != ".body" {
return nil
}
tous = append(tous, candidat{p, info.Size(), info.ModTime(), false})
return nil
})
}
for _, d := range miroir.Depots() {
tous = append(tous, candidat{d.Chemin, d.Octets, d.Maj, true})
}
for _, c := range tous {
b.Avant += c.octets
}
b.Apres = b.Avant
if b.Avant <= plafond {
return b, nil
}
sort.SliceStable(tous, func(i, j int) bool { return tous[i].usage.Before(tous[j].usage) })
for _, c := range tous {
if b.Apres <= plafond {
break
}
if !aBlanc {
if c.depot {
if err := miroir.Retirer(c.chemin); err != nil {
return b, err
}
} else {
if err := os.Remove(c.chemin); err != nil {
continue
}
// Le méta part AVEC le corps : orphelin, il ferait croire à
// une copie présente.
os.Remove(strings.TrimSuffix(c.chemin, ".body") + ".meta")
}
}
b.Apres -= c.octets
if c.depot {
b.Depots++
b.OctetsDepots += c.octets
} else {
b.Objets++
b.OctetsObjets += c.octets
}
}
return b, nil
}

View file

@ -0,0 +1,136 @@
// © 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"
"time"
)
func TestLireTaille(t *testing.T) {
cas := map[string]int64{
"50G": 50 << 30,
"50Gio": 50 << 30,
"50GiB": 50 << 30,
"1.5 G": 3 << 29,
"500M": 500 << 20,
"2t": 2 << 40,
"1024": 1024,
"64K": 64 << 10,
}
for brut, veut := range cas {
if got, err := LireTaille(brut); err != nil || got != veut {
t.Errorf("%q : %d (%v), attendu %d", brut, got, err, veut)
}
}
for _, brut := range []string{"", "abc", "5X", "-1G", "0"} {
if _, err := LireTaille(brut); err == nil {
t.Errorf("%q accepté", brut)
}
}
}
// objetGarde range un corps de n octets, daté d'il y a `age`, et rend sa clé.
func objetGarde(t *testing.T, s *Store, nom string, n int, age time.Duration) string {
t.Helper()
cle := Key("GET", "http://miroir.example/"+nom)
w, err := s.NewWriter(cle, Meta{URL: "http://miroir.example/" + nom, Method: "GET", Status: 200})
if err != nil {
t.Fatal(err)
}
w.Write([]byte(strings.Repeat("x", n)))
if err := w.Commit(int64(n)); err != nil {
t.Fatal(err)
}
_, corps := s.paths(cle)
quand := time.Now().Add(-age)
os.Chtimes(corps, quand, quand)
return cle
}
// Le moins récemment servi part d'abord, et la purge s'arrête dès que le
// cache tient sous le plafond : effacer plus rendrait de la place qu'on ne
// demandait pas, au prix de téléchargements repayés.
func TestLePlafondEffaceLePlusAncienDAbord(t *testing.T) {
s := &Store{Dir: t.TempDir()}
vieux := objetGarde(t, s, "vieux", 100, 90*24*time.Hour)
moyen := objetGarde(t, s, "moyen", 100, 30*24*time.Hour)
recent := objetGarde(t, s, "recent", 100, time.Hour)
b, err := PurgerJusqua(s, &GitMirror{}, 250, false)
if err != nil {
t.Fatal(err)
}
if b.Objets != 1 || b.Avant != 300 || b.Apres != 200 {
t.Errorf("bilan %+v, attendu 1 objet, 300 → 200", b)
}
if s.Detient(vieux) {
t.Error("le plus ancien est resté")
}
if !s.Detient(moyen) || !s.Detient(recent) {
t.Error("un objet plus récent est parti")
}
if metaPath, _ := s.paths(vieux); fichierExiste(metaPath) {
t.Error("le méta de l'objet effacé est resté")
}
}
func fichierExiste(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func TestSousLePlafondRienNePart(t *testing.T) {
s := &Store{Dir: t.TempDir()}
cle := objetGarde(t, s, "seul", 100, 90*24*time.Hour)
b, _ := PurgerJusqua(s, &GitMirror{}, 1000, false)
if b.Objets != 0 || !s.Detient(cle) {
t.Errorf("sous le plafond, %d objet(s) effacé(s)", b.Objets)
}
}
func TestABlancRienNEstEfface(t *testing.T) {
s := &Store{Dir: t.TempDir()}
cle := objetGarde(t, s, "vieux", 100, 90*24*time.Hour)
objetGarde(t, s, "recent", 100, time.Hour)
b, _ := PurgerJusqua(s, &GitMirror{}, 150, true)
if b.Objets != 1 || b.Apres != 100 {
t.Errorf("bilan à blanc %+v, attendu 1 objet compté", b)
}
if !s.Detient(cle) {
t.Error("une purge à blanc a effacé un objet")
}
}
// Objets et dépôts se disputent le même disque : un dépôt oublié depuis des
// mois part avant des paquets servis récemment.
func TestUnDepotOublieCedeSaPlaceAvantLesObjets(t *testing.T) {
s := &Store{Dir: t.TempDir()}
objet := objetGarde(t, s, "paquet", 100, time.Hour)
m := &GitMirror{Dir: t.TempDir()}
depot := filepath.Join(m.Dir, "forge.example", "vieux.git")
if err := os.MkdirAll(depot, 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(depot, "pack"), []byte(strings.Repeat("y", 500)), 0o644)
quand := time.Now().Add(-120 * 24 * time.Hour)
os.Chtimes(depot, quand, quand)
b, err := PurgerJusqua(s, m, 200, false)
if err != nil {
t.Fatal(err)
}
if b.Depots != 1 || b.Objets != 0 {
t.Errorf("bilan %+v, attendu le seul dépôt", b)
}
if fichierExiste(depot) {
t.Error("le dépôt oublié est resté")
}
if !s.Detient(objet) {
t.Error("l'objet récent est parti avant le dépôt oublié")
}
}

View file

@ -66,6 +66,10 @@ def option_langue(lue=False):
CACHE_CA = "/var/lib/erplibre_go_qemu_cache/ca.crt"
CACHE_SERVICE = "erplibre-go-qemu-cache.service"
# Le nettoyage automatique : une unité ponctuelle et son minuteur quotidien,
# posés par l'installateur, sans effet tant que leurs réglages sont vides.
CACHE_SERVICE_PURGE = "erplibre-go-qemu-cache-purge.service"
CACHE_MINUTEUR_PURGE = "erplibre-go-qemu-cache-purge.timer"
CACHE_CONF = "/etc/erplibre_go_qemu_cache/env"
CACHE_TABLE = "erplibre_qemu_cache"
CACHE_BYPASS = "/etc/erplibre_go_qemu_cache/bypass"
@ -382,6 +386,7 @@ class QemuCacheMenuMixin:
{"prompt_description": t("Cache - Fill what offline runs lacked")},
{"prompt_description": t("Cache - Logs")},
{"prompt_description": t("Cache - Copy it to another machine")},
{"prompt_description": t("Cache - Automatic cleanup")},
]
help_info = self.fill_help_info(choices)
while True:
@ -411,6 +416,8 @@ class QemuCacheMenuMixin:
self._cache_journaux()
elif status == "11":
self._cache_transfert()
elif status == "12":
self._cache_nettoyage_auto()
else:
print(t("Command not found !"))
@ -2075,6 +2082,139 @@ class QemuCacheMenuMixin:
print(f" [{recu}] {etat}")
print()
# ------------------------------------------------------------------
# [12] Nettoyage automatique
# ------------------------------------------------------------------
def _cache_nettoyage_auto(self):
"""Le nettoyage automatique : un minuteur quotidien, sans effet tant
que ses deux réglages sont vides. Le nettoyage reste donc manuel par
défaut, et le régler ici suffit à l'activer, sans réinstaller."""
print(f"\n🧹 {t('Automatic cleanup of the cache')}")
choices = [
{"prompt_description": t("Cleanup - Set the age limit")},
{"prompt_description": t("Cleanup - Set the size ceiling")},
{"prompt_description": t("Cleanup - Preview now (dry run)")},
{"prompt_description": t("Cleanup - Run now")},
]
help_info = self.fill_help_info(choices)
while True:
self._cache_nettoyage_etat()
status = click.prompt(help_info)
print()
if status == "0":
return False
elif status == "1":
self._cache_nettoyage_regler(
"EL_PURGE_AGE",
t("Not served since (e.g. 90j), empty to disable"),
reglage_age_valide,
)
elif status == "2":
self._cache_nettoyage_regler(
"EL_MAX_SIZE",
t("Size ceiling (e.g. 50G), empty to disable"),
reglage_taille_valide,
)
elif status == "3":
self._cache_nettoyage_lancer(a_blanc=True)
elif status == "4":
self._cache_nettoyage_lancer(a_blanc=False)
else:
print(t("Command not found !"))
def _cache_nettoyage_etat(self):
"""Les deux réglages lus dans le fichier du service, et le minuteur."""
age = cache_offline.reglage("EL_PURGE_AGE", CACHE_CONF)
taille = cache_offline.reglage("EL_MAX_SIZE", CACHE_CONF)
print(f" · {t('Age limit:')} {age or t('disabled')}")
print(f" · {t('Size ceiling:')} {taille or t('disabled')}")
etat = self._cache_lire(f"systemctl is-enabled {CACHE_MINUTEUR_PURGE}")
etat = etat.strip().splitlines()[0] if etat.strip() else ""
if etat in ("enabled", "static"):
print(f" · {t('Daily timer:')} {etat}")
else:
# Un cache posé avant que l'installateur ne pose le minuteur.
print(
f" ✗ {t('Timer absent: reinstall the cache from entry 1.')}"
)
print()
def _cache_nettoyage_regler(self, cle, invite, valide):
"""Demande une valeur, la vérifie, montre la commande et l'écrit après
confirmation. Une valeur vide désactive le réglage."""
valeur = click.prompt(invite, default="", show_default=False).strip()
if valeur and not valide(valeur):
print(f" ✗ {t('Unreadable value:')} {valeur}\n")
return
cmd = commande_ecrire_reglage(cle, valeur)
print(f"{t('Will execute:')} {cmd}")
if not click.confirm(t("Write this setting?")):
return
self.execute.exec_command_live(cmd, source_erplibre=False)
def _cache_nettoyage_lancer(self, a_blanc):
"""À blanc : ce que chaque réglage effacerait, lu sous le compte du
service. Sinon : l'unité du minuteur, lancée tout de suite."""
age = cache_offline.reglage("EL_PURGE_AGE", CACHE_CONF)
taille = cache_offline.reglage("EL_MAX_SIZE", CACHE_CONF)
if not age and not taille:
print(f" {t('Nothing to do: both settings are empty.')}\n")
return
if not a_blanc:
cmd = (
f"sudo systemctl start {CACHE_SERVICE_PURGE}"
f" && sudo journalctl -u {CACHE_SERVICE_PURGE} -n 20 --no-pager"
)
print(f"{t('Will execute:')} {cmd}\n")
self.execute.exec_command_live(cmd, source_erplibre=False)
return
for option, valeur in (
("--purge-older-than", age),
("--purge-to-size", taille),
):
if not valeur:
continue
cmd = (
f"sudo -u {cache_offline.SERVICE_USER} {CACHE_BIN}"
f" --cache-dir {CACHE_DIR} --git-mirror-dir {CACHE_MIROIR_GIT}"
f" {option} {shlex.quote(valeur)} --dry-run {option_langue()}"
)
print(f"{t('Will execute:')} {cmd}\n")
self.execute.exec_command_live(cmd, source_erplibre=False)
def reglage_age_valide(valeur):
"""Vrai si le binaire lira ce délai (voir LireDuree) : « 90j », « 12h »,
« 1h30m ». Un délai nul est refusé : il effacerait tout chaque jour."""
v = (valeur or "").strip().lower()
if not re.fullmatch(r"\d+(\.\d+)?[jd]|(\d+(\.\d+)?(ms|h|m|s))+", v):
return False
return any(c in "123456789" for c in v)
def reglage_taille_valide(valeur):
"""Vrai si le binaire lira cette taille (voir LireTaille) : « 50G »,
« 500M », « 1.5 T ». Une taille nulle est refusée."""
v = (valeur or "").strip().lower().replace(" ", "")
m = re.fullmatch(r"(\d+(\.\d+)?)([kmgt])?(io|ib|b|o)?", v)
return bool(m) and float(m.group(1)) > 0
def commande_ecrire_reglage(cle, valeur, fichier=CACHE_CONF):
"""La commande qui pose « cle=valeur » dans le fichier de réglages du
service : la ligne est remplacée si elle existe, ajoutée sinon. Une valeur
vide la laisse présente et vide, ce qui désactive le réglage. La valeur
est déjà validée : elle ne porte ni « | » ni guillemet."""
ligne = f"{cle}={valeur}"
f = shlex.quote(fichier)
script = (
f"if grep -q {shlex.quote('^' + cle + '=')} {f}; then"
f" sed -i {shlex.quote('s|^' + cle + '=.*|' + ligne + '|')} {f};"
f" else echo {shlex.quote(ligne)} >> {f}; fi"
)
return f"sudo sh -c {shlex.quote(script)}"
def exclusions_declarees(racine):
"""Les hôtes que le cache passe d'office en tunnel, lus dans SA source.

View file

@ -615,6 +615,7 @@ class TODO(
"_cache_exceptions": "Exceptions",
"_cache_journaux": "Logs",
"_cache_miroir_git": "Git mirrors",
"_cache_nettoyage_auto": "Automatic cleanup",
"prompt_execute_qemu": "QEMU/KVM",
"prompt_execute_proxmox": "Proxmox VE",
"prompt_execute_vpn": "VPN",

View file

@ -6459,6 +6459,70 @@ TRANSLATIONS = {
"fr": "Lire l'erreur ci-dessus, la corriger, et relancer l'entrée 1.",
"en": "Read the error above, fix it, and run entry 1.",
},
"Cache - Automatic cleanup": {
"fr": "🧹 Cache - Nettoyage automatique",
"en": "🧹 Cache - Automatic cleanup",
},
"Automatic cleanup of the cache": {
"fr": "Nettoyage automatique du cache",
"en": "Automatic cleanup of the cache",
},
"Cleanup - Set the age limit": {
"fr": "⏳ Nettoyage - Régler l'âge limite",
"en": "⏳ Cleanup - Set the age limit",
},
"Cleanup - Set the size ceiling": {
"fr": "📏 Nettoyage - Régler le plafond de taille",
"en": "📏 Cleanup - Set the size ceiling",
},
"Cleanup - Preview now (dry run)": {
"fr": "🔍 Nettoyage - Voir ce qui partirait (à blanc)",
"en": "🔍 Cleanup - Preview now (dry run)",
},
"Cleanup - Run now": {
"fr": "🧹 Nettoyage - Lancer maintenant",
"en": "🧹 Cleanup - Run now",
},
"Age limit:": {
"fr": "Âge limite :",
"en": "Age limit:",
},
"Size ceiling:": {
"fr": "Plafond de taille :",
"en": "Size ceiling:",
},
"disabled": {
"fr": "désactivé",
"en": "disabled",
},
"Daily timer:": {
"fr": "Minuteur quotidien :",
"en": "Daily timer:",
},
"Timer absent: reinstall the cache from entry 1.": {
"fr": "Minuteur absent : réinstaller le cache par l'entrée 1.",
"en": "Timer absent: reinstall the cache from entry 1.",
},
"Not served since (e.g. 90j), empty to disable": {
"fr": "Pas servi depuis (ex. 90j), vide pour désactiver",
"en": "Not served since (e.g. 90j), empty to disable",
},
"Size ceiling (e.g. 50G), empty to disable": {
"fr": "Plafond de taille (ex. 50G), vide pour désactiver",
"en": "Size ceiling (e.g. 50G), empty to disable",
},
"Unreadable value:": {
"fr": "Valeur illisible :",
"en": "Unreadable value:",
},
"Write this setting?": {
"fr": "Écrire ce réglage ?",
"en": "Write this setting?",
},
"Nothing to do: both settings are empty.": {
"fr": "Rien à faire : les deux réglages sont vides.",
"en": "Nothing to do: both settings are empty.",
},
"Cache - Copy it to another machine": {
"fr": "📦 Cache - L'emporter sur une autre machine",
"en": "📦 Cache - Copy it to another machine",
@ -13191,9 +13255,7 @@ TRANSLATIONS = {
"Pas de terminal pour saisir le mot de passe du coffre :"
" coffre non ouvert."
),
"en": (
"No terminal to type the vault password: vault left closed."
),
"en": ("No terminal to type the vault password: vault left closed."),
},
"Some gateways demand an embedded browser (SAML): openconnect stops on"
" \u00ab No SSO handler \u00bb and a helper is needed for the web step. It is"

View file

@ -280,7 +280,9 @@ class TestAccordAvecLInstallateur(unittest.TestCase):
def test_meme_nom_de_service(self):
texte = INSTALLATEUR.read_text(encoding="utf-8")
m = re.search(r"UNIT=\"/etc/systemd/system/([^\"]+)\"", texte)
# En début de ligne : « PURGE_UNIT=… » contient aussi « UNIT= », et
# désigne l'unité du nettoyage, pas celle du service.
m = re.search(r"^UNIT=\"/etc/systemd/system/([^\"]+)\"", texte, re.M)
self.assertIsNotNone(m, "le nom de l'unité est introuvable")
self.assertEqual(
QemuDeployMixin.QEMU_CACHE_SERVICE,

View file

@ -199,6 +199,88 @@ class TestUniteSystemd(unittest.TestCase):
self.assertNotIn("/root/", self.unite, "l'unité porte le compte root")
class TestNettoyageAutomatique(unittest.TestCase):
"""Le minuteur de nettoyage, tel qu'il est ÉCRIT, et les réglages qu'une
réinstallation doit garder."""
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp()
unit = Path(cls.tmp) / "purge.service"
timer = Path(cls.tmp) / "purge.timer"
prelude = (
f'export EL_SRC_DIR="{SOURCES}"\n'
f'source <(sed "/^main \\"\\$@\\"/d" {SCRIPT})\n'
"systemctl() { :; }\n"
f'PURGE_UNIT="{unit}"\n'
f'PURGE_TIMER="{timer}"\n'
"ecrire_purge\n"
)
r = subprocess.run(
["bash", "-c", prelude], capture_output=True, text=True
)
if r.returncode != 0 or not unit.is_file() or not timer.is_file():
raise AssertionError(f"minuteur non généré : {r.stdout}{r.stderr}")
cls.unite = unit.read_text(encoding="utf-8")
cls.minuteur = timer.read_text(encoding="utf-8")
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp, ignore_errors=True)
def test_rien_ne_part_tant_que_les_reglages_sont_vides(self):
"""Par défaut, manuel : chaque geste est gardé par son réglage."""
self.assertIn('[ -n "$$EL_PURGE_AGE" ]', self.unite)
self.assertIn('[ -n "$$EL_MAX_SIZE" ]', self.unite)
def test_les_deux_nettoyages_sont_appeles(self):
self.assertIn('--purge-older-than "$$EL_PURGE_AGE"', self.unite)
self.assertIn('--purge-to-size "$$EL_MAX_SIZE"', self.unite)
def test_les_variables_viennent_de_l_environnement_a_l_execution(self):
"""« $$ » dans l'unité : systemd rend un « $ » au shell. Un « $ » seul
serait remplacé par systemd AVANT, à partir de rien."""
self.assertIn("EnvironmentFile=", self.unite)
self.assertNotRegex(self.unite, r"(?<!\$)\$EL_")
def test_sous_le_compte_du_service_et_durci(self):
self.assertRegex(self.unite, re.compile(r"^User=elqcache$", re.M))
for directive in (
"Type=oneshot",
"NoNewPrivileges=true",
"ProtectSystem=strict",
):
self.assertIn(directive, self.unite)
self.assertIn("/var/cache/erplibre_go_qemu_cache", self.unite)
def test_le_minuteur_est_quotidien_et_rattrape_un_arret(self):
self.assertIn("OnCalendar=daily", self.minuteur)
self.assertIn("Persistent=true", self.minuteur)
def test_une_reinstallation_garde_les_reglages(self):
"""Le fichier env est réécrit en entier : sans relecture, les réglages
choisis depuis le menu disparaîtraient en silence."""
conf = Path(self.tmp) / "conf"
conf.mkdir(exist_ok=True)
(conf / "env").write_text(
"EL_PURGE_AGE=90j\nEL_MAX_SIZE=50G\n", encoding="utf-8"
)
def lire(env_extra):
prelude = (
f'export EL_SRC_DIR="{SOURCES}" EL_CONF_DIR="{conf}"\n'
+ env_extra
+ f'source <(sed "/^main \\"\\$@\\"/d" {SCRIPT})\n'
'echo "$EL_PURGE_AGE|$EL_MAX_SIZE"\n'
)
return subprocess.run(
["bash", "-c", prelude], capture_output=True, text=True
).stdout.strip()
self.assertEqual(lire(""), "90j|50G")
self.assertEqual(lire("export EL_PURGE_AGE=\n"), "|50G")
class TestSourcesGo(unittest.TestCase):
def test_module_present(self):
self.assertTrue(

View file

@ -241,7 +241,7 @@ class TestSousMenusDuCache(unittest.TestCase):
)
def test_le_menu_du_cache(self):
self.verifier("prompt_execute_qemu_cache", "_cache_systemctl", 11)
self.verifier("prompt_execute_qemu_cache", "_cache_systemctl", 12)
def test_le_menu_du_service(self):
self.verifier("_cache_service", "_cache_journal_service", 6)
@ -252,6 +252,9 @@ class TestSousMenusDuCache(unittest.TestCase):
def test_le_menu_des_miroirs(self):
self.verifier("_cache_miroir_git", "_cache_miroir_remplir", 5)
def test_le_menu_du_nettoyage(self):
self.verifier("_cache_nettoyage_auto", "_cache_nettoyage_etat", 4)
def test_le_menu_de_lage(self):
self.verifier("_cache_age", "_cache_lancer", 5)
@ -269,6 +272,7 @@ class TestSousMenusDuCache(unittest.TestCase):
("9", "_cache_combler"),
("10", "_cache_journaux"),
("11", "_cache_transfert"),
("12", "_cache_nettoyage_auto"),
):
self.assertRegex(
corps,
@ -1050,5 +1054,75 @@ class TestCeQueChaqueMachineATire(unittest.TestCase):
)
class TestLesReglagesDuNettoyage(unittest.TestCase):
"""Une valeur que le binaire ne lirait pas ferait échouer le minuteur chaque
nuit, sans que personne ne regarde son journal : elle est refusée ici."""
def test_les_delais_que_le_binaire_lit(self):
from script.todo.qemu_cache_menu import reglage_age_valide
for v in ("90j", "30d", "12h", "1h30m", "0.5j"):
self.assertTrue(reglage_age_valide(v), v)
for v in ("", "90", "jour", "0j", "-3j", "90j; rm -rf /"):
self.assertFalse(reglage_age_valide(v), v)
def test_les_tailles_que_le_binaire_lit(self):
from script.todo.qemu_cache_menu import reglage_taille_valide
for v in ("50G", "50Gio", "500M", "1.5 T", "1024"):
self.assertTrue(reglage_taille_valide(v), v)
for v in ("", "0G", "G", "50X", "50G|x"):
self.assertFalse(reglage_taille_valide(v), v)
def ecrire(self, contenu, cle, valeur):
import shlex
import subprocess
import tempfile
from script.todo.qemu_cache_menu import commande_ecrire_reglage
with tempfile.NamedTemporaryFile(
"w", delete=False, suffix=".env"
) as fh:
fh.write(contenu)
chemin = fh.name
cmd = commande_ecrire_reglage(cle, valeur, chemin)
self.assertTrue(cmd.startswith("sudo sh -c "))
script = shlex.split(cmd[len("sudo ") :])[2]
subprocess.run(["sh", "-c", script], check=True)
with open(chemin, encoding="utf-8") as fh:
return fh.read()
def test_une_ligne_existante_est_remplacee(self):
rendu = self.ecrire(
"EL_LANG=fr\nEL_PURGE_AGE=30j\n", "EL_PURGE_AGE", "90j"
)
self.assertEqual(rendu, "EL_LANG=fr\nEL_PURGE_AGE=90j\n")
def test_une_ligne_absente_est_ajoutee(self):
rendu = self.ecrire("EL_LANG=fr\n", "EL_MAX_SIZE", "50G")
self.assertEqual(rendu, "EL_LANG=fr\nEL_MAX_SIZE=50G\n")
def test_une_valeur_vide_desactive(self):
rendu = self.ecrire("EL_MAX_SIZE=50G\n", "EL_MAX_SIZE", "")
self.assertEqual(rendu, "EL_MAX_SIZE=\n")
def test_sans_reglage_rien_n_est_lance(self):
import contextlib
import io
from unittest import mock
from script.todo import qemu_cache_menu as menu
faux = menu.QemuCacheMenuMixin.__new__(menu.QemuCacheMenuMixin)
faux.execute = mock.MagicMock()
with mock.patch.object(
menu.cache_offline, "reglage", return_value=""
), contextlib.redirect_stdout(io.StringIO()):
for a_blanc in (True, False):
faux._cache_nettoyage_lancer(a_blanc=a_blanc)
faux.execute.exec_command_live.assert_not_called()
if __name__ == "__main__":
unittest.main()