package main import ( "bufio" "context" "errors" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "sync" "time" ) type mlPythonSetupStatus struct { Required bool `json:"required"` Running bool `json:"running"` Done bool `json:"done"` OK bool `json:"ok"` Progress int `json:"progress"` Phase string `json:"phase"` Message string `json:"message"` Error string `json:"error,omitempty"` StartedAt string `json:"startedAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` FinishedAt string `json:"finishedAt,omitempty"` } type mlPythonSetupConfig struct { enabled bool appDir string requirementsPath string venvDir string venvPython string requirementsMark string } var ( mlSetupMu sync.RWMutex mlSetupRunMu sync.Mutex mlSetupStatus mlPythonSetupStatus ) func initMLPythonSetupStatus() { cfg := mlPythonSetupConfigFromEnv() now := time.Now().UTC().Format(time.RFC3339Nano) mlSetupMu.Lock() defer mlSetupMu.Unlock() if cfg.enabled { mlSetupStatus = mlPythonSetupStatus{ Required: true, Running: false, Done: false, OK: false, Progress: 0, Phase: "pending", Message: "ML-Python-Umgebung wartet auf Start.", UpdatedAt: now, } return } mlSetupStatus = mlPythonSetupStatus{ Required: false, Running: false, Done: true, OK: true, Progress: 100, Phase: "skipped", Message: "ML-Python-Setup nicht erforderlich.", UpdatedAt: now, } } func mlPythonSetupConfigFromEnv() mlPythonSetupConfig { if strings.TrimSpace(os.Getenv("NSFWAPP_SKIP_ML_SETUP")) == "1" { return mlPythonSetupConfig{} } if strings.TrimSpace(os.Getenv("AI_SERVER_PYTHON")) != "" { return mlPythonSetupConfig{} } if strings.TrimSpace(os.Getenv("NSFWAPP_ML_SETUP")) != "1" { return mlPythonSetupConfig{} } appDir := strings.TrimSpace(os.Getenv("NSFWAPP_HOME")) if appDir == "" { if wd, err := os.Getwd(); err == nil { appDir = wd } } if appDir == "" { appDir = "." } requirementsPath := strings.TrimSpace(os.Getenv("NSFWAPP_ML_REQUIREMENTS")) if requirementsPath == "" { requirementsPath = filepath.Join(appDir, "requirements-ml.txt") } venvDir := strings.TrimSpace(os.Getenv("NSFWAPP_ML_VENV")) if venvDir == "" { venvDir = defaultMLPythonVenvDir() } return mlPythonSetupConfig{ enabled: true, appDir: filepath.Clean(appDir), requirementsPath: filepath.Clean(requirementsPath), venvDir: filepath.Clean(venvDir), venvPython: filepath.Join(filepath.Clean(venvDir), "bin", "python"), requirementsMark: filepath.Join(filepath.Clean(venvDir), ".requirements-ml.txt"), } } func defaultMLPythonVenvDir() string { base := strings.TrimSpace(os.TempDir()) if base == "" { base = "." } user := strings.TrimSpace(os.Getenv("USER")) if user == "" { user = strings.TrimSpace(os.Getenv("USERNAME")) } suffix := "nsfwapp-ml-venv" if user != "" { suffix += "-" + sanitizeMLPythonTempName(user) } return filepath.Join(base, suffix) } func sanitizeMLPythonTempName(raw string) string { var b strings.Builder for _, r := range strings.TrimSpace(raw) { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' { b.WriteRune(r) } else if b.Len() > 0 { b.WriteByte('_') } } out := strings.Trim(b.String(), "_-.") if out == "" { return "user" } return out } func mlPythonSetupStatusSnapshot() mlPythonSetupStatus { mlSetupMu.RLock() defer mlSetupMu.RUnlock() return mlSetupStatus } func updateMLPythonSetupStatus(update func(*mlPythonSetupStatus)) { mlSetupMu.Lock() defer mlSetupMu.Unlock() update(&mlSetupStatus) mlSetupStatus.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) } func mlPythonSetupStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } writeJSON(w, http.StatusOK, mlPythonSetupStatusSnapshot()) } func ensureMLPythonSetup(ctx context.Context) error { cfg := mlPythonSetupConfigFromEnv() if !cfg.enabled { initMLPythonSetupStatus() return nil } mlSetupRunMu.Lock() defer mlSetupRunMu.Unlock() if strings.TrimSpace(os.Getenv("AI_SERVER_PYTHON")) != "" { initMLPythonSetupStatus() return nil } startedAt := time.Now().UTC().Format(time.RFC3339Nano) updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) { s.Required = true s.Running = true s.Done = false s.OK = false s.Progress = 1 s.Phase = "prepare" s.Message = "ML-Python-Umgebung wird vorbereitet." s.Error = "" s.StartedAt = startedAt s.FinishedAt = "" }) reqBytes, err := os.ReadFile(cfg.requirementsPath) if err != nil { return finishMLPythonSetupError("Requirements konnten nicht gelesen werden.", err) } if err := os.MkdirAll(cfg.venvDir, 0755); err != nil { return finishMLPythonSetupError("ML-Venv-Ordner konnte nicht erstellt werden.", err) } if current, err := os.ReadFile(cfg.requirementsMark); err == nil && string(current) == string(reqBytes) { if fi, statErr := os.Stat(cfg.venvPython); statErr == nil && fi != nil && !fi.IsDir() { _ = os.Setenv("AI_SERVER_PYTHON", cfg.venvPython) finishMLPythonSetupSuccess("ML-Python-Pakete sind bereits installiert.") return nil } } if fi, err := os.Stat(cfg.venvPython); err != nil || fi == nil || fi.IsDir() { python3, lookErr := exec.LookPath("python3") if lookErr != nil { return finishMLPythonSetupError("python3 wurde nicht gefunden.", lookErr) } if err := runMLPythonSetupCommand(ctx, cfg, "venv", "Python-Umgebung wird erstellt.", 5, 20, python3, "-m", "venv", cfg.venvDir); err != nil { return finishMLPythonSetupError("Python-Umgebung konnte nicht erstellt werden.", err) } } if err := runMLPythonSetupCommand(ctx, cfg, "pip", "pip wird aktualisiert.", 22, 38, cfg.venvPython, "-m", "pip", "install", "--upgrade", "pip"); err != nil { return finishMLPythonSetupError("pip konnte nicht aktualisiert werden.", err) } if err := runMLPythonSetupCommand(ctx, cfg, "requirements", "ML-Python-Pakete werden installiert.", 40, 94, cfg.venvPython, "-m", "pip", "install", "-r", cfg.requirementsPath); err != nil { return finishMLPythonSetupError("ML-Python-Pakete konnten nicht installiert werden.", err) } if err := atomicWriteFile(cfg.requirementsMark, reqBytes); err != nil { return finishMLPythonSetupError("ML-Requirements-Marker konnte nicht geschrieben werden.", err) } _ = os.Setenv("AI_SERVER_PYTHON", cfg.venvPython) finishMLPythonSetupSuccess("ML-Python-Pakete wurden installiert.") return nil } func runMLPythonSetupCommand(ctx context.Context, cfg mlPythonSetupConfig, phase, message string, startProgress, endProgress int, name string, args ...string) error { updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) { s.Running = true s.Progress = startProgress s.Phase = phase s.Message = message }) appLogln("[ml-setup] start:", name, strings.Join(args, " ")) cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = cfg.appDir stdout, err := cmd.StdoutPipe() if err != nil { return err } stderr, err := cmd.StderrPipe() if err != nil { return err } if err := cmd.Start(); err != nil { return err } lines := make(chan string, 64) var scanWG sync.WaitGroup scan := func(r io.Reader) { defer scanWG.Done() scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { line := scanner.Text() appLogln("[ml-setup]", line) select { case lines <- line: case <-ctx.Done(): return } } } scanWG.Add(2) go scan(stdout) go scan(stderr) done := make(chan error, 1) go func() { scanWG.Wait() done <- cmd.Wait() }() lineCount := 0 progressRange := endProgress - startProgress if progressRange < 1 { progressRange = 1 } for { select { case line := <-lines: line = strings.TrimSpace(line) if line == "" { continue } lineCount++ progress := startProgress + minInt(progressRange-1, lineCount) visibleMessage := line if phase == "requirements" { visibleMessage = message } updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) { s.Progress = progress s.Phase = phase s.Message = visibleMessage }) case err := <-done: if err != nil { return err } updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) { s.Progress = endProgress s.Phase = phase s.Message = message }) return nil case <-ctx.Done(): _ = cmd.Process.Kill() return ctx.Err() } } } func finishMLPythonSetupSuccess(message string) { updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) { s.Required = true s.Running = false s.Done = true s.OK = true s.Progress = 100 s.Phase = "done" s.Message = message s.Error = "" s.FinishedAt = time.Now().UTC().Format(time.RFC3339Nano) }) } func finishMLPythonSetupError(message string, err error) error { if err == nil { err = errors.New(message) } updateMLPythonSetupStatus(func(s *mlPythonSetupStatus) { s.Required = true s.Running = false s.Done = true s.OK = false s.Phase = "error" s.Message = message s.Error = err.Error() s.FinishedAt = time.Now().UTC().Format(time.RFC3339Nano) }) return appErrorf("%s: %w", message, err) } func minInt(a, b int) int { if a < b { return a } return b }