package config import ( "fmt" "strconv" "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 } // appVerComment documents APP_VER wherever it is rendered. const appVerComment = `Version to run: "dev" disables updates, "stable" tracks the latest release, or pin an exact release like v0.1.7` // sampleEntries lists every setting in sample order. logFile activates the // LOG_FILE line (Windows install); empty keeps it commented like the rest. // CFG_VER and APP_VER are not entries — they head the file, always active. func sampleEntries(logFile string) []sampleEntry { return []sampleEntry{ {"LISTEN_OLLAMA", ":11434", "Listen address for Ollama-compatible clients (gpu-turnstile poses as Ollama here)", false}, {"LISTEN_COMFY", ":8188", "Listen address for ComfyUI clients (gpu-turnstile poses as ComfyUI here)", 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}, {"COMFY_CMD", `"C:\ComfyUI\.venv\Scripts\python.exe" ComfyUI\main.py --port 8188`, "Spawn and supervise ComfyUI on demand: the first request starts it, it stops after COMFY_IDLE_TIMEOUT to free VRAM (default: empty = unmanaged)", false}, {"COMFY_DIR", `C:\ComfyUI`, "Working directory for COMFY_CMD (default: empty = inherit)", false}, {"COMFY_IDLE_TIMEOUT", "5m", "Stop the managed ComfyUI after this long without requests or jobs (frees VRAM)", false}, {"COMFY_START_TIMEOUT", "2m", "How long a request waits for the managed ComfyUI to come up", false}, {"GAME_PROCS", "cyberpunk2077.exe,hl2.exe", "While a listed process runs, the GPU counts as held by it: requests wait, Ollama unloads, managed ComfyUI stops (default: empty = disabled)", false}, {"GPU_FOREIGN_VRAM_MB", "1024", "Also treat the GPU as held when a process not in GPU_IGNORE_PROCS uses more VRAM than this (needs nvidia-smi; 0/empty = disabled)", false}, {"GPU_IGNORE_PROCS", "ollama,ollama app,ollama_llama_server,python,pythonw", "Process names never counted as foreign GPU users (ComfyUI runs under python)", false}, {"GAME_POLL_INTERVAL", "15s", "How often game/VRAM detection runs (nvidia-smi polls keep the GPU awake; don't go below ~10s)", 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", "Probe timeout for the startup probe and the periodic health check", false}, {"HEALTH_INTERVAL", "30s", "How often enabled upstreams are probed; status changes are logged", 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}, } } // SampleEnv renders a sample .env file covering every setting, each with a // comment line. Everything is commented out — so all defaults apply — // except the CFG_VER/APP_VER header and LOG_FILE when logFile is non-empty // (a Windows service has no console). CFG_VER records the version that // wrote the file so installs — and startups after an update — can upgrade // it. func SampleEnv(version, logFile string) string { // CFG_VER is always a concrete vX.Y.Z — never "dev". A dev build // stamps v0.0.0, which sorts older than any release, so the next // release install upgrades the file and stamps a proper version. cfgVer := version if cfgVer == "" || cfgVer == "dev" { cfgVer = "v0.0.0" } var b strings.Builder fmt.Fprintf(&b, "CFG_VER=%s\n", cfgVer) b.WriteString("# Config format reference, written by the installer — do not edit.\n") b.WriteString("# The installer uses it to append newly added settings on updates.\n\n") 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") writeEntry(&b, sampleEntry{"APP_VER", "stable", appVerComment, true}) for _, e := range sampleEntries(logFile) { writeEntry(&b, e) } return b.String() } func writeEntry(b *strings.Builder, e sampleEntry) { 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) } } // SyncSample upgrades an installer-written env file: when its CFG_VER says // it was written by an older gpu-turnstile, every setting the file does not // mention — commented or not — is appended at the end, and CFG_VER is // updated to version. Files without CFG_VER (hand-written or foreign), // up-to-date files and "dev" builds are returned unchanged; changed reports // whether the returned content differs. func SyncSample(data, version, logFile string) (string, bool) { if version == "" || version == "dev" { return data, false } values, err := ParseEnvFile(strings.NewReader(data)) if err != nil || values["CFG_VER"] == "" { return data, false // not installer-written; the caller decides } if compareVersions(values["CFG_VER"], version) >= 0 { return data, false // same or newer } present := make(map[string]bool) for _, line := range strings.Split(data, "\n") { line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#")) if name, _, ok := strings.Cut(line, "="); ok { present[strings.TrimSpace(name)] = true } } lines := strings.Split(data, "\n") for i, l := range lines { if strings.HasPrefix(strings.TrimSpace(l), "CFG_VER=") { lines[i] = "CFG_VER=" + version break } } out := strings.Join(lines, "\n") if !strings.HasSuffix(out, "\n") { out += "\n" } var b strings.Builder if !present["APP_VER"] { fmt.Fprintf(&b, "\n# Added by gpu-turnstile %s:\n", version) writeEntry(&b, sampleEntry{"APP_VER", "stable", appVerComment, true}) } for _, e := range sampleEntries(logFile) { if !present[e.name] { fmt.Fprintf(&b, "\n# Added by gpu-turnstile %s:\n", version) writeEntry(&b, e) } } out += b.String() return out, out != data } // compareVersions orders two vX.Y.Z version strings. Unparseable versions // (including "dev") sort before any release. func compareVersions(a, b string) int { pa, oka := parseVersion(a) pb, okb := parseVersion(b) if oka != okb { if oka { return 1 } return -1 } for i := range pa { if pa[i] != pb[i] { if pa[i] > pb[i] { return 1 } return -1 } } return 0 } func parseVersion(v string) ([3]int, bool) { var out [3]int v = strings.TrimPrefix(strings.TrimSpace(v), "v") parts := strings.Split(v, ".") if len(parts) != 3 { return out, false } for i, p := range parts { n, err := strconv.Atoi(p) if err != nil || n < 0 { return out, false } out[i] = n } return out, true }