Files
wehub-resource-sync 1b8708893a
Security Scan / tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:12:26 +08:00

451 lines
17 KiB
Go

package localai
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/core/services/testutil"
"github.com/mudler/LocalAI/pkg/natsauth"
"github.com/nats-io/nkeys"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = DescribeTable("token validation",
func(expectedToken, providedToken string, wantMatch bool) {
if expectedToken == "" {
// No auth required — always matches
Expect(wantMatch).To(BeTrue(), "no-auth should always pass")
return
}
if providedToken == "" {
Expect(wantMatch).To(BeFalse(), "empty token should be rejected")
return
}
expectedHash := sha256.Sum256([]byte(expectedToken))
providedHash := sha256.Sum256([]byte(providedToken))
match := subtle.ConstantTimeCompare(expectedHash[:], providedHash[:]) == 1
Expect(match).To(Equal(wantMatch))
},
Entry("matching tokens", "my-secret-token", "my-secret-token", true),
Entry("mismatched tokens", "my-secret-token", "wrong-token", false),
Entry("empty expected (no auth)", "", "any-token", true),
Entry("empty provided when expected set", "my-secret-token", "", false),
)
var _ = Describe("Node HTTP handlers", func() {
var (
registry *nodes.NodeRegistry
)
BeforeEach(func() {
db := testutil.SetupTestDB()
var err error
registry, err = nodes.NewNodeRegistry(db)
Expect(err).ToNot(HaveOccurred())
})
Describe("RegisterNodeEndpoint", func() {
It("registers a backend node and returns 201", func() {
e := echo.New()
body := `{"name":"worker-1","address":"10.0.0.1:50051"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusCreated))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
Expect(resp["name"]).To(Equal("worker-1"))
Expect(resp["id"]).ToNot(BeEmpty())
Expect(resp["status"]).To(Equal(nodes.StatusHealthy))
})
It("returns nats_jwt when account seed is configured", func() {
akp, err := nkeys.CreateAccount()
Expect(err).ToNot(HaveOccurred())
seed, err := akp.Seed()
Expect(err).ToNot(HaveOccurred())
e := echo.New()
body := `{"name":"worker-nats","address":"10.0.0.2:50051"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
natsCfg := natsauth.Config{AccountSeed: string(seed)}
handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsCfg)
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusCreated))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
Expect(resp["nats_jwt"]).ToNot(BeEmpty())
})
It("returns 400 when name is missing", func() {
e := echo.New()
body := `{"address":"10.0.0.1:50051"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusBadRequest))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
errObj, ok := resp["error"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(errObj["message"]).To(ContainSubstring("name is required"))
})
It("returns 400 when name exceeds 255 characters", func() {
e := echo.New()
longName := strings.Repeat("x", 256)
body := `{"name":"` + longName + `","address":"10.0.0.1:50051"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusBadRequest))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
errObj, ok := resp["error"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(errObj["message"]).To(ContainSubstring("exceeds 255 characters"))
})
It("returns 400 when address is missing for backend node type", func() {
e := echo.New()
body := `{"name":"worker-no-addr"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusBadRequest))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
errObj, ok := resp["error"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(errObj["message"]).To(ContainSubstring("address is required"))
})
It("returns 400 when node_type is invalid", func() {
e := echo.New()
body := `{"name":"bad-type","address":"10.0.0.1:50051","node_type":"invalid"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusBadRequest))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
errObj, ok := resp["error"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(errObj["message"]).To(ContainSubstring("invalid node_type"))
})
It("returns 401 when registration token is wrong", func() {
e := echo.New()
body := `{"name":"worker-1","address":"10.0.0.1:50051","token":"wrong-token"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := RegisterNodeEndpoint(registry, "correct-token", true, nil, "", natsauth.Config{})
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
It("sets status to pending when autoApprove is false", func() {
e := echo.New()
body := `{"name":"pending-worker","address":"10.0.0.1:50051"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := RegisterNodeEndpoint(registry, "", false, nil, "", natsauth.Config{})
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusCreated))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
Expect(resp["status"]).To(Equal(nodes.StatusPending))
})
// Regression: a worker re-register used to wipe every UI-added label
// because the endpoint called SetNodeLabels (replace-all) with only
// what the worker sent. Operators reported "labels assigned to node
// do not persist" — the labels survived until the next worker
// restart, then disappeared.
It("preserves UI-added labels across worker re-register", func() {
ctx := context.Background()
e := echo.New()
// 1. Worker first-registers with one label.
body1 := `{"name":"worker-merge","address":"10.0.0.50:50051","labels":{"tier":"a","gpu":"a100"}}`
req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body1))
req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec1 := httptest.NewRecorder()
handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})
Expect(handler(e.NewContext(req1, rec1))).To(Succeed())
Expect(rec1.Code).To(Equal(http.StatusCreated))
node, err := registry.GetByName(ctx, "worker-merge")
Expect(err).ToNot(HaveOccurred())
Expect(node).ToNot(BeNil())
// 2. Operator adds a label via the UI.
Expect(registry.SetNodeLabel(ctx, node.ID, "owner", "ettore")).To(Succeed())
// 3. Worker restarts and re-registers, sending its own labels
// (different from the UI-added one).
body2 := `{"name":"worker-merge","address":"10.0.0.50:50051","labels":{"tier":"b","gpu":"a100"}}`
req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body2))
req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec2 := httptest.NewRecorder()
Expect(handler(e.NewContext(req2, rec2))).To(Succeed())
Expect(rec2.Code).To(Equal(http.StatusCreated))
// 4. Assert the UI-added label survived AND the worker labels updated.
labels, err := registry.GetNodeLabels(ctx, node.ID)
Expect(err).ToNot(HaveOccurred())
byKey := map[string]string{}
for _, l := range labels {
byKey[l.Key] = l.Value
}
Expect(byKey).To(HaveKeyWithValue("owner", "ettore"),
"UI-added label must survive a worker re-register")
Expect(byKey).To(HaveKeyWithValue("tier", "b"),
"worker label updates must apply on re-register")
Expect(byKey).To(HaveKeyWithValue("gpu", "a100"))
})
})
Describe("SetSchedulingEndpoint", func() {
postScheduling := func(body string) *httptest.ResponseRecorder {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := SetSchedulingEndpoint(registry)
Expect(handler(c)).To(Succeed())
return rec
}
It("persists prefix-cache fields and round-trips them via GET", func() {
ctx := context.Background()
rec := postScheduling(`{"model_name":"pc-model","route_policy":"prefix_cache","balance_abs_threshold":3,"min_prefix_match":0.4}`)
Expect(rec.Code).To(Equal(http.StatusOK))
cfg, err := registry.GetModelScheduling(ctx, "pc-model")
Expect(err).ToNot(HaveOccurred())
Expect(cfg).ToNot(BeNil())
Expect(cfg.RoutePolicy).To(Equal("prefix_cache"))
Expect(cfg.BalanceAbsThreshold).To(Equal(3))
Expect(cfg.MinPrefixMatch).To(BeNumerically("~", 0.4, 1e-9))
e := echo.New()
getReq := httptest.NewRequest(http.MethodGet, "/", nil)
getRec := httptest.NewRecorder()
gc := e.NewContext(getReq, getRec)
gc.SetParamNames("model")
gc.SetParamValues("pc-model")
Expect(GetSchedulingEndpoint(registry)(gc)).To(Succeed())
Expect(getRec.Code).To(Equal(http.StatusOK))
var got nodes.ModelSchedulingConfig
Expect(json.Unmarshal(getRec.Body.Bytes(), &got)).To(Succeed())
Expect(got.RoutePolicy).To(Equal("prefix_cache"))
Expect(got.BalanceAbsThreshold).To(Equal(3))
Expect(got.MinPrefixMatch).To(BeNumerically("~", 0.4, 1e-9))
})
It("returns 400 for an out-of-range min_prefix_match", func() {
rec := postScheduling(`{"model_name":"bad-mpm","min_prefix_match":2}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
errObj, ok := resp["error"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(errObj["message"]).To(ContainSubstring("min_prefix_match"))
})
It("returns 400 for an unknown route_policy", func() {
rec := postScheduling(`{"model_name":"bad-policy","route_policy":"bogus"}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
errObj, ok := resp["error"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(errObj["message"]).To(ContainSubstring("route_policy"))
})
It("returns 400 for a balance_rel_threshold between 0 and 1", func() {
rec := postScheduling(`{"model_name":"bad-rel","balance_rel_threshold":0.5}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
errObj, ok := resp["error"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(errObj["message"]).To(ContainSubstring("balance_rel_threshold"))
})
// Regression for the partial-update footgun: a min/max-only POST used to
// full-replace every column and silently reset the prefix-cache settings
// to empty/zero. The pointer-merge must preserve omitted prefix fields.
It("preserves prefix-cache settings across a min_replicas-only update", func() {
ctx := context.Background()
rec := postScheduling(`{"model_name":"merge-model","route_policy":"prefix_cache","min_prefix_match":0.4}`)
Expect(rec.Code).To(Equal(http.StatusOK))
// Update only min_replicas - omits all prefix-cache fields.
rec = postScheduling(`{"model_name":"merge-model","min_replicas":2}`)
Expect(rec.Code).To(Equal(http.StatusOK))
cfg, err := registry.GetModelScheduling(ctx, "merge-model")
Expect(err).ToNot(HaveOccurred())
Expect(cfg).ToNot(BeNil())
Expect(cfg.MinReplicas).To(Equal(2), "the provided non-prefix field must update")
Expect(cfg.RoutePolicy).To(Equal("prefix_cache"), "omitted route_policy must be preserved")
Expect(cfg.MinPrefixMatch).To(BeNumerically("~", 0.4, 1e-9), "omitted min_prefix_match must be preserved")
})
It("updates a prefix-cache field when it is explicitly provided", func() {
ctx := context.Background()
rec := postScheduling(`{"model_name":"update-model","route_policy":"prefix_cache","min_prefix_match":0.4}`)
Expect(rec.Code).To(Equal(http.StatusOK))
rec = postScheduling(`{"model_name":"update-model","route_policy":"round_robin"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
cfg, err := registry.GetModelScheduling(ctx, "update-model")
Expect(err).ToNot(HaveOccurred())
Expect(cfg).ToNot(BeNil())
Expect(cfg.RoutePolicy).To(Equal("round_robin"), "explicitly provided route_policy must update")
Expect(cfg.MinPrefixMatch).To(BeNumerically("~", 0.4, 1e-9), "omitted min_prefix_match must still be preserved")
})
})
Describe("ListNodesEndpoint", func() {
It("returns an empty list when no nodes are registered", func() {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := ListNodesEndpoint(registry)
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusOK))
var list []nodes.BackendNode
Expect(json.Unmarshal(rec.Body.Bytes(), &list)).To(Succeed())
Expect(list).To(BeEmpty())
})
It("returns registered nodes", func() {
// Register two nodes directly via the registry
ctx := context.Background()
Expect(registry.Register(ctx, &nodes.BackendNode{
Name: "alpha",
Address: "10.0.0.1:50051",
}, true)).To(Succeed())
Expect(registry.Register(ctx, &nodes.BackendNode{
Name: "beta",
Address: "10.0.0.2:50051",
}, true)).To(Succeed())
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := ListNodesEndpoint(registry)
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusOK))
var list []nodes.BackendNode
Expect(json.Unmarshal(rec.Body.Bytes(), &list)).To(Succeed())
Expect(list).To(HaveLen(2))
names := []string{list[0].Name, list[1].Name}
Expect(names).To(ConsistOf("alpha", "beta"))
})
})
Describe("ListAllNodeModelsEndpoint", func() {
It("returns an empty list when no models are loaded", func() {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := ListAllNodeModelsEndpoint(registry)
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusOK))
var list []nodes.NodeModel
Expect(json.Unmarshal(rec.Body.Bytes(), &list)).To(Succeed())
Expect(list).To(BeEmpty())
})
It("returns loaded models across healthy nodes", func() {
ctx := context.Background()
Expect(registry.Register(ctx, &nodes.BackendNode{
ID: "n1", Name: "alpha", Address: "10.0.0.1:50051", Status: nodes.StatusHealthy,
}, true)).To(Succeed())
Expect(registry.SetNodeModel(ctx, "n1", "llama-3.3", 0, "loaded", "10.0.0.1:50051", 0)).To(Succeed())
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := ListAllNodeModelsEndpoint(registry)
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusOK))
var list []nodes.NodeModel
Expect(json.Unmarshal(rec.Body.Bytes(), &list)).To(Succeed())
Expect(list).To(HaveLen(1))
Expect(list[0].ModelName).To(Equal("llama-3.3"))
Expect(list[0].NodeID).To(Equal("n1"))
})
})
})