mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Migration 0.7 automatique dans 'ajean install'
Un seul fichier, migrate_07.go, sans dependance entrante : le supprimer et retirer les deux appels suffira le jour venu. Declenchee UNIQUEMENT par install (root, delibere) et non au demarrage, contrairement a ce qui existait en 0.7 et qui pouvait laisser une machine distante sans service. Deplace configs/->presets/, MEMORY/->memory/, les .gguf->models/, reprend l'etat en base, range les anciens fichiers dans avant-0.8/ et desactive les unites jean* (sans quoi un second llama-server demarrait au boot suivant). Trois tests : le cas nominal, l'installation neuve, et la machine deja migree a la main. Ils ont trouve deux bugs reels de casse de fichiers (MEMORY/memory se confondent sur Windows et macOS).
This commit is contained in:
1 parent
35edc631a9
commit
664b13753b
7 files changed
+452
-210
No files matched your search
@@ -305,42 +305,29 @@ CGO_ENABLED=0 go build -o ajean ./cmd/ajean
|
||||
- `cmd/ajean/` : point d'entrée + ressources Windows (icône, versioninfo).
|
||||
- `internal/ajean/` : tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`.
|
||||
- `internal/ajean/ui/` : interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/ajean`.
|
||||
- `tools/` : outils hors binaire — `assemble-ui` (génère `index.html`), `gen-icon` (icônes Windows), `migrate-0.7` (reprise d'une installation 0.7.x, voir ci-dessous).
|
||||
- `tools/` : outils hors binaire — `assemble-ui` (génère `index.html`) et `gen-icon` (icônes Windows).
|
||||
|
||||
## Migrer depuis la 0.7.x
|
||||
|
||||
La 0.8 ne contient **aucun code de compatibilité** : elle ignore `config.env`, `configs/`, `MEMORY/` et les fichiers d'état de la 0.7. Sur une machine déjà installée, la reprise se fait une fois, à la main. Rien n'est supprimé : les anciens fichiers restent en place, et un retour arrière consiste à réactiver les anciennes unités.
|
||||
Une seule commande :
|
||||
|
||||
```bash
|
||||
# 0. Sauvegarde (petits fichiers ; les .gguf ne risquent rien, ils ne bougent pas de volume)
|
||||
sudo tar czf /root/ajean-avant-0.8.tar.gz -C /etc/ajean --exclude=backends --exclude='*.gguf' --exclude='*.log' .
|
||||
|
||||
sudo systemctl stop jean-link jean # anciens noms de services
|
||||
|
||||
# 1. Nouvelle arborescence — on COPIE presets et mémoire, on DÉPLACE les modèles
|
||||
sudo mkdir -p /etc/ajean/{presets,memory,models,bin}
|
||||
sudo cp -a /etc/ajean/configs/. /etc/ajean/presets/
|
||||
sudo cp -a /etc/ajean/MEMORY/. /etc/ajean/memory/
|
||||
sudo mv /etc/ajean/*.gguf /etc/ajean/models/
|
||||
|
||||
# 2. Le binaire, puis les réglages dans la base
|
||||
sudo install -m755 ajean /usr/local/bin/ajean
|
||||
go run ./tools/migrate-0.7 /etc/ajean # ou le binaire pré-compilé de l'outil
|
||||
sudo chown -R <utilisateur>:<utilisateur> /etc/ajean
|
||||
|
||||
# 3. Les deux services (remplacent jean.service et jean-link.service)
|
||||
sudo systemctl disable --now jean jean-link
|
||||
sudo ajean install # écrit ajean-engine + ajean-ui, sudoers, /etc/default
|
||||
sudo systemctl enable --now ajean-engine ajean-ui
|
||||
sudo ajean install
|
||||
```
|
||||
|
||||
`migrate-0.7` reprend la configuration, les préférences de l'interface, la conversation, les clés (API, pilotage, Crawl4AI), le jeton de liaison et l'identifiant machine, les interrupteurs agent/internet/OpenAI, les serveurs MCP et les benchmarks. Il ne touche ni aux presets, ni à la mémoire, ni aux modèles — ce sont des fichiers, ils ont juste changé de dossier.
|
||||
`install` détecte l'ancienne disposition et fait la reprise complète : il arrête et **désactive** les anciens services (`jean`, `jean-link`), déplace `configs/` vers `presets/`, `MEMORY/` vers `memory/` et les `.gguf` vers `models/`, reprend en base la configuration, les préférences, la conversation, les clés, le jeton de liaison, les interrupteurs, les serveurs MCP et les benchmarks — puis installe les deux nouvelles unités.
|
||||
|
||||
**`.e2e_key` ne doit pas être supprimé** : l'empreinte de chiffrement de bout en bout confirmée dans le portail en dépend. Il reste à sa place, la migration ne le touche pas.
|
||||
Désactiver les anciennes unités n'est pas un détail : une `jean.service` restée active relancerait un **second** `llama-server` au prochain démarrage, sur le même port et la même VRAM.
|
||||
|
||||
Vérification : `ajean where`, `ajean switch` (le preset actif doit être détecté), `ajean test`, puis `journalctl -u ajean-ui -n 20` doit afficher « lien établi ✓ » et l'empreinte E2E inchangée.
|
||||
Ce qui est garanti :
|
||||
|
||||
Si `ajean install` écrase une unité que vous aviez réglée à la main (priorité CPU, `CUDA_VISIBLE_DEVICES`, utilisateur dédié), rétablissez vos directives dans `/etc/systemd/system/ajean-engine.service` puis `systemctl daemon-reload`.
|
||||
- **Rien n'est supprimé.** Presets, mémoire et 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/` — supprimez ce dossier quand tout va bien.
|
||||
- **`.e2e_key` n'est pas touché**, donc l'empreinte de chiffrement confirmée dans le portail reste valable.
|
||||
- **Une machine déjà migrée à la main** n'est pas écrasée : si `configs/` et `presets/` existent tous les deux, l'ancien est laissé tel quel et signalé.
|
||||
|
||||
Après coup : `ajean where`, `ajean switch` (le preset actif doit être détecté) et `ajean test`. Si vous aviez réglé l'unité du moteur à la main (priorité CPU, `CUDA_VISIBLE_DEVICES`, utilisateur dédié), `install` la réécrit : reportez vos directives dans `/etc/systemd/system/ajean-engine.service` puis `systemctl daemon-reload`.
|
||||
|
||||
Le code de cette reprise vit dans un fichier unique, `internal/ajean/migrate_07.go`, et sera supprimé d'ici une version ou deux.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
Le renommage est terminé. Plus rien ne s'appelle jean : ni le binaire, ni les services, ni les variables, ni les dossiers. Et le dossier de données, qui s'était couvert d'une douzaine de petits fichiers d'état, tient désormais dans six dossiers et une base.
|
||||
|
||||
**Cette version ne reprend pas les installations précédentes.** Il n'y a aucune migration : ni des dossiers, ni des unités système, ni des réglages. Réinstallez, puis retéléchargez vos modèles ou repointez-les avec *Dossiers de modèles*.
|
||||
**La reprise d'une installation 0.7 tient en une commande : `sudo ajean install`.** Il déplace les dossiers, reprend les réglages en base, désactive les anciens services et installe les nouveaux. Rien n'est supprimé : presets, mémoire et modèles sont déplacés, les anciens fichiers d'état rangés dans `avant-0.8/`. C'est le seul code de compatibilité de la version, isolé dans un fichier prévu pour être supprimé.
|
||||
|
||||
## Une base à la place des fichiers d'état
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
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"
|
||||
"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
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Test de la reprise 0.7 → 0.8. À supprimer avec migrate_07.go.
|
||||
//
|
||||
// Ce code déplace les presets, la mémoire et des modèles de plusieurs dizaines
|
||||
// de gigaoctets sur la machine de quelqu'un : il ne peut pas ne pas être testé.
|
||||
|
||||
// fake07Home fabrique un dossier de données dans l'ancienne disposition.
|
||||
func fake07Home(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := testHome(t)
|
||||
stopLegacyUnits = func() {} // pas de systemctl pendant les tests
|
||||
t.Cleanup(func() { stopLegacyUnits = stopLegacyUnitsReal })
|
||||
|
||||
write := func(rel, body string) {
|
||||
p := filepath.Join(home, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("config.env", "MODEL=\"m.gguf\"\nCTX=4096\nBIN=/usr/bin/llama-server\n")
|
||||
write("configs/MON PRESET.env", "# NAME=MON PRESET\nMODEL=\"m.gguf\"\nCTX=8192\n")
|
||||
write("MEMORY/user-preferences.md", "# Préférences\nfuseau: Europe/Paris\n")
|
||||
write("m.gguf", "gguf")
|
||||
write(".api_key", "sk-jean-secret\n")
|
||||
write(".link_token", "jl_abcdef123456\n")
|
||||
write(".agent_enabled", "")
|
||||
write("webprefs.json", `{"theme":"dark"}`)
|
||||
write("mcp.json", `{"mcpServers":{"fs":{"command":"npx","enabled":true}}}`)
|
||||
write("sysprompt.txt", "Tu es AJEAN.\n")
|
||||
write("conversation.json", `{"seq":42,"messages":[{"role":"user","content":"salut"}]}`)
|
||||
return home
|
||||
}
|
||||
|
||||
func TestMigration07(t *testing.T) {
|
||||
home := fake07Home(t)
|
||||
if !needsMigration07(home) {
|
||||
t.Fatal("ancienne disposition non détectée")
|
||||
}
|
||||
if err := migrateFrom07(home); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Les dossiers ont bougé, pas disparu.
|
||||
for _, c := range []struct{ path, want string }{
|
||||
{filepath.Join(home, "presets", "MON PRESET.env"), "CTX=8192"},
|
||||
{filepath.Join(home, "memory", "user-preferences.md"), "Europe/Paris"},
|
||||
{filepath.Join(home, "models", "m.gguf"), "gguf"},
|
||||
} {
|
||||
b, err := os.ReadFile(c.path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s absent : %v", c.path, err)
|
||||
}
|
||||
if !strings.Contains(string(b), c.want) {
|
||||
t.Errorf("%s : contenu inattendu %q", c.path, string(b))
|
||||
}
|
||||
}
|
||||
// hasEntry et non os.Stat : sur un système de fichiers insensible à la casse,
|
||||
// Stat("MEMORY") réussit sur le dossier « memory » fraîchement créé.
|
||||
for _, gone := range []string{"configs", "MEMORY", "m.gguf", "config.env"} {
|
||||
if hasEntry(home, gone) {
|
||||
t.Errorf("%s traîne encore à la racine", gone)
|
||||
}
|
||||
}
|
||||
|
||||
// Les réglages sont en base.
|
||||
cfg := ReadConfig()
|
||||
if cfg["MODEL"] != "m.gguf" || cfg["CTX"] != "4096" {
|
||||
t.Errorf("configuration non reprise : %v", cfg)
|
||||
}
|
||||
if readAPIKey() != "sk-jean-secret" {
|
||||
t.Errorf("clé API = %q", readAPIKey())
|
||||
}
|
||||
if readLinkToken() != "jl_abcdef123456" {
|
||||
t.Errorf("jeton de liaison = %q", readLinkToken())
|
||||
}
|
||||
if !agentEnabled() {
|
||||
t.Error("mode agent perdu")
|
||||
}
|
||||
if readSysPrompt() != "Tu es AJEAN." {
|
||||
t.Errorf("prompt système = %q", readSysPrompt())
|
||||
}
|
||||
if loadWebPrefs()["theme"] != "dark" {
|
||||
t.Error("préférences perdues")
|
||||
}
|
||||
if servers, _ := LoadMCPConfig(); servers["fs"].Command != "npx" {
|
||||
t.Errorf("serveurs MCP perdus : %v", servers)
|
||||
}
|
||||
LoadConversation()
|
||||
if conv.Seq != 42 {
|
||||
t.Errorf("conversation perdue (seq %d)", conv.Seq)
|
||||
}
|
||||
|
||||
// Les fichiers consommés sont rangés, pas détruits.
|
||||
if _, err := os.Stat(filepath.Join(home, "avant-0.8", "config.env")); err != nil {
|
||||
t.Error("config.env n'a pas été conservée dans avant-0.8/")
|
||||
}
|
||||
|
||||
// Idempotence : relancer ne doit plus rien voir à migrer.
|
||||
if needsMigration07(home) {
|
||||
t.Error("la migration se redéclencherait au prochain install")
|
||||
}
|
||||
if err := migrateFrom07(home); err != nil {
|
||||
t.Fatalf("second appel : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Une installation 0.8 neuve ne doit rien déclencher.
|
||||
func TestMigration07IgnoreUneInstallationNeuve(t *testing.T) {
|
||||
home := testHome(t)
|
||||
if err := provisionDataDir(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if needsMigration07(home) {
|
||||
t.Fatal("migration déclenchée sur une installation neuve")
|
||||
}
|
||||
}
|
||||
|
||||
// Machine migrée à la main : les nouveaux dossiers sont remplis ET les anciens
|
||||
// traînent encore. L'install ne doit rien écraser, ni échouer.
|
||||
func TestMigration07NEcrasePasUnDossierDejaRempli(t *testing.T) {
|
||||
home := fake07Home(t)
|
||||
precieux := filepath.Join(home, "presets", "DEJA LA.env")
|
||||
if err := os.MkdirAll(filepath.Dir(precieux), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(precieux, []byte("CTX=1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateFrom07(home); err != nil {
|
||||
t.Fatalf("la migration doit se poursuivre, pas échouer : %v", err)
|
||||
}
|
||||
b, err := os.ReadFile(precieux)
|
||||
if err != nil || !strings.Contains(string(b), "CTX=1") {
|
||||
t.Error("le preset déjà migré a été écrasé")
|
||||
}
|
||||
if !hasEntry(home, "configs") {
|
||||
t.Error("l'ancien dossier a été supprimé alors qu'il fallait le laisser")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,13 @@ func cmdInstall(args []string) error {
|
||||
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
|
||||
fmt.Printf(" services = %s + %s\n", serviceName(), uiUnitName)
|
||||
|
||||
// 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir :
|
||||
// elle déplace des dossiers entiers, donc la destination ne doit pas
|
||||
// encore exister. Code temporaire, voir migrate_07.go.
|
||||
if err := migrateFrom07(ajeanHome); err != nil {
|
||||
return fmt.Errorf("reprise de l'installation 0.7 : %w", err)
|
||||
}
|
||||
|
||||
// 1. Arborescence + configuration de départ.
|
||||
if err := provisionDataDir(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -23,6 +23,13 @@ func cmdInstall(args []string) error {
|
||||
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
|
||||
fmt.Printf(" service = %s\n", serviceName())
|
||||
|
||||
// 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir :
|
||||
// elle déplace des dossiers entiers, donc la destination ne doit pas
|
||||
// encore exister. Code temporaire, voir migrate_07.go.
|
||||
if err := migrateFrom07(ajeanHome); err != nil {
|
||||
return fmt.Errorf("reprise de l'installation 0.7 : %w", err)
|
||||
}
|
||||
|
||||
// 1. Arborescence + configuration de départ (partagé avec le premier
|
||||
// lancement de l'app, voir sys_datadir.go).
|
||||
if err := provisionDataDir(); err != nil {
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
// migrate-0.7 — reprend une installation AJEAN 0.7.x dans la base de la 0.8.
|
||||
//
|
||||
// USAGE UNIQUE, et volontairement HORS du binaire ajean. La 0.8 ne contient
|
||||
// aucun code de compatibilité : c'est tout l'intérêt de la version. Mais une
|
||||
// machine déjà installée ne doit pas pour autant perdre ses réglages, sa
|
||||
// conversation et ses clés. D'où cet outil séparé, qu'on lance une fois puis
|
||||
// qu'on oublie — le produit reste propre, la migration reste possible.
|
||||
//
|
||||
// go run ./tools/migrate-0.7 [$AJEAN_HOME] (défaut : /etc/ajean)
|
||||
//
|
||||
// Il lit l'ancienne disposition (config.env, webprefs.json, conversation.json,
|
||||
// .api_key, .link_token…) et la recopie dans ajean.db, aux mêmes buckets et aux
|
||||
// mêmes clés que internal/ajean/store.go.
|
||||
//
|
||||
// Il ne SUPPRIME rien : les anciens fichiers restent en place, intacts. Si la
|
||||
// migration s'avère fausse, il suffit d'effacer ajean.db et de la relancer.
|
||||
//
|
||||
// Ce qu'il ne fait PAS, parce que ça se fait mieux au shell (voir le README) :
|
||||
// déplacer configs/ vers presets/, MEMORY/ vers memory/, les .gguf vers
|
||||
// models/, et remplacer les unités systemd.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
const (
|
||||
bkConfig = "config"
|
||||
bkPrefs = "prefs"
|
||||
bkState = "state"
|
||||
bkChat = "chat"
|
||||
)
|
||||
|
||||
var home = "/etc/ajean"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
home = os.Args[1]
|
||||
}
|
||||
db, err := bolt.Open(filepath.Join(home, "ajean.db"), 0o600, &bolt.Options{Timeout: 5 * time.Second})
|
||||
must(err)
|
||||
defer db.Close()
|
||||
must(db.Update(func(tx *bolt.Tx) error {
|
||||
for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} {
|
||||
if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// Configuration du moteur.
|
||||
if cfg := parseEnv(read("config.env")); len(cfg) > 0 {
|
||||
must(putAll(db, bkConfig, cfg))
|
||||
fmt.Printf("config : %d clés\n", len(cfg))
|
||||
} else {
|
||||
fmt.Println("config : ABSENTE — le service ne démarrera pas")
|
||||
}
|
||||
|
||||
// Préférences de l'interface web.
|
||||
var prefs map[string]string
|
||||
if b := read("webprefs.json"); b != "" && json.Unmarshal([]byte(b), &prefs) == nil {
|
||||
must(putAll(db, bkPrefs, prefs))
|
||||
fmt.Printf("prefs : %d clés\n", len(prefs))
|
||||
}
|
||||
|
||||
// Clés, jetons et interrupteurs.
|
||||
for key, file := 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(file)); v != "" {
|
||||
must(put(db, bkState, key, []byte(v)))
|
||||
fmt.Printf("%-12s: repris\n", key)
|
||||
}
|
||||
}
|
||||
// Les drapeaux étaient des fichiers dont seule l'EXISTENCE comptait.
|
||||
for key, file := range map[string]string{
|
||||
"agent": ".agent_enabled",
|
||||
"internet": ".internet_enabled",
|
||||
"oai_public": ".oai_public",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(home, file)); err == nil {
|
||||
must(put(db, bkState, key, []byte("1")))
|
||||
fmt.Printf("%-12s: actif\n", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Serveurs MCP : on ne garde que la map interne, la clé « mcpServers » du
|
||||
// fichier n'a plus de raison d'être.
|
||||
if b := read("mcp.json"); b != "" {
|
||||
var f struct {
|
||||
MCPServers json.RawMessage `json:"mcpServers"`
|
||||
}
|
||||
if json.Unmarshal([]byte(b), &f) == nil && len(f.MCPServers) > 0 {
|
||||
must(put(db, bkState, "mcp", f.MCPServers))
|
||||
fmt.Println("mcp : repris")
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmarks et dossiers de modèles : recopiés tels quels (même forme JSON).
|
||||
for key, file := range map[string]string{
|
||||
"last_bench": ".last_bench.json",
|
||||
"bench_presets": ".bench_presets.json",
|
||||
"model_dirs": "model_dirs.json",
|
||||
} {
|
||||
if b := read(file); b != "" {
|
||||
must(put(db, bkState, key, []byte(b)))
|
||||
fmt.Printf("%-12s: repris\n", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation partagée.
|
||||
if b := read("conversation.json"); b != "" {
|
||||
must(put(db, bkChat, "conversation", []byte(b)))
|
||||
fmt.Printf("conversation: %d octets\n", len(b))
|
||||
}
|
||||
|
||||
fmt.Println("\nTerminé — aucun ancien fichier n'a été supprimé.")
|
||||
}
|
||||
|
||||
func read(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join(home, name))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func put(db *bolt.DB, bucket, key string, val []byte) error {
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
return tx.Bucket([]byte(bucket)).Put([]byte(key), val)
|
||||
})
|
||||
}
|
||||
|
||||
func putAll(db *bolt.DB, bucket string, m map[string]string) error {
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(bucket))
|
||||
for k, v := range m {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if err := b.Put([]byte(k), []byte(v)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// parseEnv reprend à l'identique la lecture de backend_config.go.
|
||||
func parseEnv(text string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
s := strings.TrimSpace(line)
|
||||
if s == "" || strings.HasPrefix(s, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(strings.TrimPrefix(s, "export "), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), "\"")
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "migration:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user