mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-25 07:34:58 -04:00
fix(gallery): strip the oci:// scheme before every registry lookup (#12238)
A backend installed from an oci://host/repo:tag or oci://host/repo@sha256 URI was downloaded correctly, but the digest lookup that follows the install (and the one after an upgrade) passed the raw URI to the registry client. The client does not know the oci:// scheme: with a port in the host it failed to parse the reference, without one it read "oci" as the registry host and queried https://oci/v2/. The install still succeeded, so the only trace was a warning and an empty digest in metadata.json, which made the next upgrade check report an upgrade for no reason. Add downloader.URI.OCIReference and route every consumer that hands an OCI URI to a registry client through it: the install and upgrade digest lookups, the upgrade check, the OCI download path, the oci:// gallery fetch and the llama.cpp importer. 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
8588020d77
commit
3278b4a2ed
7 files changed
+147
-12
No files matched your search
@@ -436,7 +436,7 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
|
||||
|
||||
// Record the OCI digest for upgrade detection (non-fatal on failure)
|
||||
if uri.LooksLikeOCI() {
|
||||
digest, digestErr := oci.GetImageDigest(string(uri), "", nil, nil)
|
||||
digest, digestErr := oci.GetImageDigest(uri.OCIReference(), "", nil, nil)
|
||||
if digestErr != nil {
|
||||
xlog.Warn("Failed to get OCI image digest for backend", "uri", string(uri), "error", digestErr)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/crane"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/empty"
|
||||
"github.com/google/go-containerregistry/pkg/v1/mutate"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// pushBackendImage publishes a minimal backend image (just a run.sh) to the
|
||||
// in-process registry and returns the registry host and the manifest digest.
|
||||
func pushBackendImage(serverURL, repoPath, tag, runScript string) (string, string) {
|
||||
GinkgoHelper()
|
||||
host := strings.TrimPrefix(serverURL, "http://")
|
||||
|
||||
layer, err := crane.Layer(map[string][]byte{"run.sh": []byte(runScript)})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
img, err := mutate.AppendLayers(empty.Image, layer)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ref, err := name.ParseReference(host + "/" + repoPath + ":" + tag)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(remote.Write(ref, img)).To(Succeed())
|
||||
|
||||
digest, err := img.Digest()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return host, digest.String()
|
||||
}
|
||||
|
||||
var _ = Describe("backends installed from an oci:// URI", func() {
|
||||
var (
|
||||
registryURL string
|
||||
systemState *system.SystemState
|
||||
ml *model.ModelLoader
|
||||
backendsDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
srv, _, _ := ociRegistry()
|
||||
registryURL = srv.URL
|
||||
|
||||
backendsDir = GinkgoT().TempDir()
|
||||
var err error
|
||||
systemState, err = system.GetSystemState(system.WithBackendPath(backendsDir))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ml = model.NewModelLoader(systemState)
|
||||
})
|
||||
|
||||
installedDigest := func(name string) string {
|
||||
GinkgoHelper()
|
||||
meta, err := readBackendMetadata(filepath.Join(backendsDir, name))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(meta).ToNot(BeNil())
|
||||
return meta.Digest
|
||||
}
|
||||
|
||||
// The digest lookup that follows an install used to hand the raw oci://
|
||||
// URI to the registry client, which read "oci" as the registry host and
|
||||
// queried https://oci/v2/. The install still succeeded, so the only trace
|
||||
// was an empty digest in metadata.json and a warning in the log.
|
||||
DescribeTable("records the image digest from the registry the URI names",
|
||||
func(form string) {
|
||||
host, digest := pushBackendImage(registryURL, "acme/backend", "v1", "#!/bin/sh\necho v1\n")
|
||||
uri := "oci://" + host + "/acme/backend:v1"
|
||||
if form == "digest" {
|
||||
uri = "oci://" + host + "/acme/backend@" + digest
|
||||
}
|
||||
|
||||
Expect(InstallBackend(context.Background(), systemState, ml, &GalleryBackend{
|
||||
Metadata: Metadata{Name: "acme-backend"},
|
||||
URI: uri,
|
||||
}, nil, false)).To(Succeed())
|
||||
|
||||
Expect(filepath.Join(backendsDir, "acme-backend", "run.sh")).To(BeARegularFile())
|
||||
Expect(installedDigest("acme-backend")).To(Equal(digest))
|
||||
},
|
||||
Entry("tag form", "tag"),
|
||||
Entry("digest form", "digest"),
|
||||
)
|
||||
|
||||
It("records the new image digest after an upgrade", func() {
|
||||
host, oldDigest := pushBackendImage(registryURL, "acme/backend", "v1", "#!/bin/sh\necho v1\n")
|
||||
_, newDigest := pushBackendImage(registryURL, "acme/backend", "v2", "#!/bin/sh\necho v2\n")
|
||||
Expect(newDigest).ToNot(Equal(oldDigest))
|
||||
|
||||
Expect(InstallBackend(context.Background(), systemState, ml, &GalleryBackend{
|
||||
Metadata: Metadata{Name: "acme-backend"},
|
||||
URI: "oci://" + host + "/acme/backend:v1",
|
||||
}, nil, false)).To(Succeed())
|
||||
Expect(installedDigest("acme-backend")).To(Equal(oldDigest))
|
||||
|
||||
galleryFile := filepath.Join(backendsDir, "gallery.yaml")
|
||||
data, err := yaml.Marshal([]GalleryBackend{{
|
||||
Metadata: Metadata{Name: "acme-backend"},
|
||||
URI: "oci://" + host + "/acme/backend@" + newDigest,
|
||||
}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.WriteFile(galleryFile, data, 0o644)).To(Succeed())
|
||||
galleries := []config.Gallery{{Name: "acme", URL: "file://" + galleryFile}}
|
||||
|
||||
upgrades, err := CheckBackendUpgrades(context.Background(), galleries, systemState)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(upgrades).To(HaveKey("acme-backend"))
|
||||
Expect(upgrades["acme-backend"].AvailableDigest).To(Equal(newDigest))
|
||||
|
||||
Expect(UpgradeBackend(context.Background(), systemState, ml, galleries, "acme-backend", nil, false)).To(Succeed())
|
||||
Expect(installedDigest("acme-backend")).To(Equal(newDigest))
|
||||
})
|
||||
})
|
||||
@@ -143,7 +143,7 @@ func fetchOCIGalleryIndex(ctx context.Context, g config.Gallery, candidate, base
|
||||
return body, nil
|
||||
}
|
||||
|
||||
pullRef := strings.TrimPrefix(candidate, downloader.OCIPrefix)
|
||||
pullRef := downloader.URI(candidate).OCIReference()
|
||||
|
||||
if g.Verification != nil {
|
||||
// Resolve first, verify the digest, then pull that same digest.
|
||||
|
||||
@@ -182,7 +182,7 @@ func (i *LlamaCPPImporter) Import(details Details) (gallery.ModelConfig, error)
|
||||
|
||||
switch {
|
||||
case uri.LooksLikeOCI():
|
||||
ociName := strings.TrimPrefix(string(uri), downloader.OCIPrefix)
|
||||
ociName := uri.OCIReference()
|
||||
ociName = strings.TrimPrefix(ociName, downloader.OllamaPrefix)
|
||||
ociName = strings.ReplaceAll(ociName, "/", "__")
|
||||
ociName = strings.ReplaceAll(ociName, ":", "__")
|
||||
|
||||
@@ -169,13 +169,8 @@ func CheckUpgradesAgainst(ctx context.Context, galleries []config.Gallery, syste
|
||||
}
|
||||
|
||||
// Fall back to OCI digest comparison when versions are unavailable.
|
||||
if downloader.URI(galleryEntry.URI).LooksLikeOCI() {
|
||||
// Strip the oci:// scheme — name.ParseReference (called by
|
||||
// GetImageDigest) cannot parse it. Self-hosted registries must
|
||||
// set the scheme to be recognised at all, so without stripping
|
||||
// they silently lose upgrade detection.
|
||||
remoteDigest, err := oci.GetImageDigest(
|
||||
strings.TrimPrefix(galleryEntry.URI, downloader.OCIPrefix), "", nil, nil)
|
||||
if galleryURI := downloader.URI(galleryEntry.URI); galleryURI.LooksLikeOCI() {
|
||||
remoteDigest, err := oci.GetImageDigest(galleryURI.OCIReference(), "", nil, nil)
|
||||
if err != nil {
|
||||
xlog.Warn("Failed to get remote OCI digest for upgrade check", "backend", installed.Metadata.Name, "error", err)
|
||||
continue
|
||||
@@ -359,7 +354,7 @@ func UpgradeBackend(ctx context.Context, systemState *system.SystemState, modelL
|
||||
|
||||
// Record OCI digest if applicable (non-fatal on failure)
|
||||
if uri.LooksLikeOCI() {
|
||||
digest, digestErr := oci.GetImageDigest(galleryEntry.URI, "", nil, nil)
|
||||
digest, digestErr := oci.GetImageDigest(uri.OCIReference(), "", nil, nil)
|
||||
if digestErr != nil {
|
||||
xlog.Warn("Failed to get OCI image digest after upgrade", "uri", galleryEntry.URI, "error", digestErr)
|
||||
} else {
|
||||
|
||||
@@ -315,6 +315,14 @@ func (s URI) LooksLikeOCI() bool {
|
||||
strings.HasPrefix(string(s), "docker.io")
|
||||
}
|
||||
|
||||
// OCIReference returns the registry reference an OCI URI names, without the
|
||||
// oci:// scheme. The scheme is LocalAI's own marker for "this is an image":
|
||||
// registry clients do not know it and read "oci" as the registry host, so
|
||||
// every consumer that hands a URI to a registry client goes through here.
|
||||
func (s URI) OCIReference() string {
|
||||
return strings.TrimPrefix(string(s), OCIPrefix)
|
||||
}
|
||||
|
||||
func (s URI) LooksLikeOCIFile() bool {
|
||||
return strings.HasPrefix(string(s), OCIFilePrefix)
|
||||
}
|
||||
@@ -606,7 +614,7 @@ func (uri URI) DownloadFileWithContext(ctx context.Context, filePath, sha string
|
||||
return oci.ExtractOCIImage(ctx, img, url, filePath, downloadStatus)
|
||||
}
|
||||
|
||||
url = strings.TrimPrefix(url, OCIPrefix)
|
||||
url = URI(url).OCIReference()
|
||||
img, err := oci.GetImage(url, "", nil, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get image %q: %v", url, err)
|
||||
|
||||
@@ -87,6 +87,17 @@ var _ = Describe("Gallery API tests", func() {
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("OCIReference", func() {
|
||||
DescribeTable("returns the registry reference without the oci:// scheme",
|
||||
func(uri, want string) {
|
||||
Expect(URI(uri).OCIReference()).To(Equal(want))
|
||||
},
|
||||
Entry("tag form", "oci://registry.example.com/acme/backend:v1", "registry.example.com/acme/backend:v1"),
|
||||
Entry("digest form", "oci://registry.example.com:5000/acme/backend@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "registry.example.com:5000/acme/backend@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
|
||||
Entry("reference without a scheme", "quay.io/acme/backend:latest", "quay.io/acme/backend:latest"),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("ContentLength", func() {
|
||||
Context("local file", func() {
|
||||
It("returns file size for existing file", func() {
|
||||
|
||||
Reference in new issue
Block a user