diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3dcc955..1456e2c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 03c5609..4512712 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -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 diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index 6441c82..93b6910 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -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 diff --git a/internal/ajean/sys_layout.go b/internal/ajean/sys_layout.go new file mode 100644 index 0000000..41e2563 --- /dev/null +++ b/internal/ajean/sys_layout.go @@ -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 +} diff --git a/internal/ajean/sys_layout_test.go b/internal/ajean/sys_layout_test.go new file mode 100644 index 0000000..81404b6 --- /dev/null +++ b/internal/ajean/sys_layout_test.go @@ -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") + } +} diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index f1a8bd8..ff6b9b2 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -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