From ae4ae5f4256f5aef84e2de5ec14511186b096486 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 11 Apr 2026 07:45:12 +0000 Subject: [PATCH] feat: add backend versioning data model foundation Add Version, URI, and Digest fields to BackendMetadata for tracking installed backend versions and enabling upgrade detection. Add Version field to GalleryBackend. Add UpgradeAvailable/AvailableVersion fields to SystemBackend. Implement GetImageDigest() for lightweight OCI digest lookups via remote.Head. Record version, URI, and digest at install time in InstallBackend() and propagate version through meta backends. --- core/gallery/backend_types.go | 7 ++ core/gallery/backends.go | 26 ++++-- core/gallery/backends_version_test.go | 118 ++++++++++++++++++++++++++ pkg/oci/image.go | 50 +++++++++++ pkg/oci/image_test.go | 8 ++ 5 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 core/gallery/backends_version_test.go diff --git a/core/gallery/backend_types.go b/core/gallery/backend_types.go index 02ac9b275..ef5904f99 100644 --- a/core/gallery/backend_types.go +++ b/core/gallery/backend_types.go @@ -20,12 +20,19 @@ type BackendMetadata struct { GalleryURL string `json:"gallery_url,omitempty"` // InstalledAt is the timestamp when the backend was installed InstalledAt string `json:"installed_at,omitempty"` + // Version is the version of the backend at install time + Version string `json:"version,omitempty"` + // URI is the original URI used to install the backend + URI string `json:"uri,omitempty"` + // Digest is the OCI image digest at install time (for upgrade detection) + Digest string `json:"digest,omitempty"` } type GalleryBackend struct { Metadata `json:",inline" yaml:",inline"` Alias string `json:"alias,omitempty" yaml:"alias,omitempty"` URI string `json:"uri,omitempty" yaml:"uri,omitempty"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` Mirrors []string `json:"mirrors,omitempty" yaml:"mirrors,omitempty"` CapabilitiesMap map[string]string `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` } diff --git a/core/gallery/backends.go b/core/gallery/backends.go index b48aaf8a4..c2622c272 100644 --- a/core/gallery/backends.go +++ b/core/gallery/backends.go @@ -15,6 +15,7 @@ import ( "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/pkg/downloader" "github.com/mudler/LocalAI/pkg/model" + "github.com/mudler/LocalAI/pkg/oci" "github.com/mudler/LocalAI/pkg/system" "github.com/mudler/xlog" cp "github.com/otiai10/copy" @@ -158,6 +159,7 @@ func InstallBackendFromGallery(ctx context.Context, galleries []config.Gallery, Name: name, GalleryURL: backend.Gallery.URL, InstalledAt: time.Now().Format(time.RFC3339), + Version: bestBackend.Version, } if err := writeBackendMetadata(metaBackendPath, metaMetadata); err != nil { @@ -279,6 +281,18 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL Name: name, GalleryURL: config.Gallery.URL, InstalledAt: time.Now().Format(time.RFC3339), + Version: config.Version, + URI: string(uri), + } + + // Record the OCI digest for upgrade detection (non-fatal on failure) + if uri.LooksLikeOCI() { + digest, digestErr := oci.GetImageDigest(string(uri), "", nil, nil) + if digestErr != nil { + xlog.Warn("Failed to get OCI image digest for backend", "uri", string(uri), "error", digestErr) + } else { + metadata.Digest = digest + } } if config.Alias != "" { @@ -373,11 +387,13 @@ func DeleteBackendFromSystem(systemState *system.SystemState, name string) error } type SystemBackend struct { - Name string - RunFile string - IsMeta bool - IsSystem bool - Metadata *BackendMetadata + Name string + RunFile string + IsMeta bool + IsSystem bool + Metadata *BackendMetadata + UpgradeAvailable bool `json:"upgrade_available,omitempty"` + AvailableVersion string `json:"available_version,omitempty"` } type SystemBackends map[string]SystemBackend diff --git a/core/gallery/backends_version_test.go b/core/gallery/backends_version_test.go new file mode 100644 index 000000000..8e97604d4 --- /dev/null +++ b/core/gallery/backends_version_test.go @@ -0,0 +1,118 @@ +package gallery_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + + "github.com/mudler/LocalAI/core/gallery" + "github.com/mudler/LocalAI/pkg/model" + "github.com/mudler/LocalAI/pkg/system" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Backend versioning", func() { + var tempDir string + var systemState *system.SystemState + var modelLoader *model.ModelLoader + + BeforeEach(func() { + var err error + tempDir, err = os.MkdirTemp("", "gallery-version-*") + Expect(err).NotTo(HaveOccurred()) + + systemState, err = system.GetSystemState( + system.WithBackendPath(tempDir), + ) + Expect(err).NotTo(HaveOccurred()) + modelLoader = model.NewModelLoader(systemState) + }) + + AfterEach(func() { + os.RemoveAll(tempDir) + }) + + It("records version in metadata when installing a backend with a version", func() { + // Create a fake backend source directory with a run.sh + srcDir, err := os.MkdirTemp("", "gallery-version-src-*") + Expect(err).NotTo(HaveOccurred()) + defer os.RemoveAll(srcDir) + err = os.WriteFile(filepath.Join(srcDir, "run.sh"), []byte("#!/bin/sh\necho ok"), 0755) + Expect(err).NotTo(HaveOccurred()) + + backend := &gallery.GalleryBackend{} + backend.Name = "test-backend" + backend.URI = srcDir + backend.Version = "1.2.3" + + err = gallery.InstallBackend(context.Background(), systemState, modelLoader, backend, nil) + Expect(err).NotTo(HaveOccurred()) + + // Read the metadata file and check version + metadataPath := filepath.Join(tempDir, "test-backend", "metadata.json") + data, err := os.ReadFile(metadataPath) + Expect(err).NotTo(HaveOccurred()) + + var metadata map[string]any + err = json.Unmarshal(data, &metadata) + Expect(err).NotTo(HaveOccurred()) + + Expect(metadata["version"]).To(Equal("1.2.3")) + }) + + It("records URI in metadata", func() { + srcDir, err := os.MkdirTemp("", "gallery-version-src-*") + Expect(err).NotTo(HaveOccurred()) + defer os.RemoveAll(srcDir) + err = os.WriteFile(filepath.Join(srcDir, "run.sh"), []byte("#!/bin/sh\necho ok"), 0755) + Expect(err).NotTo(HaveOccurred()) + + backend := &gallery.GalleryBackend{} + backend.Name = "test-backend-uri" + backend.URI = srcDir + backend.Version = "2.0.0" + + err = gallery.InstallBackend(context.Background(), systemState, modelLoader, backend, nil) + Expect(err).NotTo(HaveOccurred()) + + metadataPath := filepath.Join(tempDir, "test-backend-uri", "metadata.json") + data, err := os.ReadFile(metadataPath) + Expect(err).NotTo(HaveOccurred()) + + var metadata map[string]any + err = json.Unmarshal(data, &metadata) + Expect(err).NotTo(HaveOccurred()) + + Expect(metadata["uri"]).To(Equal(srcDir)) + }) + + It("omits version key when version is empty", func() { + srcDir, err := os.MkdirTemp("", "gallery-version-src-*") + Expect(err).NotTo(HaveOccurred()) + defer os.RemoveAll(srcDir) + err = os.WriteFile(filepath.Join(srcDir, "run.sh"), []byte("#!/bin/sh\necho ok"), 0755) + Expect(err).NotTo(HaveOccurred()) + + backend := &gallery.GalleryBackend{} + backend.Name = "test-backend-noversion" + backend.URI = srcDir + // Version intentionally left empty + + err = gallery.InstallBackend(context.Background(), systemState, modelLoader, backend, nil) + Expect(err).NotTo(HaveOccurred()) + + metadataPath := filepath.Join(tempDir, "test-backend-noversion", "metadata.json") + data, err := os.ReadFile(metadataPath) + Expect(err).NotTo(HaveOccurred()) + + var metadata map[string]any + err = json.Unmarshal(data, &metadata) + Expect(err).NotTo(HaveOccurred()) + + // omitempty should exclude the version key entirely + _, hasVersion := metadata["version"] + Expect(hasVersion).To(BeFalse()) + }) +}) diff --git a/pkg/oci/image.go b/pkg/oci/image.go index 90d433a05..2d00c3479 100644 --- a/pkg/oci/image.go +++ b/pkg/oci/image.go @@ -188,6 +188,56 @@ func GetImage(targetImage, targetPlatform string, auth *registrytypes.AuthConfig return image, err } +// GetImageDigest returns the OCI image digest for the given image reference without downloading it. +// It uses remote.Head to fetch only the descriptor, which is much cheaper than pulling the full image. +func GetImageDigest(targetImage, targetPlatform string, auth *registrytypes.AuthConfig, t http.RoundTripper) (string, error) { + var platform *v1.Platform + var err error + + if targetPlatform != "" { + platform, err = v1.ParsePlatform(targetPlatform) + if err != nil { + return "", err + } + } else { + platform, err = v1.ParsePlatform(fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)) + if err != nil { + return "", err + } + } + + ref, err := name.ParseReference(targetImage) + if err != nil { + return "", err + } + + if t == nil { + t = http.DefaultTransport + } + + tr := transport.NewRetry(t, + transport.WithRetryBackoff(defaultRetryBackoff), + transport.WithRetryPredicate(defaultRetryPredicate), + ) + + opts := []remote.Option{ + remote.WithTransport(tr), + remote.WithPlatform(*platform), + } + if auth != nil { + opts = append(opts, remote.WithAuth(staticAuth{auth})) + } else { + opts = append(opts, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + } + + desc, err := remote.Head(ref, opts...) + if err != nil { + return "", err + } + + return desc.Digest.String(), nil +} + func GetOCIImageSize(targetImage, targetPlatform string, auth *registrytypes.AuthConfig, t http.RoundTripper) (int64, error) { var size int64 var img v1.Image diff --git a/pkg/oci/image_test.go b/pkg/oci/image_test.go index 8b26c2b87..447bc90f6 100644 --- a/pkg/oci/image_test.go +++ b/pkg/oci/image_test.go @@ -5,6 +5,7 @@ import ( "os" "runtime" + "github.com/mudler/LocalAI/pkg/oci" . "github.com/mudler/LocalAI/pkg/oci" // Update with your module path . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -36,3 +37,10 @@ var _ = Describe("OCI", func() { }) }) }) + +var _ = Describe("GetImageDigest", func() { + It("returns an error for an invalid image reference", func() { + _, err := oci.GetImageDigest("!!!invalid-ref!!!", "", nil, nil) + Expect(err).To(HaveOccurred()) + }) +})