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.
This commit is contained in:
nathaninline committed 2026-08-04 14:24:53 +02:00
1 parent 993be881d5
commit 8a2b5c6687
3 files changed
+156 -2

No files matched your search

+82
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
@@ -96,5 +97,86 @@ func migrateHome(target, legacy string) string {
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)
}
}
+72
View File
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -100,3 +101,74 @@ func TestMigrateHomeFallsBackWhenRenameFails(t *testing.T) {
t.Fatalf("l'ancien dossier a été abîmé par une migration ratée: %q (%v)", b, err)
}
}
// Le cas qui a casse une vraie machine : config.env contient un chemin ABSOLU
// vers le dossier de donnees (BIN=<home>\backends\...\llama-server.exe). Apres
// migration ce chemin doit suivre, sinon llama-server ne demarre plus — et ca
// concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal.
func TestMigrateHomeRewritesAbsolutePaths(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
if err := os.MkdirAll(filepath.Join(legacy, "configs"), 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(legacy, "backends", "llama.cpp", "build", "bin", "llama-server")
write := func(rel, body string) {
if err := os.WriteFile(filepath.Join(legacy, rel), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("config.env", "BIN="+bin+"\nMODEL=\"x.gguf\"\n")
// Les presets portent le meme BIN et doivent suivre aussi.
write(filepath.Join("configs", "gros.env"), "BIN="+bin+"\n")
// Ecriture avec des slashs : Windows accepte les deux formes.
write("model_dirs.json", `{"dirs":["`+filepath.ToSlash(legacy)+`/models"]}`)
if got := migrateHome(target, legacy); got != target {
t.Fatalf("migration echouee: %q", got)
}
for _, rel := range []string{"config.env", filepath.Join("configs", "gros.env"), "model_dirs.json"} {
b, err := os.ReadFile(filepath.Join(target, rel))
if err != nil {
t.Fatalf("%s illisible: %v", rel, err)
}
if strings.Contains(string(b), legacy) || strings.Contains(string(b), filepath.ToSlash(legacy)) {
t.Errorf("%s reference encore l'ancien dossier:\n%s", rel, b)
}
if !strings.Contains(string(b), target) && !strings.Contains(string(b), filepath.ToSlash(target)) {
t.Errorf("%s ne pointe pas vers le nouveau dossier:\n%s", rel, b)
}
}
// Le BIN reecrit doit designer un chemin reellement atteignable.
newBin := filepath.Join(target, "backends", "llama.cpp", "build", "bin", "llama-server")
if b, _ := os.ReadFile(filepath.Join(target, "config.env")); !strings.Contains(string(b), newBin) {
t.Errorf("BIN ne pointe pas sur %s:\n%s", newBin, b)
}
}
// Les fichiers d'etat du service (jean.pid/jean.log) doivent etre repris : le
// PID dit si le service tourne, l'ignorer ferait demarrer un second service.
func TestMigrateHomeAdoptsServiceStateFiles(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
if err := os.WriteFile(filepath.Join(legacy, "jean.pid"), []byte("4242\n"), 0o644); err != nil {
t.Fatal(err)
}
migrateHome(target, legacy)
b, err := os.ReadFile(filepath.Join(target, "ajean.pid"))
if err != nil {
t.Fatalf("ajean.pid absent apres migration: %v", err)
}
if strings.TrimSpace(string(b)) != "4242" {
t.Fatalf("PID perdu: %q", b)
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ import (
// et on l'expose à la fois en ligne de commande (`ajean where`) et dans l'UI.
type ajeanPaths struct {
Home string `json:"home"` // JEAN_HOME : racine des données
Home string `json:"home"` // AJEAN_HOME : racine des données
Config string `json:"config"` // config.env
Exe string `json:"exe"` // binaire en cours d'exécution
Installed string `json:"installed"` // binaire installé (peut différer de Exe)
@@ -64,7 +64,7 @@ func cmdWhere(args []string) error {
p := currentPaths()
fmt.Printf("Emplacements AJEAN\n\n")
for _, row := range [][2]string{
{"données (JEAN_HOME)", p.Home},
{"données (AJEAN_HOME)", p.Home},
{"configuration", p.Config},
{"binaire en cours", p.Exe},
{"binaire installé", p.Installed},