[FIX] long_test et cache git : sudo pour créer, seuil nommé au refus

Les tests longs lançaient la CLI sans privilège, alors que le dossier des
images appartient à root en 755 : arrêt à l'étape 1 sur « Permission
refusée », avant d'avoir rien créé. Ils ne pouvaient donc pas tourner du
tout — pas même échouer utilement. Le menu passe sudo depuis toujours ;
c'est cette forme qui est reprise, et jamais à blanc.

Le refus de place d'un miroir reprenait un champ que tout appelant laisse
à zéro : « moins de 0 o libres » n'annonce aucun seuil et ne dit pas ce
qui a été mesuré. Treize tests héritaient du plancher de production,
10 Gio, et rougissaient sur un /tmp en tmpfs.

--- EN ---

The long tests ran the CLI unprivileged, while the image directory is
owned by root, mode 755: they stopped at step 1 on "permission denied",
before creating anything. They therefore could not run at all — not even
fail usefully. The menu has always passed sudo; that is the form taken
here, and never on a dry run.

A mirror's refusal for lack of space echoed a field every caller leaves at
zero: "less than 0 B free" announces no threshold and does not say what
was measured. Thirteen tests inherited the production floor, 10 GiB, and
went red on a tmpfs /tmp.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-16 00:43:06 -04:00
parent 83b9674176
commit 2f08ec0890
7 changed files with 179 additions and 38 deletions

View file

@ -473,7 +473,12 @@ class Descente:
def creer_etage1(self, res):
"""Une VM locale, par la CLI QEMU/KVM. Le seul étage sur du métal."""
nom = self.nom_etage(1)
argv = [
# SOUS SUDO, comme le menu le fait : le dossier des images
# appartient à root en 755 sur une installation ordinaire de libvirt,
# et la CLI s'arrête à l'étape 1 sur « Permission refusée » avant
# d'avoir rien créé. Jamais à blanc — un essai qui n'écrit rien n'a
# aucune raison de demander un mot de passe.
argv = ([] if self.dry_run else ["sudo"]) + [
os.path.join(RACINE, ".venv.erplibre/bin/python"),
os.path.join(RACINE, "script/qemu/deploy_qemu.py"),
"--distro",

View file

@ -183,7 +183,15 @@ def creer_vm(nom, journal, dry_run, memoire=MEMOIRE_MO):
Rend (nom, uuid) ou (None, ""). L'UUID est ce qui identifie la machine
pour la destruction : un nom se réutilise, un UUID non.
"""
argv = [
# SOUS SUDO, comme le menu le fait (« parts = [] if dry_run else
# ["sudo"] ») : le dossier des images appartient à root en 755 sur une
# installation ordinaire de libvirt, et la CLI s'arrête à l'étape 1 sur
# « Permission refusée » avant d'avoir rien créé. Sans cela le test ne
# peut pas tourner du tout — pas même échouer utilement.
#
# Jamais à blanc : un essai qui n'écrit rien n'a aucune raison de
# demander un mot de passe.
argv = ([] if dry_run else ["sudo"]) + [
os.path.join(RACINE, ".venv.erplibre/bin/python"),
os.path.join(RACINE, "script/qemu/deploy_qemu.py"),
"--distro",

View file

@ -30,15 +30,15 @@ var anglais = map[string]string{
"[à blanc] rien n'a été effacé.": "[dry run] nothing was erased.",
"erplibre_go_qemu_cache : requête adressée au cache lui-même, refusée.\n demandé : %s\nLa relayer la renverrait vers cette même écoute, sans fin.\n": "erplibre_go_qemu_cache: request addressed to the cache itself, refused.\n requested: %s\nRelaying it would send it back to this same listener, endlessly.\n",
"boucle refusée : %s vise le cache lui-même": "loop refused: %s targets the cache itself",
"adresse MAC vide": "empty MAC address",
"adresse MAC illisible %q": "unreadable MAC address %q",
"adresse MAC de %d octets, six attendus": "MAC address of %d bytes, six expected",
"%s est une adresse de groupe : jamais une adresse source": "%s is a group address: never a source address",
"l'adresse nulle n'identifie aucune machine": "the null address identifies no machine",
"dépôt illisible %q": "unreadable repository %q",
"dépôt sans chemin %q": "repository without a path %q",
"chemin de dépôt refusé %q": "repository path refused %q",
"miroir refusé pour %s : moins de %s libres sur le disque": "mirror refused for %s: less than %s free on disk",
"adresse MAC vide": "empty MAC address",
"adresse MAC illisible %q": "unreadable MAC address %q",
"adresse MAC de %d octets, six attendus": "MAC address of %d bytes, six expected",
"%s est une adresse de groupe : jamais une adresse source": "%s is a group address: never a source address",
"l'adresse nulle n'identifie aucune machine": "the null address identifies no machine",
"dépôt illisible %q": "unreadable repository %q",
"dépôt sans chemin %q": "repository without a path %q",
"chemin de dépôt refusé %q": "repository path refused %q",
"miroir refusé pour %s : %s libres sous %s, plancher de %s": "mirror refused for %s: %s free under %s, floor of %s",
"miroir git : sonde de %s en échec (%v) ; ses miroirs sont servis sans rafraîchissement jusqu'à ce qu'il réponde": "git mirror: probe of %s failed (%v); its mirrors are served without refresh until it answers",
"git %s : %v : %s": "git %s: %v: %s",
"miroir éteint": "mirror disabled",

View file

@ -216,10 +216,11 @@ func (g *GitMirror) Assurer(ctx context.Context, depot string) (string, bool) {
return chemin, true
}
if !existe {
if !g.placeSuffisante() {
if libres, plancher, assez := g.placeSuffisante(); !assez {
log.Printf(
T("miroir refusé pour %s : moins de %s libres sur le disque"),
depot, HumanBytes(g.PlancherLibre))
T("miroir refusé pour %s : %s libres sous %s,"+
" plancher de %s"),
depot, HumanBytes(libres), g.Dir, HumanBytes(plancher))
return "", false
}
if err := os.MkdirAll(filepath.Dir(chemin), 0o755); err != nil {
@ -611,12 +612,18 @@ func DepotsDuFichier(chemin string) ([]string, error) {
// 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.
// placeSuffisante dit s'il reste de quoi créer un miroir de plus, et rend de
// quoi le DIRE : (octets libres, plancher effectif, assez).
//
// Les trois valeurs et non le seul booléen : le refus est la seule trace que
// l'opérateur verra, et un refus qui ne nomme ni le seuil ni la mesure envoie
// chercher au mauvais endroit. Le plancher effectif n'est pas le champ —
// laissé à zéro, il vaut PlancherParDefaut — et c'est celui-là qui a refusé.
//
// 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 {
func (g *GitMirror) placeSuffisante() (int64, int64, bool) {
plancher := g.PlancherLibre
if plancher <= 0 {
plancher = PlancherParDefaut
@ -624,10 +631,12 @@ func (g *GitMirror) placeSuffisante() bool {
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
// qu'on ne sait pas faire. Le -1 dit « non mesuré », qu'aucun compte
// d'octets ne peut valoir.
return -1, plancher, true
}
return int64(st.Bavail)*int64(st.Bsize) > plancher
libres := int64(st.Bavail) * int64(st.Bsize)
return libres, plancher, libres > plancher
}
// Depot décrit un miroir tenu sur le disque.

View file

@ -153,6 +153,53 @@ func depotDEssai(t *testing.T) string {
return nu
}
// Le refus de place est la SEULE trace qu'un opérateur verra, et il nommait
// le champ PlancherLibre — laissé à zéro par tout appelant ordinaire — au
// lieu du plancher qui avait refusé. « moins de 0 o libres sur le disque »
// n'est ni vrai ni exploitable : il n'annonce aucun seuil et ne dit pas ce
// qu'il a mesuré.
func TestLeRefusConnaitLePlancherQuiARefuse(t *testing.T) {
g := &GitMirror{Dir: t.TempDir()}
libres, plancher, assez := g.placeSuffisante()
if plancher != PlancherParDefaut {
t.Fatalf("plancher rendu %d, le défaut est %d",
plancher, PlancherParDefaut)
}
if libres < 0 {
t.Skip("place non mesurable sur ce répertoire")
}
if assez != (libres > plancher) {
t.Fatalf("verdict %v pour %d libres sous un plancher de %d",
assez, libres, plancher)
}
}
// Une mesure impossible ne bloque pas — c'est le choix déjà écrit — mais elle
// doit se DISTINGUER d'un disque vide : zéro octet libre est une mesure, pas
// une absence de mesure.
func TestUnePlaceNonMesurableNeBloquePasEtSeDistingue(t *testing.T) {
g := &GitMirror{Dir: filepath.Join(t.TempDir(), "nexiste", "pas")}
libres, _, assez := g.placeSuffisante()
if !assez {
t.Fatal("une mesure impossible ne doit pas refuser le miroir")
}
if libres != -1 {
t.Fatalf("libres %d : une mesure absente doit se distinguer", libres)
}
}
// Le plancher des tests qui ne portent PAS sur le plancher.
//
// Le défaut de production — 10 Gio laissés au disque de l'orchestrateur — n'a
// rien à voir avec un miroir de trois commits dans un répertoire temporaire,
// et il fait échouer treize tests sur toute machine dont le répertoire
// temporaire offre moins que cela : un /tmp en tmpfs y suffit. Le refus était
// alors pris pour une panne du miroir.
//
// Le plancher lui-même est éprouvé plus bas, avec une valeur qu'aucun disque
// n'atteint.
const plancherDEssai int64 = 1
// 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) {
@ -160,7 +207,8 @@ func TestClonerAuTraversDuMiroir(t *testing.T) {
t.Skip("git-http-backend absent de cette machine")
}
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 2 * time.Minute}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 2 * time.Minute}
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
@ -200,7 +248,8 @@ func TestClonerAuTraversDuMiroir(t *testing.T) {
// peut faire de ce protocole.
func TestUnMiroirExistantSertQuandLAmontEstMuet(t *testing.T) {
amont, srvAmont := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: time.Minute}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: time.Minute}
if _, pret := g.Assurer(context.Background(), amont); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
@ -221,7 +270,8 @@ func TestUnMiroirExistantSertQuandLAmontEstMuet(t *testing.T) {
// 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}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 20 * time.Second}
chemin, pret := g.Assurer(
context.Background(), "https://127.0.0.1:1/nexiste/pas.git")
if pret {
@ -270,7 +320,8 @@ func TestDepotsDuFichier(t *testing.T) {
// 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}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 30 * time.Second}
depots := []string{
bon,
"https://127.0.0.1:1/absent.git",
@ -293,7 +344,8 @@ func TestUnDepotEnEchecNEmportePasLesAutres(t *testing.T) {
// ê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}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 30 * time.Second}
if r, e := g.Prefetch(
context.Background(), []string{amont}, 2, nil,
); r != 1 || e != 0 {
@ -331,7 +383,8 @@ func TestLePlancherRefuseUnMiroirDeplus(t *testing.T) {
// 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}
g := &GitMirror{
PlancherLibre: plancherDEssai, 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éé")
}
@ -350,7 +403,8 @@ func TestLePlancherNEmpechePasDeServirLexistant(t *testing.T) {
// 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}
g := &GitMirror{
PlancherLibre: plancherDEssai, 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éé")
@ -383,7 +437,8 @@ func TestCeQueLeMiroirSertEstCompte(t *testing.T) {
// 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()}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir()}
for nom, poids := range map[string]int{
"h/petit.git": 10, "h/gros.git": 5000, "h/moyen.git": 500,
} {
@ -415,7 +470,8 @@ func TestLesDepotsSontTriesParTaille(t *testing.T) {
// 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()}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir()}
dehors := filepath.Join(t.TempDir(), "ailleurs.git")
if err := os.MkdirAll(dehors, 0o755); err != nil {
t.Fatal(err)
@ -432,7 +488,8 @@ func TestRetirerRefuseCeQuiEstDehors(t *testing.T) {
func TestRetirerEffaceLeMiroir(t *testing.T) {
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
g := &GitMirror{
PlancherLibre: plancherDEssai, 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éé")
@ -458,9 +515,10 @@ func TestRetirerEffaceLeMiroir(t *testing.T) {
func TestUnRafraichissementNattendPasCommeUnClonage(t *testing.T) {
amont, srv := amontGit(t)
g := &GitMirror{
Dir: t.TempDir(),
Delai: 30 * time.Minute,
DelaiMaj: 2 * time.Second,
PlancherLibre: plancherDEssai,
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éé")

View file

@ -355,7 +355,8 @@ func amontGitEnPanne(t *testing.T) (string, string, *int64, *atomic.Bool) {
func TestUnAmontConnuMuetNeRafraichitPasLeMiroir(t *testing.T) {
depot, appels, _ := amontGitCompte(t)
g := &GitMirror{
Dir: t.TempDir(), Delai: 30 * time.Second, Muets: NouvelleJoignabilite(),
PlancherLibre: plancherDEssai,
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éé")
@ -377,7 +378,8 @@ func TestUnAmontConnuMuetNeRafraichitPasLeMiroir(t *testing.T) {
func TestUneSondeRefuseeRetientLHoteEtSertLeMiroir(t *testing.T) {
depot, appels, _ := amontGitCompte(t)
g := &GitMirror{
Dir: t.TempDir(), Delai: 30 * time.Second, Muets: NouvelleJoignabilite(),
PlancherLibre: plancherDEssai,
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éé")
@ -414,7 +416,8 @@ func sansMandataire(*http.Request) (*url.URL, error) { return nil, nil }
func miroirCree(t *testing.T, depot string) string {
t.Helper()
dir := t.TempDir()
creation := &GitMirror{Dir: dir, Delai: 30 * time.Second}
creation := &GitMirror{
PlancherLibre: plancherDEssai, Dir: dir, Delai: 30 * time.Second}
if _, pret := creation.Assurer(context.Background(), depot); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
@ -427,7 +430,8 @@ func miroirCree(t *testing.T, depot string) string {
func TestUnRafraichissementManqueSurUnAmontJoignableEstRetente(t *testing.T) {
depot, appels, panne := amontGitCompte(t)
g := &GitMirror{
Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute,
PlancherLibre: plancherDEssai,
Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute,
Muets: NouvelleJoignabilite(),
Sonder: func(context.Context, string) error { return nil },
}
@ -452,7 +456,8 @@ 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,
PlancherLibre: plancherDEssai,
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.
@ -519,7 +524,8 @@ func pousserUnCommit(t *testing.T, nu string) string {
func TestUnRafraichissementEn500NeFigePasLeMiroir(t *testing.T) {
depot, nu, _, panne := amontGitEnPanne(t)
g := &GitMirror{
Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute,
PlancherLibre: plancherDEssai,
Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute,
Muets: NouvelleJoignabilite(), Mandataire: sansMandataire,
}
panne.Store(true)

View file

@ -312,5 +312,60 @@ class LeVerrouEtLeMenu(unittest.TestCase):
self.assertTrue(TRANSLATIONS[cle].get("en"))
class LaCreationDeLaVmEstPrivilegiee(unittest.TestCase):
"""Le dossier des images appartient à root en 755 sur une installation
ordinaire de libvirt.
Sans sudo, la CLI s'arrête à l'étape 1 sur « Permission refusée » avant
d'avoir rien créé : le test ne peut pas tourner du tout, pas même échouer
utilement. Le menu — le chemin du produit — passe sudo dès que ce n'est
pas un essai à blanc, et c'est cette forme qui est reprise ici.
"""
def _module(self):
sys.path.insert(0, str(RACINE / "long_test"))
import install_nixos
return install_nixos
def test_the_real_run_asks_for_privilege(self):
mod = self._module()
vu = {}
class Fini:
returncode = 1
def faux_run(argv, **kw):
vu["argv"] = argv
return Fini()
vrai = mod.subprocess.run
self.addCleanup(setattr, mod.subprocess, "run", vrai)
mod.subprocess.run = faux_run
mod.creer_vm("essai", None, dry_run=False)
self.assertEqual("sudo", vu["argv"][0])
self.assertIn("deploy_qemu.py", " ".join(vu["argv"]))
def test_the_dry_run_asks_for_none(self):
"""Un essai qui n'écrit rien n'a aucune raison de demander un mot de
passe, et le menu ne le demande pas non plus."""
mod = self._module()
vu = []
vrai = mod.dire
self.addCleanup(setattr, mod, "dire", vrai)
mod.dire = lambda texte, journal=None: vu.append(texte)
nom, uuid = mod.creer_vm("essai", None, dry_run=True)
self.assertEqual(("essai", ""), (nom, uuid))
ligne = " ".join(vu)
self.assertIn("deploy_qemu.py", ligne)
self.assertNotIn("sudo", ligne)
def test_its_sibling_does_the_same(self):
"""descente.py crée l'étage 1 de la même façon, et l'oubli y était
le même : un correctif posé d'un seul côté laisse l'autre au mur."""
src = (RACINE / "long_test/descente.py").read_text(encoding="utf-8")
self.assertIn('([] if self.dry_run else ["sudo"]) + [', src)
if __name__ == "__main__":
unittest.main()