diff --git a/long_test/descente.py b/long_test/descente.py index 7616f01..f51dcf9 100644 --- a/long_test/descente.py +++ b/long_test/descente.py @@ -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", diff --git a/long_test/install_nixos.py b/long_test/install_nixos.py index 76ffa21..939be46 100755 --- a/long_test/install_nixos.py +++ b/long_test/install_nixos.py @@ -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", diff --git a/script/qemu_cache/catalogue_en.go b/script/qemu_cache/catalogue_en.go index bd4a686..a6c9b1c 100644 --- a/script/qemu_cache/catalogue_en.go +++ b/script/qemu_cache/catalogue_en.go @@ -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", diff --git a/script/qemu_cache/gitmirror.go b/script/qemu_cache/gitmirror.go index 096dc65..96a4f4f 100644 --- a/script/qemu_cache/gitmirror.go +++ b/script/qemu_cache/gitmirror.go @@ -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. diff --git a/script/qemu_cache/gitmirror_test.go b/script/qemu_cache/gitmirror_test.go index 9b417f9..53d274f 100644 --- a/script/qemu_cache/gitmirror_test.go +++ b/script/qemu_cache/gitmirror_test.go @@ -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éé") diff --git a/script/qemu_cache/joignable_test.go b/script/qemu_cache/joignable_test.go index c5230a1..5f7860f 100644 --- a/script/qemu_cache/joignable_test.go +++ b/script/qemu_cache/joignable_test.go @@ -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) diff --git a/test/test_longtest_install_nixos.py b/test/test_longtest_install_nixos.py index c09fb27..898129a 100644 --- a/test/test_longtest_install_nixos.py +++ b/test/test_longtest_install_nixos.py @@ -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()