[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:
parent
cb57a79da0
commit
f20e21b7d2
14 changed files with 742 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
142
script/qemu_cache/plafond.go
Normal file
142
script/qemu_cache/plafond.go
Normal 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
|
||||
}
|
||||
136
script/qemu_cache/plafond_test.go
Normal file
136
script/qemu_cache/plafond_test.go
Normal 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é")
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue