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/services/galleryop/backend_force_test.go b/core/services/galleryop/backend_force_test.go index 35ee24b4b..d53999f3f 100644 --- a/core/services/galleryop/backend_force_test.go +++ b/core/services/galleryop/backend_force_test.go @@ -25,6 +25,8 @@ var _ = Describe("LocalBackendManager force semantics", func() { backendsDir string srcDir string mgr *galleryop.LocalBackendManager + systemState *system.SystemState + ml *model.ModelLoader ) const installedRunSh = "#!/bin/sh\necho installed\n" @@ -56,13 +58,14 @@ var _ = Describe("LocalBackendManager force semantics", func() { 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)) + 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)) + ml = model.NewModelLoader(systemState) + mgr = galleryop.NewLocalBackendManager(appConfig, ml) }) AfterEach(func() { @@ -94,4 +97,18 @@ var _ = Describe("LocalBackendManager force semantics", func() { 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 f95d2c482..338a097b4 100644 --- a/core/services/galleryop/managers_local.go +++ b/core/services/galleryop/managers_local.go @@ -110,7 +110,7 @@ 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 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) }