Files
LocalAI/core/services/galleryop/backends_test.go
LocalAI [bot] 6eea3ef2ac fix(backends): make backend install ops idempotent unless forced (#10643)
* fix(backends): make backend install ops idempotent unless forced

POST /backends/apply hardcoded force=true through
LocalBackendManager.InstallBackend, so applying an already-installed
backend re-downloaded and re-extracted the whole artifact every time.
API clients that ensure a backend exists at startup paid a full OCI
image pull on every boot.

Backend install ops now default to non-forced — an installed, runnable
backend short-circuits (the orphaned-meta reinstall path in
InstallBackendFromGallery is preserved) — and reinstall stays available:

- ManagementOp gains a Force field; the local manager passes it through
  instead of hardcoding true.
- /backends/apply accepts an optional "force" boolean in the body.
- The React UI install route keeps forcing, since its button doubles as
  the explicit "Reinstall backend" action.

Distributed installs already behaved this way (workers skip when the
binary exists unless force is set); this aligns single-node behavior.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(backends): don't force-reinstall LOCALAI_EXTERNAL_BACKENDS on boot

The startup loop for LOCALAI_EXTERNAL_BACKENDS runs
InstallExternalBackend for each listed backend on every boot, and its
gallery-name path hardcoded force=true — so every start re-downloaded
and re-extracted each listed backend's OCI image even when it was
installed and runnable. Supervising apps that list several backends
paid several full OCI pulls per launch.

Give InstallExternalBackend an explicit force parameter (it only
affects the gallery-name fallback; URI installs always write) and pass:

- false from the boot loop and `local-ai backends install` (idempotent
  ensure — `backends upgrade` is the refresh path),
- op.Force from the local manager's external-URI op,
- the request's force on the worker install path and true on its
  upgrade path (behavior unchanged).

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-02 19:16:29 +02:00

262 lines
7.4 KiB
Go

package galleryop_test
import (
"context"
"os"
"path/filepath"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
var _ = Describe("InstallExternalBackend", func() {
var (
tempDir string
galleries []config.Gallery
ml *model.ModelLoader
systemState *system.SystemState
)
BeforeEach(func() {
var err error
tempDir, err = os.MkdirTemp("", "backends-service-test-*")
Expect(err).NotTo(HaveOccurred())
systemState, err = system.GetSystemState(system.WithBackendPath(tempDir))
Expect(err).NotTo(HaveOccurred())
ml = model.NewModelLoader(systemState)
// Setup test gallery
galleries = []config.Gallery{
{
Name: "test-gallery",
URL: "file://" + filepath.Join(tempDir, "test-gallery.yaml"),
},
}
})
AfterEach(func() {
os.RemoveAll(tempDir)
})
Context("with gallery backend name", func() {
BeforeEach(func() {
// Create a test gallery file with a test backend
testBackend := []map[string]any{
{
"name": "test-backend",
"uri": "https://gist.githubusercontent.com/mudler/71d5376bc2aa168873fa519fa9f4bd56/raw/testbackend/run.sh",
},
}
data, err := yaml.Marshal(testBackend)
Expect(err).NotTo(HaveOccurred())
err = os.WriteFile(filepath.Join(tempDir, "test-gallery.yaml"), data, 0644)
Expect(err).NotTo(HaveOccurred())
})
It("should fail when name or alias is provided for gallery backend", func() {
err := galleryop.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
"test-backend", // gallery name
"custom-name", // name should not be allowed
"",
false, // force
false,
)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("specifying a name or alias is not supported for gallery backends"))
})
It("should fail when backend is not found in gallery", func() {
err := galleryop.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
"non-existent-backend",
"",
"",
false, // force
false,
)
Expect(err).To(HaveOccurred())
})
})
Context("with OCI image", func() {
It("should fail when name is not provided for OCI image", func() {
err := galleryop.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
"oci://quay.io/mudler/tests:localai-backend-test",
"", // name is required for OCI images
"",
false, // force
false,
)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("specifying a name is required for OCI images"))
})
})
Context("with directory path", func() {
var testBackendPath string
BeforeEach(func() {
// Create a test backend directory with required files
testBackendPath = filepath.Join(tempDir, "source-backend")
err := os.MkdirAll(testBackendPath, 0750)
Expect(err).NotTo(HaveOccurred())
// Create run.sh
err = os.WriteFile(filepath.Join(testBackendPath, "run.sh"), []byte("#!/bin/bash\necho test"), 0755)
Expect(err).NotTo(HaveOccurred())
})
It("should infer name from directory path when name is not provided", func() {
// This test verifies that the function attempts to install using the directory name
// The actual installation may fail due to test environment limitations
err := galleryop.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
testBackendPath,
"", // name should be inferred as "source-backend"
"",
false, // force
false,
)
// The function should at least attempt to install with the inferred name
// Even if it fails for other reasons, it shouldn't fail due to missing name
if err != nil {
Expect(err.Error()).NotTo(ContainSubstring("name is required"))
}
})
It("should use provided name when specified", func() {
err := galleryop.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
testBackendPath,
"custom-backend-name",
"",
false, // force
false,
)
// The function should use the provided name
if err != nil {
Expect(err.Error()).NotTo(ContainSubstring("name is required"))
}
})
It("should support alias when provided", func() {
err := galleryop.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
testBackendPath,
"custom-backend-name",
"custom-alias",
false, // force
false,
)
// The function should accept alias for directory paths
if err != nil {
Expect(err.Error()).NotTo(ContainSubstring("alias is not supported"))
}
})
})
})
var _ = Describe("ManagementOp with External Backend", func() {
It("should have external backend fields in ManagementOp", func() {
// Test that the ManagementOp struct has the new external backend fields
op := galleryop.ManagementOp[string, string]{
ExternalURI: "oci://example.com/backend:latest",
ExternalName: "test-backend",
ExternalAlias: "test-alias",
}
Expect(op.ExternalURI).To(Equal("oci://example.com/backend:latest"))
Expect(op.ExternalName).To(Equal("test-backend"))
Expect(op.ExternalAlias).To(Equal("test-alias"))
})
Context("TargetNodeID field", func() {
It("defaults to empty string", func() {
op := galleryop.ManagementOp[string, string]{
ExternalURI: "oci://example.com/backend:latest",
}
Expect(op.TargetNodeID).To(BeEmpty())
})
It("preserves TargetNodeID across a channel send", func() {
ch := make(chan galleryop.ManagementOp[string, string], 1)
ch <- galleryop.ManagementOp[string, string]{
GalleryElementName: "llama-cpp",
TargetNodeID: "node-abc-123",
}
received := <-ch
Expect(received.TargetNodeID).To(Equal("node-abc-123"))
Expect(received.GalleryElementName).To(Equal("llama-cpp"))
})
})
Describe("NodeScopedKey", func() {
It("builds a unique key per (nodeID, backend) pair", func() {
Expect(galleryop.NodeScopedKey("node-a", "llama-cpp")).To(Equal("node:node-a:llama-cpp"))
Expect(galleryop.NodeScopedKey("node-b", "llama-cpp")).To(Equal("node:node-b:llama-cpp"))
Expect(galleryop.NodeScopedKey("node-a", "vllm")).To(Equal("node:node-a:vllm"))
})
It("handles backend names containing colons", func() {
// Gallery IDs sometimes look like "official@llama-cpp"; nodeIDs are UUIDs
// without colons, but the backend slug may contain anything. Splitting on
// the first colon after the prefix MUST yield the full backend back.
key := galleryop.NodeScopedKey("node-1", "official@llama-cpp:v2")
node, backend, ok := galleryop.ParseNodeScopedKey(key)
Expect(ok).To(BeTrue())
Expect(node).To(Equal("node-1"))
Expect(backend).To(Equal("official@llama-cpp:v2"))
})
It("rejects keys without the node prefix", func() {
_, _, ok := galleryop.ParseNodeScopedKey("llama-cpp")
Expect(ok).To(BeFalse())
_, _, ok = galleryop.ParseNodeScopedKey("official@llama-cpp")
Expect(ok).To(BeFalse())
})
It("rejects malformed node-prefixed keys", func() {
_, _, ok := galleryop.ParseNodeScopedKey("node:only-one-segment")
Expect(ok).To(BeFalse())
})
It("rejects keys with an empty nodeID segment", func() {
_, _, ok := galleryop.ParseNodeScopedKey("node::llama-cpp")
Expect(ok).To(BeFalse())
})
})
})