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
+5 -1
View File
@@ -72,6 +72,7 @@ override file values. Invalid values fail at startup.
| `UPDATE_INTERVAL` | `6h` | Auto-update check interval | | `UPDATE_INTERVAL` | `6h` | Auto-update check interval |
| `UPDATE_REPO` | `https://git.rambossek.at/PUBLIC/gpu-turnstile` | Repository checked for releases | | `UPDATE_REPO` | `https://git.rambossek.at/PUBLIC/gpu-turnstile` | Repository checked for releases |
| `UPDATE_ASSET` | `gpu-turnstile.exe` | Release asset to download | | `UPDATE_ASSET` | `gpu-turnstile.exe` | Release asset to download |
| `APP_VER` | `stable` | `dev` disables updates, `stable` tracks latest, or pin an exact `vX.Y.Z` |
## Observability ## Observability
@@ -151,7 +152,10 @@ containers and interactive shells.
release on startup and every `UPDATE_INTERVAL`, verifies the Ed25519 release on startup and every `UPDATE_INTERVAL`, verifies the Ed25519
signature of the download against the public key embedded at build time, signature of the download against the public key embedded at build time,
and — once the GPU lock is idle — restarts the service onto the new and — once the GPU lock is idle — restarts the service onto the new
version. Disable with `AUTO_UPDATE=false`. Releases are signed by CI with version. `APP_VER` controls the target: `dev` disables updates, `stable`
(the default) tracks the latest release, and an exact `vX.Y.Z` pins that
release (even as a downgrade or to replace a dev build). Disable entirely
with `AUTO_UPDATE=false`. Releases are signed by CI with
OpenSSL; the matching public key lives in `internal/update/pubkey.go` OpenSSL; the matching public key lives in `internal/update/pubkey.go`
(one-time setup: `openssl genpkey -algorithm ed25519 -out private.pem`, (one-time setup: `openssl genpkey -algorithm ed25519 -out private.pem`,
`openssl pkey -in private.pem -pubout -out public.pem`; private key goes `openssl pkey -in private.pem -pubout -out public.pem`; private key goes
+22 -16
View File
@@ -158,6 +158,8 @@ override file values. A missing file is fine; a malformed one is fatal.
| `UPDATE_INTERVAL` | `6h` | auto-update check interval | | `UPDATE_INTERVAL` | `6h` | auto-update check interval |
| `UPDATE_REPO` | `https://git.rambossek.at/PUBLIC/gpu-turnstile` | repository to check for releases | | `UPDATE_REPO` | `https://git.rambossek.at/PUBLIC/gpu-turnstile` | repository to check for releases |
| `UPDATE_ASSET` | `gpu-turnstile.exe` | release asset to download | | `UPDATE_ASSET` | `gpu-turnstile.exe` | release asset to download |
| `APP_VER` | `stable` | version to run: `dev` disables updates, `stable` tracks the latest release, or an exact `vX.Y.Z` pin (up- or downgraded to) |
| `CFG_VER` | _(installer-managed)_ | config format reference written by `--install-service`; missing = the file is replaced with a fresh sample (backup `.bak`) |
Startup fails fast on unparsable values and when neither consumer URL is Startup fails fast on unparsable values and when neither consumer URL is
set. Enabled upstreams are probed once at start (`/api/version`, set. Enabled upstreams are probed once at start (`/api/version`,
@@ -185,19 +187,18 @@ started again only if it was running before.
By default install creates the canonical layout and copies the binary into By default install creates the canonical layout and copies the binary into
it (Windows: `%ProgramFiles%\gpu-turnstile\`, plus it (Windows: `%ProgramFiles%\gpu-turnstile\`, plus
`%ProgramData%\gpu-turnstile\` for logs; Linux: `/var/lib/gpu-turnstile/` `%ProgramData%\gpu-turnstile\` for logs; Linux: `/var/lib/gpu-turnstile/`
with the config at `/etc/gpu-turnstile.env`). An existing config in the with the config at `/etc/gpu-turnstile.env`). If there is no config at all,
target location is never overwritten. If there is no config at all, install install writes a sample env file covering every setting — each with a
writes a sample env file covering every setting — each with a comment line, comment line, everything commented out — except the `CFG_VER`/`APP_VER`
everything commented out — except `LOG_FILE`, which is active on Windows header and `LOG_FILE`, which is active on Windows
(`%ProgramData%\gpu-turnstile\gpu-turnstile.log`) since a service has no (`%ProgramData%\gpu-turnstile\gpu-turnstile.log`) since a service has no
console; on Linux it stays commented because stderr goes to the journal. console; on Linux it stays commented because stderr goes to the journal.
The first line of an installer-written file is a version marker The first line is `CFG_VER=vX.Y.Z`, recording the installer version. When a
(`# gpu-turnstile version: vX.Y.Z`); when a later version's install finds later version's install finds an older `CFG_VER`, it appends every setting
an older marker, it appends every setting the file does not mention the file does not mention (commented or not) at the end and updates
(commented or not) at the end and bumps the marker — hand-written configs `CFG_VER`; a file without `CFG_VER` is invalid and gets replaced by a fresh
without the marker are never touched. An existing config without `LOG_FILE` sample, with the old content kept as `<file>.bak`. `--no-copy` registers
gets that line appended on Windows. `--no-copy` registers the current the current executable location as-is and leaves the config untouched.
executable location as-is and leaves the config untouched.
### Windows ### Windows
@@ -255,14 +256,18 @@ executable location as-is and leaves the config untouched.
`RestartSec=5s` brings up the staged binary after the updater exits with `RestartSec=5s` brings up the staged binary after the updater exits with
code 3. code 3.
- **Auto-update**: on startup and every `UPDATE_INTERVAL`, the binary - **Auto-update**: on startup and every `UPDATE_INTERVAL`, the binary
checks `UPDATE_REPO`'s latest release; if its tag is a newer `vX.Y.Z`, consults `APP_VER`: `dev` disables updates; `stable` (the default)
it downloads `UPDATE_ASSET` plus its `.sig` (and `.sha256` when present) fetches `UPDATE_REPO`'s latest release and applies it when its tag is a
and verifies an Ed25519 signature against the public key embedded in newer `vX.Y.Z` (a `dev` binary cannot be compared and is replaced by the
latest release); a `vX.Y.Z` pin fetches that exact tag and stages it on
any difference, including downgrades. Applying means downloading
`UPDATE_ASSET` plus its `.sig` (and `.sha256` when present) and verifying
an Ed25519 signature against the public key embedded in
`internal/update/pubkey.go`. A verified binary is swapped in next to the `internal/update/pubkey.go`. A verified binary is swapped in next to the
running exe (rename-aside, allowed on Windows), and once the GPU lock is running exe (rename-aside, allowed on Windows), and once the GPU lock is
idle the process exits with code 3 so the service recovery restarts it idle the process exits with code 3 so the service recovery restarts it
on the new version. Interactive runs only log "restart to apply". on the new version. Interactive runs only log "restart to apply".
`dev` builds and builds without an embedded public key never update. Builds without an embedded public key never update.
- **`--force-update`** runs the same check immediately, single-shot: one - **`--force-update`** runs the same check immediately, single-shot: one
attempt with a 30 s timeout, then exit — "up to date" (exit 0) or the attempt with a 30 s timeout, then exit — "up to date" (exit 0) or the
error (exit 1), no retries. When a newer release is found it downloads, error (exit 1), no retries. When a newer release is found it downloads,
@@ -353,7 +358,8 @@ are new.
receives the first chunk before the last is sent (no buffering). receives the first chunk before the last is sent (no buffering).
- `internal/config`: env-file parsing, precedence, fail-fast values. - `internal/config`: env-file parsing, precedence, fail-fast values.
- `internal/update`: fake Gitea releases API; staged update happy path, - `internal/update`: fake Gitea releases API; staged update happy path,
tampered signature rejected, older versions and dev builds skipped. tampered signature rejected, older versions skipped, APP_VER=dev and
pinned releases honored.
## Build and CI ## Build and CI
+6 -2
View File
@@ -313,7 +313,11 @@ func forceUpdateCommand(configPath string, elevatedChild bool) int {
} }
log, _, logCloser := newLogger(cfg) log, _, logCloser := newLogger(cfg)
defer logCloser.Close() defer logCloser.Close()
u := &update.Updater{Repo: cfg.UpdateRepo, Asset: cfg.UpdateAsset, Version: version, Log: log} if cfg.AppVersion == "dev" {
fmt.Printf("%s: APP_VER=dev, updates disabled\n", versionLine())
return 0
}
u := &update.Updater{Repo: cfg.UpdateRepo, Asset: cfg.UpdateAsset, Version: version, Desired: cfg.AppVersion, Log: log}
// Single-shot: one attempt, fail fast when the server is unreachable // Single-shot: one attempt, fail fast when the server is unreachable
// instead of hanging in a TCP connect for minutes. // instead of hanging in a TCP connect for minutes.
@@ -566,7 +570,7 @@ func updateLoop(ctx context.Context, cfg config.Config, log *slog.Logger, lk *lo
log.Warn("auto-update disabled: cannot locate executable", "err", err) log.Warn("auto-update disabled: cannot locate executable", "err", err)
return return
} }
u := &update.Updater{Repo: cfg.UpdateRepo, Asset: cfg.UpdateAsset, Version: version, Log: log} u := &update.Updater{Repo: cfg.UpdateRepo, Asset: cfg.UpdateAsset, Version: version, Desired: cfg.AppVersion, Log: log}
for { for {
staged, err := u.Check(ctx, exePath) staged, err := u.Check(ctx, exePath)
if err != nil && ctx.Err() == nil { if err != nil && ctx.Err() == nil {
+17
View File
@@ -38,6 +38,11 @@ type Config struct {
UpdateRepo string UpdateRepo string
UpdateAsset 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 // LLMBusyMode is "wait" (hold requests until the lock is free or
// LLMWaitTimeout expires) or "reject" (immediately answer with // LLMWaitTimeout expires) or "reject" (immediately answer with
// LLMBusyStatus + Retry-After when an image job is active or pending). // LLMBusyStatus + Retry-After when an image job is active or pending).
@@ -76,6 +81,7 @@ func Defaults() Config {
UpdateInterval: 6 * time.Hour, UpdateInterval: 6 * time.Hour,
UpdateRepo: "https://git.rambossek.at/PUBLIC/gpu-turnstile", UpdateRepo: "https://git.rambossek.at/PUBLIC/gpu-turnstile",
UpdateAsset: "gpu-turnstile.exe", UpdateAsset: "gpu-turnstile.exe",
AppVersion: "stable",
LLMBusyMode: "wait", LLMBusyMode: "wait",
LLMBusyStatus: 503, LLMBusyStatus: 503,
@@ -205,6 +211,17 @@ func Load(getenv func(string) string) (Config, error) {
} }
cfg.BusyRetryAfter = n 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. // LOGLEVEL is the canonical spelling; LOG_LEVEL is kept as an alias.
logLevelValue := getenv("LOGLEVEL") logLevelValue := getenv("LOGLEVEL")
if logLevelValue == "" { 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) { func TestParseEnvFile(t *testing.T) {
input := `# comment input := `# comment
OLLAMA_URL=http://host:11435 OLLAMA_URL=http://host:11435
+38 -23
View File
@@ -15,12 +15,12 @@ type sampleEntry struct {
active bool // rendered uncommented active bool // rendered uncommented
} }
// versionMarker prefixes the first line of an installer-written env file so // appVerComment documents APP_VER wherever it is rendered.
// later installs can tell which version wrote it. const appVerComment = `Version to run: "dev" disables updates, "stable" tracks the latest release, or pin an exact release like v0.1.7`
const versionMarker = "# gpu-turnstile version: "
// sampleEntries lists every setting in sample order. logFile activates the // sampleEntries lists every setting in sample order. logFile activates the
// LOG_FILE line (Windows install); empty keeps it commented like the rest. // 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 { func sampleEntries(logFile string) []sampleEntry {
return []sampleEntry{ return []sampleEntry{
{"LISTEN_OLLAMA", ":11434", "Ollama-facing listener address", false}, {"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 // SampleEnv renders a sample .env file covering every setting, each with a
// comment line. Everything is commented out — so all defaults apply — // comment line. Everything is commented out — so all defaults apply —
// except LOG_FILE when logFile is non-empty: a Windows service has no // except the CFG_VER/APP_VER header and LOG_FILE when logFile is non-empty
// console, so the installer pre-wires file logging there. The first line // (a Windows service has no console). CFG_VER records the version that
// carries the writing version so later installs can upgrade the file. // wrote the file so later installs can upgrade it.
func SampleEnv(version, logFile string) string { func SampleEnv(version, logFile string) string {
var b strings.Builder 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("# 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("# 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") 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) { for _, e := range sampleEntries(logFile) {
writeEntry(&b, e) writeEntry(&b, e)
} }
@@ -79,22 +83,21 @@ func writeEntry(b *strings.Builder, e sampleEntry) {
} }
} }
// SyncSample upgrades an installer-written env file: when its version // SyncSample upgrades an installer-written env file: when its CFG_VER says
// marker says it was written by an older gpu-turnstile, every setting the // it was written by an older gpu-turnstile, every setting the file does not
// file does not mention — commented or not — is appended at the end, and // mention — commented or not — is appended at the end, and CFG_VER is
// the marker is updated to version. Files without the marker (hand-written // updated to version. Files without CFG_VER (hand-written or foreign),
// configs) and up-to-date files are returned unchanged; changed reports // up-to-date files and "dev" builds are returned unchanged; changed reports
// whether the returned content differs. A "dev" version never upgrades. // whether the returned content differs.
func SyncSample(data, version, logFile string) (string, bool) { func SyncSample(data, version, logFile string) (string, bool) {
if version == "" || version == "dev" { if version == "" || version == "dev" {
return data, false return data, false
} }
first, _, _ := strings.Cut(data, "\n") values, err := ParseEnvFile(strings.NewReader(data))
marker, ok := strings.CutPrefix(first, versionMarker) if err != nil || values["CFG_VER"] == "" {
if !ok { return data, false // not installer-written; the caller decides
return data, false // not written by the installer
} }
if compareVersions(strings.TrimSpace(marker), version) >= 0 { if compareVersions(values["CFG_VER"], version) >= 0 {
return data, false // same or newer 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 var b strings.Builder
b.WriteString(versionMarker + version + "\n") if !present["APP_VER"] {
rest := strings.TrimPrefix(data, first) fmt.Fprintf(&b, "\n# Added by gpu-turnstile %s:\n", version)
b.WriteString(strings.TrimRight(rest, "\n")) writeEntry(&b, sampleEntry{"APP_VER", "stable", appVerComment, true})
b.WriteString("\n") }
for _, e := range sampleEntries(logFile) { for _, e := range sampleEntries(logFile) {
if !present[e.name] { if !present[e.name] {
fmt.Fprintf(&b, "\n# Added by gpu-turnstile %s:\n", version) fmt.Fprintf(&b, "\n# Added by gpu-turnstile %s:\n", version)
writeEntry(&b, e) writeEntry(&b, e)
} }
} }
out := b.String() out += b.String()
return out, out != data return out, out != data
} }
+38 -12
View File
@@ -21,8 +21,8 @@ var allSettingNames = []string{
func TestSampleEnv(t *testing.T) { func TestSampleEnv(t *testing.T) {
sample := SampleEnv("v0.1.7", sampleLogPath) sample := SampleEnv("v0.1.7", sampleLogPath)
if !strings.HasPrefix(sample, "# gpu-turnstile version: v0.1.7\n") { if !strings.HasPrefix(sample, "CFG_VER=v0.1.7\n") {
t.Errorf("first line does not carry the version marker: %q", strings.SplitN(sample, "\n", 2)[0]) t.Errorf("first line does not carry CFG_VER: %q", strings.SplitN(sample, "\n", 2)[0])
} }
// Every setting known to Load must appear. // 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)) values, err := ParseEnvFile(strings.NewReader(sample))
if err != nil { if err != nil {
t.Fatalf("sample does not parse: %v", err) t.Fatalf("sample does not parse: %v", err)
} }
if len(values) != 1 || values["LOG_FILE"] != sampleLogPath { want := map[string]string{"CFG_VER": "v0.1.7", "APP_VER": "stable", "LOG_FILE": sampleLogPath}
t.Fatalf("active values = %v, want only LOG_FILE", values) 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", ""))) values, err = ParseEnvFile(strings.NewReader(SampleEnv("v0.1.7", "")))
if err != nil { if err != nil {
t.Fatalf("sample without log path does not parse: %v", err) t.Fatalf("sample without log path does not parse: %v", err)
} }
if len(values) != 0 { if len(values) != 2 || values["LOG_FILE"] != "" {
t.Fatalf("active values = %v, want none", values) t.Fatalf("active values = %v, want only CFG_VER and APP_VER", values)
} }
} }
@@ -60,8 +67,8 @@ func TestSyncSample(t *testing.T) {
if !changed { if !changed {
t.Fatal("older installer file was not upgraded") t.Fatal("older installer file was not upgraded")
} }
if !strings.HasPrefix(out, "# gpu-turnstile version: v0.1.7\n") { if !strings.Contains(out, "\nCFG_VER=v0.1.7\n") && !strings.HasPrefix(out, "CFG_VER=v0.1.7\n") {
t.Error("marker was not updated to the new version") t.Error("CFG_VER was not updated to the new version")
} }
if !strings.Contains(out, "#UPDATE_ASSET=gpu-turnstile.exe") { if !strings.Contains(out, "#UPDATE_ASSET=gpu-turnstile.exe") {
t.Error("missing setting was not appended") t.Error("missing setting was not appended")
@@ -84,10 +91,11 @@ func TestSyncSample(t *testing.T) {
t.Error("newer-version file was modified") 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" user := "OLLAMA_URL=http://host:11434\n"
if out, changed := SyncSample(user, "v0.1.7", sampleLogPath); changed || out != user { 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. // 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) { func TestCompareVersions(t *testing.T) {
cases := []struct { cases := []struct {
a, b string a, b string
+47
View File
@@ -0,0 +1,47 @@
// Shared env-file handling for the installers (Windows and Linux).
package service
import (
"fmt"
"os"
"strings"
"gpu-turnstile/internal/config"
)
// syncEnvFile ensures the installed env file at path is a current
// installer-written sample. A missing file is created; a file without a
// CFG_VER line (hand-written or from before versioning) is invalid and
// replaced by a fresh sample after a .bak backup; a file written by an
// older version gets newly added settings appended via config.SyncSample.
// logPath activates the LOG_FILE line (Windows); empty leaves it commented.
func syncEnvFile(path, version, logPath string) error {
data, err := os.ReadFile(path)
switch {
case os.IsNotExist(err):
return writeEnvFile(path, config.SampleEnv(version, logPath))
case err != nil:
return fmt.Errorf("read %s: %w", path, err)
}
values, perr := config.ParseEnvFile(strings.NewReader(string(data)))
if perr == nil && values["CFG_VER"] != "" {
synced, changed := config.SyncSample(string(data), version, logPath)
if !changed {
return nil
}
return writeEnvFile(path, synced)
}
// No readable CFG_VER: the file is invalid — keep a backup and start
// from a fresh sample.
if err := os.WriteFile(path+".bak", data, 0o644); err != nil {
return fmt.Errorf("back up %s: %w", path, err)
}
return writeEnvFile(path, config.SampleEnv(version, logPath))
}
func writeEnvFile(path, content string) error {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
+10 -22
View File
@@ -15,8 +15,6 @@ import (
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"syscall" "syscall"
"gpu-turnstile/internal/config"
) )
// Name matches the Windows service name; the systemd unit is Name + ".service". // Name matches the Windows service name; the systemd unit is Name + ".service".
@@ -172,26 +170,16 @@ func Install(configPath string, copyBin bool, version string) error {
} }
} }
exe = installedExe exe = installedExe
data, readErr := os.ReadFile(etcConfig) if _, err := os.Stat(etcConfig); os.IsNotExist(err) && configPath != "" {
switch { copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort
case os.IsNotExist(readErr): }
if configPath != "" { // Missing configs get a fully commented sample (LOG_FILE stays
copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort // commented — stderr goes to the journal on Linux);
} else if err := os.WriteFile(etcConfig, []byte(config.SampleEnv(version, "")), 0o644); err != nil { // installer-written ones from older versions get new settings
// Fully commented sample covering every setting; LOG_FILE // appended; anything without CFG_VER is invalid and gets replaced
// stays commented — stderr goes to the journal on Linux. // (backup kept as .bak).
return fmt.Errorf("write %s: %w", etcConfig, err) if err := syncEnvFile(etcConfig, version, ""); err != nil {
} return 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)
}
}
} }
} else if configPath != "" { } else if configPath != "" {
if abs, absErr := filepath.Abs(configPath); absErr == nil { if abs, absErr := filepath.Abs(configPath); absErr == nil {
+7 -45
View File
@@ -172,28 +172,14 @@ func Install(configPath string, copyBin bool, version string) error {
} }
} }
} }
// A service has no console: without LOG_FILE the output vanishes. // A service has no console: without LOG_FILE the output vanishes, so
// With no config at all, install a fully commented sample covering // the sample pre-wires it to ProgramData. Missing configs get a
// every setting (only LOG_FILE active). An existing installer-written // fresh sample; installer-written ones from older versions get new
// config from an older version gets any new settings appended; any // settings appended; anything without CFG_VER is invalid and gets
// other existing config just gets a LOG_FILE line if it has none. // replaced (backup kept as .bak).
logPath := filepath.Join(dataDir, "gpu-turnstile.log") logPath := filepath.Join(dataDir, "gpu-turnstile.log")
data, readErr := os.ReadFile(targetCfg) if err := syncEnvFile(targetCfg, version, logPath); err != nil {
switch { return err
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 configPath = targetCfg
} }
@@ -329,30 +315,6 @@ func sameFileContent(a, b string) (bool, error) {
return bytes.Equal(ba, bb), nil return bytes.Equal(ba, bb), nil
} }
// ensureLogFile makes sure the env file at path sets LOG_FILE, creating
// the file or appending the line as needed. An existing LOG_FILE= line
// (even an empty one) is respected and left untouched.
func ensureLogFile(path, logPath string) error {
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read %s: %w", path, err)
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "LOG_FILE=") {
return nil
}
}
s := string(data)
if s != "" && !strings.HasSuffix(s, "\n") {
s += "\n"
}
s += "LOG_FILE=" + logPath + "\n"
if err := os.WriteFile(path, []byte(s), 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// copyFile copies src to dst (0755 on the new file). // copyFile copies src to dst (0755 on the new file).
func copyFile(src, dst string) error { func copyFile(src, dst string) error {
in, err := os.Open(src) in, err := os.Open(src)
+41 -16
View File
@@ -26,11 +26,15 @@ import (
// maxAssetSize bounds release asset downloads. // maxAssetSize bounds release asset downloads.
const maxAssetSize = 512 << 20 const maxAssetSize = 512 << 20
// Updater checks one Gitea repository for newer releases. // Updater checks one Gitea repository for releases.
type Updater struct { type Updater struct {
Repo string // e.g. https://git.rambossek.at/PUBLIC/gpu-turnstile Repo string // e.g. https://git.rambossek.at/PUBLIC/gpu-turnstile
Asset string // e.g. gpu-turnstile.exe Asset string // e.g. gpu-turnstile.exe
Version string // current version, e.g. v0.1.2 ("dev" disables updates) Version string // current binary version, e.g. v0.1.2 ("dev" cannot be compared)
// Desired is the APP_VER policy: "dev" disables updates, "stable" (or
// empty) tracks the latest release, anything else is an exact vX.Y.Z
// release tag to pin — staging it even when that means a downgrade.
Desired string
Log *slog.Logger Log *slog.Logger
Client *http.Client Client *http.Client
} }
@@ -167,15 +171,20 @@ func CleanupOld(exePath string) {
os.Remove(exePath + ".new") os.Remove(exePath + ".new")
} }
// Check performs a single update check. staged is true when a newer, // Check performs a single update check. staged is true when a
// signature-verified binary has been swapped into place at exePath; the // signature-verified binary has been swapped into place at exePath; the
// caller should then restart the process. A nil error with staged=false // caller should then restart the process. A nil error with staged=false
// means "no action" (up to date, disabled, or dev build); a non-nil error // means "no action" (up to date, APP_VER=dev, or no embedded public key);
// means the check failed and the running binary is untouched. // a non-nil error means the check failed and the running binary is
// untouched.
func (u *Updater) Check(ctx context.Context, exePath string) (staged bool, err error) { func (u *Updater) Check(ctx context.Context, exePath string) (staged bool, err error) {
log := u.logger() log := u.logger()
if u.Version == "" || u.Version == "dev" { desired := u.Desired
log.Debug("auto-update: dev build, skipping") if desired == "" {
desired = "stable"
}
if desired == "dev" {
log.Debug("auto-update: APP_VER=dev, skipping")
return false, nil return false, nil
} }
if publicKeyPEM == "" { if publicKeyPEM == "" {
@@ -187,21 +196,37 @@ func (u *Updater) Check(ctx context.Context, exePath string) (staged bool, err e
return false, err return false, err
} }
body, err := u.get(ctx, api+"/releases/latest") pinned := desired != "stable"
endpoint := api + "/releases/latest"
if pinned {
endpoint = api + "/releases/tags/" + desired
}
body, err := u.get(ctx, endpoint)
if err != nil { if err != nil {
return false, fmt.Errorf("fetch latest release: %w", err) return false, fmt.Errorf("fetch release: %w", err)
} }
var rel release var rel release
if err := json.Unmarshal(body, &rel); err != nil { if err := json.Unmarshal(body, &rel); err != nil {
return false, fmt.Errorf("parse release: %w", err) return false, fmt.Errorf("parse release: %w", err)
} }
newer, err := newerVersion(u.Version, rel.TagName) if pinned {
if err != nil { // Pin mode: any difference from the target tag means stage it —
return false, err // including downgrades and replacing a dev binary.
} if u.Version == rel.TagName {
if !newer { log.Debug("auto-update: already on pinned version", "version", u.Version)
log.Debug("auto-update: up to date", "version", u.Version, "latest", rel.TagName) return false, nil
return false, nil }
} else if u.Version != "" && u.Version != "dev" {
// Stable mode: only strictly newer releases count; a dev binary
// cannot be compared and is always replaced by the latest release.
newer, err := newerVersion(u.Version, rel.TagName)
if err != nil {
return false, err
}
if !newer {
log.Debug("auto-update: up to date", "version", u.Version, "latest", rel.TagName)
return false, nil
}
} }
urls := make(map[string]string, len(rel.Assets)) urls := make(map[string]string, len(rel.Assets))
+62 -5
View File
@@ -43,7 +43,7 @@ func newFakeGitea(t *testing.T, tag string, assetContent []byte) *fakeGitea {
sign := func() []byte { return ed25519.Sign(priv, f.asset) } sign := func() []byte { return ed25519.Sign(priv, f.asset) }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/o/r/releases/latest", func(w http.ResponseWriter, r *http.Request) { serveRelease := func(w http.ResponseWriter, r *http.Request) {
assets := []map[string]string{ assets := []map[string]string{
{"name": "gpu-turnstile.exe", "browser_download_url": f.srv.URL + "/dl/exe"}, {"name": "gpu-turnstile.exe", "browser_download_url": f.srv.URL + "/dl/exe"},
{"name": "gpu-turnstile.exe.sha256", "browser_download_url": f.srv.URL + "/dl/sha"}, {"name": "gpu-turnstile.exe.sha256", "browser_download_url": f.srv.URL + "/dl/sha"},
@@ -52,7 +52,9 @@ func newFakeGitea(t *testing.T, tag string, assetContent []byte) *fakeGitea {
assets = append(assets, map[string]string{"name": "gpu-turnstile.exe.sig", "browser_download_url": f.srv.URL + "/dl/sig"}) assets = append(assets, map[string]string{"name": "gpu-turnstile.exe.sig", "browser_download_url": f.srv.URL + "/dl/sig"})
} }
json.NewEncoder(w).Encode(map[string]any{"tag_name": f.tag, "assets": assets}) json.NewEncoder(w).Encode(map[string]any{"tag_name": f.tag, "assets": assets})
}) }
mux.HandleFunc("/api/v1/repos/o/r/releases/latest", serveRelease)
mux.HandleFunc("/api/v1/repos/o/r/releases/tags/"+f.tag, serveRelease)
mux.HandleFunc("/dl/exe", func(w http.ResponseWriter, r *http.Request) { w.Write(f.asset) }) mux.HandleFunc("/dl/exe", func(w http.ResponseWriter, r *http.Request) { w.Write(f.asset) })
mux.HandleFunc("/dl/sig", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/dl/sig", func(w http.ResponseWriter, r *http.Request) {
sig := sign() sig := sign()
@@ -73,6 +75,12 @@ func (f *fakeGitea) updater(version string) *Updater {
return &Updater{Repo: f.srv.URL + "/o/r", Asset: "gpu-turnstile.exe", Version: version} return &Updater{Repo: f.srv.URL + "/o/r", Asset: "gpu-turnstile.exe", Version: version}
} }
func (f *fakeGitea) updaterDesired(version, desired string) *Updater {
u := f.updater(version)
u.Desired = desired
return u
}
func fakeExe(t *testing.T) string { func fakeExe(t *testing.T) string {
t.Helper() t.Helper()
exe := filepath.Join(t.TempDir(), "gpu-turnstile.exe") exe := filepath.Join(t.TempDir(), "gpu-turnstile.exe")
@@ -153,12 +161,61 @@ func TestCheckSkipsWithoutPublicKey(t *testing.T) {
} }
} }
func TestCheckSkipsDevBuild(t *testing.T) { func TestCheckDevBuildGetsStable(t *testing.T) {
// A dev binary cannot be compared; APP_VER=stable replaces it with the
// latest release.
f := newFakeGitea(t, "v9.9.9", []byte("new-binary")) f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
withPublicKey(t, f.pubPEM) withPublicKey(t, f.pubPEM)
staged, err := f.updater("dev").Check(context.Background(), fakeExe(t)) exe := fakeExe(t)
staged, err := f.updater("dev").Check(context.Background(), exe)
if err != nil {
t.Fatal(err)
}
if !staged {
t.Fatal("expected dev binary to be replaced by the latest release")
}
content, _ := os.ReadFile(exe)
if string(content) != "new-binary" {
t.Fatalf("exe content = %q", content)
}
}
func TestCheckDesiredDevDisables(t *testing.T) {
f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
withPublicKey(t, f.pubPEM)
for _, version := range []string{"dev", "v0.1.2"} {
staged, err := f.updaterDesired(version, "dev").Check(context.Background(), fakeExe(t))
if err != nil || staged {
t.Fatalf("version %s: staged=%v err=%v, want no action with APP_VER=dev", version, staged, err)
}
}
}
func TestCheckPinned(t *testing.T) {
// Pin mode stages the exact tag — up or down — and skips when the
// binary already matches.
for _, version := range []string{"v0.1.2", "v9.9.9", "dev"} {
f := newFakeGitea(t, "v0.5.0", []byte("pinned-binary"))
withPublicKey(t, f.pubPEM)
exe := fakeExe(t)
staged, err := f.updaterDesired(version, "v0.5.0").Check(context.Background(), exe)
if err != nil {
t.Fatalf("version %s: %v", version, err)
}
if !staged {
t.Fatalf("version %s: expected pinned v0.5.0 to be staged", version)
}
content, _ := os.ReadFile(exe)
if string(content) != "pinned-binary" {
t.Fatalf("version %s: exe content = %q", version, content)
}
}
f := newFakeGitea(t, "v0.5.0", []byte("pinned-binary"))
withPublicKey(t, f.pubPEM)
staged, err := f.updaterDesired("v0.5.0", "v0.5.0").Check(context.Background(), fakeExe(t))
if err != nil || staged { if err != nil || staged {
t.Fatalf("staged=%v err=%v, want no action for dev build", staged, err) t.Fatalf("staged=%v err=%v, want no action when already on the pinned version", staged, err)
} }
} }