mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-02 12:26:49 -04:00
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>
This commit is contained in:
@@ -65,6 +65,10 @@ type BackendEndpointService struct {
|
||||
|
||||
type GalleryBackend struct {
|
||||
ID string `json:"id"`
|
||||
// Force reinstalls the backend even when it is already installed and
|
||||
// runnable. Off by default so apply stays idempotent for supervising
|
||||
// apps that ensure their backend on every boot.
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
func CreateBackendEndpointService(galleries []config.Gallery, systemState *system.SystemState, backendApplier *galleryop.GalleryService, upgradeChecker UpgradeInfoProvider) BackendEndpointService {
|
||||
@@ -103,7 +107,9 @@ func (mgs *BackendEndpointService) GetAllStatusEndpoint() echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyBackendEndpoint installs a new backend to a LocalAI instance
|
||||
// ApplyBackendEndpoint installs a new backend to a LocalAI instance. The op is
|
||||
// idempotent: an already-installed, runnable backend is left alone unless the
|
||||
// request sets "force": true (explicit reinstall).
|
||||
// @Summary Install backends to LocalAI.
|
||||
// @Tags backends
|
||||
// @Param request body GalleryBackend true "query params"
|
||||
@@ -137,6 +143,7 @@ func (mgs *BackendEndpointService) ApplyBackendEndpoint(systemState *system.Syst
|
||||
ID: uuid.String(),
|
||||
GalleryElementName: input.ID,
|
||||
Galleries: mgs.galleries,
|
||||
Force: input.Force,
|
||||
}
|
||||
|
||||
return c.JSON(200, schema.BackendResponse{ID: uuid.String(), StatusURL: fmt.Sprintf("%sbackends/jobs/%s", middleware.BaseURL(c), uuid.String())})
|
||||
|
||||
87
core/http/endpoints/localai/backend_apply_test.go
Normal file
87
core/http/endpoints/localai/backend_apply_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package localai_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
. "github.com/mudler/LocalAI/core/http/endpoints/localai"
|
||||
"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"
|
||||
)
|
||||
|
||||
// POST /backends/apply must be idempotent by default: supervising apps call it
|
||||
// on every boot to ensure a backend exists, and forcing a reinstall there
|
||||
// re-downloads the whole artifact each time. Reinstall stays available behind
|
||||
// the explicit force flag.
|
||||
var _ = Describe("POST /backends/apply force plumbing", func() {
|
||||
var (
|
||||
app *echo.Echo
|
||||
gs *galleryop.GalleryService
|
||||
tmpDir string
|
||||
received chan galleryop.ManagementOp[gallery.GalleryBackend, any]
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
app = echo.New()
|
||||
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "backends-apply-test-*")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
systemState, err := system.GetSystemState(system.WithBackendPath(tmpDir))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
appConfig := &config.ApplicationConfig{SystemState: systemState}
|
||||
|
||||
// The service is deliberately not started: the test reads the op off
|
||||
// the (unbuffered) channel itself.
|
||||
gs = galleryop.NewGalleryService(appConfig, model.NewModelLoader(systemState))
|
||||
svc := CreateBackendEndpointService(nil, systemState, gs, nil)
|
||||
app.POST("/backends/apply", svc.ApplyBackendEndpoint(systemState))
|
||||
|
||||
received = make(chan galleryop.ManagementOp[gallery.GalleryBackend, any], 1)
|
||||
go func() {
|
||||
op := <-gs.BackendGalleryChannel
|
||||
received <- op
|
||||
}()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
Expect(os.RemoveAll(tmpDir)).To(Succeed())
|
||||
})
|
||||
|
||||
apply := func(body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/backends/apply", strings.NewReader(body))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
It("enqueues a non-forced op by default", func() {
|
||||
rec := apply(`{"id":"llama-cpp"}`)
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var op galleryop.ManagementOp[gallery.GalleryBackend, any]
|
||||
Eventually(received).Should(Receive(&op))
|
||||
Expect(op.GalleryElementName).To(Equal("llama-cpp"))
|
||||
Expect(op.Force).To(BeFalse())
|
||||
})
|
||||
|
||||
It("enqueues a forced op when the request sets force", func() {
|
||||
rec := apply(`{"id":"llama-cpp","force":true}`)
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var op galleryop.ManagementOp[gallery.GalleryBackend, any]
|
||||
Eventually(received).Should(Receive(&op))
|
||||
Expect(op.GalleryElementName).To(Equal("llama-cpp"))
|
||||
Expect(op.Force).To(BeTrue())
|
||||
})
|
||||
})
|
||||
@@ -1243,6 +1243,9 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
Galleries: appConfig.BackendGalleries,
|
||||
Context: ctx,
|
||||
CancelFunc: cancelFunc,
|
||||
// The React UI's "Reinstall backend" action reuses this route, so
|
||||
// the op must force even when the backend is already installed.
|
||||
Force: true,
|
||||
}
|
||||
// Store cancellation function immediately so queued operations can be cancelled
|
||||
galleryService.StoreCancellation(uid, cancelFunc)
|
||||
|
||||
97
core/services/galleryop/backend_force_test.go
Normal file
97
core/services/galleryop/backend_force_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package galleryop_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
"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"
|
||||
)
|
||||
|
||||
// The install op must be idempotent unless Force is set: API clients call
|
||||
// POST /backends/apply on every boot to make sure the backend exists, and an
|
||||
// unconditional force here re-downloads the whole backend artifact each time.
|
||||
// Reinstall is an explicit, opted-in action.
|
||||
var _ = Describe("LocalBackendManager force semantics", func() {
|
||||
var (
|
||||
backendsDir string
|
||||
srcDir string
|
||||
mgr *galleryop.LocalBackendManager
|
||||
)
|
||||
|
||||
const installedRunSh = "#!/bin/sh\necho installed\n"
|
||||
const galleryRunSh = "#!/bin/sh\necho from-gallery\n"
|
||||
|
||||
installedRunShPath := func() string {
|
||||
return filepath.Join(backendsDir, "test-backend", "run.sh")
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
backendsDir, err = os.MkdirTemp("", "force-backends-*")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
srcDir, err = os.MkdirTemp("", "force-src-*")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// The gallery serves test-backend from a plain directory (offline).
|
||||
// The gallery yaml itself must live under the backends path: file://
|
||||
// galleries outside the trusted root are rejected by the downloader.
|
||||
Expect(os.WriteFile(filepath.Join(srcDir, "run.sh"), []byte(galleryRunSh), 0o755)).To(Succeed())
|
||||
entries := []map[string]any{{"name": "test-backend", "uri": srcDir}}
|
||||
data, err := yaml.Marshal(entries)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
galleryYAML := filepath.Join(backendsDir, "gallery.yaml")
|
||||
Expect(os.WriteFile(galleryYAML, data, 0o644)).To(Succeed())
|
||||
|
||||
// test-backend is already installed, with content that differs from
|
||||
// the gallery's so a reinstall is observable.
|
||||
Expect(os.MkdirAll(filepath.Join(backendsDir, "test-backend"), 0o755)).To(Succeed())
|
||||
Expect(os.WriteFile(installedRunShPath(), []byte(installedRunSh), 0o755)).To(Succeed())
|
||||
|
||||
systemState, err := system.GetSystemState(system.WithBackendPath(backendsDir))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
appConfig := &config.ApplicationConfig{
|
||||
SystemState: systemState,
|
||||
BackendGalleries: []config.Gallery{{Name: "test", URL: "file://" + galleryYAML}},
|
||||
}
|
||||
mgr = galleryop.NewLocalBackendManager(appConfig, model.NewModelLoader(systemState))
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
Expect(os.RemoveAll(backendsDir)).To(Succeed())
|
||||
Expect(os.RemoveAll(srcDir)).To(Succeed())
|
||||
})
|
||||
|
||||
It("skips an already-installed backend when Force is not set", func() {
|
||||
op := &galleryop.ManagementOp[gallery.GalleryBackend, any]{
|
||||
ID: "op-1",
|
||||
GalleryElementName: "test-backend",
|
||||
}
|
||||
Expect(mgr.InstallBackend(context.Background(), op, nil)).To(Succeed())
|
||||
|
||||
content, err := os.ReadFile(installedRunShPath())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(content)).To(Equal(installedRunSh), "install without Force must not overwrite an installed backend")
|
||||
})
|
||||
|
||||
It("reinstalls an already-installed backend when Force is set", func() {
|
||||
op := &galleryop.ManagementOp[gallery.GalleryBackend, any]{
|
||||
ID: "op-2",
|
||||
GalleryElementName: "test-backend",
|
||||
Force: true,
|
||||
}
|
||||
Expect(mgr.InstallBackend(context.Background(), op, nil)).To(Succeed())
|
||||
|
||||
content, err := os.ReadFile(installedRunShPath())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(content)).To(Equal(galleryRunSh), "install with Force must overwrite the installed backend")
|
||||
})
|
||||
})
|
||||
@@ -112,8 +112,11 @@ func (b *LocalBackendManager) InstallBackend(ctx context.Context, op *Management
|
||||
return InstallExternalBackend(ctx, b.backendGalleries, b.systemState, b.modelLoader,
|
||||
progressCb, op.ExternalURI, op.ExternalName, op.ExternalAlias, b.requireBackendIntegrity)
|
||||
}
|
||||
// op.Force distinguishes an explicit reinstall from an idempotent
|
||||
// "make sure it's installed" op; the latter must not re-download an
|
||||
// already-runnable backend (supervisors apply on every boot).
|
||||
return gallery.InstallBackendFromGallery(ctx, b.backendGalleries, b.systemState,
|
||||
b.modelLoader, op.GalleryElementName, progressCb, true, b.requireBackendIntegrity)
|
||||
b.modelLoader, op.GalleryElementName, progressCb, op.Force, b.requireBackendIntegrity)
|
||||
}
|
||||
|
||||
func (b *LocalBackendManager) IsDistributed() bool { return false }
|
||||
|
||||
@@ -45,6 +45,13 @@ type ManagementOp[T any, E any] struct {
|
||||
|
||||
// Upgrade is true if this is an upgrade operation (not a fresh install)
|
||||
Upgrade bool
|
||||
|
||||
// Force reinstalls a backend even when it is already installed and
|
||||
// runnable. Without it a backend install op is idempotent — API clients
|
||||
// that ensure a backend exists on every boot must not trigger a full
|
||||
// artifact re-download each time. The UI's explicit "Reinstall backend"
|
||||
// action sets it.
|
||||
Force bool
|
||||
}
|
||||
|
||||
type OpStatus struct {
|
||||
|
||||
@@ -3605,6 +3605,10 @@ const docTemplate = `{
|
||||
"localai.GalleryBackend": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"force": {
|
||||
"description": "Force reinstalls the backend even when it is already installed and\nrunnable. Off by default so apply stays idempotent for supervising\napps that ensure their backend on every boot.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -3602,6 +3602,10 @@
|
||||
"localai.GalleryBackend": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"force": {
|
||||
"description": "Force reinstalls the backend even when it is already installed and\nrunnable. Off by default so apply stays idempotent for supervising\napps that ensure their backend on every boot.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -303,6 +303,12 @@ definitions:
|
||||
type: object
|
||||
localai.GalleryBackend:
|
||||
properties:
|
||||
force:
|
||||
description: |-
|
||||
Force reinstalls the backend even when it is already installed and
|
||||
runnable. Off by default so apply stays idempotent for supervising
|
||||
apps that ensure their backend on every boot.
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
type: object
|
||||
|
||||
Reference in New Issue
Block a user