diff --git a/SPEC.md b/SPEC.md index bd5b28f..2769a40 100644 --- a/SPEC.md +++ b/SPEC.md @@ -191,9 +191,13 @@ 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. +The first line of an installer-written file is a version marker +(`# gpu-turnstile version: vX.Y.Z`); when a later version's install finds +an older marker, it appends every setting the file does not mention +(commented or not) at the end and bumps the marker — hand-written configs +without the marker are never touched. 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/cmd/gpu-turnstile/main.go b/cmd/gpu-turnstile/main.go index 290b242..85ad834 100644 --- a/cmd/gpu-turnstile/main.go +++ b/cmd/gpu-turnstile/main.go @@ -381,7 +381,7 @@ func serviceCommand(configPath string, install, noCopy, elevatedChild bool) int if abs, absErr := filepath.Abs(path); absErr == nil { path = abs } - err = service.Install(path, !noCopy) + err = service.Install(path, !noCopy, version) } else { err = service.Remove() } diff --git a/internal/config/sample.go b/internal/config/sample.go index 3153ec9..46e98d4 100644 --- a/internal/config/sample.go +++ b/internal/config/sample.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "strconv" "strings" ) @@ -14,12 +15,14 @@ type sampleEntry struct { 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{ +// versionMarker prefixes the first line of an installer-written env file so +// later installs can tell which version wrote it. +const versionMarker = "# gpu-turnstile version: " + +// sampleEntries lists every setting in sample order. logFile activates the +// LOG_FILE line (Windows install); empty keeps it commented like the rest. +func sampleEntries(logFile string) []sampleEntry { + return []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}, @@ -48,18 +51,111 @@ func SampleEnv(logFile string) string { {"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 LOG_FILE when logFile is non-empty: a Windows service has no +// console, so the installer pre-wires file logging there. The first line +// carries the writing version so later installs can upgrade the file. +func SampleEnv(version, logFile string) string { var b strings.Builder - b.WriteString("# gpu-turnstile configuration\n") + b.WriteString(versionMarker + version + "\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) - } + 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 version +// marker 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 +// the marker is updated to version. Files without the marker (hand-written +// configs) and up-to-date files are returned unchanged; changed reports +// whether the returned content differs. A "dev" version never upgrades. +func SyncSample(data, version, logFile string) (string, bool) { + if version == "" || version == "dev" { + return data, false + } + first, _, _ := strings.Cut(data, "\n") + marker, ok := strings.CutPrefix(first, versionMarker) + if !ok { + return data, false // not written by the installer + } + if compareVersions(strings.TrimSpace(marker), 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 + } + } + + var b strings.Builder + b.WriteString(versionMarker + version + "\n") + rest := strings.TrimPrefix(data, first) + b.WriteString(strings.TrimRight(rest, "\n")) + b.WriteString("\n") + 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 +} diff --git a/internal/config/sample_test.go b/internal/config/sample_test.go index 158959a..6c9e903 100644 --- a/internal/config/sample_test.go +++ b/internal/config/sample_test.go @@ -5,20 +5,28 @@ import ( "testing" ) +const sampleLogPath = `C:\ProgramData\gpu-turnstile\gpu-turnstile.log` + +var allSettingNames = []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", +} + func TestSampleEnv(t *testing.T) { - sample := SampleEnv(`C:\ProgramData\gpu-turnstile\gpu-turnstile.log`) + sample := SampleEnv("v0.1.7", sampleLogPath) + + if !strings.HasPrefix(sample, "# gpu-turnstile version: v0.1.7\n") { + t.Errorf("first line does not carry the version marker: %q", strings.SplitN(sample, "\n", 2)[0]) + } // 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", - } { + for _, name := range allSettingNames { if !strings.Contains(sample, name+"=") { t.Errorf("sample is missing %s", name) } @@ -29,12 +37,12 @@ func TestSampleEnv(t *testing.T) { 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` { + if len(values) != 1 || values["LOG_FILE"] != sampleLogPath { t.Fatalf("active values = %v, want only LOG_FILE", values) } // Without a log path everything is commented out. - values, err = ParseEnvFile(strings.NewReader(SampleEnv(""))) + values, err = ParseEnvFile(strings.NewReader(SampleEnv("v0.1.7", ""))) if err != nil { t.Fatalf("sample without log path does not parse: %v", err) } @@ -42,3 +50,68 @@ func TestSampleEnv(t *testing.T) { t.Fatalf("active values = %v, want none", values) } } + +func TestSyncSample(t *testing.T) { + old := SampleEnv("v0.1.6", sampleLogPath) + // Simulate a setting that did not exist yet when the file was written. + old = strings.Replace(old, "#UPDATE_ASSET=gpu-turnstile.exe\n", "", 1) + + out, changed := SyncSample(old, "v0.1.7", sampleLogPath) + if !changed { + t.Fatal("older installer file was not upgraded") + } + if !strings.HasPrefix(out, "# gpu-turnstile version: v0.1.7\n") { + t.Error("marker was not updated to the new version") + } + if !strings.Contains(out, "#UPDATE_ASSET=gpu-turnstile.exe") { + t.Error("missing setting was not appended") + } + if !strings.Contains(out, "LOG_FILE="+sampleLogPath) { + t.Error("existing active LOG_FILE was lost") + } + if !strings.Contains(out, "Added by gpu-turnstile v0.1.7") { + t.Error("appended section is not attributed") + } + if _, err := ParseEnvFile(strings.NewReader(out)); err != nil { + t.Fatalf("upgraded file does not parse: %v", err) + } + + // A file written by the same or a newer version is left alone. + if _, changed := SyncSample(SampleEnv("v0.1.7", sampleLogPath), "v0.1.7", sampleLogPath); changed { + t.Error("same-version file was modified") + } + if _, changed := SyncSample(SampleEnv("v0.2.0", sampleLogPath), "v0.1.7", sampleLogPath); changed { + t.Error("newer-version file was modified") + } + + // Hand-written configs (no marker) are never touched. + user := "OLLAMA_URL=http://host:11434\n" + if out, changed := SyncSample(user, "v0.1.7", sampleLogPath); changed || out != user { + t.Error("hand-written config was modified") + } + + // A dev build never upgrades. + if _, changed := SyncSample(old, "dev", sampleLogPath); changed { + t.Error("dev build modified the file") + } +} + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"v0.1.6", "v0.1.7", -1}, + {"v0.1.7", "v0.1.7", 0}, + {"v1.0.0", "v0.9.9", 1}, + {"0.1.7", "v0.1.6", 1}, + {"dev", "v0.1.7", -1}, + {"v0.1.7", "dev", 1}, + {"dev", "dev", 0}, + } + for _, c := range cases { + if got := compareVersions(c.a, c.b); got != c.want { + t.Errorf("compareVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go index 9c672ab..2b2ad64 100644 --- a/internal/service/service_linux.go +++ b/internal/service/service_linux.go @@ -140,7 +140,7 @@ func copyFile(src, dst string, mode os.FileMode) error { // *directory*, and granting the sandboxed service user write access to a // shared system directory would let a compromised service overwrite other // binaries. /var/lib/gpu-turnstile is exclusively ours. -func Install(configPath string, copyBin bool) error { +func Install(configPath string, copyBin bool, version string) error { exe, err := os.Executable() if err != nil { return err @@ -172,14 +172,23 @@ func Install(configPath string, copyBin bool) error { } } exe = installedExe - if _, err := os.Stat(etcConfig); os.IsNotExist(err) { + data, readErr := os.ReadFile(etcConfig) + switch { + case os.IsNotExist(readErr): 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 { + } else if err := os.WriteFile(etcConfig, []byte(config.SampleEnv(version, "")), 0o644); err != nil { + // Fully commented sample covering every setting; LOG_FILE + // stays commented — stderr goes to the journal on Linux. + return fmt.Errorf("write %s: %w", etcConfig, err) + } + case readErr != nil: + return fmt.Errorf("read %s: %w", etcConfig, readErr) + default: + // An installer-written config from an older version gets any + // new settings appended; hand-written files stay untouched. + if synced, changed := config.SyncSample(string(data), version, ""); changed { + if err := os.WriteFile(etcConfig, []byte(synced), 0o644); err != nil { return fmt.Errorf("write %s: %w", etcConfig, err) } } diff --git a/internal/service/service_other.go b/internal/service/service_other.go index a08813d..c9fb510 100644 --- a/internal/service/service_other.go +++ b/internal/service/service_other.go @@ -29,7 +29,7 @@ func Run(run func(ctx context.Context) error) error { } // Install is unsupported on non-Windows, non-Linux platforms. -func Install(string, bool) error { return errUnsupported } +func Install(string, bool, string) error { return errUnsupported } // Remove is unsupported on non-Windows, non-Linux platforms. func Remove() error { return errUnsupported } diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go index 1ea3319..de272cb 100644 --- a/internal/service/service_windows.go +++ b/internal/service/service_windows.go @@ -116,7 +116,7 @@ func (h *handler) Execute(_ []string, requests <-chan svc.ChangeRequest, status // the installed binary is refreshed only when the content differs, the // registration is updated only where it drifted, and the service is // started again only if it was running before. -func Install(configPath string, copyBin bool) error { +func Install(configPath string, copyBin bool, version string) error { exe, err := os.Executable() if err != nil { return err @@ -174,15 +174,26 @@ func Install(configPath string, copyBin bool) error { } // 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. + // every setting (only LOG_FILE active). An existing installer-written + // config from an older version gets any new settings appended; any + // other existing config just gets a LOG_FILE line 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 { + data, readErr := os.ReadFile(targetCfg) + switch { + case os.IsNotExist(readErr): + if err := os.WriteFile(targetCfg, []byte(config.SampleEnv(version, logPath)), 0o644); err != nil { return fmt.Errorf("write %s: %w", targetCfg, err) } - } else if err := ensureLogFile(targetCfg, logPath); err != nil { - return err + case readErr != nil: + return fmt.Errorf("read %s: %w", targetCfg, readErr) + default: + if synced, changed := config.SyncSample(string(data), version, logPath); changed { + if err := os.WriteFile(targetCfg, []byte(synced), 0o644); err != nil { + return fmt.Errorf("write %s: %w", targetCfg, err) + } + } else if err := ensureLogFile(targetCfg, logPath); err != nil { + return err + } } configPath = targetCfg }