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
714 lines
23 KiB
Go
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)
|
|
}
|