Skip to content

Commit 4d463e9

Browse files
committed
feat: add backend upgrade detection and execution logic
Add CheckBackendUpgrades() to compare installed backend versions/digests against gallery entries, and UpgradeBackend() to perform atomic upgrades with backup-based rollback on failure. Includes Agent A's data model changes (Version/URI/Digest fields, GetImageDigest).
1 parent ae4ae5f commit 4d463e9

2 files changed

Lines changed: 442 additions & 0 deletions

File tree

core/gallery/upgrade.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package gallery
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
10+
"github.com/mudler/LocalAI/core/config"
11+
"github.com/mudler/LocalAI/pkg/downloader"
12+
"github.com/mudler/LocalAI/pkg/model"
13+
"github.com/mudler/LocalAI/pkg/oci"
14+
"github.com/mudler/LocalAI/pkg/system"
15+
"github.com/mudler/xlog"
16+
cp "github.com/otiai10/copy"
17+
)
18+
19+
// UpgradeInfo holds details about an available backend upgrade.
20+
type UpgradeInfo struct {
21+
BackendName string `json:"backend_name"`
22+
InstalledVersion string `json:"installed_version"`
23+
AvailableVersion string `json:"available_version"`
24+
InstalledDigest string `json:"installed_digest,omitempty"`
25+
AvailableDigest string `json:"available_digest,omitempty"`
26+
}
27+
28+
// CheckBackendUpgrades compares installed backends against gallery entries
29+
// and returns a map of backend names to UpgradeInfo for those that have
30+
// newer versions or different OCI digests available.
31+
func CheckBackendUpgrades(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState) (map[string]UpgradeInfo, error) {
32+
galleryBackends, err := AvailableBackends(galleries, systemState)
33+
if err != nil {
34+
return nil, fmt.Errorf("failed to list available backends: %w", err)
35+
}
36+
37+
installedBackends, err := ListSystemBackends(systemState)
38+
if err != nil {
39+
return nil, fmt.Errorf("failed to list installed backends: %w", err)
40+
}
41+
42+
result := make(map[string]UpgradeInfo)
43+
44+
for _, installed := range installedBackends {
45+
// Skip system backends — they are managed outside the gallery
46+
if installed.IsSystem {
47+
continue
48+
}
49+
if installed.Metadata == nil {
50+
continue
51+
}
52+
53+
// Find matching gallery entry by metadata name
54+
galleryEntry := FindGalleryElement(galleryBackends, installed.Metadata.Name)
55+
if galleryEntry == nil {
56+
continue
57+
}
58+
59+
installedVersion := installed.Metadata.Version
60+
galleryVersion := galleryEntry.Version
61+
62+
// If both sides have versions, compare them
63+
if galleryVersion != "" && installedVersion != "" {
64+
if galleryVersion != installedVersion {
65+
result[installed.Metadata.Name] = UpgradeInfo{
66+
BackendName: installed.Metadata.Name,
67+
InstalledVersion: installedVersion,
68+
AvailableVersion: galleryVersion,
69+
}
70+
}
71+
// Versions match — no upgrade needed
72+
continue
73+
}
74+
75+
// If either version is empty, fall back to OCI digest comparison
76+
if installed.Metadata.Digest != "" && downloader.URI(galleryEntry.URI).LooksLikeOCI() {
77+
remoteDigest, err := oci.GetImageDigest(galleryEntry.URI, "", nil, nil)
78+
if err != nil {
79+
xlog.Warn("Failed to get remote OCI digest for upgrade check", "backend", installed.Metadata.Name, "error", err)
80+
continue
81+
}
82+
if remoteDigest != installed.Metadata.Digest {
83+
result[installed.Metadata.Name] = UpgradeInfo{
84+
BackendName: installed.Metadata.Name,
85+
InstalledDigest: installed.Metadata.Digest,
86+
AvailableDigest: remoteDigest,
87+
}
88+
}
89+
}
90+
// No version info and no digest to compare — skip
91+
}
92+
93+
return result, nil
94+
}
95+
96+
// UpgradeBackend upgrades a single backend to the latest gallery version using
97+
// an atomic swap with backup-based rollback on failure.
98+
func UpgradeBackend(ctx context.Context, systemState *system.SystemState, modelLoader *model.ModelLoader, galleries []config.Gallery, backendName string, downloadStatus func(string, string, string, float64)) error {
99+
// Look up the installed backend
100+
installedBackends, err := ListSystemBackends(systemState)
101+
if err != nil {
102+
return fmt.Errorf("failed to list installed backends: %w", err)
103+
}
104+
105+
installed, ok := installedBackends.Get(backendName)
106+
if !ok {
107+
return fmt.Errorf("backend %q: %w", backendName, ErrBackendNotFound)
108+
}
109+
110+
if installed.IsSystem {
111+
return fmt.Errorf("system backend %q cannot be upgraded via gallery", backendName)
112+
}
113+
114+
// If this is a meta backend, recursively upgrade the concrete backend it points to
115+
if installed.Metadata != nil && installed.Metadata.MetaBackendFor != "" {
116+
xlog.Info("Meta backend detected, upgrading concrete backend", "meta", backendName, "concrete", installed.Metadata.MetaBackendFor)
117+
return UpgradeBackend(ctx, systemState, modelLoader, galleries, installed.Metadata.MetaBackendFor, downloadStatus)
118+
}
119+
120+
// Find the gallery entry
121+
galleryBackends, err := AvailableBackends(galleries, systemState)
122+
if err != nil {
123+
return fmt.Errorf("failed to list available backends: %w", err)
124+
}
125+
126+
galleryEntry := FindGalleryElement(galleryBackends, backendName)
127+
if galleryEntry == nil {
128+
return fmt.Errorf("no gallery entry found for backend %q", backendName)
129+
}
130+
131+
backendPath := filepath.Join(systemState.Backend.BackendsPath, backendName)
132+
tmpPath := backendPath + ".upgrade-tmp"
133+
backupPath := backendPath + ".backup"
134+
135+
// Clean up any stale tmp/backup dirs from prior attempts
136+
os.RemoveAll(tmpPath)
137+
os.RemoveAll(backupPath)
138+
139+
// Step 1: Download the new backend into the tmp directory
140+
if err := os.MkdirAll(tmpPath, 0750); err != nil {
141+
return fmt.Errorf("failed to create upgrade tmp dir: %w", err)
142+
}
143+
144+
uri := downloader.URI(galleryEntry.URI)
145+
if uri.LooksLikeDir() {
146+
if err := cp.Copy(string(uri), tmpPath); err != nil {
147+
os.RemoveAll(tmpPath)
148+
return fmt.Errorf("failed to copy backend from directory: %w", err)
149+
}
150+
} else {
151+
if err := uri.DownloadFileWithContext(ctx, tmpPath, "", 1, 1, downloadStatus); err != nil {
152+
os.RemoveAll(tmpPath)
153+
return fmt.Errorf("failed to download backend: %w", err)
154+
}
155+
}
156+
157+
// Step 2: Validate — check that run.sh exists in the new content
158+
newRunFile := filepath.Join(tmpPath, runFile)
159+
if _, err := os.Stat(newRunFile); os.IsNotExist(err) {
160+
os.RemoveAll(tmpPath)
161+
return fmt.Errorf("upgrade validation failed: run.sh not found in new backend")
162+
}
163+
164+
// Step 3: Atomic swap — rename current to backup, then tmp to current
165+
if err := os.Rename(backendPath, backupPath); err != nil {
166+
os.RemoveAll(tmpPath)
167+
return fmt.Errorf("failed to move current backend to backup: %w", err)
168+
}
169+
170+
if err := os.Rename(tmpPath, backendPath); err != nil {
171+
// Restore backup on failure
172+
xlog.Error("Failed to move new backend into place, restoring backup", "error", err)
173+
if restoreErr := os.Rename(backupPath, backendPath); restoreErr != nil {
174+
xlog.Error("Failed to restore backup", "error", restoreErr)
175+
}
176+
os.RemoveAll(tmpPath)
177+
return fmt.Errorf("failed to move new backend into place: %w", err)
178+
}
179+
180+
// Step 4: Write updated metadata, preserving alias from old metadata
181+
var oldAlias string
182+
if installed.Metadata != nil {
183+
oldAlias = installed.Metadata.Alias
184+
}
185+
186+
newMetadata := &BackendMetadata{
187+
Name: backendName,
188+
Version: galleryEntry.Version,
189+
URI: galleryEntry.URI,
190+
InstalledAt: time.Now().Format(time.RFC3339),
191+
Alias: oldAlias,
192+
}
193+
194+
if galleryEntry.Gallery.URL != "" {
195+
newMetadata.GalleryURL = galleryEntry.Gallery.URL
196+
}
197+
198+
// Record OCI digest if applicable (non-fatal on failure)
199+
if uri.LooksLikeOCI() {
200+
digest, digestErr := oci.GetImageDigest(galleryEntry.URI, "", nil, nil)
201+
if digestErr != nil {
202+
xlog.Warn("Failed to get OCI image digest after upgrade", "uri", galleryEntry.URI, "error", digestErr)
203+
} else {
204+
newMetadata.Digest = digest
205+
}
206+
}
207+
208+
if err := writeBackendMetadata(backendPath, newMetadata); err != nil {
209+
// Metadata write failure is not worth rolling back the entire upgrade
210+
xlog.Error("Failed to write metadata after upgrade", "error", err)
211+
}
212+
213+
// Step 5: Re-register backends so the model loader picks up any changes
214+
if err := RegisterBackends(systemState, modelLoader); err != nil {
215+
xlog.Warn("Failed to re-register backends after upgrade", "error", err)
216+
}
217+
218+
// Step 6: Remove backup
219+
os.RemoveAll(backupPath)
220+
221+
xlog.Info("Backend upgraded successfully", "backend", backendName, "version", galleryEntry.Version)
222+
return nil
223+
}

0 commit comments

Comments
 (0)