Skip to content

Commit ae4ae5f

Browse files
committed
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.
1 parent 7c1865b commit ae4ae5f

5 files changed

Lines changed: 204 additions & 5 deletions

File tree

core/gallery/backend_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,19 @@ type BackendMetadata struct {
2020
GalleryURL string `json:"gallery_url,omitempty"`
2121
// InstalledAt is the timestamp when the backend was installed
2222
InstalledAt string `json:"installed_at,omitempty"`
23+
// Version is the version of the backend at install time
24+
Version string `json:"version,omitempty"`
25+
// URI is the original URI used to install the backend
26+
URI string `json:"uri,omitempty"`
27+
// Digest is the OCI image digest at install time (for upgrade detection)
28+
Digest string `json:"digest,omitempty"`
2329
}
2430

2531
type GalleryBackend struct {
2632
Metadata `json:",inline" yaml:",inline"`
2733
Alias string `json:"alias,omitempty" yaml:"alias,omitempty"`
2834
URI string `json:"uri,omitempty" yaml:"uri,omitempty"`
35+
Version string `json:"version,omitempty" yaml:"version,omitempty"`
2936
Mirrors []string `json:"mirrors,omitempty" yaml:"mirrors,omitempty"`
3037
CapabilitiesMap map[string]string `json:"capabilities,omitempty" yaml:"capabilities,omitempty"`
3138
}

core/gallery/backends.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/mudler/LocalAI/core/config"
1616
"github.com/mudler/LocalAI/pkg/downloader"
1717
"github.com/mudler/LocalAI/pkg/model"
18+
"github.com/mudler/LocalAI/pkg/oci"
1819
"github.com/mudler/LocalAI/pkg/system"
1920
"github.com/mudler/xlog"
2021
cp "github.com/otiai10/copy"
@@ -158,6 +159,7 @@ func InstallBackendFromGallery(ctx context.Context, galleries []config.Gallery,
158159
Name: name,
159160
GalleryURL: backend.Gallery.URL,
160161
InstalledAt: time.Now().Format(time.RFC3339),
162+
Version: bestBackend.Version,
161163
}
162164

163165
if err := writeBackendMetadata(metaBackendPath, metaMetadata); err != nil {
@@ -279,6 +281,18 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
279281
Name: name,
280282
GalleryURL: config.Gallery.URL,
281283
InstalledAt: time.Now().Format(time.RFC3339),
284+
Version: config.Version,
285+
URI: string(uri),
286+
}
287+
288+
// Record the OCI digest for upgrade detection (non-fatal on failure)
289+
if uri.LooksLikeOCI() {
290+
digest, digestErr := oci.GetImageDigest(string(uri), "", nil, nil)
291+
if digestErr != nil {
292+
xlog.Warn("Failed to get OCI image digest for backend", "uri", string(uri), "error", digestErr)
293+
} else {
294+
metadata.Digest = digest
295+
}
282296
}
283297

284298
if config.Alias != "" {
@@ -373,11 +387,13 @@ func DeleteBackendFromSystem(systemState *system.SystemState, name string) error
373387
}
374388

375389
type SystemBackend struct {
376-
Name string
377-
RunFile string
378-
IsMeta bool
379-
IsSystem bool
380-
Metadata *BackendMetadata
390+
Name string
391+
RunFile string
392+
IsMeta bool
393+
IsSystem bool
394+
Metadata *BackendMetadata
395+
UpgradeAvailable bool `json:"upgrade_available,omitempty"`
396+
AvailableVersion string `json:"available_version,omitempty"`
381397
}
382398

383399
type SystemBackends map[string]SystemBackend
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package gallery_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/mudler/LocalAI/core/gallery"
10+
"github.com/mudler/LocalAI/pkg/model"
11+
"github.com/mudler/LocalAI/pkg/system"
12+
. "github.com/onsi/ginkgo/v2"
13+
. "github.com/onsi/gomega"
14+
)
15+
16+
var _ = Describe("Backend versioning", func() {
17+
var tempDir string
18+
var systemState *system.SystemState
19+
var modelLoader *model.ModelLoader
20+
21+
BeforeEach(func() {
22+
var err error
23+
tempDir, err = os.MkdirTemp("", "gallery-version-*")
24+
Expect(err).NotTo(HaveOccurred())
25+
26+
systemState, err = system.GetSystemState(
27+
system.WithBackendPath(tempDir),
28+
)
29+
Expect(err).NotTo(HaveOccurred())
30+
modelLoader = model.NewModelLoader(systemState)
31+
})
32+
33+
AfterEach(func() {
34+
os.RemoveAll(tempDir)
35+
})
36+
37+
It("records version in metadata when installing a backend with a version", func() {
38+
// Create a fake backend source directory with a run.sh
39+
srcDir, err := os.MkdirTemp("", "gallery-version-src-*")
40+
Expect(err).NotTo(HaveOccurred())
41+
defer os.RemoveAll(srcDir)
42+
err = os.WriteFile(filepath.Join(srcDir, "run.sh"), []byte("#!/bin/sh\necho ok"), 0755)
43+
Expect(err).NotTo(HaveOccurred())
44+
45+
backend := &gallery.GalleryBackend{}
46+
backend.Name = "test-backend"
47+
backend.URI = srcDir
48+
backend.Version = "1.2.3"
49+
50+
err = gallery.InstallBackend(context.Background(), systemState, modelLoader, backend, nil)
51+
Expect(err).NotTo(HaveOccurred())
52+
53+
// Read the metadata file and check version
54+
metadataPath := filepath.Join(tempDir, "test-backend", "metadata.json")
55+
data, err := os.ReadFile(metadataPath)
56+
Expect(err).NotTo(HaveOccurred())
57+
58+
var metadata map[string]any
59+
err = json.Unmarshal(data, &metadata)
60+
Expect(err).NotTo(HaveOccurred())
61+
62+
Expect(metadata["version"]).To(Equal("1.2.3"))
63+
})
64+
65+
It("records URI in metadata", func() {
66+
srcDir, err := os.MkdirTemp("", "gallery-version-src-*")
67+
Expect(err).NotTo(HaveOccurred())
68+
defer os.RemoveAll(srcDir)
69+
err = os.WriteFile(filepath.Join(srcDir, "run.sh"), []byte("#!/bin/sh\necho ok"), 0755)
70+
Expect(err).NotTo(HaveOccurred())
71+
72+
backend := &gallery.GalleryBackend{}
73+
backend.Name = "test-backend-uri"
74+
backend.URI = srcDir
75+
backend.Version = "2.0.0"
76+
77+
err = gallery.InstallBackend(context.Background(), systemState, modelLoader, backend, nil)
78+
Expect(err).NotTo(HaveOccurred())
79+
80+
metadataPath := filepath.Join(tempDir, "test-backend-uri", "metadata.json")
81+
data, err := os.ReadFile(metadataPath)
82+
Expect(err).NotTo(HaveOccurred())
83+
84+
var metadata map[string]any
85+
err = json.Unmarshal(data, &metadata)
86+
Expect(err).NotTo(HaveOccurred())
87+
88+
Expect(metadata["uri"]).To(Equal(srcDir))
89+
})
90+
91+
It("omits version key when version is empty", func() {
92+
srcDir, err := os.MkdirTemp("", "gallery-version-src-*")
93+
Expect(err).NotTo(HaveOccurred())
94+
defer os.RemoveAll(srcDir)
95+
err = os.WriteFile(filepath.Join(srcDir, "run.sh"), []byte("#!/bin/sh\necho ok"), 0755)
96+
Expect(err).NotTo(HaveOccurred())
97+
98+
backend := &gallery.GalleryBackend{}
99+
backend.Name = "test-backend-noversion"
100+
backend.URI = srcDir
101+
// Version intentionally left empty
102+
103+
err = gallery.InstallBackend(context.Background(), systemState, modelLoader, backend, nil)
104+
Expect(err).NotTo(HaveOccurred())
105+
106+
metadataPath := filepath.Join(tempDir, "test-backend-noversion", "metadata.json")
107+
data, err := os.ReadFile(metadataPath)
108+
Expect(err).NotTo(HaveOccurred())
109+
110+
var metadata map[string]any
111+
err = json.Unmarshal(data, &metadata)
112+
Expect(err).NotTo(HaveOccurred())
113+
114+
// omitempty should exclude the version key entirely
115+
_, hasVersion := metadata["version"]
116+
Expect(hasVersion).To(BeFalse())
117+
})
118+
})

pkg/oci/image.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,56 @@ func GetImage(targetImage, targetPlatform string, auth *registrytypes.AuthConfig
188188
return image, err
189189
}
190190

191+
// GetImageDigest returns the OCI image digest for the given image reference without downloading it.
192+
// It uses remote.Head to fetch only the descriptor, which is much cheaper than pulling the full image.
193+
func GetImageDigest(targetImage, targetPlatform string, auth *registrytypes.AuthConfig, t http.RoundTripper) (string, error) {
194+
var platform *v1.Platform
195+
var err error
196+
197+
if targetPlatform != "" {
198+
platform, err = v1.ParsePlatform(targetPlatform)
199+
if err != nil {
200+
return "", err
201+
}
202+
} else {
203+
platform, err = v1.ParsePlatform(fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH))
204+
if err != nil {
205+
return "", err
206+
}
207+
}
208+
209+
ref, err := name.ParseReference(targetImage)
210+
if err != nil {
211+
return "", err
212+
}
213+
214+
if t == nil {
215+
t = http.DefaultTransport
216+
}
217+
218+
tr := transport.NewRetry(t,
219+
transport.WithRetryBackoff(defaultRetryBackoff),
220+
transport.WithRetryPredicate(defaultRetryPredicate),
221+
)
222+
223+
opts := []remote.Option{
224+
remote.WithTransport(tr),
225+
remote.WithPlatform(*platform),
226+
}
227+
if auth != nil {
228+
opts = append(opts, remote.WithAuth(staticAuth{auth}))
229+
} else {
230+
opts = append(opts, remote.WithAuthFromKeychain(authn.DefaultKeychain))
231+
}
232+
233+
desc, err := remote.Head(ref, opts...)
234+
if err != nil {
235+
return "", err
236+
}
237+
238+
return desc.Digest.String(), nil
239+
}
240+
191241
func GetOCIImageSize(targetImage, targetPlatform string, auth *registrytypes.AuthConfig, t http.RoundTripper) (int64, error) {
192242
var size int64
193243
var img v1.Image

pkg/oci/image_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"runtime"
77

8+
"github.com/mudler/LocalAI/pkg/oci"
89
. "github.com/mudler/LocalAI/pkg/oci" // Update with your module path
910
. "github.com/onsi/ginkgo/v2"
1011
. "github.com/onsi/gomega"
@@ -36,3 +37,10 @@ var _ = Describe("OCI", func() {
3637
})
3738
})
3839
})
40+
41+
var _ = Describe("GetImageDigest", func() {
42+
It("returns an error for an invalid image reference", func() {
43+
_, err := oci.GetImageDigest("!!!invalid-ref!!!", "", nil, nil)
44+
Expect(err).To(HaveOccurred())
45+
})
46+
})

0 commit comments

Comments
 (0)