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