Install writes a fully commented sample env file when no config exists

This commit is contained in:
mram
2026-09-21 08:48:51 +02:00
parent dd3187f951
commit cf48796183
6 changed files with 145 additions and 18 deletions
+5 -3
View File
@@ -112,9 +112,11 @@ gpu-turnstile.exe --remove-service
``` ```
Layout: `C:\Program Files\gpu-turnstile\` holds the exe and Layout: `C:\Program Files\gpu-turnstile\` holds the exe and
`gpu-turnstile.env`, logs go to `C:\ProgramData\gpu-turnstile\` — the `gpu-turnstile.env`, logs go to `C:\ProgramData\gpu-turnstile\`. If you
installer sets `LOG_FILE` in the env file by default (there is no console; install without a config, the installer writes a sample env file with every
your own `LOG_FILE` setting is kept). The service always runs 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, as the virtual account `NT SERVICE\gpu-turnstile` (low-privilege,
per-service, no password); the installer automatically grants it write per-service, no password); the installer automatically grants it write
access to the install and data directories — nothing else to do. access to the install and data directories — nothing else to do.
+8 -7
View File
@@ -186,13 +186,14 @@ By default install creates the canonical layout and copies the binary into
it (Windows: `%ProgramFiles%\gpu-turnstile\`, plus it (Windows: `%ProgramFiles%\gpu-turnstile\`, plus
`%ProgramData%\gpu-turnstile\` for logs; Linux: `/var/lib/gpu-turnstile/` `%ProgramData%\gpu-turnstile\` for logs; Linux: `/var/lib/gpu-turnstile/`
with the config at `/etc/gpu-turnstile.env`). An existing config in the with the config at `/etc/gpu-turnstile.env`). An existing config in the
target location is never overwritten. On Windows the install also makes target location is never overwritten. If there is no config at all, install
sure the env file sets `LOG_FILE` to writes a sample env file covering every setting — each with a comment line,
`%ProgramData%\gpu-turnstile\gpu-turnstile.log` (appended only when no everything commented out — except `LOG_FILE`, which is active on Windows
`LOG_FILE=` line exists) since a service has no console; on Linux logs go (`%ProgramData%\gpu-turnstile\gpu-turnstile.log`) since a service has no
to the journal via stderr, so no default is set there. `--no-copy` console; on Linux it stays commented because stderr goes to the journal.
registers the current executable location as-is and leaves the config An existing config without `LOG_FILE` gets that line appended on Windows.
untouched. `--no-copy` registers the current executable location as-is and leaves the
config untouched.
### Windows ### Windows
+65
View File
@@ -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/<id> 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()
}
+44
View File
@@ -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)
}
}
+12 -3
View File
@@ -15,6 +15,8 @@ import (
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"syscall" "syscall"
"gpu-turnstile/internal/config"
) )
// Name matches the Windows service name; the systemd unit is Name + ".service". // 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 exe = installedExe
if _, err := os.Stat(etcConfig); os.IsNotExist(err) && configPath != "" { if _, err := os.Stat(etcConfig); os.IsNotExist(err) {
// Missing config is not fatal: the service fails fast with a if configPath != "" {
// clear "no consumer URL" error until the user writes one.
copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort 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 != "" { } else if configPath != "" {
if abs, absErr := filepath.Abs(configPath); absErr == nil { if abs, absErr := filepath.Abs(configPath); absErr == nil {
+10 -4
View File
@@ -172,10 +172,16 @@ func Install(configPath string, copyBin bool) error {
} }
} }
} }
// A service has no console: without LOG_FILE the output vanishes, so // A service has no console: without LOG_FILE the output vanishes.
// the installed env file defaults to logging into ProgramData. An // With no config at all, install a fully commented sample covering
// existing LOG_FILE setting is left alone. // every setting (only LOG_FILE active); an existing config just gets
if err := ensureLogFile(targetCfg, filepath.Join(dataDir, "gpu-turnstile.log")); err != nil { // 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 return err
} }
configPath = targetCfg configPath = targetCfg