Sample env carries a version marker; install appends settings missing since the writing version
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+110
-14
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSampleEnv(t *testing.T) {
|
||||
sample := SampleEnv(`C:\ProgramData\gpu-turnstile\gpu-turnstile.log`)
|
||||
const sampleLogPath = `C:\ProgramData\gpu-turnstile\gpu-turnstile.log`
|
||||
|
||||
// Every setting known to Load must appear.
|
||||
for _, name := range []string{
|
||||
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",
|
||||
@@ -18,7 +16,17 @@ func TestSampleEnv(t *testing.T) {
|
||||
"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("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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,16 +174,27 @@ 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
binPath := fmt.Sprintf(`"%s" -config "%s"`, exe, configPath)
|
||||
|
||||
Reference in New Issue
Block a user