diff --git a/core/cli/backends.go b/core/cli/backends.go index 91449032d..70b0e5697 100644 --- a/core/cli/backends.go +++ b/core/cli/backends.go @@ -63,6 +63,7 @@ func (bl *BackendsList) Run(ctx *cliContext.Context) error { systemState, err := system.GetSystemState( system.WithBackendSystemPath(bl.BackendsSystemPath), system.WithBackendPath(bl.BackendsPath), + system.WithRequireBackendIntegrity(bl.RequireBackendIntegrity), ) if err != nil { return err @@ -107,6 +108,7 @@ func (bi *BackendsInstall) Run(ctx *cliContext.Context) error { systemState, err := system.GetSystemState( system.WithBackendSystemPath(bi.BackendsSystemPath), system.WithBackendPath(bi.BackendsPath), + system.WithRequireBackendIntegrity(bi.RequireBackendIntegrity), ) if err != nil { return err @@ -144,6 +146,7 @@ func (bu *BackendsUpgrade) Run(ctx *cliContext.Context) error { systemState, err := system.GetSystemState( system.WithBackendSystemPath(bu.BackendsSystemPath), system.WithBackendPath(bu.BackendsPath), + system.WithRequireBackendIntegrity(bu.RequireBackendIntegrity), ) if err != nil { return err diff --git a/core/cli/models.go b/core/cli/models.go index c6961c402..7b09f5ed5 100644 --- a/core/cli/models.go +++ b/core/cli/models.go @@ -82,6 +82,7 @@ func (mi *ModelsInstall) Run(ctx *cliContext.Context) error { systemState, err := system.GetSystemState( system.WithModelPath(mi.ModelsPath), system.WithBackendPath(mi.BackendsPath), + system.WithRequireBackendIntegrity(mi.RequireBackendIntegrity), ) if err != nil { return err diff --git a/core/cli/run.go b/core/cli/run.go index 9e663687c..3dc165ecc 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -284,6 +284,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error { system.WithBackendImagesBranchTag(r.BackendImagesBranchTag), system.WithBackendDevSuffix(r.BackendDevSuffix), system.WithPreferDevelopmentBackends(r.PreferDevelopmentBackends), + system.WithRequireBackendIntegrity(r.RequireBackendIntegrity), ) if err != nil { return err diff --git a/core/gallery/gallery.go b/core/gallery/gallery.go index 04752b3d1..ffe250eed 100644 --- a/core/gallery/gallery.go +++ b/core/gallery/gallery.go @@ -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) } diff --git a/core/gallery/gallery_mirrors.go b/core/gallery/gallery_mirrors.go index a4b9384ed..0d5db2370 100644 --- a/core/gallery/gallery_mirrors.go +++ b/core/gallery/gallery_mirrors.go @@ -197,7 +197,7 @@ func persistGalleryIndex(basePath, url string, body []byte) { // If no candidate answers, the last known good copy on disk is served and its // path is returned as the source. Nothing else in the chain helps a machine // that has no network at all. -func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string) ([]byte, string, error) { +func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string, requireIntegrity bool) ([]byte, string, error) { candidates := galleryCandidates(g) if len(candidates) == 0 { return nil, "", fmt.Errorf("gallery %q has no URL", g.Name) @@ -218,12 +218,23 @@ func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string) ( attemptCtx, cancel := context.WithTimeout(ctx, galleryFetchTimeout) var body []byte - err := downloader.URI(candidate).ReadWithAuthorizationAndCallback( - attemptCtx, basePath, "", - func(_ string, d []byte) error { - body = d - return nil - }) + var err error + // An oci:// candidate is an artifact in a registry, not a document at + // a URL: it is pulled, optionally signature-checked and unpacked. The + // rest of this loop does not care which it was, so mirrors, cooldown, + // the per-candidate timeout and the last known good copy all work the + // same for both schemes, and a gallery can even mirror an OCI primary + // with an HTTP fallback. + if looksLikeOCIGallery(candidate) { + body, err = fetchOCIGalleryIndex(attemptCtx, g, candidate, basePath, requireIntegrity) + } else { + err = downloader.URI(candidate).ReadWithAuthorizationAndCallback( + attemptCtx, basePath, "", + func(_ string, d []byte) error { + body = d + return nil + }) + } cancel() if err == nil { diff --git a/core/gallery/gallery_mirrors_test.go b/core/gallery/gallery_mirrors_test.go index b631b7aeb..7903c63a9 100644 --- a/core/gallery/gallery_mirrors_test.go +++ b/core/gallery/gallery_mirrors_test.go @@ -126,7 +126,7 @@ var _ = Describe("fetchGalleryIndex", func() { body, served, err := fetchGalleryIndex(context.Background(), config.Gallery{ URL: primary.URL, Mirrors: []string{mirror.URL}, - }, tempModelsDir()) + }, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) Expect(served).To(Equal(mirror.URL)) Expect(string(body)).To(Equal("- name: from-mirror\n")) @@ -139,7 +139,7 @@ var _ = Describe("fetchGalleryIndex", func() { body, served, err := fetchGalleryIndex(context.Background(), config.Gallery{ URL: primary.URL, Mirrors: []string{mirror.URL}, - }, tempModelsDir()) + }, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) Expect(served).To(Equal(primary.URL)) Expect(string(body)).To(Equal("- name: from-primary\n")) @@ -152,13 +152,13 @@ var _ = Describe("fetchGalleryIndex", func() { _, _, err := fetchGalleryIndex(context.Background(), config.Gallery{ URL: down.URL, Mirrors: []string{down.URL + "/other"}, - }, tempModelsDir()) + }, tempModelsDir(), false) Expect(err).To(HaveOccurred(), "want an error when nothing can serve the index") Expect(hits.Load()).To(BeEquivalentTo(2), "want both candidates tried") }) It("errors for a gallery with neither a URL nor mirrors", func() { - _, _, err := fetchGalleryIndex(context.Background(), config.Gallery{Name: "empty"}, tempModelsDir()) + _, _, err := fetchGalleryIndex(context.Background(), config.Gallery{Name: "empty"}, tempModelsDir(), false) Expect(err).To(HaveOccurred()) }) @@ -172,7 +172,7 @@ var _ = Describe("fetchGalleryIndex", func() { body, served, err := fetchGalleryIndex(context.Background(), config.Gallery{ URL: primary.URL, Mirrors: []string{mirror.URL}, - }, tempModelsDir()) + }, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) Expect(served).To(Equal(mirror.URL), "a 404 body was taken for an index") Expect(string(body)).To(Equal("- name: from-mirror\n")) @@ -186,7 +186,7 @@ var _ = Describe("fetchGalleryIndex", func() { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, err := fetchGalleryIndex(ctx, config.Gallery{URL: srv.URL}, tempModelsDir()) + _, _, err := fetchGalleryIndex(ctx, config.Gallery{URL: srv.URL}, tempModelsDir(), false) Expect(err).To(HaveOccurred(), "want an error when the caller's context is already cancelled") Expect(hits.Load()).To(BeZero(), "server dialled despite a cancelled context") // The source did nothing wrong. Blaming it would blackhole a healthy @@ -229,7 +229,7 @@ var _ = Describe("fetchGalleryIndex", func() { done := make(chan outcome, 1) go func() { defer GinkgoRecover() - _, served, err := fetchGalleryIndex(context.Background(), g, basePath) + _, served, err := fetchGalleryIndex(context.Background(), g, basePath, false) done <- outcome{served, err} }() @@ -261,7 +261,7 @@ var _ = Describe("fetchGalleryIndex", func() { expireGalleryFailure(down.URL+"/a", time.Now()) expireGalleryFailure(down.URL+"/b", time.Now()) - _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir()) + _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false) Expect(err).To(HaveOccurred(), "want an error when nothing can serve the index") Expect(err.Error()).To(And( ContainSubstring("3 configured"), @@ -282,7 +282,7 @@ var _ = Describe("the gallery source cooldown", func() { g := config.Gallery{URL: primary.URL, Mirrors: []string{mirror.URL}} for i := 0; i < 3; i++ { - _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir()) + _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred(), "fetch %d", i) } Expect(hits.Load()).To(BeEquivalentTo(1), "primary re-dialled — cooldown is not holding") @@ -293,12 +293,12 @@ var _ = Describe("the gallery source cooldown", func() { mirror, _ := countingServer(http.StatusOK, "- name: from-mirror\n") g := config.Gallery{URL: primary.URL, Mirrors: []string{mirror.URL}} - _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir()) + _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) // Age the recorded failure past the cooldown rather than sleeping. expireGalleryFailure(primary.URL, time.Now().Add(-2*galleryFailureCooldown)) - _, _, err = fetchGalleryIndex(context.Background(), g, tempModelsDir()) + _, _, err = fetchGalleryIndex(context.Background(), g, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) Expect(hits.Load()).To(BeEquivalentTo(2), "primary was not re-dialled — cooldown never expired") }) @@ -317,7 +317,7 @@ var _ = Describe("the gallery source cooldown", func() { _, served, err := fetchGalleryIndex(context.Background(), config.Gallery{ URL: primary.URL, Mirrors: []string{mirror.URL}, - }, tempModelsDir()) + }, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) Expect(served).To(Equal(mirror.URL)) Expect(primaryHits.Load()).To(BeEquivalentTo(1), "cooldown should have been ignored, not obeyed") @@ -332,7 +332,7 @@ var _ = Describe("the gallery source cooldown", func() { expireGalleryFailure(srv.URL, time.Now()) g := config.Gallery{URL: srv.URL} for i := 0; i < 2; i++ { - _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir()) + _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred(), "fetch %d", i) } Expect(hits.Load()).To(BeEquivalentTo(2), "a successful fetch must clear the cooldown") @@ -352,7 +352,7 @@ var _ = Describe("getGalleryElements", func() { g := config.Gallery{Name: "mirror-fallback-spec", URL: primary.URL, Mirrors: []string{mirror.URL}} DeferCleanup(func() { galleryCache.Delete(g.Name + "-" + g.URL) }) - models, err := getGalleryElements(g, tempModelsDir(), func(*GalleryModel) bool { return false }) + models, err := getGalleryElements(g, tempModelsDir(), false, func(*GalleryModel) bool { return false }) Expect(err).ToNot(HaveOccurred()) Expect(models).To(HaveLen(1)) Expect(models[0].Name).To(Equal("mirror-model")) @@ -433,7 +433,7 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) body, err := os.ReadFile(galleryCachePath(base, srv.URL)) @@ -448,13 +448,13 @@ var _ = Describe("the last known good gallery index", func() { })) base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) srv.Close() // now nothing is reachable resetGalleryFailures() - body, served, err := fetchGalleryIndex(context.Background(), g, base) + body, served, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred(), "want the cached copy") Expect(string(body)).To(Equal("- name: cached\n")) Expect(served).To(Equal(galleryCachePath(base, srv.URL))) @@ -466,7 +466,7 @@ var _ = Describe("the last known good gallery index", func() { srv.Close() _, _, err := fetchGalleryIndex(context.Background(), - config.Gallery{URL: url, Name: "localai"}, tempModelsDir()) + config.Gallery{URL: url, Name: "localai"}, tempModelsDir(), false) Expect(err).To(HaveOccurred(), "want an error when there is neither a source nor a cached copy") }) @@ -481,7 +481,7 @@ var _ = Describe("the last known good gallery index", func() { Expect(os.WriteFile(filepath.Join(base, "..", "cache"), []byte("not a directory"), 0o600)).To(Succeed()) body, served, err := fetchGalleryIndex(context.Background(), - config.Gallery{URL: srv.URL, Name: "localai"}, base) + config.Gallery{URL: srv.URL, Name: "localai"}, base, false) Expect(err).ToNot(HaveOccurred(), "a cache write failure failed the whole fetch") Expect(served).To(Equal(srv.URL)) Expect(string(body)).To(Equal("- name: live\n")) @@ -495,7 +495,7 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: primary.URL, Mirrors: []string{mirror.URL}, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) body, err := os.ReadFile(galleryCachePath(base, g.URL)) @@ -519,11 +519,11 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) served = "- name: new\n" - body, from, err := fetchGalleryIndex(context.Background(), g, base) + body, from, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) Expect(string(body)).To(Equal("- name: new\n"), "want the live index from the source") Expect(from).To(Equal(srv.URL)) @@ -545,7 +545,7 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) cacheDir := filepath.Dir(galleryCachePath(base, g.URL)) @@ -555,7 +555,7 @@ var _ = Describe("the last known good gallery index", func() { srv.Close() resetGalleryFailures() - _, _, err = fetchGalleryIndex(context.Background(), g, base) + _, _, err = fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred(), "fallback") body, err := os.ReadFile(galleryCachePath(base, g.URL)) @@ -580,12 +580,12 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) // Now the same URL answers 200 with an interception page. served = "Sign in to the network\nPlease authenticate\n" - body, from, err := fetchGalleryIndex(context.Background(), g, base) + body, from, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) // The live body is still handed back — rejecting it here would hide the // failure from the caller that actually parses it. @@ -607,18 +607,18 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) // A proxy starts answering 200 with something that is not YAML at all. served = "\t\n\t 502 Bad Gateway\n\n" - _, _, err = fetchGalleryIndex(context.Background(), g, base) + _, _, err = fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) srv.Close() // and now the machine is offline resetGalleryFailures() - body, from, err := fetchGalleryIndex(context.Background(), g, base) + body, from, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred(), "offline fallback") Expect(from).To(Equal(galleryCachePath(base, g.URL)), "want the cached copy") @@ -644,11 +644,11 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) served = empty - _, _, err = fetchGalleryIndex(context.Background(), g, base) + _, _, err = fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) onDisk, err := os.ReadFile(galleryCachePath(base, g.URL)) @@ -668,7 +668,7 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) _, err = os.Stat(galleryCachePath(base, g.URL)) diff --git a/core/gallery/gallery_oci.go b/core/gallery/gallery_oci.go new file mode 100644 index 000000000..11735ecf6 --- /dev/null +++ b/core/gallery/gallery_oci.go @@ -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 +} diff --git a/core/gallery/gallery_oci_test.go b/core/gallery/gallery_oci_test.go new file mode 100644 index 000000000..4071d80ba --- /dev/null +++ b/core/gallery/gallery_oci_test.go @@ -0,0 +1,287 @@ +package gallery + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + + "github.com/google/go-containerregistry/pkg/registry" + "github.com/mudler/LocalAI/core/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras-go/v2/registry/remote" +) + +// errNoGallerySignature stands in for what the real verifier reports when an +// artifact has no signature attached. +var errNoGallerySignature = errors.New("no signature found for the gallery artifact") + +// stubGalleryVerifier replaces the signature check for the duration of a spec +// and restores it afterwards. +func stubGalleryVerifier(f func(context.Context, *config.GalleryVerification, string) error) { + GinkgoHelper() + original := verifyGalleryArtifact + verifyGalleryArtifact = f + DeferCleanup(func() { verifyGalleryArtifact = original }) +} + +// ociGalleryFile is one layer of a test gallery artifact: the title the puller +// lays the content out by, and the content itself. +type ociGalleryFile struct { + title string + body string +} + +// ociRegistry starts an in-process registry and returns it together with the +// counters the specs assert on: every request, and specifically blob reads. +// Blob reads are what "nothing was unpacked" means from the registry's side, +// so a spec can prove that a refused gallery never got as far as content. +func ociRegistry() (*httptest.Server, *atomic.Int64, *atomic.Int64) { + GinkgoHelper() + var requests, blobs atomic.Int64 + upstream := registry.New() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if strings.Contains(r.URL.Path, "/blobs/") { + blobs.Add(1) + } + upstream.ServeHTTP(w, r) + })) + DeferCleanup(srv.Close) + return srv, &requests, &blobs +} + +// pushGalleryArtifact publishes a gallery artifact to the in-process registry +// and returns the oci:// URL a gallery entry would carry. The publish goes +// through the same counting handler the fetch does, so specs reset the +// counters afterwards with resetCounters. +func pushGalleryArtifact(serverURL, repoPath, artifactType string, files []ociGalleryFile) string { + GinkgoHelper() + host := strings.TrimPrefix(serverURL, "http://") + ctx := context.Background() + + store := memory.New() + layers := []ocispec.Descriptor{} + for _, f := range files { + body := []byte(f.body) + desc := content.NewDescriptorFromBytes("application/yaml", body) + desc.Annotations = map[string]string{ocispec.AnnotationTitle: f.title} + Expect(store.Push(ctx, desc, bytes.NewReader(body))).To(Succeed()) + layers = append(layers, desc) + } + + manifestDesc, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_1, artifactType, oras.PackManifestOptions{ + Layers: layers, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(store.Tag(ctx, manifestDesc, "latest")).To(Succeed()) + + repo, err := remote.NewRepository(host + "/" + repoPath) + Expect(err).ToNot(HaveOccurred()) + repo.PlainHTTP = true + _, err = oras.Copy(ctx, store, "latest", repo, "latest", oras.DefaultCopyOptions) + Expect(err).ToNot(HaveOccurred()) + + return "oci://" + host + "/" + repoPath + ":latest" +} + +// resetCounters zeroes the request counters, so a spec measures the fetch and +// not the publish that set the scene for it. +func resetCounters(counters ...*atomic.Int64) { + for _, c := range counters { + c.Store(0) + } +} + +// ociCacheEntries lists what the OCI gallery cache root holds, so a spec can +// assert that a refused or failed pull left nothing behind at all, staging +// directories included. +func ociCacheEntries(basePath string) []string { + GinkgoHelper() + root := filepath.Join(basePath, "..", "cache", "gallery", "oci") + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil + } + Expect(err).ToNot(HaveOccurred()) + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} + +var _ = Describe("oci:// galleries", func() { + const index = "- name: premium-model\n" + + BeforeEach(resetGalleryFailures) + + It("reads the index from an artifact in a registry", func() { + srv, _, _ := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/premium", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + {title: "base/virtual.yaml", body: "- name: virtual\n"}, + }) + + base := tempModelsDir() + body, served, err := fetchGalleryIndex(context.Background(), + config.Gallery{URL: url, Name: "premium"}, base, false) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(index)) + Expect(served).To(Equal(url)) + + // The whole tree is unpacked, not just the index: entry URLs resolve + // against it. + Expect(filepath.Join(ociGalleryCacheDir(base, url), "base", "virtual.yaml")).To(BeAnExistingFile()) + }) + + It("serves a second fetch from the cache instead of pulling again", func() { + srv, requests, _ := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/cached", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + + base := tempModelsDir() + g := config.Gallery{URL: url, Name: "cached"} + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) + Expect(err).ToNot(HaveOccurred()) + + resetCounters(requests) + body, _, err := fetchGalleryIndex(context.Background(), g, base, false) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(index)) + Expect(requests.Load()).To(BeZero(), "the registry was contacted although the artifact was already cached") + }) + + It("refuses an artifact that is not a gallery", func() { + srv, _, _ := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/wrongtype", "application/vnd.oci.image.config.v1+json", []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + + base := tempModelsDir() + _, _, err := fetchGalleryIndex(context.Background(), + config.Gallery{URL: url, Name: "wrongtype"}, base, false) + Expect(err).To(HaveOccurred()) + Expect(ociCacheEntries(base)).To(BeEmpty()) + }) + + Context("with a verification policy", func() { + policy := &config.GalleryVerification{ + Issuer: "https://token.actions.githubusercontent.com", + IdentityRegex: "^https://github.com/localai/premium/.*$", + } + + It("refuses a gallery whose artifact carries no signature, and caches nothing", func() { + srv, _, blobs := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/unsigned", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + resetCounters(blobs) + + // The real verifier reaches the public Sigstore TUF mirror, which + // a test must not depend on. The seam keeps the spec about what + // this package decides: an artifact that does not verify is never + // unpacked. + stubGalleryVerifier(func(_ context.Context, _ *config.GalleryVerification, _ string) error { + return errNoGallerySignature + }) + + base := tempModelsDir() + _, _, err := fetchGalleryIndex(context.Background(), + config.Gallery{URL: url, Name: "unsigned", Verification: policy}, base, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("signature")) + Expect(ociCacheEntries(base)).To(BeEmpty(), "unverified content landed in the cache") + Expect(blobs.Load()).To(BeZero(), "content was fetched before the signature was verified") + }) + + It("verifies the digest, not the tag, and only then unpacks", func() { + srv, _, _ := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/signed", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + + verified := "" + stubGalleryVerifier(func(_ context.Context, _ *config.GalleryVerification, ref string) error { + verified = ref + return nil + }) + + base := tempModelsDir() + body, _, err := fetchGalleryIndex(context.Background(), + config.Gallery{URL: url, Name: "signed", Verification: policy}, base, false) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(index)) + Expect(verified).To(ContainSubstring("@sha256:"), "the tag was verified instead of the digest it resolved to") + Expect(verified).ToNot(ContainSubstring(":latest")) + }) + }) + + It("refuses an unsigned gallery in strict integrity mode", func() { + srv, _, blobs := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/strict", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + resetCounters(blobs) + + base := tempModelsDir() + // Strict integrity is the --require-backend-integrity / + // LOCALAI_REQUIRE_BACKEND_INTEGRITY switch, carried here on the system + // state the listing already had. + _, _, err := fetchGalleryIndex(context.Background(), + config.Gallery{URL: url, Name: "strict"}, base, true) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("verification")) + Expect(ociCacheEntries(base)).To(BeEmpty()) + Expect(blobs.Load()).To(BeZero()) + }) + + // A pull that dies halfway leaves partial files behind. Serving those as + // if they were the gallery is worse than failing: the user gets a + // truncated index with no sign that anything went wrong. + It("does not serve a half-written pull to a later fetch", func() { + var fail atomic.Bool + fail.Store(true) + upstream := registry.New() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fail.Load() && r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/") { + // Answer with content that does not match the descriptor, the + // way a broken proxy or a truncated transfer would. + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte("- name: trunc")) + return + } + upstream.ServeHTTP(w, r) + })) + DeferCleanup(srv.Close) + + url := pushGalleryArtifact(srv.URL, "galleries/partial", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + base := tempModelsDir() + g := config.Gallery{URL: url, Name: "partial"} + + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) + Expect(err).To(HaveOccurred()) + Expect(ociCacheEntries(base)).To(BeEmpty(), "a failed pull left a directory a later fetch would serve") + + // With the registry healthy again the gallery must come back with the + // real index, not with whatever the failed attempt left on disk. + fail.Store(false) + resetGalleryFailures() + body, _, err := fetchGalleryIndex(context.Background(), g, base, false) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(index)) + }) +}) diff --git a/core/services/worker/worker.go b/core/services/worker/worker.go index a74e04ff7..b40980462 100644 --- a/core/services/worker/worker.go +++ b/core/services/worker/worker.go @@ -43,6 +43,7 @@ func Run(ctx *cliContext.Context, cfg *Config) error { system.WithModelPath(cfg.ModelsPath), system.WithBackendPath(cfg.BackendsPath), system.WithBackendSystemPath(cfg.BackendsSystemPath), + system.WithRequireBackendIntegrity(cfg.RequireBackendIntegrity), ) if err != nil { return fmt.Errorf("getting system state: %w", err) diff --git a/pkg/oci/artifact.go b/pkg/oci/artifact.go index d622bd6f1..3048c05c9 100644 --- a/pkg/oci/artifact.go +++ b/pkg/oci/artifact.go @@ -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 /@sha256:, without fetching any content. +// +// A caller that must verify a signature before it unpacks anything needs the +// digest first: the signature is taken over the manifest digest, and pulling +// the same digest afterwards is what makes the verification bind to the bytes +// that land on disk. Verifying a tag and then pulling that tag again would +// leave a window in which the tag moved. +func ResolveArtifactDigestRef(ctx context.Context, ref string) (string, error) { + repo, err := artifactRepository(ref) + if err != nil { + return "", err + } + desc, err := repo.Resolve(ctx, repo.Reference.ReferenceOrDefault()) + if err != nil { + return "", fmt.Errorf("failed to resolve %q: %w", ref, err) + } + return repo.Reference.Registry + "/" + repo.Reference.Repository + "@" + desc.Digest.String(), nil +} + // plainHTTP mirrors go-containerregistry's scheme detection, which the image // paths of this package already rely on, so both agree on which registries are // not expected to serve TLS. diff --git a/pkg/oci/artifact_test.go b/pkg/oci/artifact_test.go index 8eef8fa2a..a44f59b8c 100644 --- a/pkg/oci/artifact_test.go +++ b/pkg/oci/artifact_test.go @@ -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()) + }) +}) diff --git a/pkg/system/state.go b/pkg/system/state.go index 2ad52b7d3..e87df528a 100644 --- a/pkg/system/state.go +++ b/pkg/system/state.go @@ -30,6 +30,13 @@ type SystemState struct { // backend URI (the released image becomes a fallback) rather than only using // development as a download fallback when the released image is missing. PreferDevelopmentBackends bool + + // RequireBackendIntegrity is the strict-integrity switch bound to + // --require-backend-integrity / LOCALAI_REQUIRE_BACKEND_INTEGRITY. The + // backend install path takes it as a call argument; the gallery index + // fetch reads it from here because the listing code it runs under never + // sees the application config. + RequireBackendIntegrity bool } type SystemStateOptions func(*SystemState) @@ -70,6 +77,12 @@ func WithBackendDevSuffix(suffix string) SystemStateOptions { } } +func WithRequireBackendIntegrity(require bool) SystemStateOptions { + return func(s *SystemState) { + s.RequireBackendIntegrity = require + } +} + func WithPreferDevelopmentBackends(prefer bool) SystemStateOptions { return func(s *SystemState) { s.PreferDevelopmentBackends = prefer