mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user