mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-21 05:34:56 -04:00
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]
This commit is contained in:
1 parent
9c5d1e4f5a
commit
22b146ab2b
12 files changed
+633
-58
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -543,7 +543,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 +591,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 +620,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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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 {
|
||||
if !filepath.IsAbs(basePath) {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(url))
|
||||
return filepath.Join(basePath, "..", "cache", "gallery", "oci", hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
+45
-15
@@ -81,23 +81,10 @@ func PullArtifact(ctx context.Context, ref, dest string, opts ...ArtifactPullOpt
|
||||
return "", err
|
||||
}
|
||||
|
||||
repo, err := remote.NewRepository(ref)
|
||||
repo, err := artifactRepository(ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create repository: %w", err)
|
||||
return "", 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
|
||||
|
||||
manifestDesc, rc, err := repo.FetchReference(ctx, repo.Reference.ReferenceOrDefault())
|
||||
if err != nil {
|
||||
@@ -157,6 +144,49 @@ func PullArtifact(ctx context.Context, ref, dest string, opts ...ArtifactPullOpt
|
||||
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.
|
||||
|
||||
@@ -169,3 +169,37 @@ var _ = Describe("PullArtifact", func() {
|
||||
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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user