mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 00:59:28 -04:00
feat(backends): install a backend from a library entry
A library can now list backends. An entry of kind backend names the image of the backend gallery by digest, and POST /backends/library/install installs it on the local node. The gallery stays the source of truth and its install path is the one used: the library resolves the entry, takes the signature policy from the library, pins the image by digest and calls the same install as the gallery. Installing a name that came from a gallery, another library or by hand is refused and changes nothing. The signature policy of a backend library names the signer with an anchored identityRegex, so images signed by the merge workflow on master and on tags both verify. A pattern that is not anchored is refused. An install is bounded by a size limit per kind: add-ons keep 512 MiB and a backend image defaults to 8 GiB, set with --addons-backend-max-bytes. The limit reads the manifest after the signature check and before any layer is fetched. The metadata of an installed backend records the library it came from. scripts/backends/cmd/backendcatalog renders entries from backend/index.yaml and pins them against the registry. The pilot is the CPU build of local-store, in gallery/backends/index.yaml. The library is registered on the first start only with --addons-backend-library, which is off by default. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
23 files changed
+1061
-17
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}})
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/.+)$`,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-1
@@ -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": {
|
||||
|
||||
+66
-1
@@ -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": {
|
||||
|
||||
+52
-8
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user