Skip to content

Commit 2da1a4d

Browse files
committed
feat(distributed): per-node backend installation from the gallery
In distributed mode the Backends gallery used to fan every install out to every worker — fine for auto-resolving (meta) backends like llama-cpp where each node picks its own variant, but wrong for hardware-specific builds like cpu-llama-cpp that would silently land on every GPU node. Adds a node-targeted install path through the existing POST /api/nodes/:id/backends/install plumbing, with two entry points: - Backends gallery row gets a split-button in distributed mode. Auto- resolving keeps "Install on all nodes" as the primary; chevron menu opens the picker. Hardware-specific routes the primary directly to the picker — no fan-out path on the row. - Nodes-page drawer gets a "+ Add backend" button that navigates to /app/backends?target=<node-id>; the gallery scopes itself to that node (banner, single per-row install button, Reinstall/Remove for already- installed). One gallery, two scopes — no second UI to maintain. The picker (new NodeInstallPicker) shows a 3-state suitability column (Compatible / Override / Installed), an auto-expanding variant override disclosure that fires when selected nodes have no working GPU, parallel per-node installs with inline status and Retry-failed-nodes, and a mismatch confirm that names the consequence on the button itself. A 409 fan-out guard on /api/backends/apply protects CLI/Terraform/script users from the same footgun: hardware-specific installs in distributed mode now return code "concrete_backend_requires_target" with a human- readable error and a meta_alternative pointer. The gallery list payload now surfaces capabilities, metaBackendFor and per-row nodes (NodeBackendRef) so the picker and the new Nodes column have everything they need without re-walking the gallery client-side. GODEBUG=netdns=go is set on the compose services because the cgo DNS resolver follows the container's nsswitch.conf to host systemd-resolved (127.0.0.53), unreachable from inside the container; the pure-Go resolver reads /etc/resolv.conf directly and uses Docker's embedded DNS. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-7[1m] [Edit] [Bash] [Read] [Write]
1 parent 988430c commit 2da1a4d

14 files changed

Lines changed: 1172 additions & 41 deletions

File tree

core/http/endpoints/localai/backend.go

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,26 @@ func (mgs *BackendEndpointService) GetAllStatusEndpoint() echo.HandlerFunc {
9898
// @Param request body GalleryBackend true "query params"
9999
// @Success 200 {object} schema.BackendResponse "Response"
100100
// @Router /backends/apply [post]
101-
func (mgs *BackendEndpointService) ApplyBackendEndpoint() echo.HandlerFunc {
101+
func (mgs *BackendEndpointService) ApplyBackendEndpoint(systemState *system.SystemState) echo.HandlerFunc {
102102
return func(c echo.Context) error {
103103
input := new(GalleryBackend)
104104
// Get input data from the request body
105105
if err := c.Bind(input); err != nil {
106106
return err
107107
}
108108

109+
// In distributed mode, refuse to fan out a hardware-specific build to
110+
// every node — a CPU build landing on a GPU cluster is almost always
111+
// wrong, and the silent footgun is exactly what this guard exists for.
112+
// Auto-resolving (meta) backends are fine because each node picks its
113+
// own variant. Tooling can recover by hitting
114+
// POST /api/nodes/{id}/backends/install per target node.
115+
if mgs.backendApplier.BackendManager().IsDistributed() && input.ID != "" {
116+
if guard := concreteFanOutGuard(c, mgs.galleries, systemState, input.ID); guard != nil {
117+
return guard
118+
}
119+
}
120+
109121
uuid, err := uuid.NewUUID()
110122
if err != nil {
111123
return err
@@ -120,6 +132,66 @@ func (mgs *BackendEndpointService) ApplyBackendEndpoint() echo.HandlerFunc {
120132
}
121133
}
122134

135+
// concreteFanOutGuard returns a 409 response if the requested backend is a
136+
// hardware-specific build (not auto-resolving / meta) and we are in
137+
// distributed mode. It looks up the backend in the configured galleries; if
138+
// the lookup itself fails (gallery unreachable, name not found), the guard
139+
// stays out of the way and lets the install enqueue normally — a missing
140+
// name will surface from the worker as a clearer error than the guard could
141+
// produce here. The response body deliberately speaks human, with `code` and
142+
// `meta_alternative` as the programmatic contract for tooling.
143+
func concreteFanOutGuard(c echo.Context, galleries []config.Gallery, systemState *system.SystemState, backendID string) error {
144+
// Use the unfiltered listing because in distributed mode the frontend's
145+
// hardware is irrelevant — the install targets workers, not us — and the
146+
// filtered list would hide variants that don't match the frontend host
147+
// (e.g. a CUDA build on a CPU-only frontend), preventing the guard from
148+
// firing for exactly the cases it's meant to protect against.
149+
available, err := gallery.AvailableBackendsUnfiltered(galleries, systemState)
150+
if err != nil {
151+
return nil
152+
}
153+
requested := available.FindByName(backendID)
154+
if requested == nil || requested.IsMeta() {
155+
return nil
156+
}
157+
158+
// Try to find an auto-resolving (meta) backend that has this concrete
159+
// variant in its CapabilitiesMap, so we can suggest it as a one-shot
160+
// alternative. Optional — empty string is fine if no parent exists.
161+
metaAlternative := ""
162+
for _, b := range available {
163+
if !b.IsMeta() {
164+
continue
165+
}
166+
for _, concrete := range b.CapabilitiesMap {
167+
if concrete == backendID {
168+
metaAlternative = b.Name
169+
break
170+
}
171+
}
172+
if metaAlternative != "" {
173+
break
174+
}
175+
}
176+
177+
msg := fmt.Sprintf(
178+
"Backend %q is a hardware-specific build and won't run correctly on every node in this cluster. In distributed mode, install it on specific nodes:\n\n POST /api/nodes/{node_id}/backends/install\n {\"backend\": %q}",
179+
backendID, backendID,
180+
)
181+
if metaAlternative != "" {
182+
msg += fmt.Sprintf(
183+
"\n\nTo install across all nodes, use the auto-resolving backend %q — each node picks its own variant based on its hardware.",
184+
metaAlternative,
185+
)
186+
}
187+
188+
return c.JSON(409, map[string]any{
189+
"error": msg,
190+
"code": "concrete_backend_requires_target",
191+
"meta_alternative": metaAlternative,
192+
})
193+
}
194+
123195
// DeleteBackendEndpoint lets delete backends from a LocalAI instance
124196
// @Summary delete backends from LocalAI.
125197
// @Tags backends

core/http/endpoints/localai/nodes.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,9 @@ func ResumeNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
363363
}
364364

365365
// InstallBackendOnNodeEndpoint triggers backend installation on a worker node via NATS.
366+
// Backend can be either a gallery ID (resolved against BackendGalleries) or a
367+
// direct URI install (URI + Name + optional Alias) — same shape as the
368+
// standalone /api/backends/install-external path, just scoped to one node.
366369
func InstallBackendOnNodeEndpoint(unloader nodes.NodeCommandSender) echo.HandlerFunc {
367370
return func(c echo.Context) error {
368371
if unloader == nil {
@@ -372,17 +375,24 @@ func InstallBackendOnNodeEndpoint(unloader nodes.NodeCommandSender) echo.Handler
372375
var req struct {
373376
Backend string `json:"backend"`
374377
BackendGalleries string `json:"backend_galleries,omitempty"`
378+
URI string `json:"uri,omitempty"`
379+
Name string `json:"name,omitempty"`
380+
Alias string `json:"alias,omitempty"`
375381
}
376-
if err := c.Bind(&req); err != nil || req.Backend == "" {
377-
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "backend name required"))
382+
if err := c.Bind(&req); err != nil {
383+
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "invalid request body"))
384+
}
385+
// Either a gallery backend name or a direct URI must be supplied.
386+
if req.Backend == "" && req.URI == "" {
387+
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "backend name or uri required"))
378388
}
379-
reply, err := unloader.InstallBackend(nodeID, req.Backend, "", req.BackendGalleries, "", "", "")
389+
reply, err := unloader.InstallBackend(nodeID, req.Backend, "", req.BackendGalleries, req.URI, req.Name, req.Alias)
380390
if err != nil {
381-
xlog.Error("Failed to install backend on node", "node", nodeID, "backend", req.Backend, "error", err)
391+
xlog.Error("Failed to install backend on node", "node", nodeID, "backend", req.Backend, "uri", req.URI, "error", err)
382392
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to install backend on node"))
383393
}
384394
if !reply.Success {
385-
xlog.Error("Backend install failed on node", "node", nodeID, "backend", req.Backend, "error", reply.Error)
395+
xlog.Error("Backend install failed on node", "node", nodeID, "backend", req.Backend, "uri", req.URI, "error", reply.Error)
386396
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "backend installation failed"))
387397
}
388398
return c.JSON(http.StatusOK, map[string]string{"message": "backend installed"})

0 commit comments

Comments
 (0)