CFG_VER/APP_VER in the env file: invalid configs replaced, updates follow APP_VER (dev/stable/pin)
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user