From cf48796183f6568944bdd079e2dd809b416c220c Mon Sep 17 00:00:00 2001 From: mram Date: Mon, 21 Sep 2026 08:48:51 +0200 Subject: [PATCH] Install writes a fully commented sample env file when no config exists --- README.md | 8 ++-- SPEC.md | 15 +++---- internal/config/sample.go | 65 +++++++++++++++++++++++++++++ internal/config/sample_test.go | 44 +++++++++++++++++++ internal/service/service_linux.go | 17 ++++++-- internal/service/service_windows.go | 14 +++++-- 6 files changed, 145 insertions(+), 18 deletions(-) create mode 100644 internal/config/sample.go create mode 100644 internal/config/sample_test.go diff --git a/README.md b/README.md index 39be971..9ba0914 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,11 @@ gpu-turnstile.exe --remove-service ``` Layout: `C:\Program Files\gpu-turnstile\` holds the exe and -`gpu-turnstile.env`, logs go to `C:\ProgramData\gpu-turnstile\` — the -installer sets `LOG_FILE` in the env file by default (there is no console; -your own `LOG_FILE` setting is kept). The service always runs +`gpu-turnstile.env`, logs go to `C:\ProgramData\gpu-turnstile\`. If you +install without a config, the installer writes a sample env file with every +setting commented and explained — only `LOG_FILE` is active (a service has +no console). Your own `LOG_FILE` setting is always kept. The service always +runs as the virtual account `NT SERVICE\gpu-turnstile` (low-privilege, per-service, no password); the installer automatically grants it write access to the install and data directories — nothing else to do. diff --git a/SPEC.md b/SPEC.md index 442cda2..bd5b28f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -186,13 +186,14 @@ By default install creates the canonical layout and copies the binary into it (Windows: `%ProgramFiles%\gpu-turnstile\`, plus `%ProgramData%\gpu-turnstile\` for logs; Linux: `/var/lib/gpu-turnstile/` with the config at `/etc/gpu-turnstile.env`). An existing config in the -target location is never overwritten. On Windows the install also makes -sure the env file sets `LOG_FILE` to -`%ProgramData%\gpu-turnstile\gpu-turnstile.log` (appended only when no -`LOG_FILE=` line exists) since a service has no console; on Linux logs go -to the journal via stderr, so no default is set there. `--no-copy` -registers the current executable location as-is and leaves the config -untouched. +target location is never overwritten. If there is no config at all, install +writes a sample env file covering every setting — each with a comment line, +everything commented out — except `LOG_FILE`, which is active on Windows +(`%ProgramData%\gpu-turnstile\gpu-turnstile.log`) since a service has no +console; on Linux it stays commented because stderr goes to the journal. +An existing config without `LOG_FILE` gets that line appended on Windows. +`--no-copy` registers the current executable location as-is and leaves the +config untouched. ### Windows diff --git a/internal/config/sample.go b/internal/config/sample.go new file mode 100644 index 0000000..3153ec9 --- /dev/null +++ b/internal/config/sample.go @@ -0,0 +1,65 @@ +package config + +import ( + "fmt" + "strings" +) + +// sampleEntry is one line pair in the sample env file: a comment and the +// (usually commented-out) KEY=VALUE line. +type sampleEntry struct { + name string + value string + comment string + active bool // rendered uncommented +} + +// SampleEnv renders a sample .env file covering every setting, each with a +// comment line. Everything is commented out — so all defaults apply — +// except LOG_FILE when logFile is non-empty: a Windows service has no +// console, so the installer pre-wires file logging there. +func SampleEnv(logFile string) string { + entries := []sampleEntry{ + {"LISTEN_OLLAMA", ":11434", "Ollama-facing listener address", false}, + {"LISTEN_COMFY", ":8188", "ComfyUI-facing listener address", false}, + {"OLLAMA_URL", "http://127.0.0.1:11434", "Ollama upstream URL; setting it enables the Ollama consumer (default: empty = disabled)", false}, + {"COMFY_URL", "http://127.0.0.1:8188", "ComfyUI upstream URL; setting it enables the ComfyUI consumer (default: empty = disabled)", false}, + {"WARM_MODEL", "", "Optional model to reload after an image job (default: empty = none)", false}, + {"UNLOAD_TIMEOUT", "60s", "How long to wait for Ollama to unload a model", false}, + {"JOB_TIMEOUT", "15m", "Maximum time to wait for a ComfyUI job", false}, + {"LLM_WAIT_TIMEOUT", "10m", "Max time an LLM request waits for the GPU before being answered 503 (wait mode)", false}, + {"LLM_BUSY_MODE", "wait", `How blocked LLM requests are handled: "wait" holds them, "reject" fails them immediately`, false}, + {"LLM_BUSY_STATUS", "503", "HTTP status for rejected LLM requests in reject mode (400-599, e.g. 429)", false}, + {"BUSY_RETRY_AFTER", "30", "Seconds sent as Retry-After on busy responses (both modes)", false}, + {"LOGLEVEL", "warn", `Log verbosity: debug, info, warn, error; "info" logs every request (LOG_LEVEL works too)`, false}, + {"LOG_FORMAT", "text", `Log format: "text" or "json"`, false}, + {"LOG_FILE", logFile, "Append logs to this file instead of stderr (a Windows service has no console)", logFile != ""}, + {"UNLOAD_POLL_INTERVAL", "500ms", "/api/ps poll interval while unloading", false}, + {"HISTORY_POLL_INTERVAL", "1s", "/history/ poll interval while a job runs", false}, + {"PROBE_TIMEOUT", "5s", "Startup probe timeout for the enabled upstreams", false}, + {"FREE_TIMEOUT", "30s", "Timeout for the POST /free call after an image job", false}, + {"WARM_TIMEOUT", "2m", "Timeout for the warm-model reload after an image job", false}, + {"SHUTDOWN_TIMEOUT", "10s", "Graceful shutdown timeout on SIGINT/SIGTERM", false}, + {"BACKOFF_INITIAL", "1s", "First retry wait when an upstream connection fails", false}, + {"BACKOFF_MAX", "60s", "Cap for the exponential retry backoff", false}, + {"PROMPT_CAPTURE_LIMIT", "65536", "Bytes of the /prompt response buffered to find prompt_id (pass-through unaffected)", false}, + {"AUTO_UPDATE", "true", "Poll the releases API for signed updates", false}, + {"UPDATE_INTERVAL", "6h", "Auto-update check interval", false}, + {"UPDATE_REPO", "https://git.rambossek.at/PUBLIC/gpu-turnstile", "Repository to check for releases", false}, + {"UPDATE_ASSET", "gpu-turnstile.exe", "Release asset to download", false}, + } + var b strings.Builder + b.WriteString("# gpu-turnstile configuration\n") + b.WriteString("# KEY=VALUE lines; \"#\" starts a comment. Every setting below is at its\n") + b.WriteString("# default and commented out — remove the \"#\" to change it.\n") + b.WriteString("# At least one of OLLAMA_URL / COMFY_URL must be set for the proxy to start.\n\n") + for _, e := range entries { + fmt.Fprintf(&b, "# %s\n", e.comment) + if e.active { + fmt.Fprintf(&b, "%s=%s\n\n", e.name, e.value) + } else { + fmt.Fprintf(&b, "#%s=%s\n\n", e.name, e.value) + } + } + return b.String() +} diff --git a/internal/config/sample_test.go b/internal/config/sample_test.go new file mode 100644 index 0000000..158959a --- /dev/null +++ b/internal/config/sample_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "strings" + "testing" +) + +func TestSampleEnv(t *testing.T) { + sample := SampleEnv(`C:\ProgramData\gpu-turnstile\gpu-turnstile.log`) + + // Every setting known to Load must appear. + for _, name := range []string{ + "LISTEN_OLLAMA", "LISTEN_COMFY", "OLLAMA_URL", "COMFY_URL", + "WARM_MODEL", "UNLOAD_TIMEOUT", "JOB_TIMEOUT", "LLM_WAIT_TIMEOUT", + "LLM_BUSY_MODE", "LLM_BUSY_STATUS", "BUSY_RETRY_AFTER", + "LOGLEVEL", "LOG_FORMAT", "LOG_FILE", + "UNLOAD_POLL_INTERVAL", "HISTORY_POLL_INTERVAL", "PROBE_TIMEOUT", + "FREE_TIMEOUT", "WARM_TIMEOUT", "SHUTDOWN_TIMEOUT", + "BACKOFF_INITIAL", "BACKOFF_MAX", "PROMPT_CAPTURE_LIMIT", + "AUTO_UPDATE", "UPDATE_INTERVAL", "UPDATE_REPO", "UPDATE_ASSET", + } { + if !strings.Contains(sample, name+"=") { + t.Errorf("sample is missing %s", name) + } + } + + // The sample must parse cleanly, and only LOG_FILE is active. + values, err := ParseEnvFile(strings.NewReader(sample)) + if err != nil { + t.Fatalf("sample does not parse: %v", err) + } + if len(values) != 1 || values["LOG_FILE"] != `C:\ProgramData\gpu-turnstile\gpu-turnstile.log` { + t.Fatalf("active values = %v, want only LOG_FILE", values) + } + + // Without a log path everything is commented out. + values, err = ParseEnvFile(strings.NewReader(SampleEnv(""))) + if err != nil { + t.Fatalf("sample without log path does not parse: %v", err) + } + if len(values) != 0 { + t.Fatalf("active values = %v, want none", values) + } +} diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go index b1cb644..9c672ab 100644 --- a/internal/service/service_linux.go +++ b/internal/service/service_linux.go @@ -15,6 +15,8 @@ import ( "os/signal" "path/filepath" "syscall" + + "gpu-turnstile/internal/config" ) // Name matches the Windows service name; the systemd unit is Name + ".service". @@ -170,10 +172,17 @@ func Install(configPath string, copyBin bool) error { } } exe = installedExe - if _, err := os.Stat(etcConfig); os.IsNotExist(err) && configPath != "" { - // Missing config is not fatal: the service fails fast with a - // clear "no consumer URL" error until the user writes one. - copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort + if _, err := os.Stat(etcConfig); os.IsNotExist(err) { + if configPath != "" { + copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort + } else { + // No config at all: install a fully commented sample + // covering every setting. LOG_FILE stays commented — + // stderr goes to the journal on Linux. + if err := os.WriteFile(etcConfig, []byte(config.SampleEnv("")), 0o644); err != nil { + return fmt.Errorf("write %s: %w", etcConfig, err) + } + } } } else if configPath != "" { if abs, absErr := filepath.Abs(configPath); absErr == nil { diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go index c8dea52..1ea3319 100644 --- a/internal/service/service_windows.go +++ b/internal/service/service_windows.go @@ -172,10 +172,16 @@ func Install(configPath string, copyBin bool) error { } } } - // A service has no console: without LOG_FILE the output vanishes, so - // the installed env file defaults to logging into ProgramData. An - // existing LOG_FILE setting is left alone. - if err := ensureLogFile(targetCfg, filepath.Join(dataDir, "gpu-turnstile.log")); err != nil { + // A service has no console: without LOG_FILE the output vanishes. + // With no config at all, install a fully commented sample covering + // every setting (only LOG_FILE active); an existing config just gets + // a LOG_FILE line appended if it has none. + logPath := filepath.Join(dataDir, "gpu-turnstile.log") + if _, statErr := os.Stat(targetCfg); os.IsNotExist(statErr) { + if err := os.WriteFile(targetCfg, []byte(config.SampleEnv(logPath)), 0o644); err != nil { + return fmt.Errorf("write %s: %w", targetCfg, err) + } + } else if err := ensureLogFile(targetCfg, logPath); err != nil { return err } configPath = targetCfg