mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-25 15:44:56 -04:00
fix(gallery): tie oci:// gallery caches to the verification policy (#12239)
The unpacked oci:// gallery cache and the last known good copy of the index were keyed on the gallery URL only. After an operator tightened a gallery's verification policy (added source_repository, moved not_before forward), content verified under the older policy, or under none, was still served for up to an hour from the unpacked cache, and indefinitely from the last known good copy while fetches failed. A fetch refused by signature verification also fell back to that last known good copy, so a refusal became a silent downgrade. Turning strict integrity on did not stop an unverified cached copy from being served either. Name both caches by the URL plus a stable hash of the policy. A gallery without a policy keeps its old URL-only name, so existing caches stay usable. A fetch refused by the policy, or by strict integrity, is now reported and never answered with a cached copy; a network failure still falls back, but only to a copy verified under the current policy. The strict integrity check runs before the cache is read. Assisted-by: Claude:claude-opus-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
3278b4a2ed
commit
f5c4083d7e
7 files changed
+267
-41
No files matched your search
@@ -42,7 +42,7 @@ func ociGalleryRoot(g config.Gallery, basePath string) string {
|
||||
if !looksLikeOCIGallery(candidate) {
|
||||
continue
|
||||
}
|
||||
dir := ociGalleryCacheDir(basePath, candidate)
|
||||
dir := ociGalleryCacheDir(basePath, candidate, g.Verification)
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -101,12 +103,34 @@ func inCooldown(url string) bool {
|
||||
// resolve the sibling against the process' working directory, which is not
|
||||
// somewhere LocalAI should be dropping files. Only an absolute models
|
||||
// directory names a location we can reason about.
|
||||
func galleryCachePath(basePath, url string) string {
|
||||
func galleryCachePath(basePath, url string, policy *config.GalleryVerification) string {
|
||||
if !filepath.IsAbs(basePath) {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(url))
|
||||
return filepath.Join(basePath, "..", "cache", "gallery", hex.EncodeToString(sum[:])+".yaml")
|
||||
return filepath.Join(basePath, "..", "cache", "gallery", galleryCacheName(url, policy)+".yaml")
|
||||
}
|
||||
|
||||
// galleryCacheName names a cached copy of a gallery by its URL and the
|
||||
// verification policy it was fetched under.
|
||||
//
|
||||
// The policy is part of the name because a cached copy is only as trusted as
|
||||
// the policy that admitted it. Keyed on the URL alone, a copy verified under
|
||||
// an older, looser policy (or none) kept being served after an operator
|
||||
// tightened it, until the cache expired or, while fetches failed, forever. A
|
||||
// changed policy now simply finds no copy and fetches again.
|
||||
//
|
||||
// A gallery without a policy keeps the URL-only name it always had, so the
|
||||
// copies already on disk stay usable across an upgrade.
|
||||
func galleryCacheName(url string, policy *config.GalleryVerification) string {
|
||||
key := url
|
||||
if policy != nil {
|
||||
// Marshalling a struct is deterministic (field order, no maps), which
|
||||
// is all a stable key needs. It cannot fail for a struct of strings.
|
||||
encoded, _ := json.Marshal(policy)
|
||||
key = url + "\x00" + string(encoded)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// isUsableGalleryIndex reports whether body is worth keeping as the last known
|
||||
@@ -143,8 +167,8 @@ func isUsableGalleryIndex(body []byte) bool {
|
||||
// Every failure here is logged at debug and otherwise ignored: the copy is an
|
||||
// optimisation, and a read-only or full disk must not turn a gallery that was
|
||||
// fetched perfectly well into a failed listing.
|
||||
func persistGalleryIndex(basePath, url string, body []byte) {
|
||||
path := galleryCachePath(basePath, url)
|
||||
func persistGalleryIndex(basePath, url string, policy *config.GalleryVerification, body []byte) {
|
||||
path := galleryCachePath(basePath, url, policy)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
@@ -213,7 +237,7 @@ func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string, r
|
||||
attempt = candidates
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var lastErr, refused error
|
||||
for _, candidate := range attempt {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, galleryFetchTimeout)
|
||||
|
||||
@@ -244,11 +268,15 @@ func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string, r
|
||||
// Keyed on the gallery's own URL rather than the candidate that
|
||||
// answered: a mirror serves the same index, so a mirror-served
|
||||
// fetch must refresh the copy an offline run will look for.
|
||||
persistGalleryIndex(basePath, g.URL, body)
|
||||
persistGalleryIndex(basePath, g.URL, g.Verification, body)
|
||||
return body, candidate, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
var notVerified *galleryVerificationError
|
||||
if errors.As(err, ¬Verified) {
|
||||
refused = err
|
||||
}
|
||||
// Only blame the source for its own failures. If the caller gave up —
|
||||
// a browser disconnecting mid-listing, once a request context is wired
|
||||
// through here — recording that would blackhole every candidate for ten
|
||||
@@ -260,10 +288,19 @@ func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string, r
|
||||
"gallery", g.Name, "url", candidate, "error", err)
|
||||
}
|
||||
|
||||
// A source that answered with content the policy refuses is not an
|
||||
// outage: it is the publisher (or someone in between) serving something
|
||||
// this machine must not trust. Falling back to an older copy there would
|
||||
// turn a refusal into a silent downgrade, so it is reported instead.
|
||||
if refused != nil {
|
||||
return nil, "", fmt.Errorf("gallery %q was refused by its verification policy and no cached copy is served: %w", g.Name, refused)
|
||||
}
|
||||
|
||||
// Every source failed. A copy from a previous run is much better than no
|
||||
// gallery at all — this is what lets an offline or airgapped machine still
|
||||
// list what it already knows about.
|
||||
cachePath := galleryCachePath(basePath, g.URL)
|
||||
// list what it already knows about. The copy is looked up under the
|
||||
// current policy, so it is one that policy admitted.
|
||||
cachePath := galleryCachePath(basePath, g.URL, g.Verification)
|
||||
if cachePath != "" {
|
||||
// #nosec G304 -- cachePath is galleryCachePath's own construction: a
|
||||
// hex sha256 of the URL under the fixed <basePath>/../cache/gallery
|
||||
|
||||
@@ -369,7 +369,7 @@ var _ = Describe("galleryCachePath", func() {
|
||||
// interpreted as an installed model config.
|
||||
It("is outside the models directory", func() {
|
||||
base := tempModelsDir()
|
||||
got := galleryCachePath(base, "https://example/index.yaml")
|
||||
got := galleryCachePath(base, "https://example/index.yaml", nil)
|
||||
Expect(filepath.Dir(got)).ToNot(Equal(base), "cache path is inside the models directory")
|
||||
|
||||
// Nor anywhere below it: the models directory is walked and listed, and
|
||||
@@ -385,19 +385,19 @@ var _ = Describe("galleryCachePath", func() {
|
||||
// from being served as the other.
|
||||
It("distinguishes galleries", func() {
|
||||
base := tempModelsDir()
|
||||
models := galleryCachePath(base, "https://example/index.yaml")
|
||||
backends := galleryCachePath(base, "https://example/backends.yaml")
|
||||
models := galleryCachePath(base, "https://example/index.yaml", nil)
|
||||
backends := galleryCachePath(base, "https://example/backends.yaml", nil)
|
||||
Expect(models).ToNot(Equal(backends), "one gallery would overwrite the other")
|
||||
Expect(galleryCachePath(base, "https://example/index.yaml")).To(Equal(models),
|
||||
Expect(galleryCachePath(base, "https://example/index.yaml", nil)).To(Equal(models),
|
||||
"the same gallery URL produced two different cache paths")
|
||||
})
|
||||
|
||||
// Without a models directory there is no sensible place for the cache, and
|
||||
// a relative path would write next to the process' working directory.
|
||||
It("yields nothing without a models directory", func() {
|
||||
Expect(galleryCachePath("", "https://example/index.yaml")).To(BeEmpty())
|
||||
Expect(galleryCachePath("", "https://example/index.yaml", nil)).To(BeEmpty())
|
||||
// Must not panic or write anywhere either.
|
||||
persistGalleryIndex("", "https://example/index.yaml", []byte("- name: x\n"))
|
||||
persistGalleryIndex("", "https://example/index.yaml", nil, []byte("- name: x\n"))
|
||||
})
|
||||
|
||||
// A relative models directory is the same failure as an empty one: "." and
|
||||
@@ -405,10 +405,10 @@ var _ = Describe("galleryCachePath", func() {
|
||||
// be running in, which is exactly what the guard exists to prevent.
|
||||
DescribeTable("rejects a relative models directory",
|
||||
func(base string) {
|
||||
Expect(galleryCachePath(base, "https://example/index.yaml")).To(BeEmpty(),
|
||||
Expect(galleryCachePath(base, "https://example/index.yaml", nil)).To(BeEmpty(),
|
||||
"it resolves against the working directory")
|
||||
// And nothing may be written next to the working directory either.
|
||||
persistGalleryIndex(base, "https://example/index.yaml", []byte("- name: x\n"))
|
||||
persistGalleryIndex(base, "https://example/index.yaml", nil, []byte("- name: x\n"))
|
||||
},
|
||||
Entry("the working directory itself", "."),
|
||||
Entry("a bare relative name", "models"),
|
||||
@@ -418,7 +418,7 @@ var _ = Describe("galleryCachePath", func() {
|
||||
|
||||
// Sanity: the guard must still let a real absolute models directory through.
|
||||
It("accepts an absolute models directory", func() {
|
||||
Expect(galleryCachePath(tempModelsDir(), "https://example/index.yaml")).ToNot(BeEmpty())
|
||||
Expect(galleryCachePath(tempModelsDir(), "https://example/index.yaml", nil)).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -436,7 +436,7 @@ var _ = Describe("the last known good gallery index", func() {
|
||||
_, _, err := fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
body, err := os.ReadFile(galleryCachePath(base, srv.URL))
|
||||
body, err := os.ReadFile(galleryCachePath(base, srv.URL, nil))
|
||||
Expect(err).ToNot(HaveOccurred(), "no cached copy written")
|
||||
Expect(string(body)).To(Equal("- name: cached\n"))
|
||||
})
|
||||
@@ -457,7 +457,7 @@ var _ = Describe("the last known good gallery index", func() {
|
||||
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)))
|
||||
Expect(served).To(Equal(galleryCachePath(base, srv.URL, nil)))
|
||||
})
|
||||
|
||||
It("cannot rescue a fetch when there is no copy and no network", func() {
|
||||
@@ -498,11 +498,11 @@ var _ = Describe("the last known good gallery index", func() {
|
||||
_, _, err := fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
body, err := os.ReadFile(galleryCachePath(base, g.URL))
|
||||
body, err := os.ReadFile(galleryCachePath(base, g.URL, nil))
|
||||
Expect(err).ToNot(HaveOccurred(), "no copy cached under the gallery's own URL")
|
||||
Expect(string(body)).To(Equal("- name: from-mirror\n"))
|
||||
|
||||
_, err = os.ReadFile(galleryCachePath(base, mirror.URL))
|
||||
_, err = os.ReadFile(galleryCachePath(base, mirror.URL, nil))
|
||||
Expect(err).To(HaveOccurred(),
|
||||
"the copy was cached under the mirror's URL, where an offline run will not look for it")
|
||||
})
|
||||
@@ -528,7 +528,7 @@ var _ = Describe("the last known good gallery index", func() {
|
||||
Expect(string(body)).To(Equal("- name: new\n"), "want the live index from the source")
|
||||
Expect(from).To(Equal(srv.URL))
|
||||
|
||||
onDisk, err := os.ReadFile(galleryCachePath(base, srv.URL))
|
||||
onDisk, err := os.ReadFile(galleryCachePath(base, srv.URL, nil))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(onDisk)).To(Equal("- name: new\n"),
|
||||
"the cached copy was not refreshed with what the source served")
|
||||
@@ -548,7 +548,7 @@ var _ = Describe("the last known good gallery index", func() {
|
||||
_, _, err := fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
cacheDir := filepath.Dir(galleryCachePath(base, g.URL))
|
||||
cacheDir := filepath.Dir(galleryCachePath(base, g.URL, nil))
|
||||
entries, err := os.ReadDir(cacheDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entries).To(HaveLen(1), "want just the index — a staging file was left behind")
|
||||
@@ -558,7 +558,7 @@ var _ = Describe("the last known good gallery index", func() {
|
||||
_, _, err = fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).ToNot(HaveOccurred(), "fallback")
|
||||
|
||||
body, err := os.ReadFile(galleryCachePath(base, g.URL))
|
||||
body, err := os.ReadFile(galleryCachePath(base, g.URL, nil))
|
||||
Expect(err).ToNot(HaveOccurred(), "the cached copy is gone after a failed fetch")
|
||||
Expect(string(body)).To(Equal("- name: cached\n"), "want it untouched by a failed fetch")
|
||||
|
||||
@@ -592,7 +592,7 @@ var _ = Describe("the last known good gallery index", func() {
|
||||
Expect(from).To(Equal(srv.URL))
|
||||
Expect(string(body)).To(Equal(served), "want the live response")
|
||||
|
||||
onDisk, err := os.ReadFile(galleryCachePath(base, g.URL))
|
||||
onDisk, err := os.ReadFile(galleryCachePath(base, g.URL, nil))
|
||||
Expect(err).ToNot(HaveOccurred(), "the cached copy is gone")
|
||||
Expect(string(onDisk)).To(Equal("- name: cached\n"), "a 200 HTML page overwrote the good index")
|
||||
})
|
||||
@@ -620,7 +620,7 @@ var _ = Describe("the last known good gallery index", func() {
|
||||
|
||||
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")
|
||||
Expect(from).To(Equal(galleryCachePath(base, g.URL, nil)), "want the cached copy")
|
||||
|
||||
// Readable by the offline path means parseable, not merely present.
|
||||
var models []GalleryModel
|
||||
@@ -651,7 +651,7 @@ var _ = Describe("the last known good gallery index", func() {
|
||||
_, _, err = fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
onDisk, err := os.ReadFile(galleryCachePath(base, g.URL))
|
||||
onDisk, err := os.ReadFile(galleryCachePath(base, g.URL, nil))
|
||||
Expect(err).ToNot(HaveOccurred(), "the cached copy is gone after an empty body %q", empty)
|
||||
Expect(string(onDisk)).To(Equal("- name: cached\n"), "want the populated index kept")
|
||||
},
|
||||
@@ -671,7 +671,7 @@ var _ = Describe("the last known good gallery index", func() {
|
||||
_, _, err := fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = os.Stat(galleryCachePath(base, g.URL))
|
||||
_, err = os.Stat(galleryCachePath(base, g.URL, nil))
|
||||
Expect(err).To(HaveOccurred(), "an HTML page was written as the last known good gallery index")
|
||||
})
|
||||
})
|
||||
+23
-10
@@ -2,8 +2,6 @@ package gallery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -34,6 +32,15 @@ const (
|
||||
maxGalleryArtifactBytes = int64(64 << 20)
|
||||
)
|
||||
|
||||
// galleryVerificationError marks a fetch that reached the source and was
|
||||
// refused by the verification policy (or by strict integrity), as opposed to
|
||||
// one that could not reach it. The caller must not answer a refusal with an
|
||||
// older cached copy.
|
||||
type galleryVerificationError struct{ err error }
|
||||
|
||||
func (e *galleryVerificationError) Error() string { return e.err.Error() }
|
||||
func (e *galleryVerificationError) Unwrap() error { return e.err }
|
||||
|
||||
// ociGalleryCacheTTL is how long an unpacked gallery artifact is served
|
||||
// without asking the registry again.
|
||||
//
|
||||
@@ -75,16 +82,17 @@ func looksLikeOCIGallery(candidate string) bool {
|
||||
//
|
||||
// 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
|
||||
// by a digest of the gallery URL and its verification policy so two galleries
|
||||
// cannot collide and a changed policy never reuses what another one admitted,
|
||||
// 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 {
|
||||
func ociGalleryCacheDir(basePath, url string, policy *config.GalleryVerification) string {
|
||||
root := ociGalleryCacheRoot(basePath)
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(url))
|
||||
return filepath.Join(root, hex.EncodeToString(sum[:]))
|
||||
return filepath.Join(root, galleryCacheName(url, policy))
|
||||
}
|
||||
|
||||
// ociGalleryCacheRoot is the directory every unpacked gallery artifact lives
|
||||
@@ -135,7 +143,14 @@ func readCachedOCIGallery(cacheDir string) ([]byte, bool) {
|
||||
// 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)
|
||||
// Checked before the cache: a copy unpacked while strict integrity was
|
||||
// off was never verified, and turning strict integrity on must not keep
|
||||
// serving it for the rest of its TTL.
|
||||
if g.Verification == nil && requireIntegrity {
|
||||
return nil, &galleryVerificationError{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)}
|
||||
}
|
||||
|
||||
cacheDir := ociGalleryCacheDir(basePath, candidate, g.Verification)
|
||||
if cacheDir == "" {
|
||||
return nil, fmt.Errorf("gallery %q needs an absolute models directory to cache %q", g.Name, candidate)
|
||||
}
|
||||
@@ -154,11 +169,9 @@ func fetchOCIGalleryIndex(ctx context.Context, g config.Gallery, candidate, base
|
||||
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)
|
||||
return nil, &galleryVerificationError{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)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// expireOCIGalleryCache makes every unpacked gallery artifact stale for the
|
||||
// rest of the spec, so the next fetch goes back to the registry the way it
|
||||
// would an hour later.
|
||||
func expireOCIGalleryCache() {
|
||||
GinkgoHelper()
|
||||
original := ociGalleryCacheTTL
|
||||
ociGalleryCacheTTL = 0
|
||||
DeferCleanup(func() { ociGalleryCacheTTL = original })
|
||||
}
|
||||
|
||||
var _ = Describe("oci:// gallery caches and the verification policy", func() {
|
||||
const index = "- name: acme-model\n"
|
||||
|
||||
loose := &config.GalleryVerification{
|
||||
Issuer: "https://token.actions.githubusercontent.com",
|
||||
IdentityRegex: "^https://github.com/acme/.*$",
|
||||
}
|
||||
tightened := &config.GalleryVerification{
|
||||
Issuer: "https://token.actions.githubusercontent.com",
|
||||
IdentityRegex: "^https://github.com/acme/.*$",
|
||||
SourceRepository: "https://github.com/acme/gallery",
|
||||
}
|
||||
|
||||
BeforeEach(resetGalleryFailures)
|
||||
|
||||
// countingVerifier stubs the signature check: it counts the calls and
|
||||
// refuses any policy the refuse func rejects.
|
||||
countingVerifier := func(refuse func(*config.GalleryVerification) bool) *atomic.Int64 {
|
||||
GinkgoHelper()
|
||||
var calls atomic.Int64
|
||||
stubGalleryVerifier(func(_ context.Context, p *config.GalleryVerification, _ string) error {
|
||||
calls.Add(1)
|
||||
if refuse(p) {
|
||||
return errNoGallerySignature
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return &calls
|
||||
}
|
||||
|
||||
It("fetches and verifies again when the policy changes", func() {
|
||||
srv, requests, _ := ociRegistry()
|
||||
url := pushGalleryArtifact(srv.URL, "galleries/acme", galleryArtifactType, []ociGalleryFile{
|
||||
{title: "index.yaml", body: index},
|
||||
})
|
||||
calls := countingVerifier(func(*config.GalleryVerification) bool { return false })
|
||||
base := tempModelsDir()
|
||||
|
||||
_, _, err := fetchGalleryIndex(context.Background(),
|
||||
config.Gallery{URL: url, Name: "acme", Verification: loose}, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(calls.Load()).To(Equal(int64(1)))
|
||||
|
||||
resetCounters(requests)
|
||||
body, _, err := fetchGalleryIndex(context.Background(),
|
||||
config.Gallery{URL: url, Name: "acme", Verification: tightened}, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(body)).To(Equal(index))
|
||||
Expect(calls.Load()).To(Equal(int64(2)), "content verified under the old policy was reused under the new one")
|
||||
Expect(requests.Load()).ToNot(BeZero(), "the registry was not contacted after the policy changed")
|
||||
})
|
||||
|
||||
It("does not serve content verified under the old policy when the new one refuses it", func() {
|
||||
srv, _, _ := ociRegistry()
|
||||
url := pushGalleryArtifact(srv.URL, "galleries/acme", galleryArtifactType, []ociGalleryFile{
|
||||
{title: "index.yaml", body: index},
|
||||
})
|
||||
countingVerifier(func(p *config.GalleryVerification) bool { return p.SourceRepository != "" })
|
||||
base := tempModelsDir()
|
||||
|
||||
_, _, err := fetchGalleryIndex(context.Background(),
|
||||
config.Gallery{URL: url, Name: "acme", Verification: loose}, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
body, served, err := fetchGalleryIndex(context.Background(),
|
||||
config.Gallery{URL: url, Name: "acme", Verification: tightened}, base, false)
|
||||
Expect(err).To(HaveOccurred(), "served %q from %q", string(body), served)
|
||||
Expect(err.Error()).To(ContainSubstring("signature"))
|
||||
})
|
||||
|
||||
It("does not fall back to the last known good copy after a verification failure", func() {
|
||||
srv, _, _ := ociRegistry()
|
||||
url := pushGalleryArtifact(srv.URL, "galleries/acme", galleryArtifactType, []ociGalleryFile{
|
||||
{title: "index.yaml", body: index},
|
||||
})
|
||||
var refuse atomic.Bool
|
||||
countingVerifier(func(*config.GalleryVerification) bool { return refuse.Load() })
|
||||
base := tempModelsDir()
|
||||
g := config.Gallery{URL: url, Name: "acme", Verification: loose}
|
||||
|
||||
_, _, err := fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(galleryCachePath(base, url, loose)).To(BeARegularFile())
|
||||
|
||||
// The publisher's artifact now fails the same policy, for example
|
||||
// because it was re-signed by an identity the policy does not trust.
|
||||
expireOCIGalleryCache()
|
||||
refuse.Store(true)
|
||||
|
||||
body, served, err := fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).To(HaveOccurred(), "served %q from %q", string(body), served)
|
||||
Expect(err.Error()).To(ContainSubstring("signature"))
|
||||
})
|
||||
|
||||
It("does not serve an unverified copy once strict integrity is on", func() {
|
||||
srv, _, _ := ociRegistry()
|
||||
url := pushGalleryArtifact(srv.URL, "galleries/acme", galleryArtifactType, []ociGalleryFile{
|
||||
{title: "index.yaml", body: index},
|
||||
})
|
||||
base := tempModelsDir()
|
||||
g := config.Gallery{URL: url, Name: "acme"}
|
||||
|
||||
_, _, err := fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
body, served, err := fetchGalleryIndex(context.Background(), g, base, true)
|
||||
Expect(err).To(HaveOccurred(), "served %q from %q", string(body), served)
|
||||
Expect(err.Error()).To(ContainSubstring("verification"))
|
||||
})
|
||||
|
||||
Context("when the registry is unreachable", func() {
|
||||
It("falls back to a copy verified under the same policy", func() {
|
||||
srv, _, _ := ociRegistry()
|
||||
url := pushGalleryArtifact(srv.URL, "galleries/acme", galleryArtifactType, []ociGalleryFile{
|
||||
{title: "index.yaml", body: index},
|
||||
})
|
||||
countingVerifier(func(*config.GalleryVerification) bool { return false })
|
||||
base := tempModelsDir()
|
||||
g := config.Gallery{URL: url, Name: "acme", Verification: loose}
|
||||
|
||||
_, _, err := fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
expireOCIGalleryCache()
|
||||
srv.Close()
|
||||
|
||||
body, served, err := fetchGalleryIndex(context.Background(), g, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(body)).To(Equal(index))
|
||||
Expect(served).To(Equal(galleryCachePath(base, url, loose)))
|
||||
})
|
||||
|
||||
It("does not fall back to a copy verified under another policy", func() {
|
||||
srv, _, _ := ociRegistry()
|
||||
url := pushGalleryArtifact(srv.URL, "galleries/acme", galleryArtifactType, []ociGalleryFile{
|
||||
{title: "index.yaml", body: index},
|
||||
})
|
||||
countingVerifier(func(*config.GalleryVerification) bool { return false })
|
||||
base := tempModelsDir()
|
||||
|
||||
_, _, err := fetchGalleryIndex(context.Background(),
|
||||
config.Gallery{URL: url, Name: "acme", Verification: loose}, base, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
expireOCIGalleryCache()
|
||||
srv.Close()
|
||||
|
||||
body, served, err := fetchGalleryIndex(context.Background(),
|
||||
config.Gallery{URL: url, Name: "acme", Verification: tightened}, base, false)
|
||||
Expect(err).To(HaveOccurred(), "served %q from %q", string(body), served)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -142,7 +142,7 @@ var _ = Describe("oci:// galleries", func() {
|
||||
|
||||
// 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())
|
||||
Expect(filepath.Join(ociGalleryCacheDir(base, url, nil), "base", "virtual.yaml")).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("serves a second fetch from the cache instead of pulling again", func() {
|
||||
|
||||
@@ -136,6 +136,8 @@ GALLERIES=[{"name":"premium","url":"oci://quay.io/acme/gallery:latest","verifica
|
||||
|
||||
The tag is resolved to a digest, the signature is checked against that digest, and the same digest is then pulled. A gallery that fails verification is never written to the cache, so no unverified file reaches your disk. The optional `not_before` RFC3339 value revokes signatures logged before that time, exactly as it does for backends.
|
||||
|
||||
Cached copies of a gallery are kept per verification policy. When you change the `verification` block (for example, you add `source_repository` or move `not_before` forward), LocalAI fetches the gallery again and verifies it under the new policy. It does not serve a copy that an older policy admitted. If the registry is unreachable, LocalAI serves the last copy that was verified under the current policy. If the registry answers with an artifact that fails verification, LocalAI shows an error and does not serve a cached copy.
|
||||
|
||||
The optional `source_repository` value works the same for `oci://` galleries as it does for backends: it pins the repository the signature was made for when a shared reusable workflow does the signing. See [Verifying OCI Backends]({{%relref "features/backends#verifying-oci-backends" %}}).
|
||||
|
||||
{{% notice warning %}}
|
||||
|
||||
Reference in new issue
Block a user