mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
NewEngine reached past lura's config twice, and both overrides threw away what
the operator had asked for. Both reproduce against the SHIPPING config with the
real binary — not only in the fixture suite:
$ ./gateway run -c configs/hanzo/gateway.json
$ curl -sio- localhost:8080/v1/models # upstream unreachable
HTTP/1.1 500 Internal Server Error
Content-Length: 0 # <- nothing to report
$ curl -so/dev/null -w '%{http_code}\n' localhost:8080/V1/models
404 # <- no Location, no redirect
1. disable_health was injected by REPLACING extra_config["...router..."] with a
fresh one-key map. `return_error_msg: true` is set in configs/hanzo,
configs/lux AND tests/fixtures — every one of them was silently discarded, so
the production edge answered every upstream failure with a bare status and a
zero-length body and a caller seeing a 500 had nothing to go on. The clobber
also made the error_body 404/405 re-read at the bottom of NewEngine dead code:
it re-read the map it had just overwritten. Now withRouterOption MERGES, and is
the one way this package forces a router option; it is copy-on-write, because
the service config is shared with the check/audit commands.
Consequence, deliberately: `return_error_msg: true` now actually takes effect,
so upstream error text (including internal service DNS names) reaches 5xx
bodies. That is what both shipping configs ask for. The one knob to change it
is that key — not a hardcode here.
2. `engine.RedirectFixedPath = false` ran AFTER luragin.NewEngine, which sets it
true and exposes `disable_redirect_fixed_path` for operators. The hardcode made
that knob dead and 404'd any case-differing path. Its comment blamed a panic in
gin 1.9.1 on mixed static/param nodes (gin-gonic/gin#3348); gin is v1.12.0 here
and TestNewEngine_MixedStaticParamRoutes_FixedPathRedirect registers exactly
that route shape — /v1/async-invoke, /v1/async-invoke/:request_id,
/v1/async-invoke/:request_id/status, /v1/ats/assets/:id/quote — to pin that the
panic is gone, so this is not a 404 traded for a crash. Operators who want it
off keep the lura knob, which now works.
Both are pinned end-to-end too: router_redirect, backend_404, cel-1, cel-2 and
lua_2 fail on the parent commit and pass here.
Co-authored-by: Hanzo Dev <[email protected]>
159 lines
6.2 KiB
Go
159 lines
6.2 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
//go:build legacy
|
|
// +build legacy
|
|
|
|
// Regression guards for NewEngine's two overrides of lura's router config.
|
|
//
|
|
// Both were WRITE-ONLY over the operator's `router` extra_config, and both
|
|
// reproduced against the SHIPPING config (configs/hanzo/gateway.json) with the
|
|
// real binary, not just in the fixture suite:
|
|
//
|
|
// - the disable_health injection REPLACED the whole `router` map, so
|
|
// `return_error_msg: true` — set in configs/hanzo, configs/lux AND
|
|
// tests/fixtures — never reached lura. Every upstream failure answered with
|
|
// a bare status and Content-Length: 0.
|
|
// - `engine.RedirectFixedPath = false` ran AFTER lura's NewEngine, so lura's
|
|
// own `disable_redirect_fixed_path` knob was dead and a case-differing path
|
|
// 404'd instead of redirecting.
|
|
//
|
|
// The tests below are the *unit* pins. tests/fixtures/specs/router_redirect.json
|
|
// and backend_404/cel-1/cel-2/lua_2 are the end-to-end ones.
|
|
package gateway
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/hanzoai/gateway/v2/internal/lura/config"
|
|
luragin "github.com/hanzoai/gateway/v2/internal/lura/router/gin"
|
|
)
|
|
|
|
// TestWithRouterOption_PreservesOperatorKeys is the direct pin on the clobber:
|
|
// forcing one key must not drop the others.
|
|
func TestWithRouterOption_PreservesOperatorKeys(t *testing.T) {
|
|
operator := map[string]interface{}{"return_error_msg": true, "auto_options": true}
|
|
ec := config.ExtraConfig{luragin.Namespace: operator}
|
|
|
|
got, ok := withRouterOption(ec, "disable_health", true)[luragin.Namespace].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("router namespace is not a map: %#v", ec[luragin.Namespace])
|
|
}
|
|
for k, want := range map[string]interface{}{
|
|
"return_error_msg": true,
|
|
"auto_options": true,
|
|
"disable_health": true,
|
|
} {
|
|
if got[k] != want {
|
|
t.Errorf("router[%q] = %v, want %v (full map: %#v)", k, got[k], want, got)
|
|
}
|
|
}
|
|
// Copy-on-write: the service config is shared with the check/audit commands,
|
|
// so forcing an option must not mutate the caller's nested map.
|
|
if _, mutated := operator["disable_health"]; mutated {
|
|
t.Error("withRouterOption mutated the operator's map in place")
|
|
}
|
|
}
|
|
|
|
// TestNewEngine_KeepsOperatorRouterConfig is the pin on the clobber as NewEngine
|
|
// itself performs it. The config it hands lura must still carry the operator's
|
|
// keys — `return_error_msg: true` is set in configs/hanzo, configs/lux and
|
|
// tests/fixtures, and losing it is what emptied every 5xx body on the edge.
|
|
func TestNewEngine_KeepsOperatorRouterConfig(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
cfg := config.ServiceConfig{
|
|
ExtraConfig: config.ExtraConfig{
|
|
luragin.Namespace: map[string]interface{}{"return_error_msg": true},
|
|
},
|
|
}
|
|
e := NewEngine(cfg, luragin.EngineOptions{})
|
|
|
|
router, ok := cfg.ExtraConfig[luragin.Namespace].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("router namespace is not a map: %#v", cfg.ExtraConfig[luragin.Namespace])
|
|
}
|
|
if router["return_error_msg"] != true {
|
|
t.Errorf("return_error_msg = %v, want true — NewEngine dropped the operator's router config: %#v",
|
|
router["return_error_msg"], router)
|
|
}
|
|
if router["disable_health"] != true {
|
|
t.Errorf("disable_health = %v, want true — the forced option did not land: %#v",
|
|
router["disable_health"], router)
|
|
}
|
|
|
|
// ...and the engine still serves: the merge did not corrupt what lura reads.
|
|
rec := httptest.NewRecorder()
|
|
e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/no-such-endpoint", nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Server"); got != "gateway" {
|
|
t.Errorf("Server = %q, want %q — ProductionHeadersMiddleware did not run", got, "gateway")
|
|
}
|
|
}
|
|
|
|
// TestNewEngine_MixedStaticParamRoutes_FixedPathRedirect is the load-bearing
|
|
// one. `engine.RedirectFixedPath = false` was carried with a comment blaming a
|
|
// gin 1.9.1 panic on mixed static/param nodes (gin-gonic/gin#3348). gin is
|
|
// v1.12.0 now; this pins that the panic is gone AND that the redirect works, so
|
|
// the removal is not a trade of a 404 for a crash.
|
|
//
|
|
// The route shape is the production one: a static segment with a param sibling
|
|
// and a static child under the param (configs/hanzo/gateway.json ships
|
|
// /v1/async-invoke, /v1/async-invoke/{id} and /v1/async-invoke/{id}/status).
|
|
func TestNewEngine_MixedStaticParamRoutes_FixedPathRedirect(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
e := NewEngine(config.ServiceConfig{}, luragin.EngineOptions{})
|
|
|
|
ok := func(c *gin.Context) { c.String(http.StatusOK, "ok") }
|
|
for _, p := range []string{
|
|
"/v1/async-invoke",
|
|
"/v1/async-invoke/:request_id",
|
|
"/v1/async-invoke/:request_id/status",
|
|
"/v1/ats/assets/:id/quote",
|
|
"/v1/ats/assets/search",
|
|
} {
|
|
e.GET(p, ok) // panics here if #3348 were still live
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
path, want string
|
|
code int
|
|
}{
|
|
{"/v1/async-invoke", "", http.StatusOK},
|
|
{"/V1/Async-Invoke", "/v1/async-invoke", http.StatusMovedPermanently},
|
|
{"/v1/ATS/assets/search", "/v1/ats/assets/search", http.StatusMovedPermanently},
|
|
} {
|
|
rec := httptest.NewRecorder()
|
|
e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
|
if rec.Code != tc.code {
|
|
t.Errorf("GET %s: status = %d, want %d", tc.path, rec.Code, tc.code)
|
|
}
|
|
if tc.want != "" && rec.Header().Get("Location") != tc.want {
|
|
t.Errorf("GET %s: Location = %q, want %q", tc.path, rec.Header().Get("Location"), tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNewEngine_OperatorCanDisableFixedPathRedirect pins that deleting the
|
|
// hardcode restored the KNOB rather than replacing one hardcode with another:
|
|
// lura's `disable_redirect_fixed_path` is the one way to turn it off.
|
|
func TestNewEngine_OperatorCanDisableFixedPathRedirect(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
e := NewEngine(config.ServiceConfig{
|
|
ExtraConfig: config.ExtraConfig{
|
|
luragin.Namespace: map[string]interface{}{"disable_redirect_fixed_path": true},
|
|
},
|
|
}, luragin.EngineOptions{})
|
|
e.GET("/v1/models", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
|
|
|
rec := httptest.NewRecorder()
|
|
e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/V1/Models", nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("with disable_redirect_fixed_path: status = %d, want 404", rec.Code)
|
|
}
|
|
}
|