erplibre/script/qemu_cache/gitmirror.go
Mathieu Benoit 2f08ec0890 [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
2026-09-16 21:31:03 -04:00

714 lines
23 KiB
Go

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/cgi"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
)
// Pourquoi un MIROIR et non un cache pour git.
//
// Le protocole « smart HTTP » de git est une NÉGOCIATION : le client annonce
// ce qu'il détient, le serveur calcule et envoie ce qui manque. Deux clients
// dans des états différents reçoivent des octets différents pour la même URL,
// si bien qu'aucune réponse ne se réutilise — c'est pour cela que le cache ne
// garde rien de ces échanges.
//
// Or c'est là que part l'essentiel du réseau d'une installation ERPLibre : le
// dépôt tire plus de trois cents dépôts, et le trafic git y pèse plusieurs
// fois celui des paquets. Un cache qui l'ignore laisse donc le gros du travail
// sortir deux fois.
//
// La réponse n'est pas de garder des réponses mais de tenir des DÉPÔTS. Un
// miroir nu par dépôt amont, rafraîchi quand l'amont répond, et le protocole
// servi depuis ce miroir par « git http-backend » — le programme de git
// lui-même, plutôt qu'une réimplémentation qui divergerait à la première
// version du protocole.
//
// Ce que cela coûte, et qu'il faut savoir : un miroir est COMPLET. Trois cents
// dépôts Odoo pèsent plusieurs gigaoctets sur le disque de l'orchestrateur, et
// rien ne les efface, comme pour le reste de ce cache.
// GitMirror tient les dépôts nus et les sert.
type GitMirror struct {
// Dir est la racine des miroirs. Vide, le miroir est éteint et git
// retombe sur le simple relais vers l'amont.
Dir string
// Frais borne le rafraîchissement : deux requêtes rapprochées sur le même
// dépôt ne déclenchent qu'une récupération. « repo sync » interroge chaque
// dépôt plusieurs fois de suite, et sans cette borne chacune paierait un
// aller-retour vers l'amont.
Frais time.Duration
// Backend est le chemin de « git-http-backend ». Vide, il est cherché aux
// endroits usuels.
Backend string
// Delai borne un CLONAGE. Un dépôt Odoo complet descend en minutes, pas
// en secondes, et l'abandonner à mi-chemin ne laisse rien d'utilisable.
Delai time.Duration
// DelaiMaj borne un RAFRAÎCHISSEMENT, et il est court à dessein.
//
// Un miroir qui existe est déjà servable : si l'amont ne répond pas, la
// bonne réponse est de servir ce qu'on a, tout de suite. Avec le délai du
// clonage, un amont coupé ferait attendre chaque dépôt jusqu'à son terme —
// pour les trois cents dépôts d'une installation, des heures d'attente
// pour un déploiement qui aurait pu être servi en entier depuis le disque.
DelaiMaj time.Duration
// PlancherLibre est la place qu'on refuse d'entamer. En dessous, aucun
// NOUVEAU miroir n'est créé et la requête repart vers l'amont : le cache
// perd son avance, il ne remplit pas le disque de l'orchestrateur.
//
// Un miroir est complet là où « repo sync » clone en profondeur un : le
// facteur entre les deux est celui de l'historique, et il ne se devine
// pas. Les miroirs DÉJÀ tenus continuent d'être rafraîchis et servis —
// une mise à jour ne coûte que ce qui a changé.
PlancherLibre int64
// Muets est la mémoire des amonts injoignables, partagée avec le relais.
// Elle ne retient que des établissements manqués. Nulle, rien n'est
// retenu d'une requête à l'autre.
Muets *Joignabilite
// Sonder vérifie qu'un amont accepte une connexion. Nul, une connexion
// TCP est ouverte puis refermée.
Sonder func(ctx context.Context, adresse string) error
// Mandataire dit par quel mandataire HTTP une URL sortirait. Nul, c'est
// http.ProxyFromEnvironment : l'environnement même que « git remote
// update » lit.
Mandataire func(*http.Request) (*url.URL, error)
mu sync.Mutex
verrous map[string]*sync.Mutex
vus map[string]time.Time
// figes retient les hôtes dont une sonde manquée a déjà été dite au
// journal : une ligne par panne, pas une par requête.
figes map[string]bool
}
// backendsUsuels : les chemins où les distributions posent git-http-backend.
// Aucune ne les met au même endroit, et il n'est pas dans le PATH.
var backendsUsuels = []string{
"/usr/lib/git-core/git-http-backend",
"/usr/libexec/git-core/git-http-backend",
"/usr/local/libexec/git-core/git-http-backend",
}
// TrouverBackend rend le chemin de git-http-backend, ou "" s'il manque.
func TrouverBackend() string {
for _, c := range backendsUsuels {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
// Actif dit si le miroir peut servir. Un répertoire sans le programme de git
// ne sert à rien : mieux vaut le relais vers l'amont, qui fonctionne.
func (g *GitMirror) Actif() bool {
return g != nil && g.Dir != "" && g.backend() != ""
}
func (g *GitMirror) backend() string {
if g.Backend == "" {
return TrouverBackend()
}
// Le chemin imposé est vérifié comme les autres : un miroir qui se dirait
// actif avec un programme absent servirait des 500 à chaque dépôt, là où
// le relais vers l'amont, lui, fonctionne.
if st, err := os.Stat(g.Backend); err == nil && !st.IsDir() {
return g.Backend
}
return ""
}
// DepotDeURL découpe une URL de négociation en (URL du dépôt amont, reste).
//
// « https://h/o/d.git/info/refs?service=… » rend « https://h/o/d.git » et
// « /info/refs ». Rend faux quand l'URL n'est pas une négociation : le
// découpage n'aurait alors aucun sens.
func DepotDeURL(u *url.URL) (string, string, bool) {
if u == nil {
return "", "", false
}
for _, s := range gitSmartPaths {
if strings.HasSuffix(u.Path, s) {
base := strings.TrimSuffix(u.Path, s)
if base == "" || base == "/" {
return "", "", false
}
amont := *u
amont.Path = base
amont.RawQuery = ""
return amont.String(), s, true
}
}
return "", "", false
}
// CheminMiroir rend le répertoire du miroir d'un dépôt.
//
// L'hôte fait partie du chemin : deux forges peuvent servir « /odoo/odoo », et
// les confondre donnerait à l'une le contenu de l'autre. Le « .git » final est
// posé une seule fois, l'amont l'écrivant tantôt et tantôt non.
func (g *GitMirror) CheminMiroir(depot string) (string, error) {
u, err := url.Parse(depot)
if err != nil || u.Host == "" {
return "", fmt.Errorf(T("dépôt illisible %q"), depot)
}
chemin := strings.Trim(u.Path, "/")
chemin = strings.TrimSuffix(chemin, ".git")
if chemin == "" {
return "", fmt.Errorf(T("dépôt sans chemin %q"), depot)
}
// Un « .. » dans le chemin ferait écrire hors de la racine.
for _, seg := range strings.Split(chemin, "/") {
if seg == "." || seg == ".." || seg == "" {
return "", fmt.Errorf(T("chemin de dépôt refusé %q"), depot)
}
}
return filepath.Join(g.Dir, u.Host, chemin+".git"), nil
}
func (g *GitMirror) verrou(cle string) *sync.Mutex {
g.mu.Lock()
defer g.mu.Unlock()
if g.verrous == nil {
g.verrous = map[string]*sync.Mutex{}
}
if _, ok := g.verrous[cle]; !ok {
g.verrous[cle] = &sync.Mutex{}
}
return g.verrous[cle]
}
// Assurer rend le miroir prêt et dit s'il est utilisable.
//
// Trois issues, et la troisième est celle qui rend le hors-ligne possible :
// le miroir est créé, le miroir est rafraîchi, ou l'amont est muet mais un
// miroir existe déjà — il sert alors tel quel. Un amont muet SANS miroir rend
// faux, et l'appelant retombe sur le relais, qui donnera au client la vraie
// erreur du réseau plutôt qu'une erreur inventée ici.
func (g *GitMirror) Assurer(ctx context.Context, depot string) (string, bool) {
chemin, err := g.CheminMiroir(depot)
if err != nil {
return "", false
}
v := g.verrou(chemin)
v.Lock()
defer v.Unlock()
_, statErr := os.Stat(filepath.Join(chemin, "HEAD"))
existe := statErr == nil
if existe && g.recent(chemin) {
return chemin, true
}
if !existe {
if libres, plancher, assez := g.placeSuffisante(); !assez {
log.Printf(
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 {
return "", false
}
if err := g.git(ctx, "", "clone", "--mirror", depot, chemin); err != nil {
// Un clonage à moitié fait laisserait un répertoire que la
// prochaine requête prendrait pour un miroir valide.
os.RemoveAll(chemin)
return "", false
}
g.noter(chemin)
return chemin, true
}
if !g.amontJoignable(ctx, depot) {
// Amont connu muet, ou qui vient de refuser la sonde : le miroir
// sert tel quel, sans payer le délai d'une mise à jour vouée à
// l'échec.
return chemin, true
}
if err := g.gitBorne(
ctx, g.delaiMaj(), chemin, "remote", "update", "--prune",
); err != nil {
// Le miroir d'hier vaut mieux que rien : il sert, et c'est ce qui
// permet de déployer sans réseau. La tentative n'est notée comme un
// rafraîchissement — les requêtes suivantes du dépôt servies sans
// la rejouer pendant « Frais » — que si la sonde échoue désormais.
// Un amont qui l'accepte a échoué autrement : un 5xx, une
// poignée de main coupée, un délai sur un gros dépôt. La requête
// suivante retente alors, sans quoi le miroir servirait une branche
// en retard à une installation dont la forge répond.
if g.sondeRefusee(ctx, depot) {
g.noter(chemin)
}
return chemin, true
}
g.noter(chemin)
return chemin, true
}
// amontJoignable dit si l'amont d'un dépôt vaut une tentative de mise à jour.
//
// Faux quand la mémoire partagée le tient pour muet, ou quand la sonde se
// voit refuser la connexion (sondeRefusee) ; l'échec est alors retenu, et les
// dépôts suivants du même hôte n'essaient même plus. Sans cette sonde, chaque
// dépôt d'un amont coupé paie le délai entier d'une mise à jour : pour les
// centaines de dépôts d'une installation, l'attente domine tout le reste.
//
// Vrai quand l'adresse ne se déduit pas : git tentera, et son propre délai
// bornera l'attente.
func (g *GitMirror) amontJoignable(ctx context.Context, depot string) bool {
u, adresse, ok := adresseDuDepot(depot)
if !ok {
return true
}
if g.Muets.ConnuMuet(adresse) {
return false
}
return !g.sonderAmont(ctx, u, adresse)
}
// adresseDuDepot rend l'URL d'un dépôt et son « hôte:port », faux quand
// l'adresse ne se déduit pas.
func adresseDuDepot(depot string) (*url.URL, string, bool) {
u, err := url.Parse(depot)
if err != nil || u.Hostname() == "" {
return nil, "", false
}
adresse := adresseAmont(u)
if !strings.Contains(adresse, ":") {
return nil, "", false
}
return u, adresse, true
}
// sondeRefusee sonde l'amont d'un dépôt, sans consulter la mémoire, et dit
// si la connexion a échoué. Faux quand l'adresse ne se déduit pas, ou quand
// un mandataire porte l'URL.
func (g *GitMirror) sondeRefusee(ctx context.Context, depot string) bool {
u, adresse, ok := adresseDuDepot(depot)
if !ok {
return false
}
return g.sonderAmont(ctx, u, adresse)
}
// sonderAmont ouvre une connexion vers l'amont et dit si elle a échoué.
//
// Un échec d'établissement est retenu dans la mémoire partagée, et dit au
// journal UNE fois par panne et par hôte : un miroir servi sans
// rafraîchissement doit se voir, sans qu'une installation de trois cents
// dépôts n'y écrive trois cents lignes. La première réussite efface l'un et
// l'autre.
//
// Aucune sonde quand un mandataire porte l'URL : « git remote update » passe
// par lui, et une connexion directe, refusée sur un hôte dont c'est la seule
// sortie, figerait tous ses miroirs pour toujours. L'amont est alors tenu
// pour joignable, et c'est git qui tranche.
func (g *GitMirror) sonderAmont(
ctx context.Context, u *url.URL, adresse string,
) bool {
if g.parMandataire(u) {
return false
}
sonder := g.Sonder
if sonder == nil {
sonder = sonderTCP
}
err := sonder(ctx, adresse)
g.mu.Lock()
defer g.mu.Unlock()
if err == nil {
g.Muets.Reussite(adresse)
delete(g.figes, adresse)
return false
}
g.Muets.Echec(adresse, err)
if !g.figes[adresse] {
if g.figes == nil {
g.figes = map[string]bool{}
}
g.figes[adresse] = true
log.Printf(T("miroir git : sonde de %s en échec (%v) ; ses miroirs"+
" sont servis sans rafraîchissement jusqu'à ce qu'il réponde"),
adresse, err)
}
return true
}
// parMandataire dit si l'URL sortirait par un mandataire HTTP.
func (g *GitMirror) parMandataire(u *url.URL) bool {
mandataire := g.Mandataire
if mandataire == nil {
mandataire = http.ProxyFromEnvironment
}
par, err := mandataire(&http.Request{URL: u, Header: http.Header{}})
return err == nil && par != nil
}
func (g *GitMirror) recent(chemin string) bool {
if g.Frais <= 0 {
return false
}
g.mu.Lock()
defer g.mu.Unlock()
t, ok := g.vus[chemin]
return ok && time.Since(t) < g.Frais
}
func (g *GitMirror) noter(chemin string) {
g.mu.Lock()
defer g.mu.Unlock()
if g.vus == nil {
g.vus = map[string]time.Time{}
}
g.vus[chemin] = time.Now()
}
// DelaiMajParDefaut : de quoi laisser un amont sain répondre, pas de quoi
// attendre un amont absent.
const DelaiMajParDefaut = 45 * time.Second
func (g *GitMirror) delaiMaj() time.Duration {
if g.DelaiMaj > 0 {
return g.DelaiMaj
}
return DelaiMajParDefaut
}
func (g *GitMirror) git(ctx context.Context, dir string, args ...string) error {
delai := g.Delai
if delai <= 0 {
delai = 30 * time.Minute
}
return g.gitBorne(ctx, delai, dir, args...)
}
func (g *GitMirror) gitBorne(
ctx context.Context, delai time.Duration, dir string, args ...string,
) error {
ctx, annule := context.WithTimeout(ctx, delai)
defer annule()
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
// Le délai tue « git », mais git délègue le réseau à un auxiliaire —
// « git-remote-https » — qui SURVIT et garde le tube ouvert. Sans ce
// second délai, la lecture de la sortie attend cet auxiliaire, donc pour
// toujours quand l'amont accepte la connexion et ne répond jamais : le
// délai qu'on vient de poser ne borne alors plus rien.
cmd.WaitDelay = 5 * time.Second
// Aucune invite : un dépôt privé doit ÉCHOUER et retomber sur le relais,
// et non bloquer le service en attendant un mot de passe que personne ne
// tapera jamais.
cmd.Env = append(os.Environ(),
"GIT_TERMINAL_PROMPT=0",
"GIT_ASKPASS=/bin/true",
"GCM_INTERACTIVE=never",
)
sortie, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf(T("git %s : %v : %s"),
strings.Join(args, " "), err, court(string(sortie)))
}
return nil
}
func court(s string) string {
s = strings.TrimSpace(s)
if len(s) > 300 {
return s[:300] + "…"
}
return s
}
// Servir répond depuis le miroir, par le programme de git lui-même.
//
// « git http-backend » est une passerelle CGI : il attend le chemin du dépôt
// dans PATH_INFO, la racine dans GIT_PROJECT_ROOT, et rend le protocole exact,
// version 0 comme version 2. Le réimplémenter reviendrait à le suivre à chaque
// version.
// compteur retient ce qu'une réponse a réellement pesé.
//
// La passerelle CGI écrit directement dans la réponse : sans ce compteur, le
// journal note zéro octet pour tout ce que le miroir sert. Un outil dont le
// journal EST la mesure ne peut pas avoir un chemin qui ne compte pas — c'est
// justement celui qui porte l'essentiel du trafic d'une installation.
type compteur struct {
http.ResponseWriter
n int64
}
func (c *compteur) Write(p []byte) (int, error) {
n, err := c.ResponseWriter.Write(p)
c.n += int64(n)
return n, err
}
// Servir répond depuis le miroir et rend ce que la réponse a pesé.
func (g *GitMirror) Servir(
w http.ResponseWriter, r *http.Request, chemin, reste string,
) int64 {
racine := filepath.Dir(chemin)
h := &cgi.Handler{
Path: g.backend(),
Dir: racine,
Env: []string{
"GIT_PROJECT_ROOT=" + racine,
// Le miroir n'a pas de « git-daemon-export-ok », et n'en aura
// pas : il ne sert que le pont des VM de cet hôte.
"GIT_HTTP_EXPORT_ALL=1",
},
InheritEnv: []string{"PATH"},
}
// La passerelle lit le chemin du dépôt dans l'URL qu'on lui présente : ce
// n'est pas celle que la VM a demandée, mais celle du miroir local.
r2 := r.Clone(r.Context())
r2.URL = &url.URL{
Path: "/" + filepath.Base(chemin) + reste,
RawQuery: r.URL.RawQuery,
}
r2.RequestURI = ""
c := &compteur{ResponseWriter: w}
h.ServeHTTP(c, r2)
return c.n
}
// Occupation rend (nombre de dépôts, octets) du miroir.
//
// Le disque de l'orchestrateur est surveillé À LA MAIN — aucune éviction n'est
// écrite, ici pas plus qu'ailleurs — et un miroir est COMPLET : il pèse ce que
// pèse le dépôt amont, historique compris. Le relevé doit donc le dire, sans
// quoi la place disparaît sans que rien ne l'annonce.
func (g *GitMirror) Occupation() (int, int64) {
if g == nil || g.Dir == "" {
return 0, 0
}
depots, octets := 0, int64(0)
filepath.Walk(g.Dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if info.IsDir() {
if strings.HasSuffix(p, ".git") {
depots++
}
return nil
}
octets += info.Size()
return nil
})
return depots, octets
}
// Prefetch tient en miroir toute une liste de dépôts, d'avance.
//
// À la demande, le miroir se remplit au fil des requêtes : la PREMIÈRE machine
// paie chaque clonage, et pour un dépôt qui en tire trois cents cela déplace
// le coût plutôt que de le supprimer. Le pré-remplissage le paie une fois, à
// l'heure choisie par l'opérateur.
//
// Les dépôts sont pris à PLUSIEURS à la fois : un clonage passe l'essentiel de
// son temps à attendre le réseau, et les enchaîner un par un tiendrait des
// heures là où la bande passante n'est pas le facteur.
//
// Un dépôt qui échoue ne fait pas échouer les autres : sur une liste de cette
// taille, il y a toujours un dépôt privé, déplacé ou retiré, et tout arrêter
// pour lui perdrait le travail déjà fait. Rend (réussis, échoués).
func (g *GitMirror) Prefetch(
ctx context.Context, depots []string, parallele int, dire func(string),
) (int, int) {
if parallele < 1 {
parallele = 1
}
type resultat struct {
depot string
ok bool
}
taches := make(chan string)
sorties := make(chan resultat)
var wg sync.WaitGroup
for i := 0; i < parallele; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for d := range taches {
_, ok := g.Assurer(ctx, d)
sorties <- resultat{d, ok}
}
}()
}
go func() {
defer close(taches)
for _, d := range depots {
select {
case taches <- d:
case <-ctx.Done():
return
}
}
}()
go func() { wg.Wait(); close(sorties) }()
reussis, echoues, vus := 0, 0, 0
for r := range sorties {
vus++
if r.ok {
reussis++
} else {
echoues++
}
if dire != nil {
etat := "✓"
if !r.ok {
etat = "✗"
}
dire(fmt.Sprintf(" %s %d/%d %s", etat, vus, len(depots), r.depot))
}
}
return reussis, echoues
}
// DepotsDuFichier lit une liste de dépôts, un par ligne.
//
// Les lignes vides et les commentaires sautent, les doublons aussi : un même
// dépôt figure dans plusieurs manifestes, et le cloner deux fois ne ferait que
// perdre du temps.
func DepotsDuFichier(chemin string) ([]string, error) {
data, err := os.ReadFile(chemin)
if err != nil {
return nil, err
}
vus := map[string]bool{}
var out []string
for _, l := range strings.Split(string(data), "\n") {
l = strings.TrimSpace(l)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
if !vus[l] {
vus[l] = true
out = append(out, l)
}
}
return out, nil
}
// PlancherParDefaut : ce qu'on laisse au disque de l'orchestrateur. Assez pour
// 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, 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() (int64, int64, bool) {
plancher := g.PlancherLibre
if plancher <= 0 {
plancher = PlancherParDefaut
}
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. Le -1 dit « non mesuré », qu'aucun compte
// d'octets ne peut valoir.
return -1, plancher, true
}
libres := int64(st.Bavail) * int64(st.Bsize)
return libres, plancher, libres > plancher
}
// Depot décrit un miroir tenu sur le disque.
type Depot struct {
// Chemin est le répertoire du dépôt nu.
Chemin string
// Nom est ce qu'il vaut mieux montrer : « github.com/OCA/server-tools ».
Nom string
// Octets est ce qu'il occupe, Maj la dernière fois qu'il a été rafraîchi.
Octets int64
Maj time.Time
}
// Depots rend les miroirs tenus, du plus lourd au plus léger.
//
// Par la TAILLE et non par le nom : la place se surveille à la main — aucune
// éviction n'est écrite — et ce qu'on cherche en la surveillant, c'est ce qui
// pèse. Trois dépôts font ici les trois quarts du total ; les lister par ordre
// alphabétique obligerait à les chercher.
func (g *GitMirror) Depots() []Depot {
if g == nil || g.Dir == "" {
return nil
}
var out []Depot
filepath.Walk(g.Dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil || !info.IsDir() ||
!strings.HasSuffix(p, ".git") {
return nil
}
d := Depot{
Chemin: p,
Nom: strings.TrimSuffix(
strings.TrimPrefix(p, g.Dir+string(os.PathSeparator)), ".git"),
Maj: info.ModTime(),
}
filepath.Walk(p, func(_ string, i os.FileInfo, e error) error {
if e == nil && i != nil && !i.IsDir() {
d.Octets += i.Size()
}
return nil
})
out = append(out, d)
// Un dépôt nu n'en contient pas d'autre : inutile de descendre.
return filepath.SkipDir
})
sort.Slice(out, func(i, j int) bool { return out[i].Octets > out[j].Octets })
return out
}
// Retirer efface un miroir. Il se refera au prochain besoin, au prix du
// clonage — c'est la propriété qui rend l'effacement sans danger.
//
// Le chemin est vérifié comme appartenant à la racine des miroirs : un appel
// mal formé ne doit pas pouvoir effacer autre chose.
func (g *GitMirror) Retirer(chemin string) error {
if g == nil || g.Dir == "" {
return fmt.Errorf("%s", T("miroir éteint"))
}
abs, err := filepath.Abs(chemin)
if err != nil {
return err
}
racine, err := filepath.Abs(g.Dir)
if err != nil {
return err
}
if !strings.HasPrefix(abs, racine+string(os.PathSeparator)) ||
!strings.HasSuffix(abs, ".git") {
return fmt.Errorf(T("hors des miroirs : %s"), chemin)
}
v := g.verrou(abs)
v.Lock()
defer v.Unlock()
return os.RemoveAll(abs)
}