mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Bug trouve en testant la migration sur une vraie machine (5,4 Go, 51 351 fichiers). Le rename du dossier reussissait, mais config.env contenait BIN=C:\ProgramData\jean\backends\llama.cpp\build\bin\Release\llama-server.exe soit un chemin ABSOLU vers le dossier qu'on venait de renommer. Resultat : le dossier migre intact, et llama-server ne demarre plus. Ca touche tous ceux qui ont fait un `llamacpp install`, donc le cas nominal, pas un cas limite. Les presets de configs/ portent le meme BIN et etaient touches pareil (deux presets concernes sur la machine de test). Apres un rename reussi, on reecrit donc les references a l'ancien chemin dans les fichiers de config qu'on ecrit nous-memes : config.env, model_dirs.json, mcp.json, webprefs.json et les presets de configs/. Les deux ecritures de separateur sont couvertes, Windows acceptant \ et / dans une meme valeur. Restreint a ces fichiers texte : on ne reecrit pas a l'aveugle les 51 000 fichiers d'un dossier de donnees. Les fichiers d'etat du service sont repris au passage (jean.pid/jean.log -> ajean.pid/ajean.log), meme raison que pour le worker de lien : le PID dit si le service tourne, l'ignorer ferait demarrer un second service. Non traite volontairement : l'arborescence de build llama.cpp (CMakeCache.txt et consorts) contient aussi des chemins absolus. Elle est regenerable par `ajean llamacpp update` et la reecrire en masse serait plus risque qu'utile. Verifie de bout en bout sur la machine de test : service demarre, llama-server chargé depuis le nouveau chemin, /health ok, completion reelle ok. Deux tests ajoutes reproduisant le cas.
183 lines
7.2 KiB
Go
183 lines
7.2 KiB
Go
package ajean
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Migration du dossier de données jean → ajean.
|
|
//
|
|
// C'est le seul endroit du renommage qui peut faire PERDRE des données : le
|
|
// dossier contient la config, les clés, la mémoire, les conversations, et
|
|
// surtout des .gguf qui pèsent des dizaines de gigaoctets. Trois règles en
|
|
// découlent, et elles expliquent tout le code ci-dessous :
|
|
//
|
|
// 1. On déplace par os.Rename DANS LE DOSSIER PARENT de l'ancien. Un rename
|
|
// intra-volume est atomique et instantané — il ne recopie pas les 40 Go, et
|
|
// il n'existe aucun instant où les données seraient à moitié quelque part.
|
|
// Choisir le parent de l'ancien chemin (et pas le défaut de la plateforme)
|
|
// garantit qu'on reste sur le même volume, donc que le rename est bien un
|
|
// rename et non un copy+delete déguisé.
|
|
//
|
|
// 2. Si le rename échoue — service en cours qui tient des handles sous Windows,
|
|
// droits insuffisants, volume monté en lecture seule — on CONTINUE SUR
|
|
// L'ANCIEN CHEMIN. Une machine qui n'a pas migré marche exactement comme
|
|
// avant ; une machine qui a perdu ses modèles, non. La migration est donc
|
|
// retentée à chaque démarrage jusqu'à ce qu'elle passe.
|
|
//
|
|
// 3. Aucune suppression, jamais. Le pire cas est « rien n'a bougé ».
|
|
|
|
var (
|
|
homeOnce sync.Once
|
|
homePath string
|
|
)
|
|
|
|
// migratedDefaultHome renvoie le dossier de données par défaut, en migrant
|
|
// l'ancien dossier « jean » vers « ajean » à la première résolution du process.
|
|
// Le résultat est mis en cache : deux appels ne doivent jamais désigner deux
|
|
// dossiers différents, sinon une moitié du programme écrirait à côté de l'autre.
|
|
//
|
|
// N'est PAS appelé quand $AJEAN_HOME/$JEAN_HOME ou /etc/default/* imposent un
|
|
// chemin : un choix explicite de l'utilisateur ne se migre pas.
|
|
func migratedDefaultHome() string {
|
|
homeOnce.Do(func() { homePath = resolveDefaultHome() })
|
|
return homePath
|
|
}
|
|
|
|
func resolveDefaultHome() string {
|
|
return migrateHome(defaultAjeanHome(), legacyDefaultHome())
|
|
}
|
|
|
|
// retryHomeMigration relance la résolution — donc la migration — après avoir
|
|
// arrêté ce qui tournait. Renvoie true si le dossier a effectivement changé.
|
|
//
|
|
// Raison d'être : sous Windows, un rename de dossier échoue tant qu'un process
|
|
// y tient un handle. Or le service AJEAN détaché en tient en permanence, si bien
|
|
// que la migration tentée au démarrage échouerait indéfiniment et que la machine
|
|
// resterait sur l'ancien chemin. L'installateur, lui, a une fenêtre où tout est
|
|
// arrêté : c'est là qu'on retente.
|
|
//
|
|
// À N'APPELER QUE dans cette fenêtre, et avant que quoi que ce soit d'autre
|
|
// n'ait ouvert de fichier de données : réinitialiser le cache n'est ni atomique
|
|
// ni sûr vis-à-vis des goroutines, et surtout les chemins déjà calculés par
|
|
// l'appelant deviennent obsolètes (voir migrateThenResolveTarget).
|
|
func retryHomeMigration() bool {
|
|
before := migratedDefaultHome()
|
|
homeOnce = sync.Once{}
|
|
homePath = ""
|
|
return migratedDefaultHome() != before
|
|
}
|
|
|
|
// migrateHome contient toute la logique de migration, isolée des chemins réels
|
|
// de la plateforme pour être testable telle quelle. Renvoie le dossier à
|
|
// utiliser — le nouveau si la migration a réussi ou n'était pas nécessaire,
|
|
// l'ancien si elle a échoué.
|
|
func migrateHome(target, legacy string) string {
|
|
if isDir(target) {
|
|
return target // déjà migré (ou installation neuve déjà faite)
|
|
}
|
|
if !isDir(legacy) {
|
|
return target // installation neuve : rien à migrer
|
|
}
|
|
|
|
// Même parent que l'ancien dossier ⇒ même volume ⇒ rename atomique.
|
|
sibling := filepath.Join(filepath.Dir(legacy), filepath.Base(target))
|
|
if isDir(sibling) {
|
|
return sibling
|
|
}
|
|
if err := os.Rename(legacy, sibling); err != nil {
|
|
// Cas le plus courant sous Windows : un service AJEAN tourne encore et
|
|
// tient un handle dans le dossier. On reste sur l'ancien chemin — tout
|
|
// fonctionne — et on retentera au prochain démarrage.
|
|
fmt.Fprintf(os.Stderr, "[info] dossier de données pas encore migré vers %s (%v) — on continue sur %s\n",
|
|
sibling, err, legacy)
|
|
return legacy
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[ok] dossier de données migré : %s → %s\n", legacy, sibling)
|
|
rewriteHomeReferences(legacy, sibling)
|
|
return sibling
|
|
}
|
|
|
|
// configFilesToRewrite liste les fichiers de configuration susceptibles de
|
|
// contenir un chemin ABSOLU vers le dossier de données. Volontairement restreint
|
|
// à des fichiers texte, petits et écrits par nous : on ne réécrit pas à l'aveugle
|
|
// les 51 000 fichiers d'un dossier de données.
|
|
func configFilesToRewrite(home string) []string {
|
|
files := []string{
|
|
filepath.Join(home, "config.env"),
|
|
filepath.Join(home, "model_dirs.json"),
|
|
filepath.Join(home, "mcp.json"),
|
|
filepath.Join(home, "webprefs.json"),
|
|
}
|
|
// Les presets sont des config.env alternatives : ils portent le même BIN.
|
|
presets, _ := filepath.Glob(filepath.Join(home, "configs", "*"))
|
|
return append(files, presets...)
|
|
}
|
|
|
|
// rewriteHomeReferences réécrit les chemins absolus pointant vers l'ANCIEN
|
|
// dossier de données dans les fichiers de configuration.
|
|
//
|
|
// Sans ça, la migration casse l'installation qu'elle est censée préserver :
|
|
// `config.env` contient typiquement
|
|
//
|
|
// BIN=C:\ProgramData\jean\backends\llama.cpp\build\bin\Release\llama-server.exe
|
|
//
|
|
// c'est-à-dire un chemin absolu VERS le dossier qu'on vient de renommer. Le
|
|
// dossier a bougé, la ligne pointe dans le vide, et llama-server ne démarre plus.
|
|
// Ça concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal.
|
|
//
|
|
// On couvre les deux écritures de séparateur, Windows acceptant indifféremment
|
|
// « \ » et « / » dans une même valeur. Best-effort par fichier : un fichier
|
|
// illisible est sauté sans compromettre les autres.
|
|
func rewriteHomeReferences(oldHome, newHome string) {
|
|
variants := [][2]string{{oldHome, newHome}}
|
|
if slash := filepath.ToSlash(oldHome); slash != oldHome {
|
|
variants = append(variants, [2]string{slash, filepath.ToSlash(newHome)})
|
|
}
|
|
for _, path := range configFilesToRewrite(newHome) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out := string(b)
|
|
for _, v := range variants {
|
|
out = strings.ReplaceAll(out, v[0], v[1])
|
|
}
|
|
if out == string(b) {
|
|
continue
|
|
}
|
|
fi, err := os.Stat(path)
|
|
mode := os.FileMode(0o644)
|
|
if err == nil {
|
|
mode = fi.Mode()
|
|
}
|
|
if err := os.WriteFile(path, []byte(out), mode); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[warn] %s non réécrit (%v) — vérifie les chemins absolus qu'il contient\n", path, err)
|
|
continue
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[ok] chemins mis à jour dans %s\n", filepath.Base(path))
|
|
}
|
|
adoptLegacyStateFiles(newHome)
|
|
}
|
|
|
|
// adoptLegacyStateFiles reprend les fichiers d'état nommés d'après le service
|
|
// (jean.pid / jean.log → ajean.pid / ajean.log). Le fichier PID dit si le
|
|
// service tourne : ne pas le reprendre reviendrait à croire qu'il est arrêté et
|
|
// à en démarrer un second.
|
|
func adoptLegacyStateFiles(home string) {
|
|
for _, ext := range []string{".pid", ".log"} {
|
|
from := filepath.Join(home, legacyServiceName()+ext)
|
|
to := filepath.Join(home, "ajean"+ext)
|
|
if _, err := os.Stat(to); err == nil {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(from); err != nil {
|
|
continue
|
|
}
|
|
_ = os.Rename(from, to)
|
|
}
|
|
}
|