mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Supervision du moteur sans systemd (conteneur Docker)
Nouveau sys_service_container.go : supervision par fichier PID portée du mode utilisateur macOS (Setsid, SIGTERM sur le groupe puis SIGKILL, log fichier). sys_service_linux.go bascule automatiquement quand /run/systemd/system est absent ou que LOKI_CONTAINER=1 ; une install systemd classique est inchangée. Le tunnel ajean.link suit la même logique (uiServiceCtl / uiServiceActive). Sans ce repli, changer de modèle depuis l'UI (serviceAction restart) échouait sur un systemctl absent — le conteneur était inutilisable. Testé sans systemd : start / status / restart / stop, PID suivi, processus enfant arrêté avec le groupe. build linux+darwin+windows OK, go vet + go test verts.
This commit is contained in:
10 files changed
+236
-9
No files matched your search
@@ -12,7 +12,7 @@ func TestCompiledFile(t *testing.T) {
|
|||||||
`[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu",
|
`[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu",
|
||||||
// La ligne de commande nvcc géante ne doit PAS être prise pour un fichier.
|
// La ligne de commande nvcc géante ne doit PAS être prise pour un fichier.
|
||||||
` C:\...\nvcc.exe -x cu ... -o ggml-cuda.dir\Release\acc.obj "C:\...\acc.cu"`: "",
|
` C:\...\nvcc.exe -x cu ... -o ggml-cuda.dir\Release\acc.obj "C:\...\acc.cu"`: "",
|
||||||
`Building Custom Rule C:/ProgramData/loki/...`: "",
|
`Building Custom Rule C:/ProgramData/loki/...`: "",
|
||||||
`-- UI: running npm install`: "",
|
`-- UI: running npm install`: "",
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
func TestParseEnvKeepsInnerQuotes(t *testing.T) {
|
func TestParseEnvKeepsInnerQuotes(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
`EXTRA_ARGS=--jinja --chat-template-file "/etc/loki/tpl.jinja"`: `--jinja --chat-template-file "/etc/loki/tpl.jinja"`,
|
`EXTRA_ARGS=--jinja --chat-template-file "/etc/loki/tpl.jinja"`: `--jinja --chat-template-file "/etc/loki/tpl.jinja"`,
|
||||||
`EXTRA_ARGS="--jinja --flash-attn"`: `--jinja --flash-attn`,
|
`EXTRA_ARGS="--jinja --flash-attn"`: `--jinja --flash-attn`,
|
||||||
`MODEL='/mnt/d/x.gguf'`: `/mnt/d/x.gguf`,
|
`MODEL='/mnt/d/x.gguf'`: `/mnt/d/x.gguf`,
|
||||||
`MODEL=/mnt/d/x.gguf`: `/mnt/d/x.gguf`,
|
`MODEL=/mnt/d/x.gguf`: `/mnt/d/x.gguf`,
|
||||||
}
|
}
|
||||||
for line, want := range cases {
|
for line, want := range cases {
|
||||||
m := parseEnv(line)
|
m := parseEnv(line)
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/R0m1k3/Loki/internal/nodewire"
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/coder/websocket/wsjson"
|
"github.com/coder/websocket/wsjson"
|
||||||
"github.com/R0m1k3/Loki/internal/nodewire"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/R0m1k3/Loki/internal/nodewire"
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/coder/websocket/wsjson"
|
"github.com/coder/websocket/wsjson"
|
||||||
"github.com/R0m1k3/Loki/internal/nodewire"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNodeE2E exerce tout le chemin : enrôlement SCELLÉ (le sceau du client est
|
// TestNodeE2E exerce tout le chemin : enrôlement SCELLÉ (le sceau du client est
|
||||||
|
|||||||
@@ -322,7 +322,9 @@ func uiLogPath() string { return filepath.Join(LokiHome(), uiUnitName+".log") }
|
|||||||
// « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne
|
// « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne
|
||||||
// composait jamais le tunnel.
|
// composait jamais le tunnel.
|
||||||
func uiServiceCtl(action string) error {
|
func uiServiceCtl(action string) error {
|
||||||
if runtime.GOOS != "linux" {
|
// Linux sans systemd (conteneur Docker) : même traitement que macOS/Windows —
|
||||||
|
// le process `loki web` possède le tunnel (appOwnsLink), sinon worker PID.
|
||||||
|
if runtime.GOOS != "linux" || !systemdAvailable() {
|
||||||
// Dans l'app de bureau, le tunnel tourne DANS ce process (conversation
|
// Dans l'app de bureau, le tunnel tourne DANS ce process (conversation
|
||||||
// unique) : on ne lance surtout pas un worker séparé.
|
// unique) : on ne lance surtout pas un worker séparé.
|
||||||
if appOwnsLink {
|
if appOwnsLink {
|
||||||
@@ -364,7 +366,7 @@ func uiServiceCtl(action string) error {
|
|||||||
// uiServiceActive indique si le worker de lien tourne (unité systemd sous
|
// uiServiceActive indique si le worker de lien tourne (unité systemd sous
|
||||||
// Linux, processus suivi par fichier PID ailleurs).
|
// Linux, processus suivi par fichier PID ailleurs).
|
||||||
func uiServiceActive() bool {
|
func uiServiceActive() bool {
|
||||||
if runtime.GOOS != "linux" {
|
if runtime.GOOS != "linux" || !systemdAvailable() {
|
||||||
if appOwnsLink {
|
if appOwnsLink {
|
||||||
return appLinkRunning()
|
return appLinkRunning()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sys_service_container.go — supervision du moteur SANS systemd (conteneur
|
||||||
|
// Docker, distribution sans systemd, WSL…). Même approche que le mode
|
||||||
|
// utilisateur de macOS et que Windows : `loki serve` est lancé détaché, son PID
|
||||||
|
// va dans LOKI_HOME/<svc>.pid et sa sortie dans LOKI_HOME/<svc>.log. Sans ce
|
||||||
|
// repli, chaque changement de modèle depuis l'UI (serviceAction("restart"))
|
||||||
|
// échouerait sur un systemctl absent — le conteneur serait inutilisable.
|
||||||
|
//
|
||||||
|
// La bascule est automatique : sys_service_linux.go route ici quand
|
||||||
|
// /run/systemd/system n'existe pas, ou quand LOKI_CONTAINER=1 force le mode.
|
||||||
|
|
||||||
|
// systemdAvailable dit si systemd pilote cette machine. /run/systemd/system
|
||||||
|
// n'existe que lorsque systemd est PID 1 (convention documentée par sd_booted).
|
||||||
|
func systemdAvailable() bool {
|
||||||
|
if os.Getenv("LOKI_CONTAINER") == "1" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
st, err := os.Stat("/run/systemd/system")
|
||||||
|
return err == nil && st.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid") }
|
||||||
|
func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") }
|
||||||
|
|
||||||
|
func userSvcAction(action string) error {
|
||||||
|
switch action {
|
||||||
|
case "start":
|
||||||
|
return userSvcStart()
|
||||||
|
case "stop":
|
||||||
|
return userSvcStop(true)
|
||||||
|
case "restart":
|
||||||
|
_ = userSvcStop(false)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
return userSvcStart()
|
||||||
|
case "status":
|
||||||
|
pid := readServicePID()
|
||||||
|
if pid > 0 && processAlive(pid) {
|
||||||
|
fmt.Printf("%s %s: actif (PID %d)\n", green("[ok]"), serviceName(), pid)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s %s: arrêté\n", yellow("[info]"), serviceName())
|
||||||
|
}
|
||||||
|
fmt.Printf(" logs : %s\n", logFilePath())
|
||||||
|
return nil
|
||||||
|
case "enable", "disable":
|
||||||
|
fmt.Printf("%s '%s' est sans objet ici (pas de systemd) : en conteneur, le démarrage\n", yellow("[info]"), action)
|
||||||
|
fmt.Printf(" est géré par la politique de redémarrage Docker (restart: unless-stopped).\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("action inconnue: %s", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userSvcStart() error {
|
||||||
|
if pid := readServicePID(); pid > 0 && processAlive(pid) {
|
||||||
|
fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(LokiHome(), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ouverture du log %s: %w", logFilePath(), err)
|
||||||
|
}
|
||||||
|
defer logf.Close()
|
||||||
|
|
||||||
|
cmd := exec.Command(self, "serve")
|
||||||
|
cmd.Stdout, cmd.Stderr = logf, logf
|
||||||
|
// Même répertoire de travail que sous systemd : les chemins relatifs de
|
||||||
|
// config.env (MODEL=…gguf) se résolvent depuis LOKI_HOME.
|
||||||
|
cmd.Dir = LokiHome()
|
||||||
|
// Setsid : l'enfant devient chef de groupe — il survit à la fin de ce
|
||||||
|
// process, et un signal au groupe (-pid) arrête aussi llama-server.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("démarrage de 'loki serve': %w", err)
|
||||||
|
}
|
||||||
|
pid := cmd.Process.Pid
|
||||||
|
if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("écriture du PID: %w", err)
|
||||||
|
}
|
||||||
|
_ = cmd.Process.Release()
|
||||||
|
return userCheckStarted(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userCheckStarted(pid int) error {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if processAlive(pid) {
|
||||||
|
fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid)
|
||||||
|
fmt.Printf(" logs: %s (loki logs pour suivre)\n", dim(logFilePath()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName())
|
||||||
|
fmt.Println("------------------------------------------------")
|
||||||
|
fmt.Print(tailFile(logFilePath(), 20))
|
||||||
|
fmt.Println("------------------------------------------------")
|
||||||
|
fmt.Printf("→ loki logs pour plus de détails\n→ loki edit pour corriger config.env\n")
|
||||||
|
_ = os.Remove(pidFilePath())
|
||||||
|
return fmt.Errorf("service %s non démarré", serviceName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func userSvcStop(verbose bool) error {
|
||||||
|
pid := readServicePID()
|
||||||
|
if pid <= 0 || !processAlive(pid) {
|
||||||
|
_ = os.Remove(pidFilePath())
|
||||||
|
if verbose {
|
||||||
|
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Setsid a fait de l'enfant un chef de groupe : le PID négatif vise le
|
||||||
|
// groupe entier, donc llama-server s'arrête avec lui.
|
||||||
|
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
|
||||||
|
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
for i := 0; i < 40 && processAlive(pid); i++ {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if processAlive(pid) {
|
||||||
|
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
_ = os.Remove(pidFilePath())
|
||||||
|
if verbose {
|
||||||
|
fmt.Println(green("[ok]") + " arrêté")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userServiceLogs affiche la fin du log puis suit les ajouts (tail -f minimal,
|
||||||
|
// sans dépendre d'un binaire `tail` présent dans l'image).
|
||||||
|
func userServiceLogs() error {
|
||||||
|
path := logFilePath()
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("aucun log à %s (le service a-t-il déjà démarré ?): %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
fmt.Print(tailFile(path, 80))
|
||||||
|
if _, err := f.Seek(0, io.SeekEnd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := f.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
os.Stdout.Write(buf[:n])
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readServicePID() int {
|
||||||
|
b, err := os.ReadFile(pidFilePath())
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
|
||||||
|
return pid
|
||||||
|
}
|
||||||
|
|
||||||
|
// processAlive : le signal 0 ne tue rien, il teste juste l'existence du process.
|
||||||
|
func processAlive(pid int) bool {
|
||||||
|
if pid <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return syscall.Kill(pid, 0) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailFile renvoie les n dernières lignes d'un fichier (best-effort).
|
||||||
|
func tailFile(path string, n int) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
|
||||||
|
if len(lines) > n {
|
||||||
|
lines = lines[len(lines)-n:]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n") + "\n"
|
||||||
|
}
|
||||||
@@ -152,6 +152,9 @@ func serviceIsActive() bool {
|
|||||||
func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid") }
|
func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid") }
|
||||||
func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") }
|
func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") }
|
||||||
|
|
||||||
|
// systemdAvailable : jamais de systemd sur macOS (consulté par relay_link.go).
|
||||||
|
func systemdAvailable() bool { return false }
|
||||||
|
|
||||||
func userSvcAction(action string) error {
|
func userSvcAction(action string) error {
|
||||||
switch action {
|
switch action {
|
||||||
case "start":
|
case "start":
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import (
|
|||||||
|
|
||||||
// serviceAction wraps `systemctl <action> <svc>` with passwordless sudo where
|
// serviceAction wraps `systemctl <action> <svc>` with passwordless sudo where
|
||||||
// it makes sense, and prints a follow-up status check after start/restart.
|
// it makes sense, and prints a follow-up status check after start/restart.
|
||||||
|
// Sans systemd (conteneur Docker, LOKI_CONTAINER=1), on bascule sur la
|
||||||
|
// supervision par fichier PID (sys_service_container.go).
|
||||||
func serviceAction(action string) error {
|
func serviceAction(action string) error {
|
||||||
|
if !systemdAvailable() {
|
||||||
|
return userSvcAction(action)
|
||||||
|
}
|
||||||
svc := serviceName()
|
svc := serviceName()
|
||||||
needsRoot := action == "start" || action == "stop" || action == "restart" || action == "enable" || action == "disable"
|
needsRoot := action == "start" || action == "stop" || action == "restart" || action == "enable" || action == "disable"
|
||||||
args := []string{}
|
args := []string{}
|
||||||
@@ -103,6 +108,9 @@ func checkStarted(svc string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serviceLogs() error {
|
func serviceLogs() error {
|
||||||
|
if !systemdAvailable() {
|
||||||
|
return userServiceLogs()
|
||||||
|
}
|
||||||
cmd := exec.Command("journalctl", "-u", serviceName(), "-n", "80", "-f")
|
cmd := exec.Command("journalctl", "-u", serviceName(), "-n", "80", "-f")
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
@@ -111,6 +119,10 @@ func serviceLogs() error {
|
|||||||
|
|
||||||
// serviceIsActive reports whether the systemd unit is currently running.
|
// serviceIsActive reports whether the systemd unit is currently running.
|
||||||
func serviceIsActive() bool {
|
func serviceIsActive() bool {
|
||||||
|
if !systemdAvailable() {
|
||||||
|
pid := readServicePID()
|
||||||
|
return pid > 0 && processAlive(pid)
|
||||||
|
}
|
||||||
out, _ := exec.Command("systemctl", "is-active", serviceName()).Output()
|
out, _ := exec.Command("systemctl", "is-active", serviceName()).Output()
|
||||||
return strings.TrimSpace(string(out)) == "active"
|
return strings.TrimSpace(string(out)) == "active"
|
||||||
}
|
}
|
||||||
@@ -118,6 +130,9 @@ func serviceIsActive() bool {
|
|||||||
// serviceLogTail renvoie les n dernières lignes du journal du service (pour
|
// serviceLogTail renvoie les n dernières lignes du journal du service (pour
|
||||||
// l'UI web). Linux : journalctl.
|
// l'UI web). Linux : journalctl.
|
||||||
func serviceLogTail(n int) string {
|
func serviceLogTail(n int) string {
|
||||||
|
if !systemdAvailable() {
|
||||||
|
return tailFile(logFilePath(), n)
|
||||||
|
}
|
||||||
out, err := exec.Command("journalctl", "-u", serviceName(), "-n", strconv.Itoa(n), "--no-pager").CombinedOutput()
|
out, err := exec.Command("journalctl", "-u", serviceName(), "-n", strconv.Itoa(n), "--no-pager").CombinedOutput()
|
||||||
if err != nil && len(out) == 0 {
|
if err != nil && len(out) == 0 {
|
||||||
return "journalctl indisponible : " + err.Error()
|
return "journalctl indisponible : " + err.Error()
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ const (
|
|||||||
func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid") }
|
func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid") }
|
||||||
func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") }
|
func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") }
|
||||||
|
|
||||||
|
// systemdAvailable : jamais de systemd sous Windows (consulté par relay_link.go).
|
||||||
|
func systemdAvailable() bool { return false }
|
||||||
|
|
||||||
func serviceAction(action string) error {
|
func serviceAction(action string) error {
|
||||||
switch action {
|
switch action {
|
||||||
case "start":
|
case "start":
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/R0m1k3/Loki/internal/nodewire"
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/coder/websocket/wsjson"
|
"github.com/coder/websocket/wsjson"
|
||||||
"github.com/R0m1k3/Loki/internal/nodewire"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
Reference in new issue
Block a user