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