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.
This commit is contained in:
Ettore Di Giacinto
2026-04-11 07:45:12 +00:00
parent 7c1865b307
commit ae4ae5f425
5 changed files with 204 additions and 5 deletions

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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())
})
})

View File

@@ -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

View File

@@ -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())
})
})