diff --git a/core/application/application.go b/core/application/application.go index f8e7edcaf..75a2a9714 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -17,6 +17,7 @@ import ( "github.com/mudler/LocalAI/core/http/auth" mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp" "github.com/mudler/LocalAI/core/services/addons" + "github.com/mudler/LocalAI/core/services/addons/backendlib" "github.com/mudler/LocalAI/core/services/addons/catalog" "github.com/mudler/LocalAI/core/services/addons/gateway" "github.com/mudler/LocalAI/core/services/addons/packages" @@ -80,6 +81,7 @@ type Application struct { addonGateway *gateway.Gateway addonServer *http.Server addonSources *catalog.Sources + backendLibrary *backendlib.Installer addonManager *addons.Manager addonHostOps *addonruntime.HostOps // The pin store is built once so the routes and the retention sweep share it. @@ -824,6 +826,10 @@ func (a *Application) StartAgentPool() { // AddonManager is nil unless the deployment explicitly enables addons. func (a *Application) AddonManager() *addons.Manager { return a.addonManager } +// BackendLibrary installs backends from the library entries of kind backend. +// It is nil unless this instance owns the add-on libraries. +func (a *Application) BackendLibrary() *backendlib.Installer { return a.backendLibrary } + // AddonSources exposes owner-only source administration and explicit live refresh. // Forwarders never load source configuration or perform catalog I/O. func (a *Application) AddonSources() *catalog.Sources { return a.addonSources } @@ -867,8 +873,15 @@ func (a *Application) startAddons() error { if err != nil { return err } + var defaults []catalog.Source if c.OfficialLibrary { - if _, err = sources.SeedDefault(catalog.OfficialLibrary()); err != nil { + defaults = append(defaults, catalog.OfficialLibrary()) + } + if c.OfficialBackendLibrary { + defaults = append(defaults, catalog.OfficialBackendLibrary()) + } + if len(defaults) > 0 { + if _, err = sources.SeedDefaults(defaults...); err != nil { return err } } @@ -936,6 +949,12 @@ func (a *Application) startAddons() error { } } a.addonSources = cat.Sources + if cat.Sources != nil { + a.backendLibrary = &backendlib.Installer{ + Catalog: cat, SystemState: a.applicationConfig.SystemState, ModelLoader: a.modelLoader, + MaxBytes: c.BackendMaxBytes, RequireIntegrity: a.applicationConfig.RequireBackendIntegrity, + } + } a.addonManager = m if err := a.startAddonGateway(); err != nil { _ = m.Close() diff --git a/core/cli/run.go b/core/cli/run.go index 211c2efa7..e1994f0f9 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -37,6 +37,8 @@ type RunCMD struct { AddonsEnabled bool `name:"addons-enabled" env:"LOCALAI_ADDONS_ENABLED" default:"true" help:"Run the add-on owner, which installs and supervises add-ons. Turn it off with --addons-enabled=false" group:"addons"` AddonsOwnershipMode string `name:"addons-ownership-mode" env:"LOCALAI_ADDONS_OWNERSHIP_MODE" help:"Who may use add-ons: named-user (each account has its own access; needs authentication) or local-owner (every caller shares one owner). Default: local-owner when authentication is off, named-user when it is on" group:"addons"` AddonsOfficialLibrary bool `name:"addons-official-library" env:"LOCALAI_ADDONS_OFFICIAL_LIBRARY" default:"true" help:"Register the official add-on library on the first start, when the add-on owner has no sources.json yet. A library the admin removes later stays removed. Turn it off with --addons-official-library=false" group:"addons"` + AddonsBackendLibrary bool `name:"addons-backend-library" env:"LOCALAI_ADDONS_BACKEND_LIBRARY" default:"false" help:"Also register the pilot backend library on the first start, when the add-on owner has no sources.json yet. The backend gallery stays the source of truth; a library backend is an additional way to install it" group:"addons"` + AddonsBackendMaxBytes int64 `name:"addons-backend-max-bytes" env:"LOCALAI_ADDONS_BACKEND_MAX_BYTES" default:"8589934592" help:"Largest compressed size, in bytes, of a backend image installed from a library. Add-ons keep their own limit of 512 MiB" group:"addons"` Pprof bool `name:"pprof" env:"LOCALAI_PPROF" default:"false" help:"Enable the private loopback profiling listener" group:"diagnostics"` PprofAddress string `name:"pprof-address" env:"LOCALAI_PPROF_ADDRESS" default:"127.0.0.1:6060" help:"Numeric loopback address and port for profiling" group:"diagnostics"` PprofMutexProfileFraction int `name:"pprof-mutex-profile-fraction" env:"LOCALAI_PPROF_MUTEX_PROFILE_FRACTION" default:"0" help:"Mutex sampling fraction (0 disables sampling); requires pprof" group:"diagnostics"` @@ -667,6 +669,8 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) { } if addonCfg.Enabled { addonCfg.OfficialLibrary = r.AddonsOfficialLibrary + addonCfg.OfficialBackendLibrary = r.AddonsBackendLibrary + addonCfg.BackendMaxBytes = r.AddonsBackendMaxBytes opts = append(opts, config.WithAddons(addonCfg)) } if authEnabled { diff --git a/core/config/application_config.go b/core/config/application_config.go index acadb3b96..d0535a3ca 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -36,6 +36,13 @@ type AddonConfig struct { // when the owner has no sources.json yet. A sources.json that exists is // never changed by it, so a library the admin removed stays removed. OfficialLibrary bool + // OfficialBackendLibrary also registers the pilot library of backends on + // the first start. It is off by default: the backend gallery stays the + // source of truth and the library is an additional way to install. + OfficialBackendLibrary bool + // BackendMaxBytes bounds the compressed size of one backend image + // installed from a library; zero is 8 GiB. Add-ons keep their own limit. + BackendMaxBytes int64 Host contract.HostCompatibility AdminCheck func(context.Context, contract.Actor) error `json:"-" yaml:"-"` // BackupKeep is how many backups of one add-on the owner keeps; zero is diff --git a/core/gallery/backend_types.go b/core/gallery/backend_types.go index 1f8363764..813a80527 100644 --- a/core/gallery/backend_types.go +++ b/core/gallery/backend_types.go @@ -27,6 +27,9 @@ type BackendMetadata struct { URI string `json:"uri,omitempty"` // Digest is the OCI image digest at install time (for upgrade detection) Digest string `json:"digest,omitempty"` + // Library is the id of the library the backend was installed from. It is + // empty for a backend installed from a gallery. + Library string `json:"library,omitempty"` } type GalleryBackend struct { @@ -39,6 +42,12 @@ type GalleryBackend struct { // SHA256 is the expected sha256 of the backend tarball at URI / Mirrors. // Empty disables the integrity check; OCI URIs carry their own digest. SHA256 string `json:"sha256,omitempty" yaml:"sha256,omitempty"` + + // Library and MaxBytes are set by the install from a library, never read + // from a gallery file. Library records the origin in the metadata of the + // installed backend; MaxBytes bounds the compressed size of the image. + Library string `json:"-" yaml:"-"` + MaxBytes int64 `json:"-" yaml:"-"` } func (backend *GalleryBackend) FindBestBackendFromMeta(systemState *system.SystemState, backends GalleryElements[*GalleryBackend]) *GalleryBackend { diff --git a/core/gallery/backends.go b/core/gallery/backends.go index 6cb5be2bf..770e39dd5 100644 --- a/core/gallery/backends.go +++ b/core/gallery/backends.go @@ -340,6 +340,9 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL return fmt.Errorf("backend %q: %w", config.Name, optsErr) } downloadOpts = append(downloadOpts, downloader.WithStagingDir(systemState.StagingPath)) + if config.MaxBytes > 0 { + downloadOpts = append(downloadOpts, downloader.WithMaxBytes(config.MaxBytes)) + } // PreferDevelopmentBackends installs the development image as the primary URI, // keeping the released image reachable as the first fallback — instead of only @@ -446,6 +449,7 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL InstalledAt: time.Now().Format(time.RFC3339), Version: config.Version, URI: string(uri), + Library: config.Library, } // Record the OCI digest for upgrade detection (non-fatal on failure) diff --git a/core/http/endpoints/addons/catalogui_test.go b/core/http/endpoints/addons/catalogui_test.go index a99aa9e86..672b0467a 100644 --- a/core/http/endpoints/addons/catalogui_test.go +++ b/core/http/endpoints/addons/catalogui_test.go @@ -74,6 +74,23 @@ var _ = Describe("catalog and management actions", Label("catalogui"), func() { s = newAppsStack() }) + It("lists add-ons by default, and takes a kind that this host reads", func() { + s.each(func(name string, e http.Handler) { + admin := bearerOf(s.tokens["alice"]) + addons := send(e, "POST", "/api/addons/dispatch", dispatchBody("kind-addon-"+name, "", "catalog-search", 0, `{"kind":"addon"}`), admin) + Expect(addons.code).To(Equal(200), name) + Expect(payloadOf(addons)).To(HaveKeyWithValue("total", BeNumerically("==", 2)), name) + backends := send(e, "POST", "/api/addons/dispatch", dispatchBody("kind-backend-"+name, "", "catalog-search", 0, `{"kind":"backend"}`), admin) + Expect(backends.code).To(Equal(200), name) + Expect(payloadOf(backends)).To(HaveKeyWithValue("total", BeNumerically("==", 0)), name) + all := send(e, "POST", "/api/addons/dispatch", dispatchBody("kind-all-"+name, "", "catalog-search", 0, `{"kind":"all"}`), admin) + Expect(all.code).To(Equal(200), name) + Expect(payloadOf(all)).To(HaveKeyWithValue("total", BeNumerically("==", 2)), name) + model := send(e, "POST", "/api/addons/dispatch", dispatchBody("kind-model-"+name, "", "catalog-search", 0, `{"kind":"model"}`), admin) + Expect(model.code).To(Equal(403), name+": a kind this host does not read is a denied request") + }) + }) + It("serves catalog search, details and the installed management view to administrators only, the same from every frontend", func() { s.each(func(name string, e http.Handler) { admin := bearerOf(s.tokens["alice"]) diff --git a/core/http/endpoints/addons/gateway.go b/core/http/endpoints/addons/gateway.go index 4f1b94343..b47aaa267 100644 --- a/core/http/endpoints/addons/gateway.go +++ b/core/http/endpoints/addons/gateway.go @@ -169,7 +169,7 @@ func resultError(c echo.Context, e error) error { // Dispatch forwards typed operations to the explicitly configured owner. // @Summary Submit a typed addon lifecycle operation or read an addon resource -// @Description Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, sourceId, offset, limit up to 100), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session. +// @Description Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, kind, sourceId, offset, limit up to 100; kind is addon by default, or backend, or all), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session. // @Tags Addons // @Accept json // @Produce json diff --git a/core/http/endpoints/localai/backend_library.go b/core/http/endpoints/localai/backend_library.go new file mode 100644 index 000000000..493db1656 --- /dev/null +++ b/core/http/endpoints/localai/backend_library.go @@ -0,0 +1,77 @@ +package localai + +import ( + "context" + "errors" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/addons/backendlib" + "github.com/mudler/LocalAI/core/services/addons/catalog" + "github.com/mudler/LocalAI/pkg/downloader" + "github.com/mudler/xlog" +) + +// BackendLibraryInstaller installs a backend from a library entry. +type BackendLibraryInstaller interface { + Install(ctx context.Context, sourceID, backend, version string, status func(string, string, string, float64)) error +} + +// BackendLibraryInstallRequest names one library entry of kind backend. +type BackendLibraryInstallRequest struct { + // SourceID is the library the entry comes from. + SourceID string `json:"sourceId"` + // Name is the backend name of the entry. + Name string `json:"name"` + // Version is the version or the channel of the entry. + Version string `json:"version"` +} + +// InstallBackendFromLibraryEndpoint installs a backend from a library entry +// on this node. The install runs inside the request and answers when it is +// done. The library resolves the entry, so the request cannot name an image, +// a digest or a policy. +// @Summary Install a backend from a library entry on this node. +// @Tags backends +// @Param request body BackendLibraryInstallRequest true "library, backend name and version" +// @Success 200 {object} map[string]string "installed" +// @Failure 404 {object} schema.ErrorResponse "no such entry" +// @Failure 409 {object} schema.ErrorResponse "the name is installed from another origin" +// @Failure 413 {object} schema.ErrorResponse "the image is over the size limit" +// @Router /backends/library/install [post] +func InstallBackendFromLibraryEndpoint(installer BackendLibraryInstaller) echo.HandlerFunc { + return func(c echo.Context) error { + var in BackendLibraryInstallRequest + if err := c.Bind(&in); err != nil { + return libraryError(c, http.StatusBadRequest, "invalid request") + } + if in.SourceID == "" || in.Name == "" || in.Version == "" { + return libraryError(c, http.StatusBadRequest, "sourceId, name and version are required") + } + err := installer.Install(c.Request().Context(), in.SourceID, in.Name, in.Version, nil) + if err == nil { + return c.JSON(http.StatusOK, map[string]string{"status": "installed", "name": in.Name, "sourceId": in.SourceID}) + } + xlog.Warn("backend library install failed", "source", in.SourceID, "backend", in.Name, "error", err) + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrNotFound): + status = http.StatusNotFound + case errors.Is(err, backendlib.ErrCollision): + status = http.StatusConflict + case errors.Is(err, backendlib.ErrIntegrity), errors.Is(err, backendlib.ErrInvalid), errors.Is(err, catalog.ErrInvalid): + status = http.StatusBadRequest + case errors.Is(err, catalog.ErrUnavailable), errors.Is(err, catalog.ErrRefresh): + status = http.StatusServiceUnavailable + case errors.Is(err, downloader.ErrImageTooLarge): + status = http.StatusRequestEntityTooLarge + } + return libraryError(c, status, err.Error()) + } +} + +// libraryError answers in the error format of the other routes. +func libraryError(c echo.Context, status int, message string) error { + return c.JSON(status, schema.ErrorResponse{Error: &schema.APIError{Code: status, Message: message, Type: "backend_library"}}) +} diff --git a/core/http/endpoints/localai/backend_library_test.go b/core/http/endpoints/localai/backend_library_test.go new file mode 100644 index 000000000..eed1b3cb2 --- /dev/null +++ b/core/http/endpoints/localai/backend_library_test.go @@ -0,0 +1,63 @@ +package localai_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/http/endpoints/localai" + "github.com/mudler/LocalAI/core/services/addons/backendlib" + "github.com/mudler/LocalAI/core/services/addons/catalog" + "github.com/mudler/LocalAI/pkg/downloader" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeLibrary struct { + err error + last [3]string +} + +func (f *fakeLibrary) Install(_ context.Context, source, backend, version string, _ func(string, string, string, float64)) error { + f.last = [3]string{source, backend, version} + return f.err +} + +var _ = Describe("install a backend from a library", func() { + post := func(f *fakeLibrary, body string) *httptest.ResponseRecorder { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/backends/library/install", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + Expect(localai.InstallBackendFromLibraryEndpoint(f)(e.NewContext(req, rec))).To(Succeed()) + return rec + } + + It("installs the named entry", func() { + f := &fakeLibrary{} + rec := post(f, `{"sourceId":"pilot","name":"cpu-demo","version":"latest"}`) + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(f.last).To(Equal([3]string{"pilot", "cpu-demo", "latest"})) + }) + + It("refuses a request that does not name an entry", func() { + for _, body := range []string{`{}`, `{"sourceId":"pilot","name":"x"}`, `not json`} { + Expect(post(&fakeLibrary{}, body).Code).To(Equal(http.StatusBadRequest), body) + } + }) + + DescribeTable("maps what the install says to a status", + func(err error, status int) { + Expect(post(&fakeLibrary{err: err}, `{"sourceId":"p","name":"n","version":"v"}`).Code).To(Equal(status)) + }, + Entry("no such entry", catalog.ErrNotFound, http.StatusNotFound), + Entry("collision", fmt.Errorf("%w: %q", backendlib.ErrCollision, "n"), http.StatusConflict), + Entry("strict integrity", backendlib.ErrIntegrity, http.StatusBadRequest), + Entry("library unavailable", catalog.ErrUnavailable, http.StatusServiceUnavailable), + Entry("too large", fmt.Errorf("failed: %w", downloader.ErrImageTooLarge), http.StatusRequestEntityTooLarge), + Entry("anything else", fmt.Errorf("boom"), http.StatusInternalServerError), + ) +}) diff --git a/core/http/routes/localai.go b/core/http/routes/localai.go index 3eff66b5a..b317ea6da 100644 --- a/core/http/routes/localai.go +++ b/core/http/routes/localai.go @@ -79,6 +79,9 @@ func RegisterLocalAIRoutes(router *echo.Echo, galleryService, app.UpgradeChecker()) router.POST("/backends/apply", backendGalleryEndpointService.ApplyBackendEndpoint(appConfig.SystemState), adminMiddleware) + if lib := app.BackendLibrary(); lib != nil { + router.POST("/backends/library/install", localai.InstallBackendFromLibraryEndpoint(lib), adminMiddleware) + } router.POST("/backends/delete/:name", backendGalleryEndpointService.DeleteBackendEndpoint(), adminMiddleware) router.GET("/backends", backendGalleryEndpointService.ListBackendsEndpoint(), adminMiddleware) router.GET("/backends/available", backendGalleryEndpointService.ListAvailableBackendsEndpoint(appConfig.SystemState, ClusterCapabilityProviderFor(app), ClusterInstalledProviderFor(app)), adminMiddleware) diff --git a/core/services/addons/backendlib/install.go b/core/services/addons/backendlib/install.go new file mode 100644 index 000000000..709df6eb5 --- /dev/null +++ b/core/services/addons/backendlib/install.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT + +// Package backendlib installs a backend from a library entry of kind backend. +// +// A library entry is one more way to see and install a backend. The backend +// gallery stays the source of truth and its install path is not changed: this +// package resolves the entry from the library the owner trusts, pins the image +// by digest, takes the signature policy from the library and then hands the +// install to the same code the gallery uses, so the backend ends up in the +// same directory with the same metadata plus the library it came from. +package backendlib + +import ( + "context" + "errors" + "fmt" + "os" + "regexp" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/gallery" + "github.com/mudler/LocalAI/core/services/addons/catalog" + "github.com/mudler/LocalAI/pkg/model" + "github.com/mudler/LocalAI/pkg/system" +) + +var ( + // ErrCollision is returned when the name is already installed from another + // origin. Nothing is changed. + ErrCollision = errors.New("backend is already installed from another origin") + // ErrIntegrity is returned when strict integrity mode needs a signature + // policy and the library has none. + ErrIntegrity = errors.New("strict integrity: the library does not require a signature") + // ErrInvalid is returned for an entry that cannot be installed as given. + ErrInvalid = errors.New("invalid backend library entry") +) + +// Installer installs library backends on the local node. +type Installer struct { + Catalog catalog.Catalog + SystemState *system.SystemState + ModelLoader *model.ModelLoader + // MaxBytes bounds the compressed size of the image. Zero means the default + // for the backend kind, catalog.DefaultMaxBytes(catalog.KindBackend). + MaxBytes int64 + // RequireIntegrity is the strict mode of the gallery install + // (--require-backend-integrity): an entry whose library does not require a + // signature is refused. + RequireIntegrity bool + // install is the gallery install; tests replace it. + install func(context.Context, *system.SystemState, *model.ModelLoader, *gallery.GalleryBackend, func(string, string, string, float64), bool) error +} + +func (i *Installer) limit() int64 { + if i.MaxBytes > 0 { + return i.MaxBytes + } + return catalog.DefaultMaxBytes(catalog.KindBackend) +} + +// Install resolves the backend entry and installs it. version is the version +// or the channel of the entry, as for an add-on. +func (i *Installer) Install(ctx context.Context, sourceID, backend, version string, status func(string, string, string, float64)) error { + r, err := i.Catalog.ResolveKind(ctx, catalog.KindBackend, sourceID, backend, version) + if err != nil { + return err + } + gb, err := i.plan(r) + if err != nil { + return err + } + if err := i.checkCollision(r); err != nil { + return err + } + install := i.install + if install == nil { + install = gallery.InstallBackend + } + // With a verification policy the gallery install verifies the signature of + // the pinned image before it fetches any layer, then applies the size limit. + return install(ctx, i.SystemState, i.ModelLoader, gb, status, i.RequireIntegrity) +} + +var digestForm = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) + +// plan turns a trusted resolution into the backend the gallery install takes. +// The image is named by digest, never by tag. +func (i *Installer) plan(r catalog.Release) (*gallery.GalleryBackend, error) { + if r.EntryKind() != catalog.KindBackend || !strings.HasPrefix(r.Artifact, "oci://") || !digestForm.MatchString(r.Digest) { + return nil, ErrInvalid + } + target, arch := i.Catalog.Target() + if r.OS != target || r.Arch != arch { + return nil, ErrInvalid + } + if i.RequireIntegrity && !r.Policy.RequireSignature { + return nil, fmt.Errorf("%w (library %q)", ErrIntegrity, r.SourceID) + } + ref, err := name.ParseReference(strings.TrimPrefix(r.Artifact, "oci://")) + if err != nil { + return nil, ErrInvalid + } + gb := &gallery.GalleryBackend{ + URI: "oci://" + ref.Context().Digest(r.Digest).Name(), + Version: r.Version, + Alias: r.Alias, + Library: r.SourceID, + MaxBytes: i.limit(), + } + gb.Name = r.AddonID + gb.Gallery = config.Gallery{Name: "library:" + r.SourceID, URL: r.SourceURL} + if r.Policy.RequireSignature { + gb.Gallery.Verification = &config.GalleryVerification{ + Issuer: r.Policy.Issuer, + Identity: r.Policy.Identity, + IdentityRegex: r.Policy.IdentityRegex, + } + } + return gb, nil +} + +// checkCollision refuses to replace a backend that did not come from this +// library: one installed from a gallery, from another library or by hand. +// Installing again from the same library is a reinstall. +func (i *Installer) checkCollision(r catalog.Release) error { + backends, err := gallery.ListSystemBackends(i.SystemState) + if errors.Is(err, os.ErrNotExist) { + // No backends directory yet: nothing can collide. + return nil + } + if err != nil { + return err + } + have, ok := backends.Get(r.AddonID) + if !ok || have.IsSystem { + return nil + } + if have.Metadata == nil || have.Metadata.Library != r.SourceID { + return fmt.Errorf("%w: %q", ErrCollision, r.AddonID) + } + return nil +} diff --git a/core/services/addons/backendlib/install_test.go b/core/services/addons/backendlib/install_test.go new file mode 100644 index 000000000..56b78418d --- /dev/null +++ b/core/services/addons/backendlib/install_test.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT +package backendlib + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/mudler/LocalAI/core/gallery" + "github.com/mudler/LocalAI/core/services/addons/catalog" + "github.com/mudler/LocalAI/pkg/addons/contract" + "github.com/mudler/LocalAI/pkg/downloader" + "github.com/mudler/LocalAI/pkg/model" + "github.com/mudler/LocalAI/pkg/system" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBackendLibrary(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Backend library install") +} + +var ctx = context.Background() + +func allow(context.Context, contract.Actor) error { return nil } + +// push publishes a minimal backend image (a run.sh) and returns the registry +// host and the manifest digest. +func push(host, repo, tag, script string) string { + GinkgoHelper() + layer, err := crane.Layer(map[string][]byte{"run.sh": []byte(script)}) + Expect(err).NotTo(HaveOccurred()) + img, err := mutate.AppendLayers(empty.Image, layer) + Expect(err).NotTo(HaveOccurred()) + ref, err := name.ParseReference(host + "/" + repo + ":" + tag) + Expect(err).NotTo(HaveOccurred()) + Expect(remote.Write(ref, img)).To(Succeed()) + d, err := img.Digest() + Expect(err).NotTo(HaveOccurred()) + return d.String() +} + +var _ = Describe("installing a backend from a library", func() { + var ( + host string + backendsDir string + in *Installer + release catalog.Release + digest string + ) + + // serve starts a library that lists the release and refreshes it. + serve := func(r catalog.Release, policy catalog.VerificationPolicy) catalog.Catalog { + GinkgoHelper() + lib := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = catalog.WriteIndex(w, []catalog.Release{r}) + })) + DeferCleanup(lib.Close) + s, err := catalog.NewSources(filepath.Join(GinkgoT().TempDir(), "sources.json"), allow, catalog.CredentialResolver{}) + Expect(err).NotTo(HaveOccurred()) + Expect(s.Save(ctx, contract.Actor{}, catalog.Source{ID: "pilot", Name: "Pilot", URL: lib.URL, Enabled: true, Policy: policy})).To(Succeed()) + _, err = s.Refresh(ctx, "pilot") + Expect(err).NotTo(HaveOccurred()) + return catalog.Catalog{Sources: s, TargetOS: "linux", TargetArch: "amd64"} + } + + BeforeEach(func() { + reg := httptest.NewServer(registry.New()) + DeferCleanup(reg.Close) + host = strings.TrimPrefix(reg.URL, "http://") + digest = push(host, "acme/backends", "latest-cpu-demo", "#!/bin/sh\necho demo\n") + release = catalog.Release{ + Kind: catalog.KindBackend, AddonID: "cpu-demo", Alias: "demo", Name: "Demo (CPU)", Version: "0.0.0-latest.aaaaaaaaaaaa", + Artifact: "oci://" + host + "/acme/backends:latest-cpu-demo", Digest: digest, OS: "linux", Arch: "amd64", + } + backendsDir = GinkgoT().TempDir() + state, err := system.GetSystemState(system.WithBackendPath(backendsDir)) + Expect(err).NotTo(HaveOccurred()) + in = &Installer{Catalog: serve(release, catalog.VerificationPolicy{}), SystemState: state, ModelLoader: model.NewModelLoader(state)} + }) + + left := func() []string { + entries, err := os.ReadDir(backendsDir) + Expect(err).NotTo(HaveOccurred()) + out := []string{} + for _, e := range entries { + out = append(out, e.Name()) + } + return out + } + + It("installs by digest and records the library, the digest and the alias", func() { + Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(Succeed()) + Expect(filepath.Join(backendsDir, "cpu-demo", "run.sh")).To(BeARegularFile()) + raw, err := os.ReadFile(filepath.Join(backendsDir, "cpu-demo", "metadata.json")) + Expect(err).NotTo(HaveOccurred()) + var meta gallery.BackendMetadata + Expect(json.Unmarshal(raw, &meta)).To(Succeed()) + Expect(meta.Library).To(Equal("pilot")) + Expect(meta.Digest).To(Equal(digest)) + Expect(meta.Alias).To(Equal("demo")) + Expect(meta.Version).To(Equal(release.Version)) + Expect(meta.URI).To(HaveSuffix("@" + digest)) + // It is a backend like any other: the system lists it. + list, err := gallery.ListSystemBackends(in.SystemState) + Expect(err).NotTo(HaveOccurred()) + Expect(list.Exists("cpu-demo")).To(BeTrue()) + }) + + It("installs on a host that has no backends directory yet", func() { + fresh := filepath.Join(GinkgoT().TempDir(), "backends") + state, err := system.GetSystemState(system.WithBackendPath(fresh)) + Expect(err).NotTo(HaveOccurred()) + in.SystemState, in.ModelLoader = state, model.NewModelLoader(state) + Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(Succeed()) + Expect(filepath.Join(fresh, "cpu-demo", "run.sh")).To(BeARegularFile()) + }) + + It("installs again from the same library", func() { + Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(Succeed()) + Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(Succeed()) + }) + + It("refuses an entry whose digest is not in the registry and leaves nothing behind", func() { + bad := release + bad.Digest = "sha256:" + strings.Repeat("0", 64) + in.Catalog = serve(bad, catalog.VerificationPolicy{}) + Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).NotTo(Succeed()) + Expect(left()).To(BeEmpty()) + }) + + It("refuses an image over the size limit and leaves nothing behind", func() { + in.MaxBytes = 1 + err := in.Install(ctx, "pilot", "cpu-demo", release.Version, nil) + Expect(err).To(MatchError(ContainSubstring(downloader.ErrImageTooLarge.Error()))) + Expect(left()).To(BeEmpty()) + }) + + It("uses 8 GiB as the default limit for a backend", func() { + Expect((&Installer{}).limit()).To(Equal(int64(8 << 30))) + Expect((&Installer{MaxBytes: 5}).limit()).To(Equal(int64(5))) + }) + + It("refuses to replace a backend that came from a gallery", func() { + dir := filepath.Join(backendsDir, "cpu-demo") + Expect(os.MkdirAll(dir, 0o750)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "run.sh"), []byte("#!/bin/sh\necho gallery\n"), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(`{"name":"cpu-demo","gallery_url":"https://gallery.example/index.yaml"}`), 0o644)).To(Succeed()) + Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(MatchError(ErrCollision)) + run, err := os.ReadFile(filepath.Join(dir, "run.sh")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(run)).To(ContainSubstring("gallery")) + Expect(left()).To(Equal([]string{"cpu-demo"})) + }) + + It("refuses a library without a signature policy in strict integrity mode", func() { + in.RequireIntegrity = true + Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(MatchError(ErrIntegrity)) + Expect(left()).To(BeEmpty()) + }) + + It("does not install an add-on entry or an unknown name", func() { + Expect(in.Install(ctx, "pilot", "missing", "1.0.0", nil)).To(MatchError(catalog.ErrNotFound)) + Expect(in.Install(ctx, "nowhere", "cpu-demo", release.Version, nil)).NotTo(Succeed()) + }) + + It("takes the signature policy of the library, with the identity pattern, and pins the image by digest", func() { + pattern := `^https://github\.com/o/r/\.github/workflows/w\.yml@refs/(heads/master|tags/.+)$` + policy := catalog.VerificationPolicy{RequireSignature: true, Issuer: "https://issuer.example", IdentityRegex: pattern} + in.Catalog = serve(release, policy) + var got *gallery.GalleryBackend + in.install = func(_ context.Context, _ *system.SystemState, _ *model.ModelLoader, gb *gallery.GalleryBackend, _ func(string, string, string, float64), strict bool) error { + got = gb + return nil + } + in.RequireIntegrity = true + Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(Succeed()) + Expect(got.URI).To(Equal("oci://" + host + "/acme/backends@" + digest)) + Expect(got.Gallery.Verification).NotTo(BeNil()) + Expect(got.Gallery.Verification.Issuer).To(Equal("https://issuer.example")) + Expect(got.Gallery.Verification.IdentityRegex).To(Equal(pattern)) + Expect(got.Gallery.Verification.Identity).To(BeEmpty()) + Expect(got.MaxBytes).To(Equal(int64(8 << 30))) + Expect(got.Library).To(Equal("pilot")) + }) +}) diff --git a/core/services/addons/catalog/kind.go b/core/services/addons/catalog/kind.go index fde991f2c..7cabe0352 100644 --- a/core/services/addons/catalog/kind.go +++ b/core/services/addons/catalog/kind.go @@ -88,3 +88,20 @@ func (q Query) matchesKind(r Release) bool { func validQueryKind(k Kind) bool { return k == "" || k == QueryAllKinds || slices.Contains(ReadableKinds, k) } + +// Default size limits for one install, by kind. The limit is on the compressed +// size of the package or image. An add-on is a small package; a backend image +// can hold a runtime and its libraries, so its default is larger and the owner +// can change it. +const ( + DefaultAddonMaxBytes int64 = 512 << 20 + DefaultBackendMaxBytes int64 = 8 << 30 +) + +// DefaultMaxBytes is the default size limit of an install of the kind. +func DefaultMaxBytes(k Kind) int64 { + if k == KindBackend { + return DefaultBackendMaxBytes + } + return DefaultAddonMaxBytes +} diff --git a/core/services/addons/catalog/official.go b/core/services/addons/catalog/official.go index ffc552656..5ab0e9477 100644 --- a/core/services/addons/catalog/official.go +++ b/core/services/addons/catalog/official.go @@ -26,3 +26,29 @@ func OfficialLibrary() Source { }, } } + +const ( + // OfficialBackendLibraryID is the source ID of the pilot backend library. + OfficialBackendLibraryID = "official-backends" + // OfficialBackendLibraryURL is the YAML index of the pilot backends, kept + // in this repository at gallery/backends/index.yaml. It is rendered from + // backend/index.yaml, which stays the source of truth. + OfficialBackendLibraryURL = "https://raw.githubusercontent.com/mudler/LocalAI/master/gallery/backends/index.yaml" +) + +// OfficialBackendLibrary is the pilot library of backends. It requires the +// signature of the backend merge workflow, from master or from a tag, with the +// same signer pattern the backend gallery uses. +func OfficialBackendLibrary() Source { + return Source{ + ID: OfficialBackendLibraryID, + Name: "Official backends (pilot)", + URL: OfficialBackendLibraryURL, + Enabled: true, + Policy: VerificationPolicy{ + RequireSignature: true, + Issuer: "https://token.actions.githubusercontent.com", + IdentityRegex: `^https://github\.com/mudler/LocalAI/\.github/workflows/backend_merge\.yml@refs/(heads/master|tags/.+)$`, + }, + } +} diff --git a/core/services/addons/catalog/sources.go b/core/services/addons/catalog/sources.go index ae6b74636..74f3ffd4e 100644 --- a/core/services/addons/catalog/sources.go +++ b/core/services/addons/catalog/sources.go @@ -298,16 +298,22 @@ func (s *Sources) persist(next map[string]Source) error { // removed (the file then holds the remaining libraries, possibly none) and a // library the admin edited is not reset. It reports whether it added src. // It is trusted startup code and takes no actor; it never runs for a request. -func (s *Sources) SeedDefault(src Source) (bool, error) { +func (s *Sources) SeedDefault(src Source) (bool, error) { return s.SeedDefaults(src) } + +// SeedDefaults is SeedDefault for several libraries, written together. +func (s *Sources) SeedDefaults(srcs ...Source) (bool, error) { s.mu.Lock() defer s.mu.Unlock() if !s.fresh { return false, nil } - if !idPattern.MatchString(src.ID) || src.Removed || !validSource(src) { - return false, ErrInvalid + next := map[string]Source{} + for _, src := range srcs { + if !idPattern.MatchString(src.ID) || src.Removed || !validSource(src) { + return false, ErrInvalid + } + next[src.ID] = src } - next := map[string]Source{src.ID: src} if e := s.persist(next); e != nil { return false, e } diff --git a/core/services/addons/packages/install.go b/core/services/addons/packages/install.go index b047bf777..a7ee35e4d 100644 --- a/core/services/addons/packages/install.go +++ b/core/services/addons/packages/install.go @@ -48,7 +48,7 @@ func (i *Installer) byteLimit() int64 { if i.MaxBytes > 0 { return i.MaxBytes } - return 512 << 20 + return catalog.DefaultMaxBytes(catalog.KindAddon) } func (i *Installer) fileLimit() int { if i.MaxFiles > 0 { diff --git a/gallery/backends/index.yaml b/gallery/backends/index.yaml new file mode 100644 index 000000000..070d5ebec --- /dev/null +++ b/gallery/backends/index.yaml @@ -0,0 +1,20 @@ +# Pilot backend library: backends from the backend gallery, one entry for each +# image. The gallery, backend/index.yaml, stays the source of truth. The entries +# are rendered with scripts/backends/cmd/backendcatalog, so the digests pin the +# images the gallery names today: render the file again after the backend images +# are rebuilt, never edit a digest by hand. +- addonId: cpu-local-store + alias: local-store + arch: amd64 + artifact: oci://quay.io/go-skynet/local-ai-backends:latest-cpu-local-store + channel: latest + description: Local Store is a local-first, self-hosted, and open-source vector database. + digest: sha256:0a1056fd863328adf9d1097808aa2b52f68c4ca2e5385100a754755b3ca6218f + kind: backend + name: cpu-local-store + os: linux + policy: + identityRegex: ^https://github\.com/mudler/LocalAI/\.github/workflows/backend_merge\.yml@refs/(heads/master|tags/.+)$ + issuer: https://token.actions.githubusercontent.com + requireSignature: true + version: 0.0.0-latest.0a1056fd8633 diff --git a/pkg/downloader/uri.go b/pkg/downloader/uri.go index 64ef160a8..c3764cd92 100644 --- a/pkg/downloader/uri.go +++ b/pkg/downloader/uri.go @@ -16,6 +16,7 @@ import ( "sync" "time" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/tarball" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -72,6 +73,7 @@ type downloadOptions struct { bearerToken string transferProgress TransferProgressSink stagingDir string + maxBytes int64 } // DownloadOption configures DownloadFileWithContext / DownloadFile. @@ -94,6 +96,14 @@ func WithBearerToken(token string) DownloadOption { return func(o *downloadOptions) { o.bearerToken = token } } +// WithMaxBytes refuses an OCI image whose compressed layers add up to more +// than n bytes. The check reads the manifest and runs after signature +// verification and before any layer is fetched. It does nothing for other +// download kinds, and n <= 0 sets no limit. +func WithMaxBytes(n int64) DownloadOption { + return func(o *downloadOptions) { o.maxBytes = n } +} + func WithStagingDir(dir string) DownloadOption { return func(o *downloadOptions) { o.stagingDir = dir } } @@ -658,6 +668,12 @@ func (uri URI) DownloadFileWithContext(ctx context.Context, filePath, sha string xlog.Info("Image signature verified", "ref", pinned) } + if dopts.maxBytes > 0 { + if cerr := checkImageSize(img, dopts.maxBytes); cerr != nil { + return fmt.Errorf("image %q: %w", url, cerr) + } + } + return oci.ExtractOCIImage(ctx, img, url, filePath, dopts.stagingDir, downloadStatus) } @@ -981,3 +997,26 @@ func CalculateSHA(filePath string) (string, error) { return fmt.Sprintf("%x", hash.Sum(nil)), nil } + +// ErrImageTooLarge is returned when an image is over the limit of WithMaxBytes. +var ErrImageTooLarge = errors.New("image is larger than the size limit") + +// checkImageSize sums the compressed layer sizes the manifest declares. +func checkImageSize(img v1.Image, limit int64) error { + layers, err := img.Layers() + if err != nil { + return fmt.Errorf("reading layers: %w", err) + } + var total int64 + for _, l := range layers { + size, err := l.Size() + if err != nil || size < 0 { + return fmt.Errorf("reading layer size: %w", err) + } + if size > limit-total { + return fmt.Errorf("%w (limit %d bytes)", ErrImageTooLarge, limit) + } + total += size + } + return nil +} diff --git a/scripts/backends/cmd/backendcatalog/main.go b/scripts/backends/cmd/backendcatalog/main.go new file mode 100644 index 000000000..e86975b17 --- /dev/null +++ b/scripts/backends/cmd/backendcatalog/main.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT + +// Command backendcatalog renders library entries of kind backend from the +// backend gallery, backend/index.yaml. The gallery stays the source of truth: +// this command reads it and pins what it names, it never edits it. +// +// For each backend named on the command line it finds the concrete gallery +// entry, resolves the image the entry names to the digest of the image for the +// platform, and writes one library entry. The registry is read at render time, +// so the digests are never edited by hand: render the file again after the +// backend images are rebuilt. +// +// go run ./scripts/backends/cmd/backendcatalog \ +// --index backend/index.yaml --backends cpu-local-store \ +// --out gallery/backends/index.yaml +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "os" + "regexp" + "strings" + + "github.com/mudler/LocalAI/core/gallery" + "github.com/mudler/LocalAI/core/services/addons/catalog" + "github.com/mudler/LocalAI/pkg/oci" + "gopkg.in/yaml.v3" +) + +const header = `# Pilot backend library: backends from the backend gallery, one entry for each +# image. The gallery, backend/index.yaml, stays the source of truth. The entries +# are rendered with scripts/backends/cmd/backendcatalog, so the digests pin the +# images the gallery names today: render the file again after the backend images +# are rebuilt, never edit a digest by hand. +` + +// resolver returns the manifest digest of the image for a platform. +type resolver func(image, platform string) (string, error) + +func registryResolver(image, platform string) (string, error) { + img, err := oci.GetImage(image, platform, nil, nil) + if err != nil { + return "", err + } + d, err := img.Digest() + if err != nil { + return "", err + } + return d.String(), nil +} + +var tagPattern = regexp.MustCompile(`:([a-z][a-z0-9]*)-[A-Za-z0-9._-]+$`) + +// render builds the library entries of the named backends for one platform. +func render(gallerySource []byte, names []string, osName, arch string, resolve resolver) ([]catalog.Release, error) { + var entries []gallery.GalleryBackend + if err := yaml.Unmarshal(gallerySource, &entries); err != nil { + return nil, fmt.Errorf("reading the backend gallery: %w", err) + } + byName := map[string]gallery.GalleryBackend{} + for _, e := range entries { + byName[e.Name] = e + } + var out []catalog.Release + for _, name := range names { + e, ok := byName[name] + if !ok { + return nil, fmt.Errorf("backend %q is not in the gallery", name) + } + if e.IsMeta() || e.URI == "" { + return nil, fmt.Errorf("backend %q is a meta backend; name a concrete one", name) + } + image := strings.TrimPrefix(e.URI, "oci://") + m := tagPattern.FindStringSubmatch(image) + if m == nil || m[1] != "latest" { + return nil, fmt.Errorf("backend %q: the image %q is not a released latest-* tag", name, image) + } + digest, err := resolve(image, osName+"/"+arch) + if err != nil { + return nil, fmt.Errorf("backend %q: resolving %s: %w", name, image, err) + } + hex := strings.TrimPrefix(digest, "sha256:") + if len(hex) != 64 { + return nil, fmt.Errorf("backend %q: unexpected digest %q", name, digest) + } + out = append(out, catalog.Release{ + Kind: catalog.KindBackend, + AddonID: e.Name, + Alias: e.Alias, + Name: e.Name, + Description: strings.Join(strings.Fields(e.Description), " "), + Version: "0.0.0-latest." + hex[:12], + Channel: "latest", + Artifact: "oci://" + image, + Digest: digest, + OS: osName, + Arch: arch, + // The host replaces this with the policy of the library; it is + // written so the file says what the entries are checked against. + Policy: catalog.OfficialBackendLibrary().Policy, + }) + } + return out, nil +} + +func run(args []string) error { + fs := flag.NewFlagSet("backendcatalog", flag.ContinueOnError) + index := fs.String("index", "backend/index.yaml", "the backend gallery") + backends := fs.String("backends", "", "comma list of concrete backend names") + platform := fs.String("platform", "linux/amd64", "os/arch") + out := fs.String("out", "", "file to write (default: standard output)") + if err := fs.Parse(args); err != nil { + return err + } + osName, arch, ok := strings.Cut(*platform, "/") + if !ok || *backends == "" { + return errors.New("usage: --backends a,b [--platform os/arch] [--index file] [--out file]") + } + src, err := os.ReadFile(*index) + if err != nil { + return err + } + releases, err := render(src, strings.Split(*backends, ","), osName, arch, registryResolver) + if err != nil { + return err + } + var buf bytes.Buffer + buf.WriteString(header) + if err := catalog.WriteIndex(&buf, releases); err != nil { + return err + } + // The result must be a valid library index for this host. + if _, err := catalog.DecodeIndex(buf.Bytes()); err != nil { + return fmt.Errorf("the rendered index is not valid: %w", err) + } + if *out == "" { + _, err = os.Stdout.Write(buf.Bytes()) + return err + } + return os.WriteFile(*out, buf.Bytes(), 0o644) +} + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "backendcatalog:", err) + os.Exit(1) + } +} diff --git a/scripts/backends/cmd/backendcatalog/main_test.go b/scripts/backends/cmd/backendcatalog/main_test.go new file mode 100644 index 000000000..92d916d02 --- /dev/null +++ b/scripts/backends/cmd/backendcatalog/main_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "strings" + "testing" + + "github.com/mudler/LocalAI/core/services/addons/catalog" +) + +const fakeGallery = ` +- &demo + name: "demo" + description: | + A demo + backend. + capabilities: + default: "cpu-demo" +- !!merge <<: *demo + name: "cpu-demo" + alias: "demo" + uri: "quay.io/example/backends:latest-cpu-demo" +- !!merge <<: *demo + name: "cpu-demo-development" + uri: "quay.io/example/backends:master-cpu-demo" +` + +func fakeDigest(image, platform string) (string, error) { + return "sha256:" + strings.Repeat("ab", 32), nil +} + +func TestRenderPinsTheGalleryImage(t *testing.T) { + got, err := render([]byte(fakeGallery), []string{"cpu-demo"}, "linux", "amd64", fakeDigest) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("want 1 entry, got %d", len(got)) + } + r := got[0] + if r.EntryKind() != catalog.KindBackend || r.AddonID != "cpu-demo" || r.Alias != "demo" || r.Channel != "latest" || + r.Artifact != "oci://quay.io/example/backends:latest-cpu-demo" || r.Version != "0.0.0-latest.abababababab" || + r.Description != "A demo backend." { + t.Fatalf("unexpected entry: %+v", r) + } + data, err := catalog.EncodeIndex(got) + if err != nil { + t.Fatal(err) + } + read, err := catalog.ReadIndex(data, catalog.KindBackend) + if err != nil || len(read.Releases) != 1 || len(read.Skipped) != 0 { + t.Fatalf("the rendered entry does not read back: %v %+v", err, read) + } +} + +func TestRenderRefusesWhatItCannotPin(t *testing.T) { + for name, tc := range map[string][]string{ + "unknown": {"nope"}, + "meta": {"demo"}, + "master only": {"cpu-demo-development"}, + } { + if _, err := render([]byte(fakeGallery), tc, "linux", "amd64", fakeDigest); err == nil { + t.Errorf("%s: want an error", name) + } + } +} diff --git a/swagger/docs.go b/swagger/docs.go index 2dc4ac1a6..dfcf7c2e1 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -696,7 +696,7 @@ const docTemplate = `{ }, "/api/addons/dispatch": { "post": { - "description": "Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, sourceId, offset, limit up to 100), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session.", + "description": "Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, kind, sourceId, offset, limit up to 100; kind is addon by default, or backend, or all), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session.", "consumes": [ "application/json" ], @@ -5702,6 +5702,54 @@ const docTemplate = `{ } } }, + "/backends/library/install": { + "post": { + "tags": [ + "backends" + ], + "summary": "Install a backend from a library entry on this node.", + "parameters": [ + { + "description": "library, backend name and version", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/localai.BackendLibraryInstallRequest" + } + } + ], + "responses": { + "200": { + "description": "installed", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "no such entry", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + }, + "409": { + "description": "the name is installed from another origin", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + }, + "413": { + "description": "the image is over the size limit", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + } + } + } + }, "/backends/upgrade/{name}": { "post": { "tags": [ @@ -9609,6 +9657,23 @@ const docTemplate = `{ } } }, + "localai.BackendLibraryInstallRequest": { + "type": "object", + "properties": { + "name": { + "description": "Name is the backend name of the entry.", + "type": "string" + }, + "sourceId": { + "description": "SourceID is the library the entry comes from.", + "type": "string" + }, + "version": { + "description": "Version is the version or the channel of the entry.", + "type": "string" + } + } + }, "localai.BrandingResponse": { "type": "object", "properties": { diff --git a/swagger/swagger.json b/swagger/swagger.json index 4415ed23f..260eb787c 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -693,7 +693,7 @@ }, "/api/addons/dispatch": { "post": { - "description": "Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, sourceId, offset, limit up to 100), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session.", + "description": "Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, kind, sourceId, offset, limit up to 100; kind is addon by default, or backend, or all), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session.", "consumes": [ "application/json" ], @@ -5699,6 +5699,54 @@ } } }, + "/backends/library/install": { + "post": { + "tags": [ + "backends" + ], + "summary": "Install a backend from a library entry on this node.", + "parameters": [ + { + "description": "library, backend name and version", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/localai.BackendLibraryInstallRequest" + } + } + ], + "responses": { + "200": { + "description": "installed", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "no such entry", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + }, + "409": { + "description": "the name is installed from another origin", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + }, + "413": { + "description": "the image is over the size limit", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + } + } + } + }, "/backends/upgrade/{name}": { "post": { "tags": [ @@ -9606,6 +9654,23 @@ } } }, + "localai.BackendLibraryInstallRequest": { + "type": "object", + "properties": { + "name": { + "description": "Name is the backend name of the entry.", + "type": "string" + }, + "sourceId": { + "description": "SourceID is the library the entry comes from.", + "type": "string" + }, + "version": { + "description": "Version is the version or the channel of the entry.", + "type": "string" + } + } + }, "localai.BrandingResponse": { "type": "object", "properties": { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 2fd17daa9..bd97b5ddc 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -1551,6 +1551,18 @@ definitions: target: type: string type: object + localai.BackendLibraryInstallRequest: + properties: + name: + description: Name is the backend name of the entry. + type: string + sourceId: + description: SourceID is the library the entry comes from. + type: string + version: + description: Version is the version or the channel of the entry. + type: string + type: object localai.BrandingResponse: properties: favicon_url: @@ -6398,14 +6410,15 @@ paths: a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, - are catalog-search (arguments text, sourceId, offset, limit up to 100), catalog-details - (addonId plus arguments sourceId and version), catalog-sources and addons-installed. - The library changes are catalog-source-save (arguments are one source: id, - name, url, enabled, policy, authReference; removed true deletes it and never - touches installed addons) and catalog-source-refresh (argument sourceId). - A library names a credential only by a reference the owner configured, never - a secret. Unknown argument fields are denied. The update actions are update-status - and update-check (no arguments; the check fetches the library again and applies + are catalog-search (arguments text, kind, sourceId, offset, limit up to 100; + kind is addon by default, or backend, or all), catalog-details (addonId plus + arguments sourceId and version), catalog-sources and addons-installed. The + library changes are catalog-source-save (arguments are one source: id, name, + url, enabled, policy, authReference; removed true deletes it and never touches + installed addons) and catalog-source-refresh (argument sourceId). A library + names a credential only by a reference the owner configured, never a secret. + Unknown argument fields are denied. The update actions are update-status and + update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated @@ -9524,6 +9537,37 @@ paths: summary: List all known Backends (importer registry + curated pref-only + installed-on-disk) tags: - backends + /backends/library/install: + post: + parameters: + - description: library, backend name and version + in: body + name: request + required: true + schema: + $ref: '#/definitions/localai.BackendLibraryInstallRequest' + responses: + "200": + description: installed + schema: + additionalProperties: + type: string + type: object + "404": + description: no such entry + schema: + $ref: '#/definitions/schema.ErrorResponse' + "409": + description: the name is installed from another origin + schema: + $ref: '#/definitions/schema.ErrorResponse' + "413": + description: the image is over the size limit + schema: + $ref: '#/definitions/schema.ErrorResponse' + summary: Install a backend from a library entry on this node. + tags: + - backends /backends/upgrade/{name}: post: parameters: