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

--- FR ---

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

Assisted-by: Claude Opus 5
2026-09-14 16:46:15 -04:00

617 lines
20 KiB
Go

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