From d64743e565b43ce1399e916cf896204be6bc8415 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Thu, 2 Jul 2026 10:32:50 +0000 Subject: [PATCH] fix(backends): make backend install ops idempotent unless forced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 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 | 97 +++++++++++++++++++ core/services/galleryop/managers_local.go | 5 +- core/services/galleryop/operation.go | 7 ++ swagger/docs.go | 4 + swagger/swagger.json | 4 + swagger/swagger.yaml | 6 ++ 9 files changed, 220 insertions(+), 2 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/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..35ee24b4b --- /dev/null +++ b/core/services/galleryop/backend_force_test.go @@ -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") + }) +}) diff --git a/core/services/galleryop/managers_local.go b/core/services/galleryop/managers_local.go index 9d1b42417..f95d2c482 100644 --- a/core/services/galleryop/managers_local.go +++ b/core/services/galleryop/managers_local.go @@ -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 } 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/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