From 6eea3ef2ac912a1fc73f60a65866ddbb63cd4d7c Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:16:29 +0200 Subject: [PATCH] fix(backends): make backend install ops idempotent unless forced (#10643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/application/startup.go | 2 +- core/cli/backends.go | 2 +- core/http/endpoints/localai/backend.go | 9 +- .../endpoints/localai/backend_apply_test.go | 87 +++++++++++++ core/http/routes/ui_api.go | 3 + core/services/galleryop/backend_force_test.go | 114 ++++++++++++++++++ core/services/galleryop/backends.go | 9 +- core/services/galleryop/backends_test.go | 6 + core/services/galleryop/managers_local.go | 7 +- core/services/galleryop/operation.go | 7 ++ core/services/worker/install.go | 4 +- swagger/docs.go | 4 + swagger/swagger.json | 4 + swagger/swagger.yaml | 6 + 14 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 core/http/endpoints/localai/backend_apply_test.go create mode 100644 core/services/galleryop/backend_force_test.go diff --git a/core/application/startup.go b/core/application/startup.go index 25d965834..cf341dfa6 100644 --- a/core/application/startup.go +++ b/core/application/startup.go @@ -369,7 +369,7 @@ func New(opts ...config.AppOption) (*Application, error) { } for _, backend := range options.ExternalBackends { - if err := galleryop.InstallExternalBackend(options.Context, options.BackendGalleries, options.SystemState, application.ModelLoader(), nil, backend, "", "", options.RequireBackendIntegrity); err != nil { + if err := galleryop.InstallExternalBackend(options.Context, options.BackendGalleries, options.SystemState, application.ModelLoader(), nil, backend, "", "", false, options.RequireBackendIntegrity); err != nil { xlog.Error("error installing external backend", "error", err) } } diff --git a/core/cli/backends.go b/core/cli/backends.go index 88ecc2321..91449032d 100644 --- a/core/cli/backends.go +++ b/core/cli/backends.go @@ -127,7 +127,7 @@ func (bi *BackendsInstall) Run(ctx *cliContext.Context) error { } modelLoader := model.NewModelLoader(systemState) - err = galleryop.InstallExternalBackend(context.Background(), galleries, systemState, modelLoader, progressCallback, bi.BackendArgs, bi.Name, bi.Alias, bi.RequireBackendIntegrity) + err = galleryop.InstallExternalBackend(context.Background(), galleries, systemState, modelLoader, progressCallback, bi.BackendArgs, bi.Name, bi.Alias, false, bi.RequireBackendIntegrity) if err != nil { return err } diff --git a/core/http/endpoints/localai/backend.go b/core/http/endpoints/localai/backend.go index 6ea911363..8de9028ff 100644 --- a/core/http/endpoints/localai/backend.go +++ b/core/http/endpoints/localai/backend.go @@ -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())}) diff --git a/core/http/endpoints/localai/backend_apply_test.go b/core/http/endpoints/localai/backend_apply_test.go new file mode 100644 index 000000000..086bdcbb9 --- /dev/null +++ b/core/http/endpoints/localai/backend_apply_test.go @@ -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()) + }) +}) diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go index d9c99c6b9..03edd7fef 100644 --- a/core/http/routes/ui_api.go +++ b/core/http/routes/ui_api.go @@ -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) diff --git a/core/services/galleryop/backend_force_test.go b/core/services/galleryop/backend_force_test.go new file mode 100644 index 000000000..d53999f3f --- /dev/null +++ b/core/services/galleryop/backend_force_test.go @@ -0,0 +1,114 @@ +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 + systemState *system.SystemState + ml *model.ModelLoader + ) + + 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}}, + } + ml = model.NewModelLoader(systemState) + mgr = galleryop.NewLocalBackendManager(appConfig, ml) + }) + + 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") + }) + + // The LOCALAI_EXTERNAL_BACKENDS boot loop goes through + // InstallExternalBackend's gallery-name path on EVERY startup; it must not + // force, or each boot re-downloads every listed backend. + It("skips an already-installed backend on the non-forced external gallery-name path", func() { + err := galleryop.InstallExternalBackend(context.Background(), + []config.Gallery{{Name: "test", URL: "file://" + filepath.Join(backendsDir, "gallery.yaml")}}, + systemState, ml, nil, "test-backend", "", "", false, false) + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(installedRunShPath()) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal(installedRunSh), "non-forced external install must not overwrite an installed backend") + }) +}) diff --git a/core/services/galleryop/backends.go b/core/services/galleryop/backends.go index 4576cf9cb..0ad1e64c9 100644 --- a/core/services/galleryop/backends.go +++ b/core/services/galleryop/backends.go @@ -144,7 +144,12 @@ func (g *GalleryService) backendHandler(op *ManagementOp[gallery.GalleryBackend, // InstallExternalBackend installs a backend from an external source (OCI image, URL, or path). // This method contains the logic to detect the input type and call the appropriate installation function. // It can be used by both CLI and Web UI for installing backends from external sources. -func InstallExternalBackend(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState, modelLoader *model.ModelLoader, downloadStatus func(string, string, string, float64), backend, name, alias string, requireIntegrity bool) error { +// +// force applies only to the gallery-name fallback: a URI install (dir/OCI/file) +// always writes, but a bare gallery name is an "ensure installed" — the +// LOCALAI_EXTERNAL_BACKENDS boot loop runs it on every start and must not +// re-download an installed, runnable backend. +func InstallExternalBackend(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState, modelLoader *model.ModelLoader, downloadStatus func(string, string, string, float64), backend, name, alias string, force, requireIntegrity bool) error { uri := downloader.URI(backend) switch { case uri.LooksLikeDir(): @@ -202,7 +207,7 @@ func InstallExternalBackend(ctx context.Context, galleries []config.Gallery, sys if name != "" || alias != "" { return fmt.Errorf("specifying a name or alias is not supported for gallery backends") } - err := gallery.InstallBackendFromGallery(ctx, galleries, systemState, modelLoader, backend, downloadStatus, true, requireIntegrity) + err := gallery.InstallBackendFromGallery(ctx, galleries, systemState, modelLoader, backend, downloadStatus, force, requireIntegrity) if err != nil { return fmt.Errorf("error installing backend %s: %w", backend, err) } diff --git a/core/services/galleryop/backends_test.go b/core/services/galleryop/backends_test.go index c9e0fbc1c..a7a7b70a3 100644 --- a/core/services/galleryop/backends_test.go +++ b/core/services/galleryop/backends_test.go @@ -70,6 +70,7 @@ var _ = Describe("InstallExternalBackend", func() { "test-backend", // gallery name "custom-name", // name should not be allowed "", + false, // force false, ) Expect(err).To(HaveOccurred()) @@ -86,6 +87,7 @@ var _ = Describe("InstallExternalBackend", func() { "non-existent-backend", "", "", + false, // force false, ) Expect(err).To(HaveOccurred()) @@ -103,6 +105,7 @@ var _ = Describe("InstallExternalBackend", func() { "oci://quay.io/mudler/tests:localai-backend-test", "", // name is required for OCI images "", + false, // force false, ) Expect(err).To(HaveOccurred()) @@ -136,6 +139,7 @@ var _ = Describe("InstallExternalBackend", func() { testBackendPath, "", // name should be inferred as "source-backend" "", + false, // force false, ) // The function should at least attempt to install with the inferred name @@ -155,6 +159,7 @@ var _ = Describe("InstallExternalBackend", func() { testBackendPath, "custom-backend-name", "", + false, // force false, ) // The function should use the provided name @@ -173,6 +178,7 @@ var _ = Describe("InstallExternalBackend", func() { testBackendPath, "custom-backend-name", "custom-alias", + false, // force false, ) // The function should accept alias for directory paths diff --git a/core/services/galleryop/managers_local.go b/core/services/galleryop/managers_local.go index 9d1b42417..338a097b4 100644 --- a/core/services/galleryop/managers_local.go +++ b/core/services/galleryop/managers_local.go @@ -110,10 +110,13 @@ func (b *LocalBackendManager) CheckUpgrades(ctx context.Context) (map[string]gal func (b *LocalBackendManager) InstallBackend(ctx context.Context, op *ManagementOp[gallery.GalleryBackend, any], progressCb ProgressCallback) error { if op.ExternalURI != "" { return InstallExternalBackend(ctx, b.backendGalleries, b.systemState, b.modelLoader, - progressCb, op.ExternalURI, op.ExternalName, op.ExternalAlias, b.requireBackendIntegrity) + progressCb, op.ExternalURI, op.ExternalName, op.ExternalAlias, op.Force, 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 } diff --git a/core/services/galleryop/operation.go b/core/services/galleryop/operation.go index d4c12e8bc..1ac71dfd9 100644 --- a/core/services/galleryop/operation.go +++ b/core/services/galleryop/operation.go @@ -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 { diff --git a/core/services/worker/install.go b/core/services/worker/install.go index f7b65acf8..bcc7cc9b0 100644 --- a/core/services/worker/install.go +++ b/core/services/worker/install.go @@ -134,7 +134,7 @@ func (s *backendSupervisor) installBackend(req messaging.BackendInstallRequest, if req.URI != "" { xlog.Info("Installing backend from external URI", "backend", req.Backend, "uri", req.URI, "force", force) if err := galleryop.InstallExternalBackend( - context.Background(), galleries, s.systemState, s.ml, downloadCb, req.URI, req.Name, req.Alias, s.cfg.RequireBackendIntegrity, + context.Background(), galleries, s.systemState, s.ml, downloadCb, req.URI, req.Name, req.Alias, force, s.cfg.RequireBackendIntegrity, ); err != nil { return "", fmt.Errorf("installing backend from gallery: %w", err) } @@ -201,7 +201,7 @@ func (s *backendSupervisor) upgradeBackend(req messaging.BackendUpgradeRequest) if req.URI != "" { xlog.Info("Upgrading backend from external URI", "backend", req.Backend, "uri", req.URI) if err := galleryop.InstallExternalBackend( - context.Background(), galleries, s.systemState, s.ml, downloadCb, req.URI, req.Name, req.Alias, s.cfg.RequireBackendIntegrity, + context.Background(), galleries, s.systemState, s.ml, downloadCb, req.URI, req.Name, req.Alias, true, s.cfg.RequireBackendIntegrity, ); err != nil { return fmt.Errorf("upgrading backend from external URI: %w", err) } diff --git a/swagger/docs.go b/swagger/docs.go index e7b6b9acf..3bcbf569f 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -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" } diff --git a/swagger/swagger.json b/swagger/swagger.json index 4f9695bb1..212b62c2f 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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" } diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index cbb17d719..e005f09f3 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -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