5 Commits
Author SHA1 Message Date
mram 3aaa5d80a9 CI: lowercase image repository name; compose example too
ci / test (push) Successful in 49s
ci / docker (push) Successful in 1m7s
Docker registry names must be lowercase, so PUBLIC/gpu-turnstile was
rejected by buildx. The workflow now lowercases gitea.repository.
2026-09-20 19:25:37 +02:00
mram cef845c2e3 Add compose.yaml.example; ignore local compose files 2026-09-20 19:24:26 +02:00
mram cf9be55a6c Make poll intervals and operational timeouts configurable
ci / test (push) Successful in 47s
ci / docker (push) Failing after 30s
New env vars: UNLOAD_POLL_INTERVAL, HISTORY_POLL_INTERVAL, PROBE_TIMEOUT,
FREE_TIMEOUT, WARM_TIMEOUT, SHUTDOWN_TIMEOUT, PROMPT_CAPTURE_LIMIT.
Defaults unchanged; invalid values fail fast at startup.
2026-09-20 19:21:25 +02:00
mram 0f950f3134 CI: use gitea.actor as registry login username
ci / test (push) Successful in 48s
ci / docker (push) Skipped
Gitea authenticates registry pushes by token alone; a single
REGISTRY_TOKEN secret suffices.
2026-09-20 18:55:46 +02:00
mram 73fb8ac3ad CI: log in to registry with REGISTRY_USERNAME/REGISTRY_TOKEN secrets
ci / test (push) Successful in 47s
ci / docker (push) Skipped
The automatic GITEA_TOKEN has no write:package scope, so docker login to
git.rambossek.at failed with unauthorized.
2026-09-20 18:24:54 +02:00
7 changed files with 172 additions and 41 deletions
+9 -3
View File
@@ -37,13 +37,19 @@ jobs:
exit 1 exit 1
fi fi
- name: Compute lowercase image name
id: meta
run: |
REPO=git.rambossek.at/$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
echo "image=$REPO" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
registry: git.rambossek.at registry: git.rambossek.at
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
@@ -52,5 +58,5 @@ jobs:
build-args: | build-args: |
VERSION=${{ gitea.ref_name }} VERSION=${{ gitea.ref_name }}
tags: | tags: |
git.rambossek.at/${{ gitea.repository }}:${{ gitea.ref_name }} ${{ steps.meta.outputs.image }}:${{ gitea.ref_name }}
git.rambossek.at/${{ gitea.repository }}:latest ${{ steps.meta.outputs.image }}:latest
+2
View File
@@ -1,2 +1,4 @@
/gpu-turnstile /gpu-turnstile
/gpu-turnstile.exe /gpu-turnstile.exe
/compose.yaml
/compose.yml
+11
View File
@@ -43,6 +43,13 @@ startup.
| `WARM_MODEL` | _(empty)_ | Model to reload after an image job (off by default) | | `WARM_MODEL` | _(empty)_ | Model to reload after an image job (off by default) |
| `LOG_LEVEL` | `info` | `debug` logs every lock transition | | `LOG_LEVEL` | `info` | `debug` logs every lock transition |
| `LOG_FORMAT` | `text` | `json` for structured JSON logs | | `LOG_FORMAT` | `text` | `json` for structured JSON logs |
| `UNLOAD_POLL_INTERVAL` | `500ms` | `/api/ps` poll interval while unloading |
| `HISTORY_POLL_INTERVAL` | `1s` | `/history/<id>` poll interval while a job runs |
| `PROBE_TIMEOUT` | `5s` | Startup probe of both upstreams |
| `FREE_TIMEOUT` | `30s` | `POST /free` call after an image job |
| `WARM_TIMEOUT` | `2m` | Warm-model reload after an image job |
| `SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown on SIGINT/SIGTERM |
| `PROMPT_CAPTURE_LIMIT` | `65536` | Bytes of the `/prompt` response buffered to find `prompt_id` (pass-through is unaffected) |
## Observability ## Observability
@@ -73,6 +80,10 @@ runs `go vet` and `go test -race`, and pushing a semantic-version tag
`git.rambossek.at/<owner>/gpu-turnstile:vX.Y.Z` (and updates `:latest`). `git.rambossek.at/<owner>/gpu-turnstile:vX.Y.Z` (and updates `:latest`).
No images are built from branches. No images are built from branches.
The registry login needs one repository secret (Settings → Actions →
Secrets): `REGISTRY_TOKEN` — an access token with `write:package` scope.
The automatic `GITEA_TOKEN` cannot push packages.
## Development ## Development
```sh ```sh
+18 -5
View File
@@ -81,12 +81,14 @@ ComfyUI listener (`:8188` → `COMFY_URL`):
2. Unload Ollama: `GET /api/ps`; for each model `POST /api/generate 2. Unload Ollama: `GET /api/ps`; for each model `POST /api/generate
{"model":M,"keep_alive":0}`; if that returns non-2xx (embedding-only {"model":M,"keep_alive":0}`; if that returns non-2xx (embedding-only
models), `POST /api/embed {"model":M,"input":"x","keep_alive":0}`. Poll models), `POST /api/embed {"model":M,"input":"x","keep_alive":0}`. Poll
`/api/ps` every 500 ms until empty or `UNLOAD_TIMEOUT`. On timeout: log and `/api/ps` every `UNLOAD_POLL_INTERVAL` (default 500 ms) until empty or
`UNLOAD_TIMEOUT`. On timeout: log and
continue (degrade, don't fail the user's request). continue (degrade, don't fail the user's request).
3. Forward the original request body to ComfyUI `/prompt`, return status, 3. Forward the original request body to ComfyUI `/prompt`, return status,
headers and body to the caller unchanged, flush. headers and body to the caller unchanged, flush.
4. If the response is 200 and contains `prompt_id`: in a goroutine, poll 4. If the response is 200 and contains `prompt_id`: in a goroutine, poll
`GET /history/<prompt_id>` every 1 s until the entry has `GET /history/<prompt_id>` every `HISTORY_POLL_INTERVAL` (default 1 s)
until the entry has
`status.completed == true`, `status.status_str == "error"`, or `status.completed == true`, `status.status_str == "error"`, or
`JOB_TIMEOUT`. Then `POST /free {"unload_models":true,"free_memory":true}`. `JOB_TIMEOUT`. Then `POST /free {"unload_models":true,"free_memory":true}`.
Then release the image lock. Then release the image lock.
@@ -111,6 +113,13 @@ load time. Off by default.
| `LLM_WAIT_TIMEOUT` | `10m` | max time an LLM request waits for the lock before 503 | | `LLM_WAIT_TIMEOUT` | `10m` | max time an LLM request waits for the lock before 503 |
| `WARM_MODEL` | `` | optional model to reload after an image job | | `WARM_MODEL` | `` | optional model to reload after an image job |
| `LOG_LEVEL` | `info` | `debug` logs every lock transition | | `LOG_LEVEL` | `info` | `debug` logs every lock transition |
| `UNLOAD_POLL_INTERVAL` | `500ms` | `/api/ps` poll interval while unloading |
| `HISTORY_POLL_INTERVAL` | `1s` | `/history/<id>` poll interval while a job runs |
| `PROBE_TIMEOUT` | `5s` | startup probe of both upstreams |
| `FREE_TIMEOUT` | `30s` | `POST /free` call after an image job |
| `WARM_TIMEOUT` | `2m` | warm-model reload after an image job |
| `SHUTDOWN_TIMEOUT` | `10s` | graceful shutdown on SIGINT/SIGTERM |
| `PROMPT_CAPTURE_LIMIT` | `65536` | bytes of the `/prompt` response buffered to find `prompt_id` (pass-through is unaffected) |
Startup fails fast on unparsable values. Both upstreams are probed once at Startup fails fast on unparsable values. Both upstreams are probed once at
start (`/api/version`, `/system_stats`); failure is logged, not fatal. start (`/api/version`, `/system_stats`); failure is logged, not fatal.
@@ -185,8 +194,12 @@ are new.
available in the runner image available in the runner image
2. on a version tag only (`vX.Y.Z`, enforced): build the image with buildx 2. on a version tag only (`vX.Y.Z`, enforced): build the image with buildx
and push it to the Gitea registry and push it to the Gitea registry
`git.rambossek.at/<owner>/gpu-turnstile` tagged `:<tag>` and `:latest`, `git.rambossek.at/<owner>/gpu-turnstile` tagged `:<tag>` and `:latest`
using the workflow token (`${{ secrets.GITEA_TOKEN }}` / `gitea.actor`) (the repository path is lowercased in the workflow; Docker registry
names must be lowercase).
Login uses the repo secret `REGISTRY_TOKEN` (an access token with
`write:package` scope) because the automatic `GITEA_TOKEN` cannot push
packages; the username is just `gitea.actor`.
- Release: a git tag `vX.Y.Z` produces the versioned image; the Open WebUI - Release: a git tag `vX.Y.Z` produces the versioned image; the Open WebUI
compose pins that tag. No images are built from branches. compose pins that tag. No images are built from branches.
@@ -194,7 +207,7 @@ are new.
```yaml ```yaml
gpu-turnstile: gpu-turnstile:
image: git.rambossek.at/<owner>/gpu-turnstile:v0.1.0 image: git.rambossek.at/<owner>/gpu-turnstile:v0.1.0 # owner lowercased, e.g. "public"
environment: environment:
OLLAMA_URL: http://<workstation-ip>:11435 OLLAMA_URL: http://<workstation-ip>:11435
COMFY_URL: http://<workstation-ip>:8189 COMFY_URL: http://<workstation-ip>:8189
+54 -17
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -32,9 +33,18 @@ type config struct {
unloadTimeout time.Duration unloadTimeout time.Duration
jobTimeout time.Duration jobTimeout time.Duration
llmWaitTimeout time.Duration llmWaitTimeout time.Duration
warmModel string
logLevel slog.Level unloadPollInterval time.Duration
logJSON bool historyPollInterval time.Duration
probeTimeout time.Duration
freeTimeout time.Duration
warmTimeout time.Duration
shutdownTimeout time.Duration
promptCaptureLimit int64
warmModel string
logLevel slog.Level
logJSON bool
} }
func envDuration(getenv func(string) string, name string, dst *time.Duration) error { func envDuration(getenv func(string) string, name string, dst *time.Duration) error {
@@ -59,7 +69,16 @@ func loadConfig(getenv func(string) string) (config, error) {
unloadTimeout: time.Minute, unloadTimeout: time.Minute,
jobTimeout: 15 * time.Minute, jobTimeout: 15 * time.Minute,
llmWaitTimeout: 10 * time.Minute, llmWaitTimeout: 10 * time.Minute,
logLevel: slog.LevelInfo,
unloadPollInterval: 500 * time.Millisecond,
historyPollInterval: time.Second,
probeTimeout: 5 * time.Second,
freeTimeout: 30 * time.Second,
warmTimeout: 2 * time.Minute,
shutdownTimeout: 10 * time.Second,
promptCaptureLimit: 64 * 1024,
logLevel: slog.LevelInfo,
} }
for _, e := range []struct { for _, e := range []struct {
name string name string
@@ -82,11 +101,24 @@ func loadConfig(getenv func(string) string) (config, error) {
{"UNLOAD_TIMEOUT", &cfg.unloadTimeout}, {"UNLOAD_TIMEOUT", &cfg.unloadTimeout},
{"JOB_TIMEOUT", &cfg.jobTimeout}, {"JOB_TIMEOUT", &cfg.jobTimeout},
{"LLM_WAIT_TIMEOUT", &cfg.llmWaitTimeout}, {"LLM_WAIT_TIMEOUT", &cfg.llmWaitTimeout},
{"UNLOAD_POLL_INTERVAL", &cfg.unloadPollInterval},
{"HISTORY_POLL_INTERVAL", &cfg.historyPollInterval},
{"PROBE_TIMEOUT", &cfg.probeTimeout},
{"FREE_TIMEOUT", &cfg.freeTimeout},
{"WARM_TIMEOUT", &cfg.warmTimeout},
{"SHUTDOWN_TIMEOUT", &cfg.shutdownTimeout},
} { } {
if err := envDuration(getenv, e.name, e.dst); err != nil { if err := envDuration(getenv, e.name, e.dst); err != nil {
return cfg, err return cfg, err
} }
} }
if v := getenv("PROMPT_CAPTURE_LIMIT"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
return cfg, fmt.Errorf("PROMPT_CAPTURE_LIMIT: must be a non-negative integer (bytes)")
}
cfg.promptCaptureLimit = n
}
if v := getenv("LOG_LEVEL"); v != "" { if v := getenv("LOG_LEVEL"); v != "" {
var level slog.Level var level slog.Level
if err := level.UnmarshalText([]byte(v)); err != nil { if err := level.UnmarshalText([]byte(v)); err != nil {
@@ -139,17 +171,22 @@ func main() {
} }
srv, err := proxy.New(proxy.Config{ srv, err := proxy.New(proxy.Config{
OllamaURL: cfg.ollamaURL, OllamaURL: cfg.ollamaURL,
ComfyURL: cfg.comfyURL, ComfyURL: cfg.comfyURL,
Lock: lock.New(log), Lock: lock.New(log),
Ollama: ollamaClient, Ollama: ollamaClient,
Comfy: comfyClient, Comfy: comfyClient,
Metrics: metrics.New(), Metrics: metrics.New(),
Log: log, Log: log,
LLMWaitTimeout: cfg.llmWaitTimeout, LLMWaitTimeout: cfg.llmWaitTimeout,
UnloadTimeout: cfg.unloadTimeout, UnloadTimeout: cfg.unloadTimeout,
JobTimeout: cfg.jobTimeout, JobTimeout: cfg.jobTimeout,
WarmModel: cfg.warmModel, UnloadPollInterval: cfg.unloadPollInterval,
HistoryPollInterval: cfg.historyPollInterval,
FreeTimeout: cfg.freeTimeout,
WarmTimeout: cfg.warmTimeout,
PromptCaptureLimit: cfg.promptCaptureLimit,
WarmModel: cfg.warmModel,
}) })
if err != nil { if err != nil {
log.Error("invalid configuration", "err", err) log.Error("invalid configuration", "err", err)
@@ -157,7 +194,7 @@ func main() {
} }
// Probe both upstreams once; failure is logged, not fatal. // Probe both upstreams once; failure is logged, not fatal.
probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second) probeCtx, probeCancel := context.WithTimeout(context.Background(), cfg.probeTimeout)
if err := ollamaClient.Probe(probeCtx); err != nil { if err := ollamaClient.Probe(probeCtx); err != nil {
log.Warn("ollama probe failed", "url", cfg.ollamaURL, "err", err) log.Warn("ollama probe failed", "url", cfg.ollamaURL, "err", err)
} }
@@ -186,7 +223,7 @@ func main() {
log.Info("shutting down") log.Info("shutting down")
} }
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.shutdownTimeout)
defer shutdownCancel() defer shutdownCancel()
ollamaSrv.Shutdown(shutdownCtx) ollamaSrv.Shutdown(shutdownCtx)
comfySrv.Shutdown(shutdownCtx) comfySrv.Shutdown(shutdownCtx)
+25
View File
@@ -0,0 +1,25 @@
# Example deployment for gpu-turnstile. Copy to compose.yaml and adjust.
#
# gpu-turnstile listens on the ports the services normally use; the actual
# Ollama and ComfyUI instances run one port higher on the workstation.
services:
gpu-turnstile:
image: git.rambossek.at/public/gpu-turnstile:v0.1.1
restart: unless-stopped
environment:
OLLAMA_URL: http://<workstation-ip>:11435
COMFY_URL: http://<workstation-ip>:8189
# UNLOAD_TIMEOUT: 60s
# JOB_TIMEOUT: 15m
# LLM_WAIT_TIMEOUT: 10m
# WARM_MODEL: qwen3:14b
# LOG_LEVEL: info
# LOG_FORMAT: json
ports:
- "11434:11434" # LiteLLM api_base -> http://gpu-turnstile:11434
- "8188:8188" # Open WebUI COMFYUI_BASE_URL -> http://gpu-turnstile:8188
networks: [internal]
networks:
internal:
external: true
+53 -16
View File
@@ -21,10 +21,10 @@ import (
"gpu-turnstile/internal/ollama" "gpu-turnstile/internal/ollama"
) )
// captureLimit bounds how much of a /prompt response body is buffered while // defaultCaptureLimit bounds how much of a /prompt response body is
// looking for prompt_id. The body still passes through to the client // buffered while looking for prompt_id. The body still passes through to
// unchanged regardless of size. // the client unchanged regardless of size.
const captureLimit = 64 * 1024 const defaultCaptureLimit = 64 * 1024
// Config wires a Server. // Config wires a Server.
type Config struct { type Config struct {
@@ -40,13 +40,28 @@ type Config struct {
LLMWaitTimeout time.Duration LLMWaitTimeout time.Duration
UnloadTimeout time.Duration UnloadTimeout time.Duration
JobTimeout time.Duration JobTimeout time.Duration
WarmModel string
// UnloadPollInterval and HistoryPollInterval override the clients'
// /api/ps and /history poll intervals when > 0.
UnloadPollInterval time.Duration
HistoryPollInterval time.Duration
// FreeTimeout and WarmTimeout bound the /free call and the warm-model
// reload; zero selects the defaults.
FreeTimeout time.Duration
WarmTimeout time.Duration
// PromptCaptureLimit overrides defaultCaptureLimit when > 0.
PromptCaptureLimit int64
WarmModel string
} }
// Server serves both gpu-turnstile listeners. // Server serves both gpu-turnstile listeners.
type Server struct { type Server struct {
cfg Config cfg Config
log *slog.Logger log *slog.Logger
freeTimeout time.Duration
warmTimeout time.Duration
captureLimit int64
ollamaProxy *httputil.ReverseProxy ollamaProxy *httputil.ReverseProxy
comfyProxy *httputil.ReverseProxy comfyProxy *httputil.ReverseProxy
@@ -66,11 +81,32 @@ func New(cfg Config) (*Server, error) {
if log == nil { if log == nil {
log = slog.Default() log = slog.Default()
} }
if cfg.UnloadPollInterval > 0 {
cfg.Ollama.PollInterval = cfg.UnloadPollInterval
}
if cfg.HistoryPollInterval > 0 {
cfg.Comfy.PollInterval = cfg.HistoryPollInterval
}
freeTimeout := cfg.FreeTimeout
if freeTimeout <= 0 {
freeTimeout = 30 * time.Second
}
warmTimeout := cfg.WarmTimeout
if warmTimeout <= 0 {
warmTimeout = 2 * time.Minute
}
captureLimit := cfg.PromptCaptureLimit
if captureLimit <= 0 {
captureLimit = defaultCaptureLimit
}
return &Server{ return &Server{
cfg: cfg, cfg: cfg,
log: log, log: log,
ollamaProxy: newReverseProxy(ollamaURL, log.With("upstream", "ollama")), freeTimeout: freeTimeout,
comfyProxy: newReverseProxy(comfyURL, log.With("upstream", "comfy")), warmTimeout: warmTimeout,
captureLimit: captureLimit,
ollamaProxy: newReverseProxy(ollamaURL, log.With("upstream", "ollama")),
comfyProxy: newReverseProxy(comfyURL, log.With("upstream", "comfy")),
}, nil }, nil
} }
@@ -170,11 +206,12 @@ func (s *Server) ComfyHandler() http.Handler {
} }
// captureWriter passes the response through unchanged while recording the // captureWriter passes the response through unchanged while recording the
// status code and the first captureLimit bytes of the body. // status code and the first limit bytes of the body.
type captureWriter struct { type captureWriter struct {
http.ResponseWriter http.ResponseWriter
status int status int
buf bytes.Buffer buf bytes.Buffer
limit int64
} }
func (w *captureWriter) WriteHeader(code int) { func (w *captureWriter) WriteHeader(code int) {
@@ -183,7 +220,7 @@ func (w *captureWriter) WriteHeader(code int) {
} }
func (w *captureWriter) Write(p []byte) (int, error) { func (w *captureWriter) Write(p []byte) (int, error) {
if w.buf.Len() < captureLimit { if int64(w.buf.Len()) < w.limit {
w.buf.Write(p) w.buf.Write(p)
} }
return w.ResponseWriter.Write(p) return w.ResponseWriter.Write(p)
@@ -228,7 +265,7 @@ func (s *Server) handlePrompt(w http.ResponseWriter, r *http.Request) {
log.Info("ollama models unloaded", "seconds", elapsed.Seconds()) log.Info("ollama models unloaded", "seconds", elapsed.Seconds())
} }
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK} cw := &captureWriter{ResponseWriter: w, status: http.StatusOK, limit: s.captureLimit}
s.comfyProxy.ServeHTTP(cw, r) s.comfyProxy.ServeHTTP(cw, r)
var accepted struct { var accepted struct {
@@ -262,7 +299,7 @@ func (s *Server) finishImageJob(promptID string) {
log.Info("image job completed") log.Info("image job completed")
} }
freeCtx, freeCancel := context.WithTimeout(context.Background(), 30*time.Second) freeCtx, freeCancel := context.WithTimeout(context.Background(), s.freeTimeout)
if err := s.cfg.Comfy.Free(freeCtx); err != nil { if err := s.cfg.Comfy.Free(freeCtx); err != nil {
log.Warn("failed to free ComfyUI models", "err", err) log.Warn("failed to free ComfyUI models", "err", err)
} }
@@ -273,7 +310,7 @@ func (s *Server) finishImageJob(promptID string) {
if s.cfg.WarmModel != "" { if s.cfg.WarmModel != "" {
if state, _, _ := s.cfg.Lock.Snapshot(); state == lock.StateIdle { if state, _, _ := s.cfg.Lock.Snapshot(); state == lock.StateIdle {
wctx, wcancel := context.WithTimeout(context.Background(), 2*time.Minute) wctx, wcancel := context.WithTimeout(context.Background(), s.warmTimeout)
if err := s.cfg.Ollama.Warm(wctx, s.cfg.WarmModel); err != nil { if err := s.cfg.Ollama.Warm(wctx, s.cfg.WarmModel); err != nil {
log.Warn("warm model reload failed", "model", s.cfg.WarmModel, "err", err) log.Warn("warm model reload failed", "model", s.cfg.WarmModel, "err", err)
} else { } else {