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:
Ettore Di Giacinto committed 2026-10-21 20:15:10 +00:00
1 parent 484813f856
commit 1d8d3e3421
23 files changed
+1061 -17

No files matched your search

+151
View File
@@ -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)
}
}
}