Files
Loki/internal/ajean/sys_migrate.go
T
nathaninline 8a2b5c6687 Migration : reecrire les chemins absolus pointant vers l'ancien dossier
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.
2026-08-04 14:24:53 +02:00

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)
}
}