mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Migration automatique de l'agencement systeme (Unix)
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.
This commit is contained in:
1 parent
aa452f9799
commit
75929e7b60
6 files changed
+577
-5
No files matched your search
+9
-5
@@ -10,17 +10,21 @@ De même, `JEAN_HOME` reste lu s'il a été défini à la main, au même titre q
|
||||
|
||||
## Le dossier de données se renomme tout seul
|
||||
|
||||
Au premier lancement, `%ProgramData%\jean` devient `%ProgramData%\ajean` (`/etc/jean` → `/etc/ajean` sous Linux et macOS).
|
||||
`%ProgramData%\jean` devient `%ProgramData%\ajean`, et `/etc/jean` devient `/etc/ajean` sous Linux et macOS. Vous n'avez aucune commande à lancer : la migration se fait pendant la mise à jour.
|
||||
|
||||
Le déplacement est instantané, même avec 200 Go de modèles : rien n'est recopié, le dossier est renommé sur place. Les chemins absolus qui pointaient dedans — dont le `--chat-template-file` des presets — sont mis à jour dans la foulée.
|
||||
Le déplacement est instantané, même avec 200 Go de modèles — rien n'est recopié, le dossier est renommé sur place. Tout ce qui pointait dedans suit : les chemins absolus de `config.env`, ceux de vos presets (dont le `--chat-template-file`), le fichier `/etc/default`, et le répertoire de travail de l'unité systemd.
|
||||
|
||||
Si le renommage ne peut pas aboutir, par exemple parce qu'AJEAN tourne encore en tâche de fond, **il ne se passe rien du tout** : l'ancien dossier reste en place et continue de servir. Rien n'est jamais copié ni supprimé, et la tentative est reprise au lancement suivant.
|
||||
Votre clé d'accès distant est déplacée avec le reste : **aucun ré-appairage**, le tunnel se reconnecte seul.
|
||||
|
||||
**Si vous aviez choisi vous-même l'emplacement** — un `JEAN_HOME` sur un autre disque, par exemple — il n'est pas touché. Un chemin défini à la main est une décision, pas un héritage à corriger.
|
||||
|
||||
Et si le renommage ne peut pas aboutir, par exemple parce qu'AJEAN tourne encore en tâche de fond sous Windows, **il ne se passe rien du tout** : l'ancien dossier reste en place et continue de servir, la tentative sera reprise plus tard. Rien n'est jamais copié ni supprimé.
|
||||
|
||||
## Les serveurs Linux ne perdent pas la main
|
||||
|
||||
Une mise à jour remplace le binaire, pas les unités systemd. AJEAN utilise donc le service réellement installé sur la machine : un serveur qui tourne sous `jean.service` continue sous `jean.service`.
|
||||
Les unités systemd **ne sont pas renommées**. Un serveur qui tourne sous `jean.service` continue sous `jean.service` : vos commandes `systemctl`, vos règles `sudoers` et vos scripts de supervision restent valables. Seuls les chemins qu'elles contiennent sont corrigés, et une copie de l'unité d'origine est conservée à côté.
|
||||
|
||||
Sans cette précaution, le premier redémarrage après mise à jour aurait échoué — sur une machine distante, cela signifie l'accès coupé sans moyen simple de le rétablir.
|
||||
Une sauvegarde de la configuration, des presets et de la mémoire est déposée dans `/root` avant toute modification. En cas d'échec à n'importe quelle étape, la machine est remise dans son état initial et les services relancés.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// sys_install_darwin.go — installation macOS via launchd (LaunchDaemon), équivalent
|
||||
@@ -87,6 +88,14 @@ func cmdInstall(args []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
|
||||
}
|
||||
// Migration de l'agencement jean -> ajean AVANT de résoudre les chemins :
|
||||
// `install` est root et délibéré, c'est le bon moment. Ne fait rien si la
|
||||
// machine est déjà en ajean, ou si l'utilisateur a imposé un chemin.
|
||||
if err := migrateLayout(defaultLayoutPlan()); err != nil {
|
||||
return err
|
||||
}
|
||||
homeOnce, homePath = sync.Once{}, "" // le dossier a pu changer à l'instant
|
||||
|
||||
ajeanHome := defaultAjeanHome()
|
||||
if v := os.Getenv("JEAN_HOME"); v != "" {
|
||||
ajeanHome = v
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart
|
||||
@@ -76,6 +77,14 @@ func cmdInstall(args []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
|
||||
}
|
||||
// Migration de l'agencement jean -> ajean AVANT de résoudre les chemins :
|
||||
// `install` est root et délibéré, c'est le bon moment. Ne fait rien si la
|
||||
// machine est déjà en ajean, ou si l'utilisateur a imposé un chemin.
|
||||
if err := migrateLayout(defaultLayoutPlan()); err != nil {
|
||||
return err
|
||||
}
|
||||
homeOnce, homePath = sync.Once{}, "" // le dossier a pu changer à l'instant
|
||||
|
||||
ajeanHome := defaultAjeanHome()
|
||||
if v := os.Getenv("JEAN_HOME"); v != "" {
|
||||
ajeanHome = v
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Migration complète de l'agencement système jean → ajean (Unix).
|
||||
//
|
||||
// Renommer le dossier de données ne suffit pas sur une machine installée : le
|
||||
// chemin est aussi inscrit dans /etc/default/jean et dans l'unité systemd. Les
|
||||
// trois doivent bouger ensemble, sinon on obtient le pire des cas — des données
|
||||
// déplacées et un service qui ne redémarre plus.
|
||||
//
|
||||
// QUAND. Jamais au démarrage ordinaire : un service qui réécrit des unités
|
||||
// systemd à chaque boot finirait par en casser une, et sur un parc entier ça
|
||||
// veut dire des machines qui ne redémarrent plus. Uniquement à deux moments
|
||||
// délibérés et déjà privilégiés : `ajean install`, et juste après une mise à
|
||||
// jour réussie — le service est de toute façon sur le point d'être relancé.
|
||||
//
|
||||
// CE QU'ON S'AUTORISE À DÉPLACER. Un chemin inscrit à la main est un ordre. On
|
||||
// ne migre donc QUE si le chemin actuel est exactement l'ancien emplacement par
|
||||
// défaut, celui que notre propre installeur a écrit. Un utilisateur qui a pointé
|
||||
// JEAN_HOME vers son gros disque garde son choix, intact et silencieux.
|
||||
|
||||
// layoutPlan décrit une migration d'agencement. Les chemins sont des champs
|
||||
// plutôt que des constantes pour que la logique soit testable sur un faux
|
||||
// système de fichiers, sans toucher au vrai /etc.
|
||||
type layoutPlan struct {
|
||||
Old string // /etc/jean
|
||||
New string // /etc/ajean
|
||||
DefaultDir string // /etc/default
|
||||
UnitDir string // /etc/systemd/system
|
||||
Units []string // unités à arrêter puis relancer
|
||||
BackupDir string // où déposer l'archive de sécurité
|
||||
|
||||
// stopUnit / startUnit / reloadUnits sont injectables pour les tests.
|
||||
stopUnit func(string) error
|
||||
startUnit func(string) error
|
||||
unitActive func(string) bool
|
||||
reloadUnits func() error
|
||||
isRoot func() bool
|
||||
backup func(layoutPlan) error
|
||||
}
|
||||
|
||||
func defaultLayoutPlan() layoutPlan {
|
||||
return layoutPlan{
|
||||
Old: legacyDefaultHome(),
|
||||
New: defaultAjeanHome(),
|
||||
DefaultDir: "/etc/default",
|
||||
UnitDir: "/etc/systemd/system",
|
||||
Units: []string{serviceName(), linkServiceName()},
|
||||
BackupDir: "/root",
|
||||
stopUnit: func(u string) error { return exec.Command("systemctl", "stop", u).Run() },
|
||||
startUnit: func(u string) error { return exec.Command("systemctl", "start", u).Run() },
|
||||
unitActive: func(u string) bool {
|
||||
out, _ := exec.Command("systemctl", "is-active", u).Output()
|
||||
return strings.TrimSpace(string(out)) == "active"
|
||||
},
|
||||
reloadUnits: func() error { return exec.Command("systemctl", "daemon-reload").Run() },
|
||||
isRoot: func() bool { return os.Geteuid() == 0 },
|
||||
backup: backupLayout,
|
||||
}
|
||||
}
|
||||
|
||||
// updateLayoutPlan est la variante utilisée juste après une mise à jour, quand
|
||||
// on s'exécute DANS le service de lien. Elle n'arrête et ne relance rien.
|
||||
//
|
||||
// Deux raisons, et la première est fatale :
|
||||
//
|
||||
// 1. Arrêter jean-link depuis jean-link, c'est se tuer soi-même au milieu de la
|
||||
// migration — dossier déplacé, chemins pas encore réécrits, machine cassée.
|
||||
// 2. Arrêter le service d'inférence rechargerait un modèle de plusieurs
|
||||
// dizaines de Go, ce qui n'a rien à faire derrière un bouton « mettre à jour ».
|
||||
//
|
||||
// C'est sans danger : sous Linux un rename de dossier fonctionne même avec des
|
||||
// fichiers ouverts, et un process en cours garde son répertoire de travail par
|
||||
// son inode. L'unité est réécrite dans la foulée, donc le prochain redémarrage —
|
||||
// celui de jean-link, immédiat, puis celui du service d'inférence, plus tard —
|
||||
// repart sur les bons chemins.
|
||||
func updateLayoutPlan() layoutPlan {
|
||||
p := defaultLayoutPlan()
|
||||
p.unitActive = func(string) bool { return false }
|
||||
return p
|
||||
}
|
||||
|
||||
// pinnedHome lit le chemin impose par /etc/default/ajean ou /etc/default/jean.
|
||||
// Renvoie ("", "") si aucun des deux ne fixe de valeur.
|
||||
func (p layoutPlan) pinnedHome() (file, value string) {
|
||||
for _, name := range []string{"ajean", "jean"} {
|
||||
f := filepath.Join(p.DefaultDir, name)
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "export "))
|
||||
if s == "" || strings.HasPrefix(s, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(s, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if k = strings.TrimSpace(k); k == "AJEAN_HOME" || k == "JEAN_HOME" {
|
||||
return f, strings.Trim(strings.TrimSpace(v), "\"'")
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// layoutNeedsMigration dit si une migration est possible ET legitime, avec la
|
||||
// raison quand elle ne l'est pas (affichée telle quelle par `ajean install`).
|
||||
func (p layoutPlan) layoutNeedsMigration() (bool, string) {
|
||||
if !isDir(p.Old) {
|
||||
return false, "" // rien à migrer : machine neuve ou déjà migrée
|
||||
}
|
||||
if isDir(p.New) {
|
||||
return false, fmt.Sprintf("%s et %s existent tous les deux — migration à faire à la main", p.Old, p.New)
|
||||
}
|
||||
// Le point délicat : distinguer notre artefact d'un choix de l'utilisateur.
|
||||
if file, value := p.pinnedHome(); value != "" && value != p.Old {
|
||||
return false, fmt.Sprintf("%s fixe le dossier à %s — chemin choisi explicitement, laissé intact", file, value)
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// migrateLayout exécute la migration complète. Renvoie nil sans rien faire
|
||||
// quand il n'y a rien à migrer. En cas d'échec après le déplacement, remet la
|
||||
// machine dans son état initial et relance ce qui tournait.
|
||||
func migrateLayout(p layoutPlan) error {
|
||||
ok, why := p.layoutNeedsMigration()
|
||||
if !ok {
|
||||
if why != "" {
|
||||
fmt.Printf("%s %s\n", dim("[info]"), why)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !p.isRoot() {
|
||||
fmt.Printf("%s dossier de données encore en %s — relance en root pour le migrer\n", yellow("[info]"), p.Old)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s migration de l'agencement %s → %s\n", cyan("[migration]"), p.Old, p.New)
|
||||
|
||||
if err := p.backup(p); err != nil {
|
||||
return fmt.Errorf("sauvegarde impossible, migration annulée : %w", err)
|
||||
}
|
||||
|
||||
var stopped []string
|
||||
for _, u := range p.Units {
|
||||
if p.unitActive(u) {
|
||||
_ = p.stopUnit(u)
|
||||
stopped = append(stopped, u)
|
||||
fmt.Printf(" %s arrêté %s\n", green("✓"), u)
|
||||
}
|
||||
}
|
||||
restart := func() {
|
||||
for _, u := range stopped {
|
||||
_ = p.startUnit(u)
|
||||
}
|
||||
}
|
||||
|
||||
// Déplacement atomique : même volume, donc instantané même avec 200 Go de
|
||||
// modèles, et jamais un état où les données sont à moitié quelque part.
|
||||
if err := os.Rename(p.Old, p.New); err != nil {
|
||||
restart()
|
||||
return fmt.Errorf("déplacement impossible (%w) — rien n'a été modifié", err)
|
||||
}
|
||||
|
||||
// À partir d'ici, toute erreur doit rendre la machine à son état d'origine.
|
||||
rollback := func(cause error) error {
|
||||
_ = os.Rename(p.New, p.Old)
|
||||
restart()
|
||||
return fmt.Errorf("%w — état initial restauré", cause)
|
||||
}
|
||||
|
||||
if err := rewriteTree(p.New, p.Old, p.New); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
if err := writeDefaults(p); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
if err := rewriteUnits(p); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
_ = p.reloadUnits()
|
||||
restart()
|
||||
fmt.Printf(" %s dossier de données : %s\n", green("✓"), p.New)
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupLayout archive les petits fichiers (config, presets, mémoire, clés).
|
||||
// Les .gguf et backends/ sont exclus : des dizaines de Go qui se retéléchargent,
|
||||
// et qui ne sont de toute façon pas menacés — ils ne font que changer de nom de
|
||||
// dossier parent.
|
||||
func backupLayout(p layoutPlan) error {
|
||||
if _, err := exec.LookPath("tar"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(p.BackupDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
dst := filepath.Join(p.BackupDir, "ajean-sauvegarde-"+time.Now().Format("20060102-150405")+".tar.gz")
|
||||
cmd := exec.Command("tar", "czf", dst, "-C", p.Old,
|
||||
"--exclude=./backends", "--exclude=*.gguf", "--exclude=*.gguf.part", "--exclude=*.log", ".")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
fmt.Printf(" %s sauvegarde %s\n", green("✓"), dst)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rewriteTree remplace toute mention de oldPath par newPath dans les fichiers
|
||||
// texte de root. On PARCOURT au lieu de deviner une liste : l'expérience montre
|
||||
// que les chemins absolus se cachent partout — EXTRA_ARGS d'un preset, copies
|
||||
// .bak, notes de MEMORY. Les .gguf et backends/ sont sautés (aucun chemin à y
|
||||
// trouver, et les lire prendrait des heures).
|
||||
func rewriteTree(root, oldPath, newPath string) error {
|
||||
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // un fichier illisible ne doit pas faire échouer la migration
|
||||
}
|
||||
if info.IsDir() {
|
||||
if info.Name() == "backends" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".gguf"), strings.HasSuffix(path, ".gguf.part"):
|
||||
return nil
|
||||
case info.Size() > 4<<20: // au-delà, ce n'est plus de la configuration
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || !strings.Contains(string(b), oldPath) {
|
||||
return nil
|
||||
}
|
||||
out := strings.ReplaceAll(string(b), oldPath, newPath)
|
||||
mode := info.Mode().Perm()
|
||||
if err := os.WriteFile(path, []byte(out), mode); err != nil {
|
||||
return fmt.Errorf("réécriture de %s : %w", path, err)
|
||||
}
|
||||
fmt.Printf(" %s chemins mis à jour : %s\n", green("✓"), strings.TrimPrefix(path, root+string(os.PathSeparator)))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// writeDefaults pose /etc/default/ajean et repointe l'ancien fichier au lieu de
|
||||
// le supprimer : des scripts d'utilisateurs le sourcent pour lire $JEAN_HOME.
|
||||
func writeDefaults(p layoutPlan) error {
|
||||
if err := os.MkdirAll(p.DefaultDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := fmt.Sprintf("# Généré par ajean — racine des données\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", p.New, p.New)
|
||||
if err := os.WriteFile(filepath.Join(p.DefaultDir, "ajean"), []byte(body), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
legacy := filepath.Join(p.DefaultDir, "jean")
|
||||
if b, err := os.ReadFile(legacy); err == nil {
|
||||
if out := strings.ReplaceAll(string(b), p.Old, p.New); out != string(b) {
|
||||
if err := os.WriteFile(legacy, []byte(out), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rewriteUnits corrige les chemins DANS les unités, sans les renommer. Renommer
|
||||
// imposerait un disable/enable et une fenêtre où plus rien n'est actif, pour un
|
||||
// gain invisible : le code sait déjà utiliser l'unité réellement installée.
|
||||
func rewriteUnits(p layoutPlan) error {
|
||||
entries, err := filepath.Glob(filepath.Join(p.UnitDir, "*.service"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, unit := range entries {
|
||||
b, err := os.ReadFile(unit)
|
||||
if err != nil || !strings.Contains(string(b), p.Old) {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(unit+".avant-ajean", b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(unit, []byte(strings.ReplaceAll(string(b), p.Old, p.New)), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" %s unité corrigée : %s\n", green("✓"), filepath.Base(unit))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -416,6 +416,15 @@ func restartAfterUpdate() (bool, string) {
|
||||
if runtime.GOOS != "linux" || !linkServiceActive() {
|
||||
return false, ""
|
||||
}
|
||||
// Le seul moment où l'on peut migrer l'agencement d'une machine déjà
|
||||
// installée : on est root (jean-link tourne en root), le binaire vient
|
||||
// d'être remplacé, et le service va être relancé dans la foulée. Les
|
||||
// utilisateurs ne relancent jamais `install`, donc ne rien faire ici
|
||||
// reviendrait à ne jamais migrer un serveur Linux. Sans effet si la machine
|
||||
// est déjà en ajean ou si un chemin a été imposé à la main.
|
||||
if err := migrateLayout(updateLayoutPlan()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[warn] migration de l'agencement non effectuée : %v\n", err)
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(1500 * time.Millisecond) // laisser la réponse HTTP atteindre le client
|
||||
// --no-block : on enregistre le job puis on rend la main ; systemd exécute le
|
||||
|
||||
Reference in new issue
Block a user