Files
Loki/internal/ajean/sys_install_linux.go
T
nathaninline 24ff2f0a25 Deux services nommes ajean-engine et ajean-ui, une seule porte web
- ajean.service -> ajean-engine, ajean-link.service -> ajean-ui
- 'ajean web' est desormais l'unique serveur d'interface : il ouvre aussi le
  tunnel quand un jeton est enregistre (une seule conversation, plus de conflit
  sur :8090 ni de fils divergents)
- 'ajean link' ne gere plus que le compte ; 'ajean ui' pilote le service
- l'installateur Linux pose les deux unites et les deux regles sudoers
- 'app' sort de l'aide
2026-08-07 17:39:46 +02:00

226 lines
6.9 KiB
Go

//go:build linux
package ajean
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
)
// sudoersTemplate autorise l'utilisateur à piloter UNE unité sans mot de passe.
// Posé pour les deux unités : l'interface, qui tourne sans privilèges, doit
// pouvoir redémarrer le moteur (bascule de preset) comme elle-même (nouveau
// jeton de liaison).
const sudoersTemplate = `# Permet à %s de piloter l'unité %s sans mot de passe (posé par ajean install).
%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s
`
// uiUnitTemplate — le service d'interface : UI web locale, tunnel du relais et
// endpoint OpenAI, servis par un seul process (donc une seule conversation).
// Champs formatés : User, WorkingDirectory, ExecStart.
const uiUnitTemplate = `[Unit]
Description=AJEAN interface web + accès distant
After=network-online.target %s.service
Wants=network-online.target
[Service]
Type=simple
User=%s
WorkingDirectory=%s
ExecStart=%s
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
`
const serviceUnitTemplate = `[Unit]
Description=AJEAN llama.cpp server
After=network.target
[Service]
Type=simple
User=%s
WorkingDirectory=%s
ExecStart=%s
Restart=on-failure
RestartSec=3
# Priorité CPU : on remonte le process pour qu'il ne soit pas dépriorisé face
# aux tâches de fond (sampling/orchestration côté CPU pèsent sur le débit même
# en inference GPU). Nice négatif + scheduling normal réactif.
Nice=-10
CPUSchedulingPolicy=other
CPUAccounting=yes
[Install]
WantedBy=multi-user.target
`
func cmdInstall(args []string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)")
}
targetUser := os.Getenv("SUDO_USER")
if targetUser == "" {
targetUser = "root"
}
for _, a := range args {
if strings.HasPrefix(a, "--user=") {
targetUser = strings.TrimPrefix(a, "--user=")
}
}
u, err := user.Lookup(targetUser)
if err != nil {
return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
}
ajeanHome := defaultAjeanHome()
if v := os.Getenv("AJEAN_HOME"); v != "" {
ajeanHome = v
}
svc := serviceName()
fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser))
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
fmt.Printf(" service = %s\n", svc)
// 1. Arborescence + configuration de départ
if err := provisionDataDir(); err != nil {
return err
}
fmt.Printf(" %s arborescence prête\n", green("✓"))
// 2. Symlink current binary to /usr/local/bin/ajean
self, err := os.Executable()
if err != nil {
return err
}
if rp, e := filepath.EvalSymlinks(self); e == nil {
self = rp
}
target := installedExePath()
// Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible (l'utilisateur a
// posé le binaire dans /usr/local/bin/ajean puis lancé `sudo ajean install`),
// ne surtout pas Remove+Symlink sur soi-même — ça effacerait le vrai binaire
// et créerait /usr/local/bin/ajean -> /usr/local/bin/ajean (« Too many levels
// of symbolic links »). On compare les chemins réels (résolus).
alreadyInPlace := false
if tp, e := filepath.EvalSymlinks(target); e == nil && tp == self {
alreadyInPlace = true
}
if alreadyInPlace {
fmt.Printf(" %s %s est déjà le binaire installé (aucun lien à créer)\n", green("✓"), target)
} else {
_ = os.Remove(target)
if err := os.Symlink(self, target); err != nil {
// fall back to copy if symlink fails (e.g. cross-fs)
if data, err := os.ReadFile(self); err == nil {
_ = os.WriteFile(target, data, 0o755)
}
}
fmt.Printf(" %s %s -> %s\n", green("✓"), target, self)
}
// 3. /etc/default/ajean, pour que les invocations root résolvent AJEAN_HOME.
defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome)
if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil {
return err
}
fmt.Printf(" %s /etc/default/ajean\n", green("✓"))
// 4. Les deux unités : le moteur (exec llama-server) et l'interface.
units := map[string]string{
svc: fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve"),
uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, installedExePath()+" web"),
}
for name, body := range units {
path := "/etc/systemd/system/" + name + ".service"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
return err
}
fmt.Printf(" %s %s\n", green("✓"), path)
}
// 5. Sudoers : une règle par unité, pour que l'interface (sans privilèges)
// puisse redémarrer le moteur ET elle-même.
for _, name := range []string{svc, uiUnitName} {
sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name, targetUser, name, name, name, name, name)
path := "/etc/sudoers.d/ajean-" + name
if err := os.WriteFile(path, []byte(sudoers), 0o440); err != nil {
return err
}
fmt.Printf(" %s %s\n", green("✓"), path)
}
// 6. chown AJEAN_HOME contents to target user
chown(ajeanHome, u)
// 7. systemd reload
_ = exec.Command("systemctl", "daemon-reload").Run()
fmt.Println()
fmt.Printf("%s installation terminée.\n", green("[ok]"))
fmt.Printf("\nProchaines étapes :\n")
fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" ajean edit"))
fmt.Printf(" (renseigne BIN, MODEL, etc.)\n")
fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" ajean start"))
fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" ajean web"))
return nil
}
func cmdUninstall(args []string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("ajean uninstall doit être exécuté en root")
}
svc := serviceName()
keepData := false
for _, a := range args {
if a == "--purge" {
keepData = false
}
if a == "--keep-data" {
keepData = true
}
}
for _, name := range []string{uiUnitName, svc} {
_ = exec.Command("systemctl", "stop", name).Run()
_ = exec.Command("systemctl", "disable", name).Run()
}
for _, p := range []string{
"/etc/systemd/system/" + svc + ".service",
"/etc/systemd/system/" + uiUnitName + ".service",
"/etc/sudoers.d/ajean-" + svc,
"/etc/sudoers.d/ajean-" + uiUnitName,
"/etc/default/ajean",
installedExePath(),
} {
if err := os.Remove(p); err == nil {
fmt.Printf(" %s %s\n", green("✓"), p)
}
}
_ = exec.Command("systemctl", "daemon-reload").Run()
if !keepData {
fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_HOME manuellement si tu veux purger)"))
}
fmt.Println(green("[ok]") + " désinstallé")
return nil
}
// chown recursively changes ownership of path to the given user/group.
func chown(path string, u *user.User) {
var uid, gid int
fmt.Sscanf(u.Uid, "%d", &uid)
fmt.Sscanf(u.Gid, "%d", &gid)
filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if err == nil {
_ = os.Chown(p, uid, gid)
}
return nil
})
}