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
`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.
+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
`%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
+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)
}
}
+13 -4
View File
@@ -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 {
+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
// 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