CFG_VER/APP_VER in the env file: invalid configs replaced, updates follow APP_VER (dev/stable/pin)

This commit is contained in:
mram
2026-09-21 09:28:29 +02:00
parent 272a3a467d
commit 44a8e9fbdc
12 changed files with 322 additions and 142 deletions
+17
View File
@@ -38,6 +38,11 @@ type Config struct {
UpdateRepo string
UpdateAsset string
// AppVersion is the version the user wants to run: "dev" disables
// updates, "stable" tracks the latest release, anything else is an
// exact vX.Y.Z release to pin. From APP_VER; defaults to "stable".
AppVersion string
// LLMBusyMode is "wait" (hold requests until the lock is free or
// LLMWaitTimeout expires) or "reject" (immediately answer with
// LLMBusyStatus + Retry-After when an image job is active or pending).
@@ -76,6 +81,7 @@ func Defaults() Config {
UpdateInterval: 6 * time.Hour,
UpdateRepo: "https://git.rambossek.at/PUBLIC/gpu-turnstile",
UpdateAsset: "gpu-turnstile.exe",
AppVersion: "stable",
LLMBusyMode: "wait",
LLMBusyStatus: 503,
@@ -205,6 +211,17 @@ func Load(getenv func(string) string) (Config, error) {
}
cfg.BusyRetryAfter = n
}
if v := getenv("APP_VER"); v != "" {
switch {
case v == "dev" || v == "stable":
cfg.AppVersion = v
default:
if _, ok := parseVersion(v); !ok {
return cfg, fmt.Errorf("APP_VER: must be \"dev\", \"stable\" or a vX.Y.Z version")
}
cfg.AppVersion = "v" + strings.TrimPrefix(v, "v")
}
}
// LOGLEVEL is the canonical spelling; LOG_LEVEL is kept as an alias.
logLevelValue := getenv("LOGLEVEL")
if logLevelValue == "" {
+29
View File
@@ -45,6 +45,35 @@ func TestLoadRequiresConsumer(t *testing.T) {
}
}
func TestAppVersion(t *testing.T) {
load := func(appVer string) (Config, error) {
return Load(func(k string) string {
switch k {
case "OLLAMA_URL":
return "http://127.0.0.1:11435"
case "APP_VER":
return appVer
}
return ""
})
}
cfg, err := load("")
if err != nil || cfg.AppVersion != "stable" {
t.Fatalf("default AppVersion = %q, err %v; want stable", cfg.AppVersion, err)
}
for _, v := range []string{"dev", "stable"} {
if cfg, err := load(v); err != nil || cfg.AppVersion != v {
t.Fatalf("APP_VER=%s: got %q, err %v", v, cfg.AppVersion, err)
}
}
if cfg, err := load("1.2.3"); err != nil || cfg.AppVersion != "v1.2.3" {
t.Fatalf("APP_VER=1.2.3: got %q, err %v; want normalized v1.2.3", cfg.AppVersion, err)
}
if _, err := load("nightly"); err == nil {
t.Fatal("APP_VER=nightly: want validation error")
}
}
func TestParseEnvFile(t *testing.T) {
input := `# comment
OLLAMA_URL=http://host:11435
+38 -23
View File
@@ -15,12 +15,12 @@ type sampleEntry struct {
active bool // rendered uncommented
}
// 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: "
// 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", "Ollama-facing listener address", false},
@@ -55,15 +55,19 @@ func sampleEntries(logFile string) []sampleEntry {
// 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.
// 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 later installs can upgrade it.
func SampleEnv(version, logFile string) string {
var b strings.Builder
b.WriteString(versionMarker + version + "\n")
fmt.Fprintf(&b, "CFG_VER=%s\n", version)
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)
}
@@ -79,22 +83,21 @@ func writeEntry(b *strings.Builder, e sampleEntry) {
}
}
// 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.
// 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
}
first, _, _ := strings.Cut(data, "\n")
marker, ok := strings.CutPrefix(first, versionMarker)
if !ok {
return data, false // not written by the installer
values, err := ParseEnvFile(strings.NewReader(data))
if err != nil || values["CFG_VER"] == "" {
return data, false // not installer-written; the caller decides
}
if compareVersions(strings.TrimSpace(marker), version) >= 0 {
if compareVersions(values["CFG_VER"], version) >= 0 {
return data, false // same or newer
}
@@ -106,18 +109,30 @@ func SyncSample(data, version, logFile string) (string, bool) {
}
}
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
b.WriteString(versionMarker + version + "\n")
rest := strings.TrimPrefix(data, first)
b.WriteString(strings.TrimRight(rest, "\n"))
b.WriteString("\n")
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()
out += b.String()
return out, out != data
}
+38 -12
View File
@@ -21,8 +21,8 @@ var allSettingNames = []string{
func TestSampleEnv(t *testing.T) {
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])
if !strings.HasPrefix(sample, "CFG_VER=v0.1.7\n") {
t.Errorf("first line does not carry CFG_VER: %q", strings.SplitN(sample, "\n", 2)[0])
}
// Every setting known to Load must appear.
@@ -32,22 +32,29 @@ func TestSampleEnv(t *testing.T) {
}
}
// The sample must parse cleanly, and only LOG_FILE is active.
// The sample must parse cleanly; active values are CFG_VER, APP_VER
// and LOG_FILE.
values, err := ParseEnvFile(strings.NewReader(sample))
if err != nil {
t.Fatalf("sample does not parse: %v", err)
}
if len(values) != 1 || values["LOG_FILE"] != sampleLogPath {
t.Fatalf("active values = %v, want only LOG_FILE", values)
want := map[string]string{"CFG_VER": "v0.1.7", "APP_VER": "stable", "LOG_FILE": sampleLogPath}
if len(values) != len(want) {
t.Fatalf("active values = %v, want %v", values, want)
}
for k, v := range want {
if values[k] != v {
t.Errorf("%s = %q, want %q", k, values[k], v)
}
}
// Without a log path everything is commented out.
// Without a log path LOG_FILE stays commented out.
values, err = ParseEnvFile(strings.NewReader(SampleEnv("v0.1.7", "")))
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)
if len(values) != 2 || values["LOG_FILE"] != "" {
t.Fatalf("active values = %v, want only CFG_VER and APP_VER", values)
}
}
@@ -60,8 +67,8 @@ func TestSyncSample(t *testing.T) {
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, "\nCFG_VER=v0.1.7\n") && !strings.HasPrefix(out, "CFG_VER=v0.1.7\n") {
t.Error("CFG_VER was not updated to the new version")
}
if !strings.Contains(out, "#UPDATE_ASSET=gpu-turnstile.exe") {
t.Error("missing setting was not appended")
@@ -84,10 +91,11 @@ func TestSyncSample(t *testing.T) {
t.Error("newer-version file was modified")
}
// Hand-written configs (no marker) are never touched.
// Files without CFG_VER are not installer-written; the installer
// replaces them, SyncSample leaves them alone.
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")
t.Error("file without CFG_VER was modified")
}
// A dev build never upgrades.
@@ -96,6 +104,24 @@ func TestSyncSample(t *testing.T) {
}
}
func TestSyncSampleAppendsMissingAppVer(t *testing.T) {
old := SampleEnv("v0.1.6", sampleLogPath)
old = strings.Replace(old, "# "+appVerComment+"\n", "", 1)
old = strings.Replace(old, "APP_VER=stable\n", "", 1)
out, changed := SyncSample(old, "v0.1.7", sampleLogPath)
if !changed {
t.Fatal("file without APP_VER was not upgraded")
}
values, err := ParseEnvFile(strings.NewReader(out))
if err != nil {
t.Fatalf("upgraded file does not parse: %v", err)
}
if values["APP_VER"] != "stable" {
t.Fatalf("APP_VER = %q, want appended default \"stable\"", values["APP_VER"])
}
}
func TestCompareVersions(t *testing.T) {
cases := []struct {
a, b string