Files
Loki/internal/ajean/migrate_07.go
T
nathaninline eac12112dd Correctifs 0.8 : saisie de la cle de pilotage, identites appairees perdues a la migration
Deux defauts trouves a l'usage :

1. Impossible de saisir la cle de pilotage dans l'UI : le chargement lance une
   dizaine d'appels /api/* en parallele, chaque 401 rouvrait la modale, et
   celle-ci vide son champ a l'ouverture. Les caracteres disparaissaient au fur
   et a mesure de la frappe. Une seule demande de cle est desormais partagee par
   tous les appels concurrents. Au passage, une modale ouverte n'est plus
   ecrasee en silence (sa promesse restait pendante pour toujours).

2. migrate_07 n'importait pas .authorized_users : les identites appairees pour
   le chat chiffre. Symptome trompeur — le tunnel s'etablit, la machine
   s'affiche en ligne, mais le portail refuse toute conversation. Importe et
   couvert par un test.
2026-08-07 20:34:43 +02:00

303 lines
11 KiB
Go

package ajean
// migrate_07.go — CODE TEMPORAIRE, à supprimer d'ici une version ou deux.
//
// Reprend une installation 0.7.x au moment de `ajean install`. Tout est ici, et
// rien d'autre n'en dépend : le jour où le parc aura basculé, supprimer ce
// fichier et les deux appels à migrateFrom07() (sys_install_unix.go et
// sys_install_windows.go) suffit. C'est la seule raison d'être de ce fichier —
// la 0.8 ne contient AUCUN autre code de compatibilité.
//
// POURQUOI ICI, ET PAS AU DÉMARRAGE. La version précédente migrait à chaque
// lancement. Sur un parc, ça veut dire une réécriture d'unités systemd à chaque
// boot, et un échec laisse une machine distante sans service et sans terminal
// pour réparer. `install` est le seul bon moment : root, délibéré, et il écrit
// déjà les unités.
//
// CE QU'ON NE DÉTRUIT PAS. Les presets, la mémoire et les modèles sont
// DÉPLACÉS, jamais copiés ni effacés. Les petits fichiers d'état sont lus puis
// rangés dans avant-0.8/ — ils ne servent plus, mais ils sont là si quelque
// chose a mal tourné.
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
)
// legacyUnits : les noms de services qu'a portés le produit avant la 0.8.
//
// Les désactiver n'est pas cosmétique. Une unité `jean.service` restée active
// relance un SECOND llama-server au prochain boot : deux moteurs sur le port
// 8080, la VRAM prise deux fois, et un démarrage qui échoue à moitié sans
// raison apparente.
var legacyUnits = []string{"jean", "jean-link", "ajean", "ajean-link"}
// hasEntry dit si home contient une entrée portant EXACTEMENT ce nom.
//
// os.Stat ne convient pas : sous Windows et macOS le système de fichiers est
// insensible à la casse, donc Stat("MEMORY") réussit sur un dossier nommé
// « memory ». Une installation 0.8 toute neuve passait ainsi pour une 0.7, et
// la migration se redéclenchait à chaque `install`. On compare donc les noms
// réels, tels que le système de fichiers les rapporte.
func hasEntry(home, name string) bool {
entries, err := os.ReadDir(home)
if err != nil {
return false
}
for _, e := range entries {
if e.Name() == name {
return true
}
}
return false
}
// sameDir dit si deux chemins désignent le MÊME dossier — ce qui arrive quand
// ils ne diffèrent que par la casse sur un système de fichiers qui l'ignore.
func sameDir(a, b string) bool {
ai, err := os.Stat(a)
if err != nil {
return false
}
bi, err := os.Stat(b)
if err != nil {
return false
}
return os.SameFile(ai, bi)
}
// needsMigration07 dit si le dossier de données est encore dans l'ancienne
// disposition. On se fie à ce qui ne peut PAS exister en 0.8.
func needsMigration07(home string) bool {
return hasEntry(home, "config.env") || hasEntry(home, "configs") || hasEntry(home, "MEMORY")
}
// migrateFrom07 reprend une installation 0.7.x. Sans effet si le dossier est
// déjà en 0.8. Doit être appelée AVANT provisionDataDir : elle déplace des
// dossiers entiers, ce qui suppose que la destination n'existe pas encore.
func migrateFrom07(home string) error {
if !needsMigration07(home) {
return nil
}
fmt.Printf("\n%s installation 0.7 détectée — reprise en cours\n", cyan("[migration]"))
// 1. Arrêter les anciens services : ils tiennent les fichiers qu'on déplace,
// et sous Windows un .gguf ouvert par llama-server refuse d'être renommé.
stopLegacyUnits()
// 2. Les dossiers. Un rename sur le même volume est instantané, même pour
// 130 Go de modèles, et atomique : jamais d'état à moitié déplacé.
for _, m := range []struct{ from, to string }{
{"configs", "presets"},
{"MEMORY", "memory"},
} {
if !hasEntry(home, m.from) { // nom exact : « MEMORY » n'est pas « memory »
continue
}
src, dst := filepath.Join(home, m.from), filepath.Join(home, m.to)
// « configs » et « presets » ne se confondent pas, mais « MEMORY » et
// « memory » si : sur un système insensible à la casse, ouvrir la
// destination ouvre en réalité la source. On ne considère donc la
// destination comme distincte que si c'est un AUTRE dossier.
if !sameDir(src, dst) {
// Destination déjà remplie : la machine a été migrée à la main et
// l'ancien dossier n'a pas été retiré. Écraser détruirait le travail
// fait, fusionner devinerait qui gagne. On laisse les deux et on le
// dit — c'est le seul cas où quelqu'un doit trancher.
if n, _ := os.ReadDir(dst); len(n) > 0 {
fmt.Printf(" %s %s/ ET %s/ existent tous les deux — %s/ laissé tel quel, à supprimer à la main\n",
yellow("[info]"), m.from, m.to, m.from)
continue
}
_ = os.Remove(dst) // destination vide créée par une install précédente
}
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("%s → %s : %w", m.from, m.to, err)
}
fmt.Printf(" %s %s/ → %s/\n", green("✓"), m.from, m.to)
}
if n, err := moveModels(home); err != nil {
return err
} else if n > 0 {
fmt.Printf(" %s %d modèle(s) → models/\n", green("✓"), n)
}
// 3. Les réglages, vers la base.
if err := importLegacyState(home); err != nil {
return err
}
// SKILLS/ : les skills ont été fondus dans la mémoire avant la 0.8, le
// dossier ne sert plus à rien mais peut contenir du travail de l'utilisateur.
// On l'archive au lieu de le laisser traîner ou de l'effacer.
if hasEntry(home, "SKILLS") {
_ = os.Rename(filepath.Join(home, "SKILLS"), filepath.Join(home, "avant-0.8", "SKILLS"))
}
fmt.Printf("%s reprise terminée — les anciens fichiers sont dans avant-0.8/\n\n", green("[ok]"))
return nil
}
// stopLegacyUnits est une variable pour que le test de migration n'aille pas
// invoquer systemctl sur la machine qui exécute les tests.
var stopLegacyUnits = stopLegacyUnitsReal
// stopLegacyUnitsReal arrête et désactive les services d'avant la 0.8,
// silencieux sur ceux qui n'existent pas (le cas courant).
func stopLegacyUnitsReal() {
for _, u := range legacyUnits {
switch runtime.GOOS {
case "linux":
_ = exec.Command("systemctl", "stop", u).Run()
_ = exec.Command("systemctl", "disable", u).Run()
case "darwin":
for _, label := range []string{"com.jean." + u, "com.ajean." + u} {
_ = exec.Command("launchctl", "unload", "-w", "/Library/LaunchDaemons/"+label+".plist").Run()
}
}
}
if runtime.GOOS == "linux" {
_ = exec.Command("systemctl", "daemon-reload").Run()
}
}
// moveModels range les .gguf de la racine dans models/. Renvoie le nombre
// déplacé. Un fichier qui résiste (encore ouvert) interrompt la migration : le
// laisser derrière donnerait une installation à laquelle il manque un modèle,
// sans le dire.
func moveModels(home string) (int, error) {
entries, err := os.ReadDir(home)
if err != nil {
return 0, err
}
dst := filepath.Join(home, "models")
n := 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".gguf") {
continue
}
if n == 0 {
if err := os.MkdirAll(dst, 0o755); err != nil {
return 0, err
}
}
if err := os.Rename(filepath.Join(home, name), filepath.Join(dst, name)); err != nil {
return n, fmt.Errorf("déplacement de %s : %w", name, err)
}
n++
}
return n, nil
}
// importLegacyState recopie les anciens fichiers d'état dans la base, puis les
// range dans avant-0.8/. Les clés et les buckets sont ceux de store.go.
func importLegacyState(home string) error {
old := filepath.Join(home, "avant-0.8")
if err := os.MkdirAll(old, 0o755); err != nil {
return err
}
consumed := []string{}
read := func(name string) string {
b, err := os.ReadFile(filepath.Join(home, name))
if err != nil {
return ""
}
consumed = append(consumed, name)
return string(b)
}
// La configuration d'abord : sans elle le moteur ne démarre pas.
if cfg := parseEnv(read("config.env")); len(cfg) > 0 {
if err := WriteConfig(cfg); err != nil {
return err
}
fmt.Printf(" %s configuration (%d clés)\n", green("✓"), len(cfg))
}
if prefs := map[string]string{}; json.Unmarshal([]byte(read("webprefs.json")), &prefs) == nil && len(prefs) > 0 {
_ = replaceKV(bkPrefs, prefs)
}
if conv := read("conversation.json"); conv != "" {
_ = putBytes(bkChat, "conversation", []byte(conv))
fmt.Printf(" %s conversation\n", green("✓"))
}
// Clés et jetons : le contenu du fichier, débarrassé de sa fin de ligne.
for key, name := range map[string]string{
"api_key": ".api_key",
"web_key": ".web_key",
"crawl_key": ".crawl4ai_key",
"link_token": ".link_token",
"link_machine": ".link_machine",
} {
if v := strings.TrimSpace(read(name)); v != "" {
_ = putStr(bkState, key, v)
}
}
// Drapeaux : seule l'EXISTENCE du fichier comptait.
for key, name := range map[string]string{
"agent": ".agent_enabled",
"internet": ".internet_enabled",
"oai_public": ".oai_public",
} {
if _, err := os.Stat(filepath.Join(home, name)); err == nil {
_ = putBool(bkState, key, true)
consumed = append(consumed, name)
}
}
// Documents JSON repris tels quels.
for key, name := range map[string]string{
"model_dirs": "model_dirs.json",
"last_bench": ".last_bench.json",
"bench_presets": ".bench_presets.json",
"sysprompt": "sysprompt.txt",
} {
if v := read(name); v != "" {
if key == "sysprompt" {
_ = putStr(bkState, key, strings.TrimSpace(v))
} else {
_ = putBytes(bkState, key, []byte(v))
}
}
}
// .authorized_users : les identités appairées pour le chat chiffré de bout en
// bout, une clé publique par ligne. L'oublier coûte cher et de façon peu
// lisible : le tunnel s'établit, la machine s'affiche en ligne, mais le
// portail refuse toute conversation chiffrée — il faut ré-appairer sans
// comprendre pourquoi. Vécu sur le serveur de test.
if v := read(".authorized_users"); v != "" {
var list []string
for _, line := range strings.Split(v, "\n") {
if h := strings.ToLower(strings.TrimSpace(line)); h != "" {
list = append(list, h)
}
}
if len(list) > 0 {
sort.Strings(list)
_ = putJSON(bkState, "authorized_users", list)
fmt.Printf(" %s %d identité(s) appairée(s)\n", green("✓"), len(list))
}
}
// .pair_codes n'est PAS repris : ces codes expirent au bout de 10 minutes,
// les reprendre n'aurait aucun sens.
// mcp.json : on ne garde que la map interne, la clé « mcpServers » disparaît.
if v := read("mcp.json"); v != "" {
var f struct {
MCPServers json.RawMessage `json:"mcpServers"`
}
if json.Unmarshal([]byte(v), &f) == nil && len(f.MCPServers) > 0 {
_ = putBytes(bkState, "mcp", f.MCPServers)
}
}
for _, name := range consumed {
_ = os.Rename(filepath.Join(home, name), filepath.Join(old, name))
}
return nil
}