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