Skip to content

Commit 8ab0744

Browse files
authored
feat: backend versioning, upgrade detection and auto-upgrade (#9315)
* 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. * 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). * feat: add AutoUpgradeBackends config and runtime settings Add configuration and runtime settings for backend auto-upgrade: - RuntimeSettings field for dynamic config via API/JSON - ApplicationConfig field, option func, and roundtrip conversion - CLI flag with LOCALAI_AUTO_UPGRADE_BACKENDS env var - Config file watcher support for runtime_settings.json - Tests for ToRuntimeSettings, ApplyRuntimeSettings, and roundtrip * feat(ui): add backend version display and upgrade support - Add upgrade check/trigger API endpoints to config and api module - Backends page: version badge, upgrade indicator, upgrade button - Manage page: version in metadata, context-aware upgrade/reinstall button - Settings page: auto-upgrade backends toggle * feat: add upgrade checker service, API endpoints, and CLI command - UpgradeChecker background service: checks every 6h, auto-upgrades when enabled - API endpoints: GET /backends/upgrades, POST /backends/upgrades/check, POST /backends/upgrade/:name - CLI: `localai backends upgrade` command, version display in `backends list` - BackendManager interface: add UpgradeBackend and CheckUpgrades methods - Wire upgrade op through GalleryService backend handler - Distributed mode: fan-out upgrade to worker nodes via NATS * fix: use advisory lock for upgrade checker in distributed mode In distributed mode with multiple frontend instances, use PostgreSQL advisory lock (KeyBackendUpgradeCheck) so only one instance runs periodic upgrade checks and auto-upgrades. Prevents duplicate upgrade operations across replicas. Standalone mode is unchanged (simple ticker loop). * test: add e2e tests for backend upgrade API - Test GET /api/backends/upgrades returns 200 (even with no upgrade checker) - Test POST /api/backends/upgrade/:name accepts request and returns job ID - Test full upgrade flow: trigger upgrade via API, wait for job completion, verify run.sh updated to v2 and metadata.json has version 2.0.0 - Test POST /api/backends/upgrades/check returns 200 - Fix nil check for applicationInstance in upgrade API routes
1 parent 7c1865b commit 8ab0744

31 files changed

Lines changed: 1440 additions & 28 deletions

core/application/application.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type Application struct {
3737

3838
// Distributed mode services (nil when not in distributed mode)
3939
distributed *DistributedServices
40+
41+
// Upgrade checker (background service for detecting backend upgrades)
42+
upgradeChecker *UpgradeChecker
4043
}
4144

4245
func newApplication(appConfig *config.ApplicationConfig) *Application {
@@ -79,6 +82,19 @@ func (a *Application) AgentJobService() *agentpool.AgentJobService {
7982
return a.agentJobService
8083
}
8184

85+
func (a *Application) UpgradeChecker() *UpgradeChecker {
86+
return a.upgradeChecker
87+
}
88+
89+
// distributedDB returns the PostgreSQL database for distributed coordination,
90+
// or nil in standalone mode.
91+
func (a *Application) distributedDB() *gorm.DB {
92+
if a.distributed != nil {
93+
return a.authDB
94+
}
95+
return nil
96+
}
97+
8298
func (a *Application) AgentPoolService() *agentpool.AgentPoolService {
8399
return a.agentPoolService.Load()
84100
}

core/application/config_file_watcher.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,9 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
335335
if settings.AutoloadBackendGalleries != nil && !envAutoloadBackendGalleries {
336336
appConfig.AutoloadBackendGalleries = *settings.AutoloadBackendGalleries
337337
}
338+
if settings.AutoUpgradeBackends != nil {
339+
appConfig.AutoUpgradeBackends = *settings.AutoUpgradeBackends
340+
}
338341
if settings.ApiKeys != nil {
339342
// API keys from env vars (startup) should be kept, runtime settings keys replace all runtime keys
340343
// If runtime_settings.json specifies ApiKeys (even if empty), it replaces all runtime keys

core/application/startup.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,15 @@ func New(opts ...config.AppOption) (*Application, error) {
231231
xlog.Error("error registering external backends", "error", err)
232232
}
233233

234+
// Start background upgrade checker for backends.
235+
// In distributed mode, uses PostgreSQL advisory lock so only one frontend
236+
// instance runs periodic checks (avoids duplicate upgrades across replicas).
237+
if len(options.BackendGalleries) > 0 {
238+
uc := NewUpgradeChecker(options, application.ModelLoader(), application.distributedDB())
239+
application.upgradeChecker = uc
240+
go uc.Run(options.Context)
241+
}
242+
234243
if options.ConfigFile != "" {
235244
if err := application.ModelConfigLoader().LoadMultipleModelConfigsSingleFile(options.ConfigFile, configLoaderOpts...); err != nil {
236245
xlog.Error("error loading config file", "error", err)
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package application
2+
3+
import (
4+
"context"
5+
"sync"
6+
"time"
7+
8+
"github.com/mudler/LocalAI/core/config"
9+
"github.com/mudler/LocalAI/core/gallery"
10+
"github.com/mudler/LocalAI/core/services/advisorylock"
11+
"github.com/mudler/LocalAI/pkg/model"
12+
"github.com/mudler/LocalAI/pkg/system"
13+
"github.com/mudler/xlog"
14+
"gorm.io/gorm"
15+
)
16+
17+
// UpgradeChecker periodically checks for backend upgrades and optionally
18+
// auto-upgrades them. It caches the last check results for API queries.
19+
//
20+
// In standalone mode it runs a simple ticker loop.
21+
// In distributed mode it uses a PostgreSQL advisory lock so that only one
22+
// frontend instance performs periodic checks and auto-upgrades at a time.
23+
type UpgradeChecker struct {
24+
appConfig *config.ApplicationConfig
25+
modelLoader *model.ModelLoader
26+
galleries []config.Gallery
27+
systemState *system.SystemState
28+
db *gorm.DB // non-nil in distributed mode
29+
30+
checkInterval time.Duration
31+
stop chan struct{}
32+
done chan struct{}
33+
triggerCh chan struct{}
34+
35+
mu sync.RWMutex
36+
lastUpgrades map[string]gallery.UpgradeInfo
37+
lastCheckTime time.Time
38+
}
39+
40+
// NewUpgradeChecker creates a new UpgradeChecker service.
41+
// Pass db=nil for standalone mode, or a *gorm.DB for distributed mode
42+
// (uses advisory locks so only one instance runs periodic checks).
43+
func NewUpgradeChecker(appConfig *config.ApplicationConfig, ml *model.ModelLoader, db *gorm.DB) *UpgradeChecker {
44+
return &UpgradeChecker{
45+
appConfig: appConfig,
46+
modelLoader: ml,
47+
galleries: appConfig.BackendGalleries,
48+
systemState: appConfig.SystemState,
49+
db: db,
50+
checkInterval: 6 * time.Hour,
51+
stop: make(chan struct{}),
52+
done: make(chan struct{}),
53+
triggerCh: make(chan struct{}, 1),
54+
lastUpgrades: make(map[string]gallery.UpgradeInfo),
55+
}
56+
}
57+
58+
// Run starts the upgrade checker loop. It waits 30 seconds after startup,
59+
// performs an initial check, then re-checks every 6 hours.
60+
//
61+
// In distributed mode, periodic checks are guarded by a PostgreSQL advisory
62+
// lock so only one frontend instance runs them. On-demand triggers (TriggerCheck)
63+
// and the initial check always run locally for fast API response cache warming.
64+
func (uc *UpgradeChecker) Run(ctx context.Context) {
65+
defer close(uc.done)
66+
67+
// Initial delay: don't slow down startup
68+
select {
69+
case <-ctx.Done():
70+
return
71+
case <-uc.stop:
72+
return
73+
case <-time.After(30 * time.Second):
74+
}
75+
76+
// First check always runs locally (to warm the cache on this instance)
77+
uc.runCheck(ctx)
78+
79+
if uc.db != nil {
80+
// Distributed mode: use advisory lock for periodic checks.
81+
// RunLeaderLoop ticks every checkInterval; only the lock holder executes.
82+
go advisorylock.RunLeaderLoop(ctx, uc.db, advisorylock.KeyBackendUpgradeCheck, uc.checkInterval, func() {
83+
uc.runCheck(ctx)
84+
})
85+
86+
// Still listen for on-demand triggers (from API / settings change)
87+
// and stop signal — these run on every instance.
88+
for {
89+
select {
90+
case <-ctx.Done():
91+
return
92+
case <-uc.stop:
93+
return
94+
case <-uc.triggerCh:
95+
uc.runCheck(ctx)
96+
}
97+
}
98+
} else {
99+
// Standalone mode: simple ticker loop
100+
ticker := time.NewTicker(uc.checkInterval)
101+
defer ticker.Stop()
102+
103+
for {
104+
select {
105+
case <-ctx.Done():
106+
return
107+
case <-uc.stop:
108+
return
109+
case <-ticker.C:
110+
uc.runCheck(ctx)
111+
case <-uc.triggerCh:
112+
uc.runCheck(ctx)
113+
}
114+
}
115+
}
116+
}
117+
118+
// Shutdown stops the upgrade checker loop.
119+
func (uc *UpgradeChecker) Shutdown() {
120+
close(uc.stop)
121+
<-uc.done
122+
}
123+
124+
// TriggerCheck forces an immediate upgrade check on this instance.
125+
func (uc *UpgradeChecker) TriggerCheck() {
126+
select {
127+
case uc.triggerCh <- struct{}{}:
128+
default:
129+
// Already triggered, skip
130+
}
131+
}
132+
133+
// GetAvailableUpgrades returns the cached upgrade check results.
134+
func (uc *UpgradeChecker) GetAvailableUpgrades() map[string]gallery.UpgradeInfo {
135+
uc.mu.RLock()
136+
defer uc.mu.RUnlock()
137+
138+
// Return a copy to avoid races
139+
result := make(map[string]gallery.UpgradeInfo, len(uc.lastUpgrades))
140+
for k, v := range uc.lastUpgrades {
141+
result[k] = v
142+
}
143+
return result
144+
}
145+
146+
func (uc *UpgradeChecker) runCheck(ctx context.Context) {
147+
upgrades, err := gallery.CheckBackendUpgrades(ctx, uc.galleries, uc.systemState)
148+
149+
uc.mu.Lock()
150+
uc.lastCheckTime = time.Now()
151+
if err != nil {
152+
xlog.Debug("Backend upgrade check failed", "error", err)
153+
uc.mu.Unlock()
154+
return
155+
}
156+
uc.lastUpgrades = upgrades
157+
uc.mu.Unlock()
158+
159+
if len(upgrades) == 0 {
160+
xlog.Debug("All backends up to date")
161+
return
162+
}
163+
164+
// Log available upgrades
165+
for name, info := range upgrades {
166+
if info.AvailableVersion != "" {
167+
xlog.Info("Backend upgrade available",
168+
"backend", name,
169+
"installed", info.InstalledVersion,
170+
"available", info.AvailableVersion)
171+
} else {
172+
xlog.Info("Backend upgrade available (new build)",
173+
"backend", name)
174+
}
175+
}
176+
177+
// Auto-upgrade if enabled
178+
if uc.appConfig.AutoUpgradeBackends {
179+
for name, info := range upgrades {
180+
xlog.Info("Auto-upgrading backend", "backend", name,
181+
"from", info.InstalledVersion, "to", info.AvailableVersion)
182+
if err := gallery.UpgradeBackend(ctx, uc.systemState, uc.modelLoader,
183+
uc.galleries, name, nil); err != nil {
184+
xlog.Error("Failed to auto-upgrade backend",
185+
"backend", name, "error", err)
186+
} else {
187+
xlog.Info("Backend upgraded successfully", "backend", name,
188+
"version", info.AvailableVersion)
189+
}
190+
}
191+
// Re-check to update cache after upgrades
192+
if freshUpgrades, err := gallery.CheckBackendUpgrades(ctx, uc.galleries, uc.systemState); err == nil {
193+
uc.mu.Lock()
194+
uc.lastUpgrades = freshUpgrades
195+
uc.mu.Unlock()
196+
}
197+
}
198+
}

core/cli/backends.go

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,17 @@ type BackendsUninstall struct {
4040
BackendsCMDFlags `embed:""`
4141
}
4242

43+
type BackendsUpgrade struct {
44+
BackendArgs []string `arg:"" optional:"" name:"backends" help:"Backend names to upgrade (empty = upgrade all)"`
45+
46+
BackendsCMDFlags `embed:""`
47+
}
48+
4349
type BackendsCMD struct {
4450
List BackendsList `cmd:"" help:"List the backends available in your galleries" default:"withargs"`
4551
Install BackendsInstall `cmd:"" help:"Install a backend from the gallery"`
4652
Uninstall BackendsUninstall `cmd:"" help:"Uninstall a backend"`
53+
Upgrade BackendsUpgrade `cmd:"" help:"Upgrade backends to latest versions"`
4754
}
4855

4956
func (bl *BackendsList) Run(ctx *cliContext.Context) error {
@@ -64,11 +71,27 @@ func (bl *BackendsList) Run(ctx *cliContext.Context) error {
6471
if err != nil {
6572
return err
6673
}
74+
75+
// Check for upgrades
76+
upgrades, _ := gallery.CheckBackendUpgrades(context.Background(), galleries, systemState)
77+
6778
for _, backend := range backends {
79+
versionStr := ""
80+
if backend.Version != "" {
81+
versionStr = " v" + backend.Version
82+
}
6883
if backend.Installed {
69-
fmt.Printf(" * %s@%s (installed)\n", backend.Gallery.Name, backend.Name)
84+
if info, ok := upgrades[backend.Name]; ok {
85+
upgradeStr := info.AvailableVersion
86+
if upgradeStr == "" {
87+
upgradeStr = "new build"
88+
}
89+
fmt.Printf(" * %s@%s%s (installed, upgrade available: %s)\n", backend.Gallery.Name, backend.Name, versionStr, upgradeStr)
90+
} else {
91+
fmt.Printf(" * %s@%s%s (installed)\n", backend.Gallery.Name, backend.Name, versionStr)
92+
}
7093
} else {
71-
fmt.Printf(" - %s@%s\n", backend.Gallery.Name, backend.Name)
94+
fmt.Printf(" - %s@%s%s\n", backend.Gallery.Name, backend.Name, versionStr)
7295
}
7396
}
7497
return nil
@@ -111,6 +134,79 @@ func (bi *BackendsInstall) Run(ctx *cliContext.Context) error {
111134
return nil
112135
}
113136

137+
func (bu *BackendsUpgrade) Run(ctx *cliContext.Context) error {
138+
var galleries []config.Gallery
139+
if err := json.Unmarshal([]byte(bu.BackendGalleries), &galleries); err != nil {
140+
xlog.Error("unable to load galleries", "error", err)
141+
}
142+
143+
systemState, err := system.GetSystemState(
144+
system.WithBackendSystemPath(bu.BackendsSystemPath),
145+
system.WithBackendPath(bu.BackendsPath),
146+
)
147+
if err != nil {
148+
return err
149+
}
150+
151+
upgrades, err := gallery.CheckBackendUpgrades(context.Background(), galleries, systemState)
152+
if err != nil {
153+
return fmt.Errorf("failed to check for upgrades: %w", err)
154+
}
155+
156+
if len(upgrades) == 0 {
157+
fmt.Println("All backends are up to date.")
158+
return nil
159+
}
160+
161+
// Filter to specified backends if args given
162+
toUpgrade := upgrades
163+
if len(bu.BackendArgs) > 0 {
164+
toUpgrade = make(map[string]gallery.UpgradeInfo)
165+
for _, name := range bu.BackendArgs {
166+
if info, ok := upgrades[name]; ok {
167+
toUpgrade[name] = info
168+
} else {
169+
fmt.Printf("Backend %s: no upgrade available\n", name)
170+
}
171+
}
172+
}
173+
174+
if len(toUpgrade) == 0 {
175+
fmt.Println("No upgrades to apply.")
176+
return nil
177+
}
178+
179+
modelLoader := model.NewModelLoader(systemState)
180+
for name, info := range toUpgrade {
181+
versionStr := ""
182+
if info.AvailableVersion != "" {
183+
versionStr = " to v" + info.AvailableVersion
184+
}
185+
fmt.Printf("Upgrading %s%s...\n", name, versionStr)
186+
187+
progressBar := progressbar.NewOptions(
188+
1000,
189+
progressbar.OptionSetDescription(fmt.Sprintf("downloading %s", name)),
190+
progressbar.OptionShowBytes(false),
191+
progressbar.OptionClearOnFinish(),
192+
)
193+
progressCallback := func(fileName string, current string, total string, percentage float64) {
194+
v := int(percentage * 10)
195+
if err := progressBar.Set(v); err != nil {
196+
xlog.Error("error updating progress bar", "error", err)
197+
}
198+
}
199+
200+
if err := gallery.UpgradeBackend(context.Background(), systemState, modelLoader, galleries, name, progressCallback); err != nil {
201+
fmt.Printf("Failed to upgrade %s: %v\n", name, err)
202+
} else {
203+
fmt.Printf("Backend %s upgraded successfully\n", name)
204+
}
205+
}
206+
207+
return nil
208+
}
209+
114210
func (bu *BackendsUninstall) Run(ctx *cliContext.Context) error {
115211
for _, backendName := range bu.BackendArgs {
116212
xlog.Info("uninstalling backend", "backend", backendName)

0 commit comments

Comments
 (0)