diff --git a/core/cli/backends.go b/core/cli/backends.go
index 91449032d..70b0e5697 100644
--- a/core/cli/backends.go
+++ b/core/cli/backends.go
@@ -63,6 +63,7 @@ func (bl *BackendsList) Run(ctx *cliContext.Context) error {
systemState, err := system.GetSystemState(
system.WithBackendSystemPath(bl.BackendsSystemPath),
system.WithBackendPath(bl.BackendsPath),
+ system.WithRequireBackendIntegrity(bl.RequireBackendIntegrity),
)
if err != nil {
return err
@@ -107,6 +108,7 @@ func (bi *BackendsInstall) Run(ctx *cliContext.Context) error {
systemState, err := system.GetSystemState(
system.WithBackendSystemPath(bi.BackendsSystemPath),
system.WithBackendPath(bi.BackendsPath),
+ system.WithRequireBackendIntegrity(bi.RequireBackendIntegrity),
)
if err != nil {
return err
@@ -144,6 +146,7 @@ func (bu *BackendsUpgrade) Run(ctx *cliContext.Context) error {
systemState, err := system.GetSystemState(
system.WithBackendSystemPath(bu.BackendsSystemPath),
system.WithBackendPath(bu.BackendsPath),
+ system.WithRequireBackendIntegrity(bu.RequireBackendIntegrity),
)
if err != nil {
return err
diff --git a/core/cli/models.go b/core/cli/models.go
index c6961c402..7b09f5ed5 100644
--- a/core/cli/models.go
+++ b/core/cli/models.go
@@ -82,6 +82,7 @@ func (mi *ModelsInstall) Run(ctx *cliContext.Context) error {
systemState, err := system.GetSystemState(
system.WithModelPath(mi.ModelsPath),
system.WithBackendPath(mi.BackendsPath),
+ system.WithRequireBackendIntegrity(mi.RequireBackendIntegrity),
)
if err != nil {
return err
diff --git a/core/cli/run.go b/core/cli/run.go
index 9e663687c..3dc165ecc 100644
--- a/core/cli/run.go
+++ b/core/cli/run.go
@@ -284,6 +284,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
system.WithBackendImagesBranchTag(r.BackendImagesBranchTag),
system.WithBackendDevSuffix(r.BackendDevSuffix),
system.WithPreferDevelopmentBackends(r.PreferDevelopmentBackends),
+ system.WithRequireBackendIntegrity(r.RequireBackendIntegrity),
)
if err != nil {
return err
diff --git a/core/gallery/entry_url.go b/core/gallery/entry_url.go
new file mode 100644
index 000000000..737d8116a
--- /dev/null
+++ b/core/gallery/entry_url.go
@@ -0,0 +1,139 @@
+package gallery
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/pkg/downloader"
+ "github.com/mudler/LocalAI/pkg/oci"
+)
+
+// isRelativeEntryURL reports whether an entry url names a document inside the
+// gallery rather than a source of its own.
+//
+// The test is "no scheme at all" rather than a list of shapes to accept: every
+// transport the downloader knows announces itself with a prefix, and the bare
+// registry hosts it also accepts (quay.io, ghcr.io, docker.io) are covered by
+// LooksLikeOCI. Anything left is a path, and a path can only mean a path in
+// the gallery it was published in.
+func isRelativeEntryURL(entryURL string) bool {
+ if entryURL == "" {
+ return false
+ }
+ if strings.Contains(entryURL, "://") {
+ return false
+ }
+ uri := downloader.URI(entryURL)
+ return !uri.LooksLikeURL() && !uri.LooksLikeOCI()
+}
+
+// ociGalleryRoot returns the unpacked artifact directory a gallery's entries
+// resolve against, or "" when the gallery is not an OCI one or nothing was
+// unpacked for it.
+//
+// Mirrors are considered, and in the same order the fetch tries them: an OCI
+// gallery whose primary was unreachable is served by a mirror, and the entries
+// of the copy that actually landed on disk are the ones that can be installed.
+func ociGalleryRoot(g config.Gallery, basePath string) string {
+ for _, candidate := range galleryCandidates(g) {
+ if !looksLikeOCIGallery(candidate) {
+ continue
+ }
+ dir := ociGalleryCacheDir(basePath, candidate)
+ if dir == "" {
+ continue
+ }
+ if info, err := os.Stat(dir); err == nil && info.IsDir() {
+ return dir
+ }
+ }
+ return ""
+}
+
+// resolveGalleryEntryURL turns an entry url that is relative to the gallery
+// root into one the downloader can fetch, and leaves every other url alone.
+//
+// A gallery published as a self-contained tree, which is what an OCI gallery
+// is, names its base configs by their place in that tree: url: base/virtual.yaml
+// means the file next to the index, not a host somewhere. Without this the
+// string reaches the HTTP client verbatim and the entry cannot be installed at
+// all.
+//
+// The relative path may not climb out of the gallery root. It is published
+// data, so an entry that resolved above the root would let a gallery read
+// whatever it pointed at on the machine listing it.
+func resolveGalleryEntryURL(entryURL string, g config.Gallery, basePath string) (string, error) {
+ if !isRelativeEntryURL(entryURL) {
+ return entryURL, nil
+ }
+
+ if root := ociGalleryRoot(g, basePath); root != "" {
+ target, err := oci.ResolveInRoot(root, entryURL)
+ if err != nil {
+ return "", fmt.Errorf("entry url %q is not a path inside gallery %q: %w", entryURL, g.Name, err)
+ }
+ return downloader.LocalPrefix + target, nil
+ }
+
+ if looksLikeOCIGallery(g.URL) {
+ // The index came from the artifact, so the artifact is on disk. If it
+ // is not, the entry cannot be read from anywhere and saying so beats
+ // resolving it against a directory that is not the gallery.
+ return "", fmt.Errorf("entry url %q needs the unpacked artifact of gallery %q, which is not in the cache", entryURL, g.Name)
+ }
+
+ return resolveAgainstIndexURL(g.URL, entryURL)
+}
+
+// galleryConfigReadRoot returns the directory a gallery config read is
+// confined to.
+//
+// The downloader keeps a file:// read inside the base path it is given, and
+// that is normally the models directory. An entry of an OCI gallery lives in
+// the unpacked artifact instead, which is deliberately a sibling of the models
+// directory so unpacked YAML is never mistaken for an installed model config.
+// Reading it therefore needs the cache root as its own trusted root; the
+// downloader still resolves symlinks against it, so the confinement the models
+// directory gave is not lost, only moved to the directory the file is actually
+// in.
+func galleryConfigReadRoot(url, basePath string) string {
+ root := ociGalleryCacheRoot(basePath)
+ if root == "" || !strings.HasPrefix(url, downloader.LocalPrefix) {
+ return basePath
+ }
+ target := filepath.Clean(strings.TrimPrefix(url, downloader.LocalPrefix))
+ if target == root || strings.HasPrefix(target, root+string(os.PathSeparator)) {
+ return root
+ }
+ return basePath
+}
+
+// resolveAgainstIndexURL resolves a relative entry url against the directory
+// of the index URL.
+//
+// The URL is handled as text, the way findGalleryURLFromReferenceURL already
+// handles a .ref file, because these are not all RFC 3986 URLs: github: and
+// hf:// carry their own syntax. The one piece of that syntax that must survive
+// is the @branch a github: URL ends with, which belongs to the whole reference
+// and not to the file name.
+func resolveAgainstIndexURL(indexURL, relative string) (string, error) {
+ clean, err := oci.SafeRelativePath(relative)
+ if err != nil {
+ return "", fmt.Errorf("entry url %q is not a path inside the gallery: %w", relative, err)
+ }
+
+ cut := strings.LastIndex(indexURL, "/")
+ if cut < 0 {
+ return "", fmt.Errorf("gallery url %q names no directory to resolve %q against", indexURL, relative)
+ }
+
+ base, suffix := indexURL, ""
+ if at := strings.Index(indexURL[cut:], "@"); at >= 0 {
+ suffix = indexURL[cut+at:]
+ base = indexURL[:cut+at]
+ }
+ return base[:cut+1] + clean + suffix, nil
+}
diff --git a/core/gallery/gallery.go b/core/gallery/gallery.go
index 04752b3d1..f094cc4f3 100644
--- a/core/gallery/gallery.go
+++ b/core/gallery/gallery.go
@@ -46,7 +46,7 @@ func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) {
return config, err
}
uri := downloader.URI(url)
- err := uri.ReadWithCallback(basePath, func(url string, d []byte) error {
+ err := uri.ReadWithCallback(galleryConfigReadRoot(url, basePath), func(url string, d []byte) error {
return yaml.Unmarshal(d, &config)
})
if err != nil {
@@ -63,7 +63,7 @@ func GetGalleryConfigFromURLWithContext[T any](ctx context.Context, url string,
return config, err
}
uri := downloader.URI(url)
- err := uri.ReadWithAuthorizationAndCallback(ctx, basePath, "", func(url string, d []byte) error {
+ err := uri.ReadWithAuthorizationAndCallback(ctx, galleryConfigReadRoot(url, basePath), "", func(url string, d []byte) error {
return yaml.Unmarshal(d, &config)
})
if err != nil {
@@ -278,7 +278,7 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
// Get models from galleries
for _, gallery := range galleries {
- galleryModels, err := getGalleryElements(gallery, systemState.Model.ModelsPath, func(model *GalleryModel) bool {
+ galleryModels, err := getGalleryElements(gallery, systemState.Model.ModelsPath, systemState.RequireBackendIntegrity, func(model *GalleryModel) bool {
if _, err := os.Stat(filepath.Join(systemState.Model.ModelsPath, fmt.Sprintf("%s.yaml", model.GetName()))); err == nil {
return true
}
@@ -291,14 +291,32 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
// Resolve model URLs locally (for local galleries) and collect unique
// URLs that need fetching for backend resolution.
uniqueURLs := map[string]struct{}{}
+ usable := make([]*GalleryModel, 0, len(galleryModels))
for _, m := range galleryModels {
if m.URL != "" {
m.URL = resolveModelURLLocally(m.URL, gallery.URL)
+ // The gallery carried on the entry is the one the index was
+ // really read from, with a .ref indirection already followed,
+ // so it is the root an entry path is relative to.
+ resolved, err := resolveGalleryEntryURL(m.URL, m.GetGallery(), systemState.Model.ModelsPath)
+ if err != nil {
+ // One unusable entry must not cost the user the rest of
+ // the gallery, so it is dropped and named rather than
+ // failing the listing. It is left out entirely because an
+ // entry whose url does not resolve cannot be installed,
+ // and offering it would only fail later and further away.
+ xlog.Error("dropping a gallery entry whose url does not resolve",
+ "gallery", gallery.Name, "model", m.Name, "url", m.URL, "error", err)
+ continue
+ }
+ m.URL = resolved
}
+ usable = append(usable, m)
if m.Backend == "" && m.URL != "" {
uniqueURLs[m.URL] = struct{}{}
}
}
+ galleryModels = usable
// Pre-warm cache with parallel fetches to avoid sequential HTTP
// requests on cold start (~50 unique gallery config files).
@@ -543,7 +561,7 @@ func availableBackendsWithFilter(galleries []config.Gallery, systemState *system
// Get backends from galleries
for _, gallery := range galleries {
- galleryBackends, err := getGalleryElements(gallery, systemState.Backend.BackendsPath, func(backend *GalleryBackend) bool {
+ galleryBackends, err := getGalleryElements(gallery, systemState.Backend.BackendsPath, systemState.RequireBackendIntegrity, func(backend *GalleryBackend) bool {
return systemBackends.Exists(backend.GetName())
})
if err != nil {
@@ -591,7 +609,7 @@ func (entry galleryCacheEntry) hasExpired() bool {
var galleryCache = xsync.NewSyncedMap[string, galleryCacheEntry]()
-func getGalleryElements[T GalleryElement](gallery config.Gallery, basePath string, isInstalledCallback func(T) bool) ([]T, error) {
+func getGalleryElements[T GalleryElement](gallery config.Gallery, basePath string, requireIntegrity bool, isInstalledCallback func(T) bool) ([]T, error) {
var models []T = []T{}
if strings.HasSuffix(gallery.URL, ".ref") {
@@ -620,7 +638,7 @@ func getGalleryElements[T GalleryElement](gallery config.Gallery, basePath strin
// The cache key stays the gallery's identity rather than the URL that
// answered: a mirror serves the same index, so a mirror-served fetch
// must populate the entry the primary would have filled.
- body, servedBy, err := fetchGalleryIndex(context.Background(), gallery, basePath)
+ body, servedBy, err := fetchGalleryIndex(context.Background(), gallery, basePath, requireIntegrity)
if err != nil {
return models, fmt.Errorf("failed to read gallery elements: %w", err)
}
diff --git a/core/gallery/gallery_entry_url_test.go b/core/gallery/gallery_entry_url_test.go
new file mode 100644
index 000000000..00cfa880d
--- /dev/null
+++ b/core/gallery/gallery_entry_url_test.go
@@ -0,0 +1,110 @@
+package gallery
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/pkg/system"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// noProgress is the download callback the install specs do not care about.
+func noProgress(string, string, string, float64) {}
+
+// galleryModelNames lists what a listing produced, so a spec can say which
+// entries survived without depending on their order.
+func galleryModelNames(models GalleryElements[*GalleryModel]) []string {
+ names := []string{}
+ for _, m := range models {
+ names = append(names, m.Name)
+ }
+ return names
+}
+
+var _ = Describe("gallery entry URLs relative to the gallery root", func() {
+ BeforeEach(resetGalleryFailures)
+
+ It("installs an entry whose url is relative to an oci:// gallery root", func() {
+ srv, _, _ := ociRegistry()
+ url := pushGalleryArtifact(srv.URL, "galleries/relative", galleryArtifactType, []ociGalleryFile{
+ {title: "index.yaml", body: "- name: relative-entry\n url: base/virtual.yaml\n"},
+ {title: "base/virtual.yaml", body: "name: virtual\nconfig_file: |\n backend: llama\n"},
+ })
+
+ base := tempModelsDir()
+ systemState, err := system.GetSystemState(system.WithModelPath(base))
+ Expect(err).ToNot(HaveOccurred())
+ galleries := []config.Gallery{{Name: "relative", URL: url}}
+
+ models, err := AvailableGalleryModels(galleries, systemState)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(galleryModelNames(models)).To(ConsistOf("relative-entry"))
+
+ // The entry is only really resolved if it installs: the base config
+ // has to be read back out of the unpacked artifact.
+ err = InstallModelFromGallery(context.Background(), galleries, nil, systemState, nil,
+ "relative@relative-entry", GalleryModel{}, noProgress, false, false, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(filepath.Join(base, "relative-entry.yaml")).To(BeAnExistingFile())
+ })
+
+ It("resolves a relative entry url against the directory of an http gallery index", func() {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/gallery/index.yaml" {
+ _, _ = w.Write([]byte("- name: relative-entry\n url: base/virtual.yaml\n" +
+ "- name: absolute-entry\n url: https://example.invalid/some/other.yaml\n"))
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ DeferCleanup(srv.Close)
+
+ base := tempModelsDir()
+ systemState, err := system.GetSystemState(system.WithModelPath(base))
+ Expect(err).ToNot(HaveOccurred())
+
+ models, err := AvailableGalleryModels([]config.Gallery{{
+ Name: "http-relative",
+ URL: srv.URL + "/gallery/index.yaml",
+ }}, systemState)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(galleryModelNames(models)).To(ConsistOf("relative-entry", "absolute-entry"))
+
+ byName := map[string]string{}
+ for _, m := range models {
+ byName[m.Name] = m.URL
+ }
+ Expect(byName["relative-entry"]).To(Equal(srv.URL + "/gallery/base/virtual.yaml"))
+ // An entry that names its own source keeps it, whatever the gallery
+ // root is.
+ Expect(byName["absolute-entry"]).To(Equal("https://example.invalid/some/other.yaml"))
+ })
+
+ It("refuses an entry url that climbs out of the gallery root", func() {
+ srv, _, _ := ociRegistry()
+ url := pushGalleryArtifact(srv.URL, "galleries/escaping", galleryArtifactType, []ociGalleryFile{
+ {title: "index.yaml", body: "- name: escaping-entry\n url: ../../../etc/passwd\n" +
+ "- name: honest-entry\n url: base/virtual.yaml\n"},
+ {title: "base/virtual.yaml", body: "name: virtual\nconfig_file: |\n backend: llama\n"},
+ })
+
+ base := tempModelsDir()
+ systemState, err := system.GetSystemState(system.WithModelPath(base))
+ Expect(err).ToNot(HaveOccurred())
+ galleries := []config.Gallery{{Name: "escaping", URL: url}}
+
+ models, err := AvailableGalleryModels(galleries, systemState)
+ Expect(err).ToNot(HaveOccurred())
+ // The escaping entry is dropped, and a single bad entry does not cost
+ // the user the rest of the gallery.
+ Expect(galleryModelNames(models)).To(ConsistOf("honest-entry"))
+
+ err = InstallModelFromGallery(context.Background(), galleries, nil, systemState, nil,
+ "escaping@escaping-entry", GalleryModel{}, noProgress, false, false, false)
+ Expect(err).To(HaveOccurred())
+ })
+})
diff --git a/core/gallery/gallery_mirrors.go b/core/gallery/gallery_mirrors.go
index a4b9384ed..0d5db2370 100644
--- a/core/gallery/gallery_mirrors.go
+++ b/core/gallery/gallery_mirrors.go
@@ -197,7 +197,7 @@ func persistGalleryIndex(basePath, url string, body []byte) {
// If no candidate answers, the last known good copy on disk is served and its
// path is returned as the source. Nothing else in the chain helps a machine
// that has no network at all.
-func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string) ([]byte, string, error) {
+func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string, requireIntegrity bool) ([]byte, string, error) {
candidates := galleryCandidates(g)
if len(candidates) == 0 {
return nil, "", fmt.Errorf("gallery %q has no URL", g.Name)
@@ -218,12 +218,23 @@ func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string) (
attemptCtx, cancel := context.WithTimeout(ctx, galleryFetchTimeout)
var body []byte
- err := downloader.URI(candidate).ReadWithAuthorizationAndCallback(
- attemptCtx, basePath, "",
- func(_ string, d []byte) error {
- body = d
- return nil
- })
+ var err error
+ // An oci:// candidate is an artifact in a registry, not a document at
+ // a URL: it is pulled, optionally signature-checked and unpacked. The
+ // rest of this loop does not care which it was, so mirrors, cooldown,
+ // the per-candidate timeout and the last known good copy all work the
+ // same for both schemes, and a gallery can even mirror an OCI primary
+ // with an HTTP fallback.
+ if looksLikeOCIGallery(candidate) {
+ body, err = fetchOCIGalleryIndex(attemptCtx, g, candidate, basePath, requireIntegrity)
+ } else {
+ err = downloader.URI(candidate).ReadWithAuthorizationAndCallback(
+ attemptCtx, basePath, "",
+ func(_ string, d []byte) error {
+ body = d
+ return nil
+ })
+ }
cancel()
if err == nil {
diff --git a/core/gallery/gallery_mirrors_test.go b/core/gallery/gallery_mirrors_test.go
index b631b7aeb..7903c63a9 100644
--- a/core/gallery/gallery_mirrors_test.go
+++ b/core/gallery/gallery_mirrors_test.go
@@ -126,7 +126,7 @@ var _ = Describe("fetchGalleryIndex", func() {
body, served, err := fetchGalleryIndex(context.Background(), config.Gallery{
URL: primary.URL,
Mirrors: []string{mirror.URL},
- }, tempModelsDir())
+ }, tempModelsDir(), false)
Expect(err).ToNot(HaveOccurred())
Expect(served).To(Equal(mirror.URL))
Expect(string(body)).To(Equal("- name: from-mirror\n"))
@@ -139,7 +139,7 @@ var _ = Describe("fetchGalleryIndex", func() {
body, served, err := fetchGalleryIndex(context.Background(), config.Gallery{
URL: primary.URL,
Mirrors: []string{mirror.URL},
- }, tempModelsDir())
+ }, tempModelsDir(), false)
Expect(err).ToNot(HaveOccurred())
Expect(served).To(Equal(primary.URL))
Expect(string(body)).To(Equal("- name: from-primary\n"))
@@ -152,13 +152,13 @@ var _ = Describe("fetchGalleryIndex", func() {
_, _, err := fetchGalleryIndex(context.Background(), config.Gallery{
URL: down.URL,
Mirrors: []string{down.URL + "/other"},
- }, tempModelsDir())
+ }, tempModelsDir(), false)
Expect(err).To(HaveOccurred(), "want an error when nothing can serve the index")
Expect(hits.Load()).To(BeEquivalentTo(2), "want both candidates tried")
})
It("errors for a gallery with neither a URL nor mirrors", func() {
- _, _, err := fetchGalleryIndex(context.Background(), config.Gallery{Name: "empty"}, tempModelsDir())
+ _, _, err := fetchGalleryIndex(context.Background(), config.Gallery{Name: "empty"}, tempModelsDir(), false)
Expect(err).To(HaveOccurred())
})
@@ -172,7 +172,7 @@ var _ = Describe("fetchGalleryIndex", func() {
body, served, err := fetchGalleryIndex(context.Background(), config.Gallery{
URL: primary.URL,
Mirrors: []string{mirror.URL},
- }, tempModelsDir())
+ }, tempModelsDir(), false)
Expect(err).ToNot(HaveOccurred())
Expect(served).To(Equal(mirror.URL), "a 404 body was taken for an index")
Expect(string(body)).To(Equal("- name: from-mirror\n"))
@@ -186,7 +186,7 @@ var _ = Describe("fetchGalleryIndex", func() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
- _, _, err := fetchGalleryIndex(ctx, config.Gallery{URL: srv.URL}, tempModelsDir())
+ _, _, err := fetchGalleryIndex(ctx, config.Gallery{URL: srv.URL}, tempModelsDir(), false)
Expect(err).To(HaveOccurred(), "want an error when the caller's context is already cancelled")
Expect(hits.Load()).To(BeZero(), "server dialled despite a cancelled context")
// The source did nothing wrong. Blaming it would blackhole a healthy
@@ -229,7 +229,7 @@ var _ = Describe("fetchGalleryIndex", func() {
done := make(chan outcome, 1)
go func() {
defer GinkgoRecover()
- _, served, err := fetchGalleryIndex(context.Background(), g, basePath)
+ _, served, err := fetchGalleryIndex(context.Background(), g, basePath, false)
done <- outcome{served, err}
}()
@@ -261,7 +261,7 @@ var _ = Describe("fetchGalleryIndex", func() {
expireGalleryFailure(down.URL+"/a", time.Now())
expireGalleryFailure(down.URL+"/b", time.Now())
- _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir())
+ _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false)
Expect(err).To(HaveOccurred(), "want an error when nothing can serve the index")
Expect(err.Error()).To(And(
ContainSubstring("3 configured"),
@@ -282,7 +282,7 @@ var _ = Describe("the gallery source cooldown", func() {
g := config.Gallery{URL: primary.URL, Mirrors: []string{mirror.URL}}
for i := 0; i < 3; i++ {
- _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir())
+ _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false)
Expect(err).ToNot(HaveOccurred(), "fetch %d", i)
}
Expect(hits.Load()).To(BeEquivalentTo(1), "primary re-dialled — cooldown is not holding")
@@ -293,12 +293,12 @@ var _ = Describe("the gallery source cooldown", func() {
mirror, _ := countingServer(http.StatusOK, "- name: from-mirror\n")
g := config.Gallery{URL: primary.URL, Mirrors: []string{mirror.URL}}
- _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir())
+ _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false)
Expect(err).ToNot(HaveOccurred())
// Age the recorded failure past the cooldown rather than sleeping.
expireGalleryFailure(primary.URL, time.Now().Add(-2*galleryFailureCooldown))
- _, _, err = fetchGalleryIndex(context.Background(), g, tempModelsDir())
+ _, _, err = fetchGalleryIndex(context.Background(), g, tempModelsDir(), false)
Expect(err).ToNot(HaveOccurred())
Expect(hits.Load()).To(BeEquivalentTo(2), "primary was not re-dialled — cooldown never expired")
})
@@ -317,7 +317,7 @@ var _ = Describe("the gallery source cooldown", func() {
_, served, err := fetchGalleryIndex(context.Background(), config.Gallery{
URL: primary.URL,
Mirrors: []string{mirror.URL},
- }, tempModelsDir())
+ }, tempModelsDir(), false)
Expect(err).ToNot(HaveOccurred())
Expect(served).To(Equal(mirror.URL))
Expect(primaryHits.Load()).To(BeEquivalentTo(1), "cooldown should have been ignored, not obeyed")
@@ -332,7 +332,7 @@ var _ = Describe("the gallery source cooldown", func() {
expireGalleryFailure(srv.URL, time.Now())
g := config.Gallery{URL: srv.URL}
for i := 0; i < 2; i++ {
- _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir())
+ _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false)
Expect(err).ToNot(HaveOccurred(), "fetch %d", i)
}
Expect(hits.Load()).To(BeEquivalentTo(2), "a successful fetch must clear the cooldown")
@@ -352,7 +352,7 @@ var _ = Describe("getGalleryElements", func() {
g := config.Gallery{Name: "mirror-fallback-spec", URL: primary.URL, Mirrors: []string{mirror.URL}}
DeferCleanup(func() { galleryCache.Delete(g.Name + "-" + g.URL) })
- models, err := getGalleryElements(g, tempModelsDir(), func(*GalleryModel) bool { return false })
+ models, err := getGalleryElements(g, tempModelsDir(), false, func(*GalleryModel) bool { return false })
Expect(err).ToNot(HaveOccurred())
Expect(models).To(HaveLen(1))
Expect(models[0].Name).To(Equal("mirror-model"))
@@ -433,7 +433,7 @@ var _ = Describe("the last known good gallery index", func() {
base := tempModelsDir()
g := config.Gallery{URL: srv.URL, Name: "localai"}
- _, _, err := fetchGalleryIndex(context.Background(), g, base)
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
body, err := os.ReadFile(galleryCachePath(base, srv.URL))
@@ -448,13 +448,13 @@ var _ = Describe("the last known good gallery index", func() {
}))
base := tempModelsDir()
g := config.Gallery{URL: srv.URL, Name: "localai"}
- _, _, err := fetchGalleryIndex(context.Background(), g, base)
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
srv.Close() // now nothing is reachable
resetGalleryFailures()
- body, served, err := fetchGalleryIndex(context.Background(), g, base)
+ body, served, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred(), "want the cached copy")
Expect(string(body)).To(Equal("- name: cached\n"))
Expect(served).To(Equal(galleryCachePath(base, srv.URL)))
@@ -466,7 +466,7 @@ var _ = Describe("the last known good gallery index", func() {
srv.Close()
_, _, err := fetchGalleryIndex(context.Background(),
- config.Gallery{URL: url, Name: "localai"}, tempModelsDir())
+ config.Gallery{URL: url, Name: "localai"}, tempModelsDir(), false)
Expect(err).To(HaveOccurred(), "want an error when there is neither a source nor a cached copy")
})
@@ -481,7 +481,7 @@ var _ = Describe("the last known good gallery index", func() {
Expect(os.WriteFile(filepath.Join(base, "..", "cache"), []byte("not a directory"), 0o600)).To(Succeed())
body, served, err := fetchGalleryIndex(context.Background(),
- config.Gallery{URL: srv.URL, Name: "localai"}, base)
+ config.Gallery{URL: srv.URL, Name: "localai"}, base, false)
Expect(err).ToNot(HaveOccurred(), "a cache write failure failed the whole fetch")
Expect(served).To(Equal(srv.URL))
Expect(string(body)).To(Equal("- name: live\n"))
@@ -495,7 +495,7 @@ var _ = Describe("the last known good gallery index", func() {
base := tempModelsDir()
g := config.Gallery{URL: primary.URL, Mirrors: []string{mirror.URL}, Name: "localai"}
- _, _, err := fetchGalleryIndex(context.Background(), g, base)
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
body, err := os.ReadFile(galleryCachePath(base, g.URL))
@@ -519,11 +519,11 @@ var _ = Describe("the last known good gallery index", func() {
base := tempModelsDir()
g := config.Gallery{URL: srv.URL, Name: "localai"}
- _, _, err := fetchGalleryIndex(context.Background(), g, base)
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
served = "- name: new\n"
- body, from, err := fetchGalleryIndex(context.Background(), g, base)
+ body, from, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
Expect(string(body)).To(Equal("- name: new\n"), "want the live index from the source")
Expect(from).To(Equal(srv.URL))
@@ -545,7 +545,7 @@ var _ = Describe("the last known good gallery index", func() {
base := tempModelsDir()
g := config.Gallery{URL: srv.URL, Name: "localai"}
- _, _, err := fetchGalleryIndex(context.Background(), g, base)
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
cacheDir := filepath.Dir(galleryCachePath(base, g.URL))
@@ -555,7 +555,7 @@ var _ = Describe("the last known good gallery index", func() {
srv.Close()
resetGalleryFailures()
- _, _, err = fetchGalleryIndex(context.Background(), g, base)
+ _, _, err = fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred(), "fallback")
body, err := os.ReadFile(galleryCachePath(base, g.URL))
@@ -580,12 +580,12 @@ var _ = Describe("the last known good gallery index", func() {
base := tempModelsDir()
g := config.Gallery{URL: srv.URL, Name: "localai"}
- _, _, err := fetchGalleryIndex(context.Background(), g, base)
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
// Now the same URL answers 200 with an interception page.
served = "
Sign in to the network\nPlease authenticate\n"
- body, from, err := fetchGalleryIndex(context.Background(), g, base)
+ body, from, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
// The live body is still handed back — rejecting it here would hide the
// failure from the caller that actually parses it.
@@ -607,18 +607,18 @@ var _ = Describe("the last known good gallery index", func() {
base := tempModelsDir()
g := config.Gallery{URL: srv.URL, Name: "localai"}
- _, _, err := fetchGalleryIndex(context.Background(), g, base)
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
// A proxy starts answering 200 with something that is not YAML at all.
served = "\t\n\t 502 Bad Gateway\n\n"
- _, _, err = fetchGalleryIndex(context.Background(), g, base)
+ _, _, err = fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
srv.Close() // and now the machine is offline
resetGalleryFailures()
- body, from, err := fetchGalleryIndex(context.Background(), g, base)
+ body, from, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred(), "offline fallback")
Expect(from).To(Equal(galleryCachePath(base, g.URL)), "want the cached copy")
@@ -644,11 +644,11 @@ var _ = Describe("the last known good gallery index", func() {
base := tempModelsDir()
g := config.Gallery{URL: srv.URL, Name: "localai"}
- _, _, err := fetchGalleryIndex(context.Background(), g, base)
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
served = empty
- _, _, err = fetchGalleryIndex(context.Background(), g, base)
+ _, _, err = fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
onDisk, err := os.ReadFile(galleryCachePath(base, g.URL))
@@ -668,7 +668,7 @@ var _ = Describe("the last known good gallery index", func() {
base := tempModelsDir()
g := config.Gallery{URL: srv.URL, Name: "localai"}
- _, _, err := fetchGalleryIndex(context.Background(), g, base)
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
Expect(err).ToNot(HaveOccurred())
_, err = os.Stat(galleryCachePath(base, g.URL))
diff --git a/core/gallery/gallery_oci.go b/core/gallery/gallery_oci.go
new file mode 100644
index 000000000..27915dc6f
--- /dev/null
+++ b/core/gallery/gallery_oci.go
@@ -0,0 +1,209 @@
+package gallery
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/pkg/downloader"
+ "github.com/mudler/LocalAI/pkg/oci"
+ "github.com/mudler/xlog"
+)
+
+const (
+ // galleryArtifactType is what a published gallery declares as its
+ // artifactType. The puller refuses anything else, so a plain container
+ // image cannot be unpacked and read as a gallery.
+ galleryArtifactType = "application/vnd.localai.gallery.v1"
+
+ // galleryIndexFile is the layer title the index is published under. The
+ // rest of the tree is kept next to it because entry URLs resolve against
+ // the gallery root.
+ galleryIndexFile = "index.yaml"
+
+ // A gallery is a handful of small YAML files. Caps far below the puller's
+ // defaults keep a hostile or mistaken publisher from filling the disk of
+ // a machine that only asked to list some models.
+ maxGalleryArtifactLayers = 512
+ maxGalleryArtifactBytes = int64(64 << 20)
+)
+
+// ociGalleryCacheTTL is how long an unpacked gallery artifact is served
+// without asking the registry again.
+//
+// A gallery URL usually points at a moving tag, so an unpacked copy that never
+// expired would pin a machine to whatever the publisher shipped the first time
+// it looked. An hour matches the in-memory gallery cache in gallery.go, so a
+// refresh that reaches this layer is one the listing cache already decided to
+// make.
+//
+// A var so specs can drive expiry without sleeping.
+var ociGalleryCacheTTL = time.Hour
+
+// verifyGalleryArtifact checks the publisher signature over a digest-pinned
+// artifact reference.
+//
+// A var so tests can drive the policy path without reaching the public
+// Sigstore TUF mirror, which is a network dependency a unit test must not
+// have.
+var verifyGalleryArtifact = func(ctx context.Context, policy *config.GalleryVerification, digestRef string) error {
+ verifier, err := newGalleryVerifier(policy)
+ if err != nil {
+ return err
+ }
+ return verifier.VerifyImage(ctx, digestRef)
+}
+
+// looksLikeOCIGallery reports whether a gallery candidate is an OCI artifact
+// reference rather than something the HTTP downloader handles.
+//
+// Only the explicit oci:// scheme counts. downloader.URI.LooksLikeOCI also
+// treats bare quay.io/ghcr.io/docker.io prefixes as OCI, which is right for a
+// backend URI but wrong here: a gallery index served over HTTPS from one of
+// those hosts is a perfectly ordinary URL, and it is written with a scheme.
+func looksLikeOCIGallery(candidate string) bool {
+ return strings.HasPrefix(candidate, downloader.OCIPrefix)
+}
+
+// ociGalleryCacheDir is where an unpacked gallery artifact lives.
+//
+// It follows galleryCachePath's convention: a sibling of the models directory
+// so the unpacked YAML is never mistaken for an installed model config, named
+// by a digest of the gallery URL so two galleries cannot collide, and empty
+// for a non-absolute models directory because only an absolute one names a
+// location we can reason about.
+func ociGalleryCacheDir(basePath, url string) string {
+ root := ociGalleryCacheRoot(basePath)
+ if root == "" {
+ return ""
+ }
+ sum := sha256.Sum256([]byte(url))
+ return filepath.Join(root, hex.EncodeToString(sum[:]))
+}
+
+// ociGalleryCacheRoot is the directory every unpacked gallery artifact lives
+// under, or "" when the models directory does not name one we can reason
+// about.
+//
+// It is named on its own because it is also the trusted root a read of an
+// unpacked entry is confined to: the cache is deliberately a sibling of the
+// models directory, so the models directory cannot be that root.
+func ociGalleryCacheRoot(basePath string) string {
+ if !filepath.IsAbs(basePath) {
+ return ""
+ }
+ return filepath.Join(basePath, "..", "cache", "gallery", "oci")
+}
+
+// readCachedOCIGallery returns the cached index, if the cache holds one that
+// is fresh and usable.
+//
+// The directory only exists because a completed pull was renamed into place,
+// so its presence already means the content was verified before it landed.
+// The index is still probed: a directory truncated by a full disk or edited by
+// hand must not be served as if it were the gallery.
+func readCachedOCIGallery(cacheDir string) ([]byte, bool) {
+ info, err := os.Stat(cacheDir)
+ if err != nil || !info.IsDir() {
+ return nil, false
+ }
+ if time.Since(info.ModTime()) > ociGalleryCacheTTL {
+ return nil, false
+ }
+ // #nosec G304 -- cacheDir is ociGalleryCacheDir's own construction, a hex
+ // digest under a fixed directory, and the file name is a constant.
+ body, err := os.ReadFile(filepath.Join(cacheDir, galleryIndexFile))
+ if err != nil || !isUsableGalleryIndex(body) {
+ return nil, false
+ }
+ return body, true
+}
+
+// fetchOCIGalleryIndex pulls a gallery artifact from a registry and returns
+// its index.
+//
+// The pull lands in a staging directory and is renamed into place only once
+// the whole tree is on disk and the index reads back as an index. The puller
+// writes its layers with O_EXCL, so a directory holding partial files from an
+// interrupted attempt would fail every later pull; and a partial tree that a
+// later fetch served would hand the user a truncated gallery with no sign that
+// anything went wrong.
+func fetchOCIGalleryIndex(ctx context.Context, g config.Gallery, candidate, basePath string, requireIntegrity bool) ([]byte, error) {
+ cacheDir := ociGalleryCacheDir(basePath, candidate)
+ if cacheDir == "" {
+ return nil, fmt.Errorf("gallery %q needs an absolute models directory to cache %q", g.Name, candidate)
+ }
+ if body, ok := readCachedOCIGallery(cacheDir); ok {
+ return body, nil
+ }
+
+ pullRef := strings.TrimPrefix(candidate, downloader.OCIPrefix)
+
+ if g.Verification != nil {
+ // Resolve first, verify the digest, then pull that same digest.
+ // Nothing has been fetched at this point beyond the manifest, so a
+ // policy failure leaves no content anywhere.
+ digestRef, err := oci.ResolveArtifactDigestRef(ctx, pullRef)
+ if err != nil {
+ return nil, err
+ }
+ if err := verifyGalleryArtifact(ctx, g.Verification, digestRef); err != nil {
+ return nil, fmt.Errorf("gallery %q failed signature verification: %w", g.Name, err)
+ }
+ pullRef = digestRef
+ } else if requireIntegrity {
+ return nil, fmt.Errorf("strict integrity: gallery %q has no verification policy for %q (set verification: in the gallery configuration or disable --require-backend-integrity)", g.Name, candidate)
+ } else {
+ xlog.Warn("fetching an OCI gallery without signature verification",
+ "gallery", g.Name, "url", candidate)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(cacheDir), 0o750); err != nil {
+ return nil, fmt.Errorf("could not create the gallery cache directory: %w", err)
+ }
+ staging, err := os.MkdirTemp(filepath.Dir(cacheDir), ".staging-*")
+ if err != nil {
+ return nil, fmt.Errorf("could not stage the gallery artifact: %w", err)
+ }
+ // Removed on every path but the successful rename, which moves the
+ // directory out from under this call.
+ defer func() {
+ if rmErr := os.RemoveAll(staging); rmErr != nil && !os.IsNotExist(rmErr) {
+ xlog.Debug("could not clean up a gallery staging directory", "path", staging, "error", rmErr)
+ }
+ }()
+
+ if _, err := oci.PullArtifact(ctx, pullRef, staging,
+ oci.WithArtifactType(galleryArtifactType),
+ oci.WithMaxArtifactLayers(maxGalleryArtifactLayers),
+ oci.WithMaxArtifactBytes(maxGalleryArtifactBytes)); err != nil {
+ return nil, err
+ }
+
+ // #nosec G304 -- staging is this function's own temporary directory and
+ // the file name is a constant.
+ body, err := os.ReadFile(filepath.Join(staging, galleryIndexFile))
+ if err != nil {
+ return nil, fmt.Errorf("gallery artifact %q carries no %s: %w", candidate, galleryIndexFile, err)
+ }
+ if !isUsableGalleryIndex(body) {
+ return nil, fmt.Errorf("gallery artifact %q has an %s that is not a gallery index", candidate, galleryIndexFile)
+ }
+
+ // The bytes are already in hand, so a rename that loses a race with
+ // another fetch, or fails on a read-only cache, costs nothing but the next
+ // fetch pulling again. The caller still gets this gallery.
+ if err := os.RemoveAll(cacheDir); err != nil {
+ xlog.Debug("could not clear a stale gallery cache entry", "path", cacheDir, "error", err)
+ } else if err := os.Rename(staging, cacheDir); err != nil {
+ xlog.Debug("could not install the gallery cache entry", "path", cacheDir, "error", err)
+ }
+
+ return body, nil
+}
diff --git a/core/gallery/gallery_oci_test.go b/core/gallery/gallery_oci_test.go
new file mode 100644
index 000000000..4071d80ba
--- /dev/null
+++ b/core/gallery/gallery_oci_test.go
@@ -0,0 +1,287 @@
+package gallery
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+
+ "github.com/google/go-containerregistry/pkg/registry"
+ "github.com/mudler/LocalAI/core/config"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+ oras "oras.land/oras-go/v2"
+ "oras.land/oras-go/v2/content"
+ "oras.land/oras-go/v2/content/memory"
+ "oras.land/oras-go/v2/registry/remote"
+)
+
+// errNoGallerySignature stands in for what the real verifier reports when an
+// artifact has no signature attached.
+var errNoGallerySignature = errors.New("no signature found for the gallery artifact")
+
+// stubGalleryVerifier replaces the signature check for the duration of a spec
+// and restores it afterwards.
+func stubGalleryVerifier(f func(context.Context, *config.GalleryVerification, string) error) {
+ GinkgoHelper()
+ original := verifyGalleryArtifact
+ verifyGalleryArtifact = f
+ DeferCleanup(func() { verifyGalleryArtifact = original })
+}
+
+// ociGalleryFile is one layer of a test gallery artifact: the title the puller
+// lays the content out by, and the content itself.
+type ociGalleryFile struct {
+ title string
+ body string
+}
+
+// ociRegistry starts an in-process registry and returns it together with the
+// counters the specs assert on: every request, and specifically blob reads.
+// Blob reads are what "nothing was unpacked" means from the registry's side,
+// so a spec can prove that a refused gallery never got as far as content.
+func ociRegistry() (*httptest.Server, *atomic.Int64, *atomic.Int64) {
+ GinkgoHelper()
+ var requests, blobs atomic.Int64
+ upstream := registry.New()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests.Add(1)
+ if strings.Contains(r.URL.Path, "/blobs/") {
+ blobs.Add(1)
+ }
+ upstream.ServeHTTP(w, r)
+ }))
+ DeferCleanup(srv.Close)
+ return srv, &requests, &blobs
+}
+
+// pushGalleryArtifact publishes a gallery artifact to the in-process registry
+// and returns the oci:// URL a gallery entry would carry. The publish goes
+// through the same counting handler the fetch does, so specs reset the
+// counters afterwards with resetCounters.
+func pushGalleryArtifact(serverURL, repoPath, artifactType string, files []ociGalleryFile) string {
+ GinkgoHelper()
+ host := strings.TrimPrefix(serverURL, "http://")
+ ctx := context.Background()
+
+ store := memory.New()
+ layers := []ocispec.Descriptor{}
+ for _, f := range files {
+ body := []byte(f.body)
+ desc := content.NewDescriptorFromBytes("application/yaml", body)
+ desc.Annotations = map[string]string{ocispec.AnnotationTitle: f.title}
+ Expect(store.Push(ctx, desc, bytes.NewReader(body))).To(Succeed())
+ layers = append(layers, desc)
+ }
+
+ manifestDesc, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_1, artifactType, oras.PackManifestOptions{
+ Layers: layers,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(store.Tag(ctx, manifestDesc, "latest")).To(Succeed())
+
+ repo, err := remote.NewRepository(host + "/" + repoPath)
+ Expect(err).ToNot(HaveOccurred())
+ repo.PlainHTTP = true
+ _, err = oras.Copy(ctx, store, "latest", repo, "latest", oras.DefaultCopyOptions)
+ Expect(err).ToNot(HaveOccurred())
+
+ return "oci://" + host + "/" + repoPath + ":latest"
+}
+
+// resetCounters zeroes the request counters, so a spec measures the fetch and
+// not the publish that set the scene for it.
+func resetCounters(counters ...*atomic.Int64) {
+ for _, c := range counters {
+ c.Store(0)
+ }
+}
+
+// ociCacheEntries lists what the OCI gallery cache root holds, so a spec can
+// assert that a refused or failed pull left nothing behind at all, staging
+// directories included.
+func ociCacheEntries(basePath string) []string {
+ GinkgoHelper()
+ root := filepath.Join(basePath, "..", "cache", "gallery", "oci")
+ entries, err := os.ReadDir(root)
+ if os.IsNotExist(err) {
+ return nil
+ }
+ Expect(err).ToNot(HaveOccurred())
+ names := []string{}
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ return names
+}
+
+var _ = Describe("oci:// galleries", func() {
+ const index = "- name: premium-model\n"
+
+ BeforeEach(resetGalleryFailures)
+
+ It("reads the index from an artifact in a registry", func() {
+ srv, _, _ := ociRegistry()
+ url := pushGalleryArtifact(srv.URL, "galleries/premium", galleryArtifactType, []ociGalleryFile{
+ {title: "index.yaml", body: index},
+ {title: "base/virtual.yaml", body: "- name: virtual\n"},
+ })
+
+ base := tempModelsDir()
+ body, served, err := fetchGalleryIndex(context.Background(),
+ config.Gallery{URL: url, Name: "premium"}, base, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(body)).To(Equal(index))
+ Expect(served).To(Equal(url))
+
+ // The whole tree is unpacked, not just the index: entry URLs resolve
+ // against it.
+ Expect(filepath.Join(ociGalleryCacheDir(base, url), "base", "virtual.yaml")).To(BeAnExistingFile())
+ })
+
+ It("serves a second fetch from the cache instead of pulling again", func() {
+ srv, requests, _ := ociRegistry()
+ url := pushGalleryArtifact(srv.URL, "galleries/cached", galleryArtifactType, []ociGalleryFile{
+ {title: "index.yaml", body: index},
+ })
+
+ base := tempModelsDir()
+ g := config.Gallery{URL: url, Name: "cached"}
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ resetCounters(requests)
+ body, _, err := fetchGalleryIndex(context.Background(), g, base, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(body)).To(Equal(index))
+ Expect(requests.Load()).To(BeZero(), "the registry was contacted although the artifact was already cached")
+ })
+
+ It("refuses an artifact that is not a gallery", func() {
+ srv, _, _ := ociRegistry()
+ url := pushGalleryArtifact(srv.URL, "galleries/wrongtype", "application/vnd.oci.image.config.v1+json", []ociGalleryFile{
+ {title: "index.yaml", body: index},
+ })
+
+ base := tempModelsDir()
+ _, _, err := fetchGalleryIndex(context.Background(),
+ config.Gallery{URL: url, Name: "wrongtype"}, base, false)
+ Expect(err).To(HaveOccurred())
+ Expect(ociCacheEntries(base)).To(BeEmpty())
+ })
+
+ Context("with a verification policy", func() {
+ policy := &config.GalleryVerification{
+ Issuer: "https://token.actions.githubusercontent.com",
+ IdentityRegex: "^https://github.com/localai/premium/.*$",
+ }
+
+ It("refuses a gallery whose artifact carries no signature, and caches nothing", func() {
+ srv, _, blobs := ociRegistry()
+ url := pushGalleryArtifact(srv.URL, "galleries/unsigned", galleryArtifactType, []ociGalleryFile{
+ {title: "index.yaml", body: index},
+ })
+ resetCounters(blobs)
+
+ // The real verifier reaches the public Sigstore TUF mirror, which
+ // a test must not depend on. The seam keeps the spec about what
+ // this package decides: an artifact that does not verify is never
+ // unpacked.
+ stubGalleryVerifier(func(_ context.Context, _ *config.GalleryVerification, _ string) error {
+ return errNoGallerySignature
+ })
+
+ base := tempModelsDir()
+ _, _, err := fetchGalleryIndex(context.Background(),
+ config.Gallery{URL: url, Name: "unsigned", Verification: policy}, base, false)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("signature"))
+ Expect(ociCacheEntries(base)).To(BeEmpty(), "unverified content landed in the cache")
+ Expect(blobs.Load()).To(BeZero(), "content was fetched before the signature was verified")
+ })
+
+ It("verifies the digest, not the tag, and only then unpacks", func() {
+ srv, _, _ := ociRegistry()
+ url := pushGalleryArtifact(srv.URL, "galleries/signed", galleryArtifactType, []ociGalleryFile{
+ {title: "index.yaml", body: index},
+ })
+
+ verified := ""
+ stubGalleryVerifier(func(_ context.Context, _ *config.GalleryVerification, ref string) error {
+ verified = ref
+ return nil
+ })
+
+ base := tempModelsDir()
+ body, _, err := fetchGalleryIndex(context.Background(),
+ config.Gallery{URL: url, Name: "signed", Verification: policy}, base, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(body)).To(Equal(index))
+ Expect(verified).To(ContainSubstring("@sha256:"), "the tag was verified instead of the digest it resolved to")
+ Expect(verified).ToNot(ContainSubstring(":latest"))
+ })
+ })
+
+ It("refuses an unsigned gallery in strict integrity mode", func() {
+ srv, _, blobs := ociRegistry()
+ url := pushGalleryArtifact(srv.URL, "galleries/strict", galleryArtifactType, []ociGalleryFile{
+ {title: "index.yaml", body: index},
+ })
+ resetCounters(blobs)
+
+ base := tempModelsDir()
+ // Strict integrity is the --require-backend-integrity /
+ // LOCALAI_REQUIRE_BACKEND_INTEGRITY switch, carried here on the system
+ // state the listing already had.
+ _, _, err := fetchGalleryIndex(context.Background(),
+ config.Gallery{URL: url, Name: "strict"}, base, true)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("verification"))
+ Expect(ociCacheEntries(base)).To(BeEmpty())
+ Expect(blobs.Load()).To(BeZero())
+ })
+
+ // A pull that dies halfway leaves partial files behind. Serving those as
+ // if they were the gallery is worse than failing: the user gets a
+ // truncated index with no sign that anything went wrong.
+ It("does not serve a half-written pull to a later fetch", func() {
+ var fail atomic.Bool
+ fail.Store(true)
+ upstream := registry.New()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if fail.Load() && r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/") {
+ // Answer with content that does not match the descriptor, the
+ // way a broken proxy or a truncated transfer would.
+ w.Header().Set("Content-Type", "application/yaml")
+ _, _ = w.Write([]byte("- name: trunc"))
+ return
+ }
+ upstream.ServeHTTP(w, r)
+ }))
+ DeferCleanup(srv.Close)
+
+ url := pushGalleryArtifact(srv.URL, "galleries/partial", galleryArtifactType, []ociGalleryFile{
+ {title: "index.yaml", body: index},
+ })
+ base := tempModelsDir()
+ g := config.Gallery{URL: url, Name: "partial"}
+
+ _, _, err := fetchGalleryIndex(context.Background(), g, base, false)
+ Expect(err).To(HaveOccurred())
+ Expect(ociCacheEntries(base)).To(BeEmpty(), "a failed pull left a directory a later fetch would serve")
+
+ // With the registry healthy again the gallery must come back with the
+ // real index, not with whatever the failed attempt left on disk.
+ fail.Store(false)
+ resetGalleryFailures()
+ body, _, err := fetchGalleryIndex(context.Background(), g, base, false)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(body)).To(Equal(index))
+ })
+})
diff --git a/core/services/worker/worker.go b/core/services/worker/worker.go
index a74e04ff7..b40980462 100644
--- a/core/services/worker/worker.go
+++ b/core/services/worker/worker.go
@@ -43,6 +43,7 @@ func Run(ctx *cliContext.Context, cfg *Config) error {
system.WithModelPath(cfg.ModelsPath),
system.WithBackendPath(cfg.BackendsPath),
system.WithBackendSystemPath(cfg.BackendsSystemPath),
+ system.WithRequireBackendIntegrity(cfg.RequireBackendIntegrity),
)
if err != nil {
return fmt.Errorf("getting system state: %w", err)
diff --git a/docs/content/advanced/private-sources.md b/docs/content/advanced/private-sources.md
index 3f02fce07..3109d3bb7 100644
--- a/docs/content/advanced/private-sources.md
+++ b/docs/content/advanced/private-sources.md
@@ -84,7 +84,7 @@ The entry for such a registry needs `allow_insecure: true`, and its `match` must
## What uses the credentials
-- Gallery indexes and mirrors (`galleries`, `backend_galleries`), including `github:` URLs.
+- Gallery indexes and mirrors (`galleries`, `backend_galleries`), including `github:` URLs and galleries published as `oci://` artifacts.
- Model files and model configs downloaded over HTTP(S) or `github:`.
- Backend images and `oci://` / `ollama://` models, including resumed layer downloads and cosign signature checks.
diff --git a/docs/content/features/model-gallery.md b/docs/content/features/model-gallery.md
index bc8e524c6..4f0159ff0 100644
--- a/docs/content/features/model-gallery.md
+++ b/docs/content/features/model-gallery.md
@@ -231,6 +231,60 @@ Entries served this way may be stale: the copy is only as fresh as the last time
The copy is deliberately kept out of the models directory itself, where LocalAI reads a `.yaml` file as an installed model's configuration. Deleting the cache directory is safe — the next successful fetch recreates it — and a machine that has never reached a gallery has nothing cached, so its first listing still fails.
+## Galleries published as OCI artifacts
+
+A gallery can live in a container registry instead of on a web server. Give the gallery a `url` with the `oci://` scheme and point it at an artifact reference:
+
+```json
+GALLERIES=[{"name":"premium", "url":"oci://quay.io/acme/gallery:latest"}]
+```
+
+LocalAI pulls the artifact, unpacks it into a cache directory beside your models directory (`/../cache/gallery/oci/`) and reads `index.yaml` from it. The unpacked copy is reused for one hour before the registry is asked again. Everything else works as it does for an HTTP gallery: an `oci://` URL can be a primary `url` or one of the `mirrors`, a failed pull puts the source in the same 10 minute cooldown, and the offline cache still serves the last good listing.
+
+Downloaded artifact files are readable and writable only by the LocalAI process owner. File writes stay inside the cache directory, including when an existing subdirectory is a symbolic link.
+
+
+This is the only format that carries a whole gallery in one object, so it is what to publish when the index and the model configuration files must travel together.
+
+### Entry URLs relative to the gallery
+
+An artifact holds the index and the files it refers to, so an entry can name its base configuration by its place in the tree:
+
+```yaml
+- name: premium-model
+ url: base/virtual.yaml
+```
+
+A `url` with no scheme is resolved against the root of the gallery it was read from: the unpacked artifact for an `oci://` gallery, and the directory of the index URL for an `http://`, `https://`, `github:`, `huggingface://` or `file://` gallery. A `url` that names a scheme, such as `https://example.org/base.yaml`, is always used as written.
+
+A relative `url` cannot leave the gallery root. An entry that tries to climb out of it, for example `url: ../../etc/passwd`, is refused: LocalAI drops that entry from the listing, logs the reason and keeps the rest of the gallery.
+
+### Signature verification
+
+An `oci://` gallery can be signed, and LocalAI verifies the signature before it unpacks anything. Add a `verification` block with the Fulcio issuer and the signing identity, in the same form the [backend galleries]({{%relref "features/backends#verifying-oci-backends" %}}) use:
+
+```json
+GALLERIES=[{"name":"premium","url":"oci://quay.io/acme/gallery:latest","verification":{"issuer":"https://token.actions.githubusercontent.com","identity_regex":"^https://github\\.com/acme/gallery/\\.github/workflows/publish\\.yml@refs/tags/.+$"}}]
+```
+
+The tag is resolved to a digest, the signature is checked against that digest, and the same digest is then pulled. A gallery that fails verification is never written to the cache, so no unverified file reaches your disk. The optional `not_before` RFC3339 value revokes signatures logged before that time, exactly as it does for backends.
+
+{{% notice warning %}}
+With `--require-backend-integrity` (`LOCALAI_REQUIRE_BACKEND_INTEGRITY=1`), an `oci://` gallery that has no `verification` block is refused when the models are listed, not only when one is installed. Add a `verification` block to every `oci://` gallery before you turn strict integrity on, or the galleries without one stop listing. An `oci://` gallery without a policy still lists outside strict mode, with a warning in the log.
+{{% /notice %}}
+
+### Private registries
+
+A gallery in a private registry needs a credentials entry that matches the registry, the same entry an image pull from it would use:
+
+```yaml
+- match: quay.io/acme
+ username: bot
+ password_env: QUAY_TOKEN
+```
+
+See [Private Registries and Galleries]({{% relref "advanced/private-sources" %}}) for the file location, the other authentication types and the rules for registries on a local network, which also need `allow_insecure: true`.
+
## API Reference
### Model repositories
diff --git a/pkg/oci/artifact.go b/pkg/oci/artifact.go
new file mode 100644
index 000000000..3999aa6f8
--- /dev/null
+++ b/pkg/oci/artifact.go
@@ -0,0 +1,308 @@
+package oci
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/go-containerregistry/pkg/name"
+ "github.com/mudler/LocalAI/pkg/credentials"
+ "github.com/mudler/xlog"
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+ "oras.land/oras-go/v2/content"
+ "oras.land/oras-go/v2/registry/remote"
+ "oras.land/oras-go/v2/registry/remote/auth"
+ "oras.land/oras-go/v2/registry/remote/retry"
+)
+
+const (
+ // DefaultMaxArtifactLayers and DefaultMaxArtifactBytes bound what an
+ // unattended pull from a registry can cost on disk. A gallery tree is a
+ // handful of small YAML files, so the defaults are generous enough that a
+ // legitimate publisher never meets them.
+ DefaultMaxArtifactLayers = 1024
+ DefaultMaxArtifactBytes = int64(512 << 20)
+)
+
+// ArtifactPullOption configures PullArtifact.
+type ArtifactPullOption func(*artifactPullOptions)
+
+type artifactPullOptions struct {
+ artifactType string
+ maxLayers int
+ maxBytes int64
+}
+
+// WithArtifactType pins the artifactType the manifest must declare. It is
+// mandatory: without it a plain container image could be unpacked as if it
+// were the caller's own artifact kind.
+func WithArtifactType(artifactType string) ArtifactPullOption {
+ return func(o *artifactPullOptions) { o.artifactType = artifactType }
+}
+
+// WithMaxArtifactLayers caps how many layers the artifact may carry.
+func WithMaxArtifactLayers(layers int) ArtifactPullOption {
+ return func(o *artifactPullOptions) { o.maxLayers = layers }
+}
+
+// WithMaxArtifactBytes caps the total declared size of the artifact.
+func WithMaxArtifactBytes(bytes int64) ArtifactPullOption {
+ return func(o *artifactPullOptions) { o.maxBytes = bytes }
+}
+
+// PullArtifact pulls an ORAS artifact and lays its layers out under dest,
+// each at the relative path in its org.opencontainers.image.title annotation,
+// so a published tree keeps its subdirectories. It returns the resolved
+// manifest digest, which callers pin on and verify signatures against.
+//
+// Everything in the manifest is remote input and this writes files, so the
+// whole manifest is validated before the first byte is fetched: a layer that
+// would land outside dest, an unexpected artifact type or a tree over the
+// caps aborts the pull with nothing written.
+func PullArtifact(ctx context.Context, ref, dest string, opts ...ArtifactPullOption) (string, error) {
+ options := artifactPullOptions{
+ maxLayers: DefaultMaxArtifactLayers,
+ maxBytes: DefaultMaxArtifactBytes,
+ }
+ for _, o := range opts {
+ o(&options)
+ }
+ if options.artifactType == "" {
+ return "", fmt.Errorf("no expected artifact type given for %q", ref)
+ }
+
+ root, err := filepath.Abs(dest)
+ if err != nil {
+ return "", err
+ }
+
+ repo, err := artifactRepository(ref)
+ if err != nil {
+ return "", err
+ }
+
+ manifestDesc, rc, err := repo.FetchReference(ctx, repo.Reference.ReferenceOrDefault())
+ if err != nil {
+ return "", fmt.Errorf("failed to resolve %q: %w", ref, err)
+ }
+ defer func() { _ = rc.Close() }()
+
+ if manifestDesc.Size > options.maxBytes {
+ return "", fmt.Errorf("manifest of %q is %d bytes, over the %d byte limit", ref, manifestDesc.Size, options.maxBytes)
+ }
+ raw, err := content.ReadAll(rc, manifestDesc)
+ if err != nil {
+ return "", fmt.Errorf("failed to read the manifest of %q: %w", ref, err)
+ }
+ var manifest ocispec.Manifest
+ if err := json.Unmarshal(raw, &manifest); err != nil {
+ return "", fmt.Errorf("failed to parse the manifest of %q: %w", ref, err)
+ }
+
+ // An OCI 1.0 packer records the type in the config media type instead, so
+ // artifacts pushed by older tooling still identify themselves.
+ artifactType := manifest.ArtifactType
+ if artifactType == "" {
+ artifactType = manifest.Config.MediaType
+ }
+ if artifactType != options.artifactType {
+ return "", fmt.Errorf("%q is a %q artifact, expected %q", ref, artifactType, options.artifactType)
+ }
+
+ if len(manifest.Layers) > options.maxLayers {
+ return "", fmt.Errorf("%q has %d layers, over the %d layer limit", ref, len(manifest.Layers), options.maxLayers)
+ }
+
+ targets := make([]string, len(manifest.Layers))
+ var total int64
+ for i, layer := range manifest.Layers {
+ target, err := artifactLayerPath(root, layer.Annotations[ocispec.AnnotationTitle])
+ if err != nil {
+ return "", fmt.Errorf("refusing layer %d of %q: %w", i, ref, err)
+ }
+ targets[i], err = filepath.Rel(root, target)
+ if err != nil {
+ return "", err
+ }
+ if layer.Size < 0 {
+ return "", fmt.Errorf("layer %d of %q declares a negative size", i, ref)
+ }
+ total += layer.Size
+ if total > options.maxBytes {
+ return "", fmt.Errorf("%q is at least %d bytes, over the %d byte limit", ref, total, options.maxBytes)
+ }
+ }
+
+ if err := os.MkdirAll(root, 0o750); err != nil {
+ return "", err
+ }
+ // Confine directory creation and file writes even if dest contains symlinks.
+ output, err := os.OpenRoot(root)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = output.Close() }()
+
+ for i, layer := range manifest.Layers {
+ if err := writeArtifactLayer(ctx, repo, layer, output, targets[i]); err != nil {
+ return "", fmt.Errorf("failed to write layer %d of %q: %w", i, ref, err)
+ }
+ }
+
+ return manifestDesc.Digest.String(), nil
+}
+
+// artifactRepository builds the ORAS client a pull or a resolve talks to, with
+// the credentials and the scheme detection both paths must agree on.
+func artifactRepository(ref string) (*remote.Repository, error) {
+ repo, err := remote.NewRepository(ref)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create repository: %w", err)
+ }
+ repo.SkipReferrersGC = true
+ // Loopback and private-network registries are reached over plain HTTP,
+ // matching how the go-containerregistry paths in this package resolve the
+ // scheme, so a local registry behaves the same whichever puller is used.
+ repo.PlainHTTP = plainHTTP(repo.Reference.Registry)
+
+ client := &auth.Client{
+ Client: retry.DefaultClient,
+ Cache: auth.NewCache(),
+ }
+ client.SetUserAgent(UserAgent())
+ client.Credential = credentials.OrasCredential(repo.Reference.Registry + "/" + repo.Reference.Repository)
+ repo.Client = client
+ return repo, nil
+}
+
+// ResolveArtifactDigestRef resolves ref to a digest-pinned reference of the
+// form /@sha256:, without fetching any content.
+//
+// A caller that must verify a signature before it unpacks anything needs the
+// digest first: the signature is taken over the manifest digest, and pulling
+// the same digest afterwards is what makes the verification bind to the bytes
+// that land on disk. Verifying a tag and then pulling that tag again would
+// leave a window in which the tag moved.
+func ResolveArtifactDigestRef(ctx context.Context, ref string) (string, error) {
+ repo, err := artifactRepository(ref)
+ if err != nil {
+ return "", err
+ }
+ desc, err := repo.Resolve(ctx, repo.Reference.ReferenceOrDefault())
+ if err != nil {
+ return "", fmt.Errorf("failed to resolve %q: %w", ref, err)
+ }
+ return repo.Reference.Registry + "/" + repo.Reference.Repository + "@" + desc.Digest.String(), nil
+}
+
+// plainHTTP mirrors go-containerregistry's scheme detection, which the image
+// paths of this package already rely on, so both agree on which registries are
+// not expected to serve TLS.
+func plainHTTP(registry string) bool {
+ r, err := name.NewRegistry(registry)
+ if err != nil {
+ return false
+ }
+ return r.Scheme() == "http"
+}
+
+// SafeRelativePath validates a relative path that came from outside the
+// process and returns it cleaned, with forward slashes.
+//
+// A registry annotation and a gallery entry URL are both published data, so
+// they are treated as hostile: anything that is not a plain relative path is
+// refused rather than sanitized, since a path that needed rewriting is not a
+// path a publisher meant.
+//
+// It only judges the path's shape, so callers that resolve against something
+// which is not a directory on disk, such as the URL a gallery index was served
+// from, can use the same rule as the ones that do.
+func SafeRelativePath(relative string) (string, error) {
+ if relative == "" {
+ return "", fmt.Errorf("the path is empty")
+ }
+ if strings.ContainsAny(relative, "\\\x00") {
+ return "", fmt.Errorf("%q contains a backslash or NUL byte that is not valid in a relative path", relative)
+ }
+ if path.IsAbs(relative) || filepath.IsAbs(relative) || filepath.VolumeName(relative) != "" {
+ return "", fmt.Errorf("%q is an absolute path", relative)
+ }
+ clean := path.Clean(relative)
+ if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
+ return "", fmt.Errorf("%q escapes the root directory", relative)
+ }
+ return clean, nil
+}
+
+// ResolveInRoot resolves a relative path against root and refuses anything
+// that would land outside it. root is expected to be absolute and already
+// cleaned, which is what its callers hold.
+func ResolveInRoot(root, relative string) (string, error) {
+ clean, err := SafeRelativePath(relative)
+ if err != nil {
+ return "", err
+ }
+ target := filepath.Join(root, filepath.FromSlash(clean))
+ // filepath.Join cleans the result, so this catches anything the shape
+ // check could not see, a root with its own traversal in it included.
+ if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) {
+ return "", fmt.Errorf("%q escapes the root directory", relative)
+ }
+ return target, nil
+}
+
+// artifactLayerPath resolves a layer title against root.
+func artifactLayerPath(root, title string) (string, error) {
+ if title == "" {
+ return "", fmt.Errorf("the layer has no %s title", ocispec.AnnotationTitle)
+ }
+ target, err := ResolveInRoot(root, title)
+ if err != nil {
+ return "", fmt.Errorf("the layer title is not usable: %w", err)
+ }
+ return target, nil
+}
+
+func writeArtifactLayer(ctx context.Context, repo *remote.Repository, layer ocispec.Descriptor, root *os.Root, target string) error {
+ if err := root.MkdirAll(filepath.Dir(target), 0o750); err != nil {
+ return err
+ }
+ blob, err := repo.Fetch(ctx, layer)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = blob.Close() }()
+
+ // O_EXCL keeps the write from following a symlink already sitting at the
+ // target, and makes two layers claiming the same title an error instead of
+ // a silent overwrite.
+ f, err := root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ return err
+ }
+
+ // A registry that serves more bytes than the descriptor declares would
+ // otherwise blow past the size cap already accounted for.
+ verifier := content.NewVerifyReader(io.LimitReader(blob, layer.Size+1), layer)
+ _, err = io.Copy(f, verifier)
+ if err == nil {
+ err = verifier.Verify()
+ }
+ if closeErr := f.Close(); err == nil {
+ err = closeErr
+ }
+ if err != nil {
+ // Content that failed to verify must not be left behind for a caller
+ // to read as if it were the published layer.
+ if rmErr := root.Remove(target); rmErr != nil {
+ xlog.Debug("Could not remove a partially written artifact layer", "path", target, "error", rmErr)
+ }
+ return err
+ }
+ return nil
+}
diff --git a/pkg/oci/artifact_test.go b/pkg/oci/artifact_test.go
new file mode 100644
index 000000000..23d0b15fb
--- /dev/null
+++ b/pkg/oci/artifact_test.go
@@ -0,0 +1,221 @@
+package oci_test
+
+import (
+ "bytes"
+ "context"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/go-containerregistry/pkg/registry"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+ oras "oras.land/oras-go/v2"
+ "oras.land/oras-go/v2/content"
+ "oras.land/oras-go/v2/content/memory"
+ "oras.land/oras-go/v2/registry/remote"
+
+ localoci "github.com/mudler/LocalAI/pkg/oci"
+)
+
+const testGalleryArtifactType = "application/vnd.localai.gallery.v1"
+
+// artifactFile is one layer of a test artifact: the title annotation the
+// puller lays the content out by, and the content itself.
+type artifactFile struct {
+ title string
+ body string
+}
+
+// pushTestArtifact publishes an ORAS artifact to the in-process registry and
+// returns its reference and manifest digest, so specs can assert on the digest
+// the puller reports back.
+func pushTestArtifact(serverURL, repoPath, artifactType string, files []artifactFile) (string, string) {
+ host := strings.TrimPrefix(serverURL, "http://")
+ ctx := context.Background()
+
+ store := memory.New()
+ layers := []ocispec.Descriptor{}
+ for _, f := range files {
+ body := []byte(f.body)
+ desc := content.NewDescriptorFromBytes("application/yaml", body)
+ if f.title != "" {
+ desc.Annotations = map[string]string{ocispec.AnnotationTitle: f.title}
+ }
+ Expect(store.Push(ctx, desc, bytes.NewReader(body))).To(Succeed())
+ layers = append(layers, desc)
+ }
+
+ manifestDesc, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_1, artifactType, oras.PackManifestOptions{
+ Layers: layers,
+ })
+ Expect(err).NotTo(HaveOccurred())
+ Expect(store.Tag(ctx, manifestDesc, "latest")).To(Succeed())
+
+ repo, err := remote.NewRepository(host + "/" + repoPath)
+ Expect(err).NotTo(HaveOccurred())
+ repo.PlainHTTP = true
+ _, err = oras.Copy(ctx, store, "latest", repo, "latest", oras.DefaultCopyOptions)
+ Expect(err).NotTo(HaveOccurred())
+
+ return host + "/" + repoPath + ":latest", manifestDesc.Digest.String()
+}
+
+var _ = Describe("PullArtifact", func() {
+ var (
+ server *httptest.Server
+ dest string
+ )
+
+ BeforeEach(func() {
+ server = httptest.NewServer(registry.New())
+ DeferCleanup(server.Close)
+ // A nested destination keeps an escaping title landing in a fresh
+ // directory of this spec's own, not in the shared temp root.
+ dest = filepath.Join(GinkgoT().TempDir(), "tree")
+ Expect(os.MkdirAll(dest, 0o750)).To(Succeed())
+ })
+
+ It("writes every layer under the destination, preserving subdirectories", func() {
+ ref, manifestDigest := pushTestArtifact(server.URL, "galleries/premium", testGalleryArtifactType, []artifactFile{
+ {title: "index.yaml", body: "- name: one\n"},
+ {title: "base/virtual.yaml", body: "- name: two\n"},
+ })
+
+ digest, err := localoci.PullArtifact(context.Background(), ref, dest,
+ localoci.WithArtifactType(testGalleryArtifactType))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(digest).To(Equal(manifestDigest))
+
+ Expect(os.ReadFile(filepath.Join(dest, "index.yaml"))).To(BeEquivalentTo("- name: one\n"))
+ Expect(os.ReadFile(filepath.Join(dest, "base", "virtual.yaml"))).To(BeEquivalentTo("- name: two\n"))
+ for _, relative := range []string{"index.yaml", "base/virtual.yaml"} {
+ info, err := os.Stat(filepath.Join(dest, relative))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(info.Mode().Perm() & 0o077).To(BeZero())
+ }
+ })
+
+ It("refuses writes through a directory symlink outside the destination", func() {
+ outside := GinkgoT().TempDir()
+ Expect(os.Symlink(outside, filepath.Join(dest, "linked"))).To(Succeed())
+ ref, _ := pushTestArtifact(server.URL, "galleries/symlink", testGalleryArtifactType, []artifactFile{
+ {title: "linked/escape.yaml", body: "owned\n"},
+ })
+ _, err := localoci.PullArtifact(context.Background(), ref, dest, localoci.WithArtifactType(testGalleryArtifactType))
+ Expect(err).To(HaveOccurred())
+ Expect(filepath.Join(outside, "escape.yaml")).NotTo(BeAnExistingFile())
+ })
+
+ It("refuses a layer whose title escapes the destination with ..", func() {
+ ref, _ := pushTestArtifact(server.URL, "galleries/escape", testGalleryArtifactType, []artifactFile{
+ {title: "../escape.yaml", body: "owned\n"},
+ })
+
+ _, err := localoci.PullArtifact(context.Background(), ref, dest,
+ localoci.WithArtifactType(testGalleryArtifactType))
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("../escape.yaml"))
+ Expect(filepath.Join(filepath.Dir(dest), "escape.yaml")).NotTo(BeAnExistingFile())
+ })
+
+ It("refuses a layer whose title is an absolute path", func() {
+ outside := filepath.Join(GinkgoT().TempDir(), "absolute.yaml")
+ ref, _ := pushTestArtifact(server.URL, "galleries/absolute", testGalleryArtifactType, []artifactFile{
+ {title: outside, body: "owned\n"},
+ })
+
+ _, err := localoci.PullArtifact(context.Background(), ref, dest,
+ localoci.WithArtifactType(testGalleryArtifactType))
+ Expect(err).To(HaveOccurred())
+ Expect(outside).NotTo(BeAnExistingFile())
+ })
+
+ It("refuses a layer that carries no title", func() {
+ ref, _ := pushTestArtifact(server.URL, "galleries/untitled", testGalleryArtifactType, []artifactFile{
+ {title: "", body: "untitled\n"},
+ })
+
+ _, err := localoci.PullArtifact(context.Background(), ref, dest,
+ localoci.WithArtifactType(testGalleryArtifactType))
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("title"))
+ entries, readErr := os.ReadDir(dest)
+ Expect(readErr).NotTo(HaveOccurred())
+ Expect(entries).To(BeEmpty())
+ })
+
+ It("refuses an artifact published under a different artifact type", func() {
+ ref, _ := pushTestArtifact(server.URL, "galleries/wrongtype", "application/vnd.localai.backend.v1", []artifactFile{
+ {title: "index.yaml", body: "- name: one\n"},
+ })
+
+ _, err := localoci.PullArtifact(context.Background(), ref, dest,
+ localoci.WithArtifactType(testGalleryArtifactType))
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("application/vnd.localai.backend.v1"))
+ Expect(filepath.Join(dest, "index.yaml")).NotTo(BeAnExistingFile())
+ })
+
+ It("refuses an artifact larger than the total size cap", func() {
+ ref, _ := pushTestArtifact(server.URL, "galleries/toobig", testGalleryArtifactType, []artifactFile{
+ {title: "index.yaml", body: strings.Repeat("a", 4096)},
+ })
+
+ _, err := localoci.PullArtifact(context.Background(), ref, dest,
+ localoci.WithArtifactType(testGalleryArtifactType),
+ localoci.WithMaxArtifactBytes(1024))
+ Expect(err).To(HaveOccurred())
+ Expect(filepath.Join(dest, "index.yaml")).NotTo(BeAnExistingFile())
+ })
+
+ It("refuses an artifact with more layers than the layer cap", func() {
+ ref, _ := pushTestArtifact(server.URL, "galleries/toomany", testGalleryArtifactType, []artifactFile{
+ {title: "index.yaml", body: "- name: one\n"},
+ {title: "base/virtual.yaml", body: "- name: two\n"},
+ {title: "base/extra.yaml", body: "- name: three\n"},
+ })
+
+ _, err := localoci.PullArtifact(context.Background(), ref, dest,
+ localoci.WithArtifactType(testGalleryArtifactType),
+ localoci.WithMaxArtifactLayers(2))
+ Expect(err).To(HaveOccurred())
+ Expect(filepath.Join(dest, "index.yaml")).NotTo(BeAnExistingFile())
+ })
+})
+
+var _ = Describe("ResolveArtifactDigestRef", func() {
+ var server *httptest.Server
+
+ BeforeEach(func() {
+ server = httptest.NewServer(registry.New())
+ DeferCleanup(server.Close)
+ })
+
+ It("turns a tag into the digest the signature is taken over", func() {
+ ref, manifestDigest := pushTestArtifact(server.URL, "galleries/resolve", testGalleryArtifactType, []artifactFile{
+ {title: "index.yaml", body: "- name: one\n"},
+ })
+
+ digestRef, err := localoci.ResolveArtifactDigestRef(context.Background(), ref)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(digestRef).To(Equal(strings.TrimSuffix(ref, ":latest") + "@" + manifestDigest))
+
+ // The pinned reference must be pullable as it stands, since that is
+ // what a verified caller pulls once the signature checks out.
+ dest := filepath.Join(GinkgoT().TempDir(), "pinned")
+ Expect(os.MkdirAll(dest, 0o750)).To(Succeed())
+ _, err = localoci.PullArtifact(context.Background(), digestRef, dest,
+ localoci.WithArtifactType(testGalleryArtifactType))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(os.ReadFile(filepath.Join(dest, "index.yaml"))).To(BeEquivalentTo("- name: one\n"))
+ })
+
+ It("reports a reference that does not exist", func() {
+ host := strings.TrimPrefix(server.URL, "http://")
+ _, err := localoci.ResolveArtifactDigestRef(context.Background(), host+"/galleries/absent:latest")
+ Expect(err).To(HaveOccurred())
+ })
+})
diff --git a/pkg/system/state.go b/pkg/system/state.go
index 2ad52b7d3..e87df528a 100644
--- a/pkg/system/state.go
+++ b/pkg/system/state.go
@@ -30,6 +30,13 @@ type SystemState struct {
// backend URI (the released image becomes a fallback) rather than only using
// development as a download fallback when the released image is missing.
PreferDevelopmentBackends bool
+
+ // RequireBackendIntegrity is the strict-integrity switch bound to
+ // --require-backend-integrity / LOCALAI_REQUIRE_BACKEND_INTEGRITY. The
+ // backend install path takes it as a call argument; the gallery index
+ // fetch reads it from here because the listing code it runs under never
+ // sees the application config.
+ RequireBackendIntegrity bool
}
type SystemStateOptions func(*SystemState)
@@ -70,6 +77,12 @@ func WithBackendDevSuffix(suffix string) SystemStateOptions {
}
}
+func WithRequireBackendIntegrity(require bool) SystemStateOptions {
+ return func(s *SystemState) {
+ s.RequireBackendIntegrity = require
+ }
+}
+
func WithPreferDevelopmentBackends(prefer bool) SystemStateOptions {
return func(s *SystemState) {
s.PreferDevelopmentBackends = prefer