Native Windows deployment: config file, service, signed auto-update

- internal/config: .env-style config file (gpu-turnstile.env next to the
  exe, -config flag or GPU_TURNSTILE_CONFIG); process env overrides file.
- internal/service: Windows service via golang.org/x/sys/windows/svc —
  graceful SCM stop, 'service install/remove' commands, restart-on-failure
  recovery (also applies staged updates). First external dependency,
  Windows-only; Linux/Docker build unaffected (go.mod stays at 1.23).
- internal/update: polls the Gitea releases API, verifies the Ed25519
  signature of the downloaded binary against an embedded public key
  (openssl-signed by CI), swaps it in next to the running exe, and once
  the GPU lock is idle exits with code 3 so service recovery restarts
  onto the new version. Dev builds and empty pubkey never update.
- CI: tag builds additionally produce gpu-turnstile.exe + .sig + .sha256
  attached to a Gitea release.
- LOG_FILE env var so the service has somewhere to log.
This commit is contained in:
mram
2026-09-20 21:33:29 +02:00
parent 5f6a22c2cf
commit e363c9e4e4
14 changed files with 1318 additions and 194 deletions
+260 -181
View File
@@ -6,256 +6,335 @@ import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"path/filepath"
"strings"
"syscall"
"time"
"gpu-turnstile/internal/comfy"
"gpu-turnstile/internal/config"
"gpu-turnstile/internal/lock"
"gpu-turnstile/internal/metrics"
"gpu-turnstile/internal/ollama"
"gpu-turnstile/internal/proxy"
"gpu-turnstile/internal/service"
"gpu-turnstile/internal/update"
)
// version is injected at build time via -ldflags "-X main.version=...".
var version = "dev"
type config struct {
listenOllama string
listenComfy string
ollamaURL string
comfyURL string
unloadTimeout time.Duration
jobTimeout time.Duration
llmWaitTimeout time.Duration
unloadPollInterval time.Duration
historyPollInterval time.Duration
probeTimeout time.Duration
freeTimeout time.Duration
warmTimeout time.Duration
shutdownTimeout time.Duration
backoffInitial time.Duration
backoffMax time.Duration
promptCaptureLimit int64
warmModel string
logLevel slog.Level
logJSON bool
}
func envDuration(getenv func(string) string, name string, dst *time.Duration) error {
v := getenv(name)
if v == "" {
return nil
}
d, err := time.ParseDuration(v)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*dst = d
return nil
}
func loadConfig(getenv func(string) string) (config, error) {
cfg := config{
listenOllama: ":11434",
listenComfy: ":8188",
ollamaURL: "http://127.0.0.1:11435",
comfyURL: "http://127.0.0.1:8189",
unloadTimeout: time.Minute,
jobTimeout: 15 * time.Minute,
llmWaitTimeout: 10 * time.Minute,
unloadPollInterval: 500 * time.Millisecond,
historyPollInterval: time.Second,
probeTimeout: 5 * time.Second,
freeTimeout: 30 * time.Second,
warmTimeout: 2 * time.Minute,
shutdownTimeout: 10 * time.Second,
backoffInitial: time.Second,
backoffMax: time.Minute,
promptCaptureLimit: 64 * 1024,
logLevel: slog.LevelWarn,
}
for _, e := range []struct {
name string
dst *string
}{
{"LISTEN_OLLAMA", &cfg.listenOllama},
{"LISTEN_COMFY", &cfg.listenComfy},
{"OLLAMA_URL", &cfg.ollamaURL},
{"COMFY_URL", &cfg.comfyURL},
{"WARM_MODEL", &cfg.warmModel},
} {
if v := getenv(e.name); v != "" {
*e.dst = v
}
}
for _, e := range []struct {
name string
dst *time.Duration
}{
{"UNLOAD_TIMEOUT", &cfg.unloadTimeout},
{"JOB_TIMEOUT", &cfg.jobTimeout},
{"LLM_WAIT_TIMEOUT", &cfg.llmWaitTimeout},
{"UNLOAD_POLL_INTERVAL", &cfg.unloadPollInterval},
{"HISTORY_POLL_INTERVAL", &cfg.historyPollInterval},
{"PROBE_TIMEOUT", &cfg.probeTimeout},
{"FREE_TIMEOUT", &cfg.freeTimeout},
{"WARM_TIMEOUT", &cfg.warmTimeout},
{"SHUTDOWN_TIMEOUT", &cfg.shutdownTimeout},
{"BACKOFF_INITIAL", &cfg.backoffInitial},
{"BACKOFF_MAX", &cfg.backoffMax},
} {
if err := envDuration(getenv, e.name, e.dst); err != nil {
return cfg, err
}
}
if v := getenv("PROMPT_CAPTURE_LIMIT"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
return cfg, fmt.Errorf("PROMPT_CAPTURE_LIMIT: must be a non-negative integer (bytes)")
}
cfg.promptCaptureLimit = n
}
// LOGLEVEL is the canonical spelling; LOG_LEVEL is kept as an alias.
logLevelValue := getenv("LOGLEVEL")
if logLevelValue == "" {
logLevelValue = getenv("LOG_LEVEL")
}
if logLevelValue != "" {
var level slog.Level
if err := level.UnmarshalText([]byte(logLevelValue)); err != nil {
return cfg, fmt.Errorf("LOGLEVEL: %w", err)
}
cfg.logLevel = level
}
switch strings.ToLower(getenv("LOG_FORMAT")) {
case "", "text":
case "json":
cfg.logJSON = true
default:
return cfg, fmt.Errorf("LOG_FORMAT: must be \"text\" or \"json\"")
}
return cfg, nil
}
// exitCodeUpdate tells the service recovery configuration to restart the
// process: a signed update has been staged and the GPU lock is idle.
const exitCodeUpdate = 3
func main() {
cfg, err := loadConfig(os.Getenv)
configPath, args := splitConfigFlag(os.Args[1:])
if len(args) > 0 && args[0] == "service" {
os.Exit(serviceCommand(configPath, args[1:]))
}
if len(args) > 0 {
fmt.Fprintf(os.Stderr, "usage: gpu-turnstile [-config path] | gpu-turnstile service install|remove [-config path]\n")
os.Exit(2)
}
if exePath, err := os.Executable(); err == nil {
update.CleanupOld(exePath)
}
cfg, err := loadMergedConfig(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "gpu-turnstile: %v\n", err)
os.Exit(1)
}
log, logOut, logCloser := newLogger(cfg)
defer logCloser.Close()
opts := &slog.HandlerOptions{Level: cfg.logLevel}
var handler slog.Handler = slog.NewTextHandler(os.Stderr, opts)
if cfg.logJSON {
handler = slog.NewJSONHandler(os.Stderr, opts)
if service.IsService() {
if err := service.Run(func(ctx context.Context) error { return run(ctx, cfg, log, logOut, true) }); err != nil {
log.Error("service failed", "err", err)
os.Exit(1)
}
return
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx, cfg, log, logOut, false); err != nil {
log.Error("listener failed", "err", err)
os.Exit(1)
}
}
// splitConfigFlag extracts -config <path> (or -config=<path>) from args.
func splitConfigFlag(args []string) (string, []string) {
var configPath string
rest := args[:0]
for i := 0; i < len(args); i++ {
switch {
case args[i] == "-config" && i+1 < len(args):
configPath = args[i+1]
i++
case strings.HasPrefix(args[i], "-config="):
configPath = strings.TrimPrefix(args[i], "-config=")
default:
rest = append(rest, args[i])
}
}
return configPath, rest
}
// defaultConfigPath returns gpu-turnstile.env next to the executable.
func defaultConfigPath() string {
exe, err := os.Executable()
if err != nil {
return "gpu-turnstile.env"
}
return filepath.Join(filepath.Dir(exe), "gpu-turnstile.env")
}
// resolveConfigPath applies the precedence: -config flag, then
// GPU_TURNSTILE_CONFIG, then the default next to the executable.
func resolveConfigPath(flagValue string) string {
if flagValue != "" {
return flagValue
}
if v := os.Getenv("GPU_TURNSTILE_CONFIG"); v != "" {
return v
}
return defaultConfigPath()
}
// loadMergedConfig reads the config file (if present) and overlays process
// environment variables on top. A missing file is fine; an unreadable or
// malformed file is fatal.
func loadMergedConfig(flagValue string) (config.Config, error) {
path := resolveConfigPath(flagValue)
values := map[string]string{}
if f, err := os.Open(path); err == nil {
defer f.Close()
parsed, err := config.ParseEnvFile(f)
if err != nil {
return config.Config{}, fmt.Errorf("%s: %w", path, err)
}
values = parsed
} else if !errors.Is(err, os.ErrNotExist) {
return config.Config{}, fmt.Errorf("read config file: %w", err)
}
getenv := func(key string) string {
if v := os.Getenv(key); v != "" {
return v
}
return values[key]
}
return config.Load(getenv)
}
// newLogger builds the slog logger and returns the output writer (stderr or
// the opened LOG_FILE) plus a closer for it.
func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) {
out := io.Writer(os.Stderr)
closer := io.NopCloser(nil)
if cfg.LogFile != "" {
if f, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644); err == nil {
out = f
closer = f
} else {
fmt.Fprintf(os.Stderr, "gpu-turnstile: cannot open LOG_FILE %s: %v (logging to stderr)\n", cfg.LogFile, err)
}
}
opts := &slog.HandlerOptions{Level: cfg.LogLevel}
var handler slog.Handler = slog.NewTextHandler(out, opts)
if cfg.LogJSON {
handler = slog.NewJSONHandler(out, opts)
}
log := slog.New(handler)
slog.SetDefault(log)
return log, out, closer
}
func serviceCommand(configPath string, args []string) int {
if len(args) != 1 || (args[0] != "install" && args[0] != "remove") {
fmt.Fprintf(os.Stderr, "usage: gpu-turnstile service install|remove [-config path]\n")
return 2
}
var err error
if args[0] == "install" {
path := resolveConfigPath(configPath)
if abs, absErr := filepath.Abs(path); absErr == nil {
path = abs
}
err = service.Install(path)
} else {
err = service.Remove()
}
if err != nil {
fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", args[0], err)
return 1
}
fmt.Printf("service %s: %sd\n", service.Name, args[0])
return 0
}
func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Writer, isService bool) error {
// The startup line carries the version and every setting and is emitted
// at WARN so it is visible even with the default (quiet) log level.
log.Log(context.Background(), slog.LevelWarn, "starting gpu-turnstile",
log.Log(ctx, slog.LevelWarn, "starting gpu-turnstile",
"version", version,
"listen_ollama", cfg.listenOllama,
"listen_comfy", cfg.listenComfy,
"ollama_url", cfg.ollamaURL,
"comfy_url", cfg.comfyURL,
"unload_timeout", cfg.unloadTimeout,
"job_timeout", cfg.jobTimeout,
"llm_wait_timeout", cfg.llmWaitTimeout,
"unload_poll_interval", cfg.unloadPollInterval,
"history_poll_interval", cfg.historyPollInterval,
"probe_timeout", cfg.probeTimeout,
"free_timeout", cfg.freeTimeout,
"warm_timeout", cfg.warmTimeout,
"shutdown_timeout", cfg.shutdownTimeout,
"backoff_initial", cfg.backoffInitial,
"backoff_max", cfg.backoffMax,
"prompt_capture_limit", cfg.promptCaptureLimit,
"warm_model", cfg.warmModel,
"log_level", cfg.logLevel,
"log_format", map[bool]string{true: "json", false: "text"}[cfg.logJSON],
"listen_ollama", cfg.ListenOllama,
"listen_comfy", cfg.ListenComfy,
"ollama_url", cfg.OllamaURL,
"comfy_url", cfg.ComfyURL,
"unload_timeout", cfg.UnloadTimeout,
"job_timeout", cfg.JobTimeout,
"llm_wait_timeout", cfg.LLMWaitTimeout,
"unload_poll_interval", cfg.UnloadPollInterval,
"history_poll_interval", cfg.HistoryPollInterval,
"probe_timeout", cfg.ProbeTimeout,
"free_timeout", cfg.FreeTimeout,
"warm_timeout", cfg.WarmTimeout,
"shutdown_timeout", cfg.ShutdownTimeout,
"backoff_initial", cfg.BackoffInitial,
"backoff_max", cfg.BackoffMax,
"prompt_capture_limit", cfg.PromptCaptureLimit,
"warm_model", cfg.WarmModel,
"auto_update", cfg.AutoUpdate,
"update_interval", cfg.UpdateInterval,
"update_repo", cfg.UpdateRepo,
"update_asset", cfg.UpdateAsset,
"log_level", cfg.LogLevel,
"log_format", map[bool]string{true: "json", false: "text"}[cfg.LogJSON],
"log_file", cfg.LogFile,
)
ollamaClient, err := ollama.New(cfg.ollamaURL, log)
lk := lock.New(log)
ollamaClient, err := ollama.New(cfg.OllamaURL, log)
if err != nil {
log.Error("invalid configuration", "err", err)
os.Exit(1)
return err
}
comfyClient, err := comfy.New(cfg.comfyURL, log)
comfyClient, err := comfy.New(cfg.ComfyURL, log)
if err != nil {
log.Error("invalid configuration", "err", err)
os.Exit(1)
return err
}
srv, err := proxy.New(proxy.Config{
OllamaURL: cfg.ollamaURL,
ComfyURL: cfg.comfyURL,
Lock: lock.New(log),
OllamaURL: cfg.OllamaURL,
ComfyURL: cfg.ComfyURL,
Lock: lk,
Ollama: ollamaClient,
Comfy: comfyClient,
Metrics: metrics.New(),
Log: log,
LogColor: !cfg.logJSON && os.Getenv("NO_COLOR") == "",
LLMWaitTimeout: cfg.llmWaitTimeout,
UnloadTimeout: cfg.unloadTimeout,
JobTimeout: cfg.jobTimeout,
UnloadPollInterval: cfg.unloadPollInterval,
HistoryPollInterval: cfg.historyPollInterval,
FreeTimeout: cfg.freeTimeout,
WarmTimeout: cfg.warmTimeout,
BackoffInitial: cfg.backoffInitial,
BackoffMax: cfg.backoffMax,
PromptCaptureLimit: cfg.promptCaptureLimit,
WarmModel: cfg.warmModel,
LogColor: !cfg.LogJSON && cfg.LogFile == "" && os.Getenv("NO_COLOR") == "",
LogWriter: logOut,
LLMWaitTimeout: cfg.LLMWaitTimeout,
UnloadTimeout: cfg.UnloadTimeout,
JobTimeout: cfg.JobTimeout,
UnloadPollInterval: cfg.UnloadPollInterval,
HistoryPollInterval: cfg.HistoryPollInterval,
FreeTimeout: cfg.FreeTimeout,
WarmTimeout: cfg.WarmTimeout,
BackoffInitial: cfg.BackoffInitial,
BackoffMax: cfg.BackoffMax,
PromptCaptureLimit: cfg.PromptCaptureLimit,
WarmModel: cfg.WarmModel,
})
if err != nil {
log.Error("invalid configuration", "err", err)
os.Exit(1)
return err
}
// Probe both upstreams once; failure is logged, not fatal.
probeCtx, probeCancel := context.WithTimeout(context.Background(), cfg.probeTimeout)
probeCtx, probeCancel := context.WithTimeout(ctx, cfg.ProbeTimeout)
if err := ollamaClient.Probe(probeCtx); err != nil {
log.Warn("ollama probe failed", "url", cfg.ollamaURL, "err", err)
log.Warn("ollama probe failed", "url", cfg.OllamaURL, "err", err)
}
if err := comfyClient.Probe(probeCtx); err != nil {
log.Warn("comfy probe failed", "url", cfg.comfyURL, "err", err)
log.Warn("comfy probe failed", "url", cfg.ComfyURL, "err", err)
}
probeCancel()
ollamaSrv := &http.Server{Addr: cfg.listenOllama, Handler: srv.OllamaHandler()}
comfySrv := &http.Server{Addr: cfg.listenComfy, Handler: srv.ComfyHandler()}
ollamaSrv := &http.Server{Addr: cfg.ListenOllama, Handler: srv.OllamaHandler()}
comfySrv := &http.Server{Addr: cfg.ListenComfy, Handler: srv.ComfyHandler()}
errCh := make(chan error, 2)
go func() { errCh <- ollamaSrv.ListenAndServe() }()
go func() { errCh <- comfySrv.ListenAndServe() }()
sigCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if cfg.AutoUpdate {
go updateLoop(ctx, cfg, log, lk, isService)
}
select {
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("listener failed", "err", err)
os.Exit(1)
return err
}
case <-sigCtx.Done():
case <-ctx.Done():
log.Info("shutting down")
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.shutdownTimeout)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer shutdownCancel()
ollamaSrv.Shutdown(shutdownCtx)
comfySrv.Shutdown(shutdownCtx)
return nil
}
// updateLoop checks for signed updates on startup and every UPDATE_INTERVAL.
// In service mode a staged update is applied by exiting with exitCodeUpdate
// once the GPU lock is idle; the service recovery configuration restarts the
// process with the new binary. Interactively it only logs.
func updateLoop(ctx context.Context, cfg config.Config, log *slog.Logger, lk *lock.Lock, isService bool) {
exePath, err := os.Executable()
if err != nil {
log.Warn("auto-update disabled: cannot locate executable", "err", err)
return
}
u := &update.Updater{Repo: cfg.UpdateRepo, Asset: cfg.UpdateAsset, Version: version, Log: log}
for {
staged, err := u.Check(ctx, exePath)
if err != nil && ctx.Err() == nil {
log.Warn("auto-update check failed", "err", err)
}
if staged {
if !isService {
log.Warn("auto-update: new binary staged; restart gpu-turnstile to apply")
return
}
log.Warn("auto-update: staged; restarting once the GPU is idle")
if waitForIdle(ctx, lk, 24*time.Hour) {
log.Warn("auto-update: restarting to apply update")
os.Exit(exitCodeUpdate)
}
return
}
select {
case <-ctx.Done():
return
case <-time.After(cfg.UpdateInterval):
}
}
}
// waitForIdle polls the lock until no LLM or image work is active or
// pending, max at most. Returns false on timeout or cancellation.
func waitForIdle(ctx context.Context, lk *lock.Lock, max time.Duration) bool {
deadline := time.Now().Add(max)
for {
if state, _, pending := lk.Snapshot(); state == lock.StateIdle && !pending {
return true
}
if time.Now().After(deadline) {
return false
}
select {
case <-ctx.Done():
return false
case <-time.After(5 * time.Second):
}
}
}