erplibre/script/qemu_cache/gitmirror.go
Mathieu Benoit 87def09645 [FIX] qemu cache: git mirror clones only over http and https
The scheme of a git negotiation came from the request line the client
writes, so "ssh://any-host/x.git/info/refs" made the cache run git clone
over ssh, with its service account keys, toward a host the client chose.
A git client reaches an HTTP proxy only over HTTP(S), so nothing cached
is lost. DepotDeURL now refuses other schemes, the clone ends its options
with "--", and GIT_ALLOW_PROTOCOL=http:https bounds git itself.
Checked: ssh, git, file and ext refused, a real clone through the mirror
still passes, full qemu_cache Go suite green.

--- FR ---

[FIX] qemu cache : le miroir git ne clone qu'en http et https

Le schéma d'une négociation git venait de la ligne de requête écrite par
le client : « ssh://hôte-quelconque/x.git/info/refs » faisait lancer au
cache un git clone en ssh, avec les clés de son compte de service, vers
un hôte choisi par le client. Un client git ne joint un mandataire HTTP
qu'en HTTP(S) : rien de ce qui se met en cache n'est perdu. DepotDeURL
refuse les autres schémas, le clone clôt ses options par « -- », et
GIT_ALLOW_PROTOCOL=http:https borne git lui-même.
Vérifié : ssh, git, file et ext refusés, un vrai clone à travers le
miroir passe toujours, suite Go de qemu_cache au vert.

Assisted-by: Claude Opus 5.5
2026-09-25 05:37:03 -04:00

727 lines
24 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.
//
// Rend faux aussi hors de http et https. Le schéma vient de la ligne de
// requête, que le client écrit, et un client git ne passe par un mandataire
// HTTP qu'en HTTP(S) : ssh et git:// ouvrent leur propre connexion. Accepter
// « ssh://hôte/… » ferait cloner le cache vers un hôte choisi par le client,
// avec les clés de son compte de service.
func DepotDeURL(u *url.URL) (string, string, bool) {
if u == nil || (u.Scheme != "http" && u.Scheme != "https") {
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
}
// « -- » : le dépôt ne peut plus se lire comme une option de git.
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.
// GIT_ALLOW_PROTOCOL borne les transports de git lui-même, clone comme
// remote update : le miroir ne sert que des négociations HTTP(S), et aucun
// dépôt ne doit l'emmener vers ssh, git:// ou un chemin local.
cmd.Env = append(os.Environ(),
"GIT_TERMINAL_PROMPT=0",
"GIT_ASKPASS=/bin/true",
"GCM_INTERACTIVE=never",
"GIT_ALLOW_PROTOCOL=http:https",
)
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)
}