mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-04 14:09:34 -04:00
* 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
182 lines
5.9 KiB
Go
182 lines
5.9 KiB
Go
package galleryop
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/gallery"
|
|
"github.com/mudler/LocalAI/pkg/downloader"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
|
|
"github.com/mudler/LocalAI/pkg/utils"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
func (g *GalleryService) backendHandler(op *ManagementOp[gallery.GalleryBackend, any], systemState *system.SystemState) error {
|
|
utils.ResetDownloadTimers()
|
|
|
|
// Dedup check in distributed mode — skip if another instance is already processing this element
|
|
if g.galleryStore != nil && op.GalleryElementName != "" {
|
|
dup, err := g.galleryStore.FindDuplicate(op.GalleryElementName)
|
|
if err == nil && dup != nil && dup.ID != op.ID {
|
|
g.UpdateStatus(op.ID, &OpStatus{
|
|
Processed: true,
|
|
Message: fmt.Sprintf("already being processed by another instance (op %s)", dup.ID),
|
|
})
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Check if already cancelled
|
|
if op.Context != nil {
|
|
select {
|
|
case <-op.Context.Done():
|
|
g.UpdateStatus(op.ID, &OpStatus{
|
|
Cancelled: true,
|
|
Processed: true,
|
|
Message: "cancelled",
|
|
GalleryElementName: op.GalleryElementName,
|
|
})
|
|
return op.Context.Err()
|
|
default:
|
|
}
|
|
}
|
|
|
|
g.UpdateStatus(op.ID, &OpStatus{Message: fmt.Sprintf("processing backend: %s", op.GalleryElementName), Progress: 0, Cancellable: true})
|
|
|
|
// displayDownload displays the download progress
|
|
progressCallback := func(fileName string, current string, total string, percentage float64) {
|
|
// Check for cancellation during progress updates
|
|
if op.Context != nil {
|
|
select {
|
|
case <-op.Context.Done():
|
|
return
|
|
default:
|
|
}
|
|
}
|
|
g.UpdateStatus(op.ID, &OpStatus{Message: fmt.Sprintf(processingMessage, fileName, total, current), FileName: fileName, Progress: percentage, TotalFileSize: total, DownloadedFileSize: current, Cancellable: true})
|
|
utils.DisplayDownloadFunction(fileName, current, total, percentage)
|
|
}
|
|
|
|
ctx := op.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
var err error
|
|
if op.Upgrade {
|
|
err = g.backendManager.UpgradeBackend(ctx, op.GalleryElementName, progressCallback)
|
|
} else if op.Delete {
|
|
err = g.backendManager.DeleteBackend(op.GalleryElementName)
|
|
} else {
|
|
err = g.backendManager.InstallBackend(ctx, op, progressCallback)
|
|
// Update GalleryElementName for status tracking if a name was derived
|
|
if op.ExternalName != "" {
|
|
op.GalleryElementName = op.ExternalName
|
|
}
|
|
}
|
|
if err != nil {
|
|
// Check if error is due to cancellation
|
|
if op.Context != nil && errors.Is(err, op.Context.Err()) {
|
|
g.UpdateStatus(op.ID, &OpStatus{
|
|
Cancelled: true,
|
|
Processed: true,
|
|
Message: "cancelled",
|
|
GalleryElementName: op.GalleryElementName,
|
|
})
|
|
return err
|
|
}
|
|
xlog.Error("error installing backend", "error", err, "backend", op.GalleryElementName)
|
|
if !op.Delete {
|
|
// If we didn't install the backend, we need to make sure we don't have a leftover directory
|
|
gallery.DeleteBackendFromSystem(systemState, op.GalleryElementName)
|
|
}
|
|
return err
|
|
}
|
|
|
|
g.UpdateStatus(op.ID,
|
|
&OpStatus{
|
|
Deletion: op.Delete,
|
|
Processed: true,
|
|
GalleryElementName: op.GalleryElementName,
|
|
Message: "completed",
|
|
Progress: 100,
|
|
Cancellable: false})
|
|
return nil
|
|
}
|
|
|
|
// InstallExternalBackend installs a backend from an external source (OCI image, URL, or path).
|
|
// This method contains the logic to detect the input type and call the appropriate installation function.
|
|
// It can be used by both CLI and Web UI for installing backends from external sources.
|
|
func InstallExternalBackend(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState, modelLoader *model.ModelLoader, downloadStatus func(string, string, string, float64), backend, name, alias string) error {
|
|
uri := downloader.URI(backend)
|
|
switch {
|
|
case uri.LooksLikeDir():
|
|
if name == "" { // infer it from the path
|
|
name = filepath.Base(backend)
|
|
}
|
|
xlog.Info("Installing backend from path", "backend", backend, "name", name)
|
|
if err := gallery.InstallBackend(ctx, systemState, modelLoader, &gallery.GalleryBackend{
|
|
Metadata: gallery.Metadata{
|
|
Name: name,
|
|
},
|
|
Alias: alias,
|
|
URI: backend,
|
|
}, downloadStatus); err != nil {
|
|
return fmt.Errorf("error installing backend %s: %w", backend, err)
|
|
}
|
|
case uri.LooksLikeOCI() && !uri.LooksLikeOCIFile():
|
|
if name == "" {
|
|
return fmt.Errorf("specifying a name is required for OCI images")
|
|
}
|
|
xlog.Info("Installing backend from OCI image", "backend", backend, "name", name)
|
|
if err := gallery.InstallBackend(ctx, systemState, modelLoader, &gallery.GalleryBackend{
|
|
Metadata: gallery.Metadata{
|
|
Name: name,
|
|
},
|
|
Alias: alias,
|
|
URI: backend,
|
|
}, downloadStatus); err != nil {
|
|
return fmt.Errorf("error installing backend %s: %w", backend, err)
|
|
}
|
|
case uri.LooksLikeOCIFile():
|
|
derivedName, err := uri.FilenameFromUrl()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get filename from URL: %w", err)
|
|
}
|
|
// strip extension if any
|
|
derivedName = strings.TrimSuffix(derivedName, filepath.Ext(derivedName))
|
|
// Use provided name if available, otherwise use derived name
|
|
if name == "" {
|
|
name = derivedName
|
|
}
|
|
|
|
xlog.Info("Installing backend from OCI image", "backend", backend, "name", name)
|
|
if err := gallery.InstallBackend(ctx, systemState, modelLoader, &gallery.GalleryBackend{
|
|
Metadata: gallery.Metadata{
|
|
Name: name,
|
|
},
|
|
Alias: alias,
|
|
URI: backend,
|
|
}, downloadStatus); err != nil {
|
|
return fmt.Errorf("error installing backend %s: %w", backend, err)
|
|
}
|
|
default:
|
|
// Treat as gallery backend name
|
|
if name != "" || alias != "" {
|
|
return fmt.Errorf("specifying a name or alias is not supported for gallery backends")
|
|
}
|
|
err := gallery.InstallBackendFromGallery(ctx, galleries, systemState, modelLoader, backend, downloadStatus, true)
|
|
if err != nil {
|
|
return fmt.Errorf("error installing backend %s: %w", backend, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|