feat(gallery): galleries published as OCI artifacts (#12167)

* feat(oci): pull ORAS artifacts into a directory

Galleries published to an OCI registry arrive as an ORAS artifact whose
layers carry their tree path in org.opencontainers.image.title. Nothing in
pkg/oci could read a non-image manifest, so add PullArtifact: it lays the
layers out under a destination directory and returns the manifest digest so
callers can pin and verify signatures on it.

A registry is remote input and this writes files, so the manifest is
validated before any byte is fetched. A title that is absolute, escapes the
destination or is missing is refused, an artifact of an unexpected
artifactType is refused, and both the layer count and the total size are
capped with caller-settable limits.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5 [Claude Code]

* feat(gallery): fetch and verify galleries published as OCI artifacts

A gallery whose URL is oci://host/repo:tag is now pulled as an ORAS
artifact and its index.yaml read from the unpacked tree, alongside the
existing HTTP and file sources. Mirrors, the per-candidate timeout, the
failure cooldown and the last known good copy work the same for both
schemes, so a gallery can mirror an OCI primary with an HTTP fallback.

When the gallery declares a verification policy the artifact reference is
resolved to its manifest digest, the publisher signature is checked
against that digest, and only then is the same digest pulled. Nothing
unverified is written to disk or parsed. With strict integrity mode on,
an OCI gallery without a verification policy is refused instead of
warned about, which is what the flag already does for backend installs.

The pull lands in a staging directory that is renamed into the cache only
once the whole tree is on disk and the index reads back as an index, so a
failed or interrupted pull leaves nothing a later fetch would serve. A
fetch that finds a fresh cache entry does not contact the registry.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5 [Claude Code]

* feat(gallery): resolve entry urls relative to the gallery root

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. Such a
url reached the HTTP client verbatim and no entry of the gallery could
be installed.

A scheme-less entry url now resolves against the root the index came
from: the unpacked artifact for an oci:// gallery, and the directory of
the index URL otherwise. The relative path may not climb out of that
root, reusing the guard the artifact puller already applies to layer
titles, and an entry that does is dropped from the listing rather than
failing the whole gallery. An entry that names its own scheme is
untouched, so .ref indirection and local github: resolution behave as
before.

Reading an entry of an OCI gallery also needs the unpacked artifact
directory as its trusted root, since the cache is deliberately a sibling
of the models directory the downloader otherwise confines a file read
to.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5 [Claude Code]

* fix(oci): restrict artifact file permissions

Create artifact files with owner-only permissions to satisfy G302.
Document the existing path validation for the G304 scanner finding.
Check the permissions of downloaded layers in the artifact test.

Assisted-by: Codex:gpt-6 gosec

* fix(oci): confine artifact writes to their root

Use root-relative filesystem operations to reject escapes through existing
directory symlinks. Retain owner-only permissions and add a symlink escape
regression test, without suppressing the path traversal finding.

Assisted-by: Codex:gpt-6

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-21 09:23:05 +02:00
1 parent 875e3bfca5
commit 237cf456fd
16 files changed
+1422 -46

No files matched your search

+3
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+139
View File
@@ -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
}
+24 -6
View File
@@ -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)
}
+110
View File
@@ -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())
})
})
+18 -7
View File
@@ -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 {
+32 -32
View File
@@ -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 = "<html><head><title>Sign in to the network</title></head>\n<body>Please authenticate</body></html>\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<html>\n\t <body>502 Bad Gateway</body>\n</html>\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))
+209
View File
@@ -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
}
+287
View File
@@ -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))
})
})
+1
View File
@@ -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)
+1 -1
View File
@@ -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.
+54
View File
@@ -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 (`<MODELS_PATH>/../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
+308
View File
@@ -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 <registry>/<repository>@sha256:<hex>, 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
}
+221
View File
@@ -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())
})
})
+13
View File
@@ -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