Files
ModelRouter/cmd/llmsproxy/main.go
JianFeeeee c524831616 fix(deploy): roll back config together with the binary, and preflight it before restart
The 446-restart-loop incident: adding a headers block to a source without
removing the source's existing `headers: {}` produced a duplicate YAML key.
The process exited on startup, healthcheck failed, and rollback restored only
the binary — so the old binary kept parsing the same broken config and the
service span in systemd's restart loop. Config was treated as out of scope
for deployment; it is not.

Three changes close the loop:

1. cmd/llmsproxy: new `-check` flag validates a config (parse +
   ApplyDefaults) and exits, without starting the Lua VM, touching
   runtime.json, or binding a port — safe to run against a live service.
   Unlike normal startup it does NOT create a default config, so a missing
   file is an error.

2. deploy.sh `--config <file>`: stage a config for deployment, atomically
   renamed into place with the same copy->rename(2) technique as the binary.
   Omitted means the live config is left alone.

3. Ordering: config replacement and preflight both run BEFORE
   restart_service, so an invalid config is caught while the service is still
   healthy and never triggers a restart. rollback() now restores binary AND
   config (only when this run replaced it, so concurrent WebUI edits survive),
   then re-runs -check before restarting — refusing to restart into a config
   that still fails, instead of trading one restart storm for another.

Also: the sha256-unchanged early exit now only fires when there is no pending
config, otherwise `--config` would be silently dropped.

Verified on the live deployment:
- reproduced the exact duplicate-key config: preflight caught it, PID
  unchanged (zero interruption), binary and config both rolled back, gateway
  still answering 200
- valid config: replaced, service restarted, new value live
- no --config: binary-only deploy unaffected
- go test -tags luajit ./... passes
2026-09-05 09:46:43 +08:00

108 lines
3.6 KiB
Go

// Command llmsproxy is a standalone gateway that exposes multiple upstream LLM
// sources behind a unified OpenAI-compatible HTTP API. Protocol differences are
// handled by Lua adapters (per source), optionally signing outgoing requests.
// It supports explicit model selection or AUTO routing by priority, image
// generation, multimodal payloads, a web UI for adapter/source management, and
// concurrent/queued scheduling with backoff and failover.
package main
import (
"flag"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
"llmsproxy/internal/gateway"
)
func main() {
cfgPath := flag.String("config", "config.yaml", "path to gateway config file")
checkOnly := flag.Bool("check", false, "validate the config file and exit (0 = valid, 1 = invalid); nothing is started and no file is written")
flag.Parse()
// -check is the deploy-time preflight: parse and validate the config
// without starting the Lua VM, touching runtime.json, or binding a port.
// It deliberately does NOT call EnsureDefault, so a missing file is an
// error here instead of being silently created.
if *checkOnly {
if _, err := os.Stat(*cfgPath); err != nil {
log.Fatalf("[llmsproxy] check: %v", err)
}
if _, err := config.Load(*cfgPath); err != nil {
log.Fatalf("[llmsproxy] check: %v", err)
}
log.Printf("[llmsproxy] check: %s is valid", *cfgPath)
return
}
created, err := config.EnsureDefault(*cfgPath)
if err != nil {
log.Fatalf("[llmsproxy] config: %v", err)
}
c, err := core.New(*cfgPath)
if err != nil {
log.Fatalf("[llmsproxy] core: %v", err)
}
defer c.Close()
if created {
for _, k := range c.GatewayKeys() {
log.Printf("[llmsproxy] generated default config at %s — admin key: %s (rotate it in the WebUI after first login)", *cfgPath, k)
}
}
gw, err := gateway.New(c, c.GatewayKeys())
if err != nil {
log.Fatalf("[llmsproxy] gateway: %v", err)
}
// Ops hygiene: surface the two most common footguns instead of silently
// running with them.
if keys := c.GatewayKeys(); len(keys) == 0 {
log.Printf("[llmsproxy] WARNING: gateway_keys is EMPTY — without a key every request is rejected")
} else {
for _, k := range keys {
if k == "sk-gw-local-0001" || k == "sk-local-0001" {
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k)
}
}
}
if l := c.Listen(); strings.HasPrefix(l, "0.0.0.0:") || strings.HasPrefix(l, "::") {
log.Printf("[llmsproxy] WARNING: listen=%s binds ALL interfaces — bind an internal address in production", l)
}
srv := &http.Server{
Addr: c.Listen(),
Handler: gw.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
cert, key := c.TLS()
if cert != "" && key != "" {
log.Printf("[llmsproxy] listening HTTPS on %s (cert=%q) (default_model=%s, models=%v, adapters=%d)",
c.Listen(), cert, c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters()))
if err := srv.ListenAndServeTLS(cert, key); err != nil && err != http.ErrServerClosed {
log.Fatalf("[llmsproxy] server: %v", err)
}
return
}
log.Printf("[llmsproxy] listening HTTP on %s (default_model=%s, models=%v, adapters=%d)",
c.Listen(), c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters()))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[llmsproxy] server: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Printf("[llmsproxy] shutting down")
}