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:
@@ -0,0 +1,192 @@
|
||||
// Package config loads gpu-turnstile's configuration from environment
|
||||
// variables and an optional .env-style config file.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds every gpu-turnstile setting.
|
||||
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
|
||||
|
||||
AutoUpdate bool
|
||||
UpdateInterval time.Duration
|
||||
UpdateRepo string
|
||||
UpdateAsset string
|
||||
|
||||
WarmModel string
|
||||
LogLevel slog.Level
|
||||
LogJSON bool
|
||||
LogFile string
|
||||
}
|
||||
|
||||
// Defaults returns the configuration used when neither the environment nor
|
||||
// a config file sets a value.
|
||||
func Defaults() Config {
|
||||
return 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,
|
||||
|
||||
AutoUpdate: true,
|
||||
UpdateInterval: 6 * time.Hour,
|
||||
UpdateRepo: "https://git.rambossek.at/PUBLIC/gpu-turnstile",
|
||||
UpdateAsset: "gpu-turnstile.exe",
|
||||
|
||||
LogLevel: slog.LevelWarn,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseEnvFile parses a .env-style file: KEY=VALUE lines, blank lines and
|
||||
// #-comments are ignored, no quoting. A line without '=' is an error.
|
||||
func ParseEnvFile(r io.Reader) (map[string]string, error) {
|
||||
values := make(map[string]string)
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
lineNo := 0
|
||||
for scanner.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("line %d: expected KEY=VALUE", lineNo)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("line %d: empty key", lineNo)
|
||||
}
|
||||
values[key] = strings.TrimSpace(value)
|
||||
}
|
||||
return values, scanner.Err()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Load overlays values from getenv onto the Defaults. Unknown keys are
|
||||
// ignored. Invalid values are fatal.
|
||||
func Load(getenv func(string) string) (Config, error) {
|
||||
cfg := Defaults()
|
||||
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},
|
||||
{"UPDATE_REPO", &cfg.UpdateRepo},
|
||||
{"UPDATE_ASSET", &cfg.UpdateAsset},
|
||||
{"LOG_FILE", &cfg.LogFile},
|
||||
} {
|
||||
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},
|
||||
{"UPDATE_INTERVAL", &cfg.UpdateInterval},
|
||||
} {
|
||||
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
|
||||
}
|
||||
if v := getenv("AUTO_UPDATE"); v != "" {
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("AUTO_UPDATE: must be a boolean (true/false)")
|
||||
}
|
||||
cfg.AutoUpdate = b
|
||||
}
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
cfg, err := Load(func(string) string { return "" })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ListenOllama != ":11434" || cfg.ListenComfy != ":8188" {
|
||||
t.Fatalf("listen addrs = %s %s", cfg.ListenOllama, cfg.ListenComfy)
|
||||
}
|
||||
if cfg.UnloadTimeout != time.Minute || cfg.JobTimeout != 15*time.Minute {
|
||||
t.Fatalf("timeouts = %v %v", cfg.UnloadTimeout, cfg.JobTimeout)
|
||||
}
|
||||
if !cfg.AutoUpdate || cfg.UpdateInterval != 6*time.Hour {
|
||||
t.Fatalf("update = %v %v", cfg.AutoUpdate, cfg.UpdateInterval)
|
||||
}
|
||||
if cfg.LogLevel != slog.LevelWarn {
|
||||
t.Fatalf("log level = %v", cfg.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile(t *testing.T) {
|
||||
input := `# comment
|
||||
OLLAMA_URL=http://host:11435
|
||||
|
||||
LOGLEVEL=debug
|
||||
SPACED = value with spaces
|
||||
`
|
||||
values, err := ParseEnvFile(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["OLLAMA_URL"] != "http://host:11435" {
|
||||
t.Fatalf("OLLAMA_URL = %q", values["OLLAMA_URL"])
|
||||
}
|
||||
if values["LOGLEVEL"] != "debug" {
|
||||
t.Fatalf("LOGLEVEL = %q", values["LOGLEVEL"])
|
||||
}
|
||||
if values["SPACED"] != "value with spaces" {
|
||||
t.Fatalf("SPACED = %q", values["SPACED"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFileMalformed(t *testing.T) {
|
||||
_, err := ParseEnvFile(strings.NewReader("OK=1\nNOT_A_PAIR\n"))
|
||||
if err == nil || !strings.Contains(err.Error(), "line 2") {
|
||||
t.Fatalf("err = %v, want line 2 error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverridesFile(t *testing.T) {
|
||||
file := map[string]string{"OLLAMA_URL": "http://file:1", "UNLOAD_TIMEOUT": "42s"}
|
||||
env := map[string]string{"OLLAMA_URL": "http://env:2"}
|
||||
getenv := func(k string) string {
|
||||
if v := env[k]; v != "" {
|
||||
return v
|
||||
}
|
||||
return file[k]
|
||||
}
|
||||
cfg, err := Load(getenv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.OllamaURL != "http://env:2" {
|
||||
t.Fatalf("OllamaURL = %q, want env value", cfg.OllamaURL)
|
||||
}
|
||||
if cfg.UnloadTimeout != 42*time.Second {
|
||||
t.Fatalf("UnloadTimeout = %v, want file value", cfg.UnloadTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadErrors(t *testing.T) {
|
||||
for _, tc := range []struct{ key, value string }{
|
||||
{"UNLOAD_TIMEOUT", "bogus"},
|
||||
{"PROMPT_CAPTURE_LIMIT", "-5"},
|
||||
{"AUTO_UPDATE", "maybe"},
|
||||
{"LOGLEVEL", "shouty"},
|
||||
{"LOG_FORMAT", "yaml"},
|
||||
} {
|
||||
_, err := Load(func(k string) string {
|
||||
if k == tc.key {
|
||||
return tc.value
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("%s=%s: expected error", tc.key, tc.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user