mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
Sans ca, le renommage n'atteignait pas les machines Linux installees. Le dossier de donnees y est epingle par /etc/default/jean, que notre propre installeur ecrit ; le code traitant un chemin explicite comme un ordre, il ne migrait jamais. Constate en deployant 0.7.0 sur un vrai serveur : les chemins sont restes en /etc/jean et il a fallu un script separe. La regle qui debloque : distinguer NOTRE artefact du CHOIX de l'utilisateur. Si le chemin epingle vaut exactement l'ancien emplacement par defaut, c'est notre installeur qui l'a ecrit et on migre. Toute autre valeur est une decision de l'utilisateur et reste intacte. Le dossier, /etc/default et l'unite systemd bougent ENSEMBLE : les separer donnerait le pire cas, des donnees deplacees et un service qui ne redemarre plus. Les chemins sont reecrits par parcours du dossier plutot que d'apres une liste devinee — l'experience montre qu'ils se cachent dans EXTRA_ARGS, les copies .bak et jusque dans des notes de MEMORY. backends/ et les .gguf sont sautes. Deux moments de declenchement, tous deux root et deliberes : `install`, et juste apres une mise a jour reussie. Jamais au demarrage ordinaire — un service qui reecrit des unites systemd a chaque boot finirait par en casser une, et sur un parc entier ca veut dire des machines qui ne redemarrent plus. La variante post-mise-a-jour n'arrete AUCUN service : on s'execute dans jean-link, l'arreter reviendrait a se tuer au milieu de la migration, dossier deplace et chemins pas encore reecrits. Sans danger, un rename de dossier sous Linux tolere les fichiers ouverts et un process garde son repertoire de travail par son inode. Sauvegarde prealable bloquante, et retour a l'etat initial si une etape echoue apres le deplacement. Couvert par 6 tests sur un faux /etc, dont le respect d'un chemin impose et la garantie qu'aucun service n'est touche par la variante post-MAJ.
244 lines
8.7 KiB
Go
244 lines
8.7 KiB
Go
package ajean
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fakeLayout monte un faux /etc : dossier de donnees installe, /etc/default et
|
|
// une unite systemd, exactement l'agencement d'une machine ou `jean install` est
|
|
// passe. Les commandes systemctl sont remplacees par des mouchards.
|
|
type fakeLayout struct {
|
|
plan layoutPlan
|
|
root string
|
|
stopped []string
|
|
started []string
|
|
active map[string]bool
|
|
backedUp bool
|
|
}
|
|
|
|
func newFakeLayout(t *testing.T) *fakeLayout {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
f := &fakeLayout{
|
|
root: root,
|
|
active: map[string]bool{"jean": true, "jean-link": true},
|
|
}
|
|
old := filepath.Join(root, "etc", "jean")
|
|
for _, d := range []string{
|
|
filepath.Join(old, "configs"),
|
|
filepath.Join(old, "MEMORY"),
|
|
filepath.Join(old, "backends", "llama.cpp", "build"),
|
|
filepath.Join(root, "etc", "default"),
|
|
filepath.Join(root, "etc", "systemd", "system"),
|
|
} {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write := func(p, body string) {
|
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// Un BIN externe (donc a NE PAS toucher) et un jinja interne (a suivre).
|
|
write(filepath.Join(old, "config.env"),
|
|
"BIN=\"/home/n/llama.cpp/llama-server\"\nEXTRA_ARGS=\"--chat-template-file "+old+"/fix.jinja\"\n")
|
|
write(filepath.Join(old, "fix.jinja"), "template")
|
|
write(filepath.Join(old, "configs", "GROS MODELE (2).env"), "EXTRA_ARGS=\"--chat-template-file "+old+"/fix.jinja\"\n")
|
|
write(filepath.Join(old, "MEMORY", "note.md"), "voir "+old+"/dataset.jsonl\n")
|
|
write(filepath.Join(old, ".e2e_key"), "cle-precieuse\n")
|
|
// Un faux modele et un gros fichier de build : ni l'un ni l'autre ne doit
|
|
// etre reecrit ni meme lu.
|
|
write(filepath.Join(old, "modele.gguf"), "binaire")
|
|
write(filepath.Join(old, "backends", "llama.cpp", "build", "cache.txt"), old+"/quelque-chose\n")
|
|
write(filepath.Join(root, "etc", "default", "jean"), "# Generated by jean install\nJEAN_HOME="+old+"\n")
|
|
write(filepath.Join(root, "etc", "systemd", "system", "jean.service"),
|
|
"[Service]\nWorkingDirectory="+old+"\nExecStart=/usr/local/bin/jean serve\n")
|
|
write(filepath.Join(root, "etc", "systemd", "system", "jean-link.service"),
|
|
"[Service]\nExecStart=/usr/local/bin/jean link serve\n")
|
|
|
|
f.plan = layoutPlan{
|
|
Old: old,
|
|
New: filepath.Join(root, "etc", "ajean"),
|
|
DefaultDir: filepath.Join(root, "etc", "default"),
|
|
UnitDir: filepath.Join(root, "etc", "systemd", "system"),
|
|
Units: []string{"jean", "jean-link"},
|
|
BackupDir: filepath.Join(root, "sauvegardes"),
|
|
stopUnit: func(u string) error { f.stopped = append(f.stopped, u); f.active[u] = false; return nil },
|
|
startUnit: func(u string) error { f.started = append(f.started, u); f.active[u] = true; return nil },
|
|
unitActive: func(u string) bool { return f.active[u] },
|
|
reloadUnits: func() error { return nil },
|
|
isRoot: func() bool { return true },
|
|
// tar refuse un chemin contenant « C: » sous Windows (il y voit un hote
|
|
// distant) : on remplace la sauvegarde par un mouchard, elle est verifiee
|
|
// pour ce qu'elle est — un prealable bloquant — dans son propre test.
|
|
backup: func(layoutPlan) error { f.backedUp = true; return nil },
|
|
}
|
|
return f
|
|
}
|
|
|
|
func (f *fakeLayout) read(t *testing.T, rel string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(f.plan.New, rel))
|
|
if err != nil {
|
|
t.Fatalf("%s illisible: %v", rel, err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// Cas nominal complet : donnees deplacees, chemins reecrits partout, /etc/default
|
|
// et unite corriges, services arretes puis relances.
|
|
func TestMigrateLayoutNominal(t *testing.T) {
|
|
f := newFakeLayout(t)
|
|
if err := migrateLayout(f.plan); err != nil {
|
|
t.Fatalf("migration echouee: %v", err)
|
|
}
|
|
|
|
if isDir(f.plan.Old) {
|
|
t.Error("l'ancien dossier existe encore")
|
|
}
|
|
if !isDir(f.plan.New) {
|
|
t.Fatal("le nouveau dossier est absent")
|
|
}
|
|
// Le jinja interne suit, le BIN externe ne bouge pas.
|
|
conf := f.read(t, "config.env")
|
|
if !strings.Contains(conf, f.plan.New+"/fix.jinja") {
|
|
t.Errorf("le chat-template-file n'a pas suivi:\n%s", conf)
|
|
}
|
|
if !strings.Contains(conf, "/home/n/llama.cpp/llama-server") {
|
|
t.Errorf("le BIN externe a ete modifie a tort:\n%s", conf)
|
|
}
|
|
// Preset avec espaces/parentheses, et note de MEMORY.
|
|
if strings.Contains(f.read(t, filepath.Join("configs", "GROS MODELE (2).env")), f.plan.Old) {
|
|
t.Error("preset non reecrit")
|
|
}
|
|
if strings.Contains(f.read(t, filepath.Join("MEMORY", "note.md")), f.plan.Old) {
|
|
t.Error("note MEMORY non reecrite")
|
|
}
|
|
if f.read(t, ".e2e_key") != "cle-precieuse\n" {
|
|
t.Error("la cle E2E a ete alteree")
|
|
}
|
|
// backends/ doit etre saute : le reecrire ferait perdre des heures pour rien.
|
|
if !strings.Contains(f.read(t, filepath.Join("backends", "llama.cpp", "build", "cache.txt")), f.plan.Old) {
|
|
t.Error("backends/ a ete parcouru alors qu'il doit etre saute")
|
|
}
|
|
|
|
// /etc/default : nouveau fichier ET ancien repointe (des scripts le sourcent).
|
|
newDef, err := os.ReadFile(filepath.Join(f.plan.DefaultDir, "ajean"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(newDef), "AJEAN_HOME="+f.plan.New) || !strings.Contains(string(newDef), "JEAN_HOME="+f.plan.New) {
|
|
t.Errorf("/etc/default/ajean incomplet:\n%s", newDef)
|
|
}
|
|
oldDef, _ := os.ReadFile(filepath.Join(f.plan.DefaultDir, "jean"))
|
|
if strings.Contains(string(oldDef), f.plan.Old) {
|
|
t.Errorf("/etc/default/jean pointe encore sur l'ancien chemin:\n%s", oldDef)
|
|
}
|
|
|
|
// Unite corrigee, copie de securite conservee, nom inchange.
|
|
unit, _ := os.ReadFile(filepath.Join(f.plan.UnitDir, "jean.service"))
|
|
if strings.Contains(string(unit), f.plan.Old) {
|
|
t.Errorf("unite non corrigee:\n%s", unit)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.plan.UnitDir, "jean.service.avant-ajean")); err != nil {
|
|
t.Error("copie de securite de l'unite absente")
|
|
}
|
|
|
|
if !f.backedUp {
|
|
t.Error("aucune sauvegarde avant migration")
|
|
}
|
|
if len(f.stopped) != 2 || len(f.started) != 2 {
|
|
t.Errorf("services: arretes=%v relances=%v", f.stopped, f.started)
|
|
}
|
|
}
|
|
|
|
// Chemin choisi explicitement par l'utilisateur : on ne migre RIEN.
|
|
func TestMigrateLayoutRespectsUserChoice(t *testing.T) {
|
|
f := newFakeLayout(t)
|
|
custom := filepath.Join(f.root, "mnt", "gros-disque", "jean")
|
|
if err := os.WriteFile(filepath.Join(f.plan.DefaultDir, "jean"),
|
|
[]byte("JEAN_HOME="+custom+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if ok, why := f.plan.layoutNeedsMigration(); ok {
|
|
t.Fatal("une migration a ete jugee legitime malgre un chemin impose")
|
|
} else if !strings.Contains(why, custom) {
|
|
t.Errorf("raison peu claire: %q", why)
|
|
}
|
|
if err := migrateLayout(f.plan); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !isDir(f.plan.Old) || isDir(f.plan.New) {
|
|
t.Error("le dossier a bouge alors que l'utilisateur avait impose un chemin")
|
|
}
|
|
if len(f.stopped) != 0 {
|
|
t.Error("des services ont ete arretes pour rien")
|
|
}
|
|
}
|
|
|
|
// Sans les droits root, on n'entreprend rien et on ne casse rien.
|
|
func TestMigrateLayoutNonRoot(t *testing.T) {
|
|
f := newFakeLayout(t)
|
|
f.plan.isRoot = func() bool { return false }
|
|
if err := migrateLayout(f.plan); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !isDir(f.plan.Old) || isDir(f.plan.New) {
|
|
t.Error("migration effectuee sans les droits root")
|
|
}
|
|
}
|
|
|
|
// Les deux dossiers presents : situation ambigue, on s'abstient.
|
|
func TestMigrateLayoutBothPresent(t *testing.T) {
|
|
f := newFakeLayout(t)
|
|
if err := os.MkdirAll(f.plan.New, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ok, why := f.plan.layoutNeedsMigration(); ok || why == "" {
|
|
t.Fatalf("attendu un refus explicite, got ok=%v why=%q", ok, why)
|
|
}
|
|
}
|
|
|
|
// Deja migre (plus d'ancien dossier) : silencieux, aucun message inquietant.
|
|
func TestMigrateLayoutAlreadyDone(t *testing.T) {
|
|
f := newFakeLayout(t)
|
|
if err := os.Rename(f.plan.Old, f.plan.New); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ok, why := f.plan.layoutNeedsMigration()
|
|
if ok || why != "" {
|
|
t.Fatalf("attendu un silence, got ok=%v why=%q", ok, why)
|
|
}
|
|
}
|
|
|
|
// La variante « apres mise a jour » ne doit toucher a AUCUN service : on tourne
|
|
// dans jean-link, l'arreter reviendrait a se tuer au milieu de la migration.
|
|
func TestUpdateLayoutPlanNeverStopsServices(t *testing.T) {
|
|
f := newFakeLayout(t)
|
|
p := f.plan
|
|
p.unitActive = updateLayoutPlan().unitActive // seule difference testee
|
|
|
|
if err := migrateLayout(p); err != nil {
|
|
t.Fatalf("migration echouee: %v", err)
|
|
}
|
|
if len(f.stopped) != 0 || len(f.started) != 0 {
|
|
t.Fatalf("services touches: arretes=%v relances=%v", f.stopped, f.started)
|
|
}
|
|
// La migration doit rester complete malgre tout.
|
|
if isDir(p.Old) || !isDir(p.New) {
|
|
t.Fatal("le dossier n'a pas ete migre")
|
|
}
|
|
if strings.Contains(f.read(t, "config.env"), p.Old) {
|
|
t.Error("chemins non reecrits")
|
|
}
|
|
unit, _ := os.ReadFile(filepath.Join(p.UnitDir, "jean.service"))
|
|
if strings.Contains(string(unit), p.Old) {
|
|
t.Error("unite non corrigee : le prochain redemarrage echouerait")
|
|
}
|
|
}
|