mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-17 04:56:52 -04:00
A `-development` backend variant (e.g. `cuda12-llama-cpp-development`)
shares its `alias` with the stable counterpart and is meant to be a
drop-in replacement via ListSystemBackends alias resolution. Two paths
in the auto-upgrade flow let the stable variant slip back in on top of
the user's explicit dev pick:
1. ListSystemBackends emits a synthetic alias row keyed by the alias
name that re-uses the chosen concrete's metadata pointer. In
distributed mode, the worker's handleBackendList serialised that
row over NATS as `{Name: <alias>, URI: <dev URI>, Digest: <dev>}`
— the frontend can't reconstruct the alias relationship, and the
wire-rebuilt row then carried `Metadata.Name = <alias>` and
resolved against an unrelated gallery entry on the next upgrade
check.
2. CheckUpgradesAgainst happily iterated the synthetic row in
single-node too. Today the duplicate gallery lookup is harmless
because both rows share the same `Metadata.Name`, but any gallery
change that gives a meta backend a version, or any concrete
sharing its alias with a dev counterpart, would surface a phantom
non-dev upgrade and auto-upgrade would install it — shadowing the
dev one through alias-token preference.
Two layered fixes:
- `core/services/worker/lifecycle.go` (`handleBackendList`): drop
rows where the map key differs from `b.Metadata.Name`. Concrete
and meta entries always have `key == Metadata.Name`; only synthetic
aliases violate it. Workers now report only what's actually on disk;
the per-node UI listing and CheckUpgrades both stop seeing phantoms.
- `core/gallery/upgrade.go` (`CheckUpgradesAgainst`): iterate by key,
skip rows where `key != Metadata.Name` (belt-and-suspenders for any
caller-supplied installed set), and apply the dev-aware rule —
build a set of installed `Metadata.Name`s and drop any non-dev
candidate `X` whose `X-<devSuffix>` counterpart is installed. Uses
the configured dev suffix from `getFallbackTagValues(systemState)`.
Manual `POST /api/backends/upgrade/<name>` is unaffected: it goes
straight through `bm.UpgradeBackend(name)` without consulting the
suppression list, so users who genuinely want the stable variant
upgraded can still trigger it explicitly.
Tests in core/gallery/upgrade_test.go cover three cases under
"CheckUpgradesAgainst (distributed)": dev-only installed → only the
dev surfaces; both variants installed → dev still wins; synthetic
alias row is ignored. Generic backend names are used to avoid the
capability filter dropping cuda-prefixed entries on a CPU-only host.
Assisted-by: Claude:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
437 lines
14 KiB
Go
437 lines
14 KiB
Go
package gallery_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
. "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"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var _ = Describe("Upgrade Detection and Execution", func() {
|
|
var (
|
|
tempDir string
|
|
backendsPath string
|
|
galleryPath string
|
|
systemState *system.SystemState
|
|
galleries []config.Gallery
|
|
)
|
|
|
|
// installBackendWithVersion creates a fake installed backend directory with
|
|
// the given name, version, and optional run.sh content.
|
|
installBackendWithVersion := func(name, version string, runContent ...string) {
|
|
dir := filepath.Join(backendsPath, name)
|
|
Expect(os.MkdirAll(dir, 0750)).To(Succeed())
|
|
|
|
content := "#!/bin/sh\necho ok"
|
|
if len(runContent) > 0 {
|
|
content = runContent[0]
|
|
}
|
|
Expect(os.WriteFile(filepath.Join(dir, "run.sh"), []byte(content), 0755)).To(Succeed())
|
|
|
|
metadata := BackendMetadata{
|
|
Name: name,
|
|
Version: version,
|
|
InstalledAt: time.Now().Format(time.RFC3339),
|
|
}
|
|
data, err := json.MarshalIndent(metadata, "", " ")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(os.WriteFile(filepath.Join(dir, "metadata.json"), data, 0644)).To(Succeed())
|
|
}
|
|
|
|
// writeGalleryYAML writes a gallery YAML file with the given backends.
|
|
writeGalleryYAML := func(backends []GalleryBackend) {
|
|
data, err := yaml.Marshal(backends)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(os.WriteFile(galleryPath, data, 0644)).To(Succeed())
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
tempDir, err = os.MkdirTemp("", "upgrade-test-*")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
backendsPath = tempDir
|
|
|
|
galleryPath = filepath.Join(tempDir, "gallery.yaml")
|
|
|
|
// Write a default empty gallery
|
|
writeGalleryYAML([]GalleryBackend{})
|
|
|
|
galleries = []config.Gallery{
|
|
{
|
|
Name: "test-gallery",
|
|
URL: "file://" + galleryPath,
|
|
},
|
|
}
|
|
|
|
systemState, err = system.GetSystemState(
|
|
system.WithBackendPath(backendsPath),
|
|
)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
os.RemoveAll(tempDir)
|
|
})
|
|
|
|
Describe("CheckBackendUpgrades", func() {
|
|
It("should detect upgrade when gallery version differs from installed version", func() {
|
|
// Install a backend at v1.0.0
|
|
installBackendWithVersion("my-backend", "1.0.0")
|
|
|
|
// Gallery advertises v2.0.0
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{
|
|
Name: "my-backend",
|
|
},
|
|
URI: filepath.Join(tempDir, "some-source"),
|
|
Version: "2.0.0",
|
|
},
|
|
})
|
|
|
|
upgrades, err := CheckBackendUpgrades(context.Background(), galleries, systemState)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(upgrades).To(HaveKey("my-backend"))
|
|
Expect(upgrades["my-backend"].InstalledVersion).To(Equal("1.0.0"))
|
|
Expect(upgrades["my-backend"].AvailableVersion).To(Equal("2.0.0"))
|
|
})
|
|
|
|
It("should NOT flag upgrade when versions match", func() {
|
|
installBackendWithVersion("my-backend", "2.0.0")
|
|
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{
|
|
Name: "my-backend",
|
|
},
|
|
URI: filepath.Join(tempDir, "some-source"),
|
|
Version: "2.0.0",
|
|
},
|
|
})
|
|
|
|
upgrades, err := CheckBackendUpgrades(context.Background(), galleries, systemState)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(upgrades).To(BeEmpty())
|
|
})
|
|
|
|
It("should skip backends without version info and without OCI digest", func() {
|
|
// Install without version
|
|
installBackendWithVersion("my-backend", "")
|
|
|
|
// Gallery also without version
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{
|
|
Name: "my-backend",
|
|
},
|
|
URI: filepath.Join(tempDir, "some-source"),
|
|
},
|
|
})
|
|
|
|
upgrades, err := CheckBackendUpgrades(context.Background(), galleries, systemState)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(upgrades).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
// CheckUpgradesAgainst is the entry point used by DistributedBackendManager.
|
|
// It takes installed backends directly — typically aggregated from workers —
|
|
// instead of reading the frontend filesystem. These tests exercise drift
|
|
// detection, which is the feature the distributed path relies on.
|
|
Describe("CheckUpgradesAgainst (distributed)", func() {
|
|
It("flags upgrade when cluster nodes disagree on version, even if gallery matches majority", func() {
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{Name: "my-backend"},
|
|
URI: filepath.Join(tempDir, "some-source"),
|
|
Version: "2.0.0",
|
|
},
|
|
})
|
|
|
|
installed := SystemBackends{
|
|
"my-backend": SystemBackend{
|
|
Name: "my-backend",
|
|
Metadata: &BackendMetadata{Name: "my-backend", Version: "2.0.0"},
|
|
Nodes: []NodeBackendRef{
|
|
{NodeID: "a", NodeName: "worker-1", Version: "2.0.0"},
|
|
{NodeID: "b", NodeName: "worker-2", Version: "2.0.0"},
|
|
{NodeID: "c", NodeName: "worker-3", Version: "1.0.0"}, // drift
|
|
},
|
|
},
|
|
}
|
|
|
|
upgrades, err := CheckUpgradesAgainst(context.Background(), galleries, systemState, installed)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(upgrades).To(HaveKey("my-backend"))
|
|
info := upgrades["my-backend"]
|
|
Expect(info.AvailableVersion).To(Equal("2.0.0"))
|
|
Expect(info.NodeDrift).To(HaveLen(1))
|
|
Expect(info.NodeDrift[0].NodeName).To(Equal("worker-3"))
|
|
Expect(info.NodeDrift[0].Version).To(Equal("1.0.0"))
|
|
})
|
|
|
|
It("does not flag upgrade when all nodes agree and match gallery", func() {
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{Name: "my-backend"},
|
|
URI: filepath.Join(tempDir, "some-source"),
|
|
Version: "2.0.0",
|
|
},
|
|
})
|
|
|
|
installed := SystemBackends{
|
|
"my-backend": SystemBackend{
|
|
Name: "my-backend",
|
|
Metadata: &BackendMetadata{Name: "my-backend", Version: "2.0.0"},
|
|
Nodes: []NodeBackendRef{
|
|
{NodeID: "a", NodeName: "worker-1", Version: "2.0.0"},
|
|
{NodeID: "b", NodeName: "worker-2", Version: "2.0.0"},
|
|
},
|
|
},
|
|
}
|
|
|
|
upgrades, err := CheckUpgradesAgainst(context.Background(), galleries, systemState, installed)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(upgrades).To(BeEmpty())
|
|
})
|
|
|
|
It("surfaces empty-installed-version path the old distributed code silently missed", func() {
|
|
// Simulates the real-world bug: worker has a backend, its version
|
|
// is empty (pre-tracking or OCI-pinned-to-latest), gallery has a
|
|
// version. Pre-fix CheckUpgrades returned nothing; now it surfaces.
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{Name: "my-backend"},
|
|
URI: filepath.Join(tempDir, "some-source"),
|
|
Version: "2.0.0",
|
|
},
|
|
})
|
|
|
|
installed := SystemBackends{
|
|
"my-backend": SystemBackend{
|
|
Name: "my-backend",
|
|
Metadata: &BackendMetadata{Name: "my-backend"},
|
|
Nodes: []NodeBackendRef{
|
|
{NodeID: "a", NodeName: "worker-1"},
|
|
},
|
|
},
|
|
}
|
|
|
|
upgrades, err := CheckUpgradesAgainst(context.Background(), galleries, systemState, installed)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(upgrades).To(HaveKey("my-backend"))
|
|
Expect(upgrades["my-backend"].InstalledVersion).To(BeEmpty())
|
|
Expect(upgrades["my-backend"].AvailableVersion).To(Equal("2.0.0"))
|
|
})
|
|
|
|
// Dev-aware suppression: when `<X>-development` is installed it
|
|
// stands in for the stable `<X>` via alias resolution. Auto-upgrade
|
|
// must never reintroduce the stable variant alongside the dev one,
|
|
// because the install would land on disk and (depending on
|
|
// preference tokens) either shadow the dev pick or sit unused next
|
|
// to it. These tests fix CheckUpgradesAgainst to honor that.
|
|
// Names are kept generic ("my-backend") so the capability filter
|
|
// in AvailableBackends doesn't drop them on a CPU-only test host.
|
|
It("suppresses non-dev candidate when its -development counterpart is installed", func() {
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{Name: "my-backend"},
|
|
URI: filepath.Join(tempDir, "stable"),
|
|
Version: "2.0.0",
|
|
},
|
|
{
|
|
Metadata: Metadata{Name: "my-backend-development"},
|
|
URI: filepath.Join(tempDir, "dev"),
|
|
Version: "2.0.0",
|
|
},
|
|
})
|
|
|
|
installed := SystemBackends{
|
|
"my-backend-development": SystemBackend{
|
|
Name: "my-backend-development",
|
|
Metadata: &BackendMetadata{
|
|
Name: "my-backend-development",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
upgrades, err := CheckUpgradesAgainst(context.Background(), galleries, systemState, installed)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(upgrades).To(HaveKey("my-backend-development"))
|
|
Expect(upgrades).NotTo(HaveKey("my-backend"))
|
|
})
|
|
|
|
It("dev variant wins even when non-dev is also present (vestigial state)", func() {
|
|
// Either via legacy state, manual install, or a worker still
|
|
// emitting synthetic aliases, the non-dev row may be present
|
|
// alongside the dev one. Auto-upgrade must still keep its
|
|
// hands off the non-dev — installing the stable variant on
|
|
// top of the user's explicit dev pick is exactly what the
|
|
// alias drop-in promise forbids. Users who genuinely want
|
|
// the non-dev upgraded can trigger it manually via
|
|
// /api/backends/upgrade/<name>.
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{Name: "my-backend"},
|
|
URI: filepath.Join(tempDir, "stable"),
|
|
Version: "2.0.0",
|
|
},
|
|
{
|
|
Metadata: Metadata{Name: "my-backend-development"},
|
|
URI: filepath.Join(tempDir, "dev"),
|
|
Version: "2.0.0",
|
|
},
|
|
})
|
|
|
|
installed := SystemBackends{
|
|
"my-backend": SystemBackend{
|
|
Name: "my-backend",
|
|
Metadata: &BackendMetadata{
|
|
Name: "my-backend",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
"my-backend-development": SystemBackend{
|
|
Name: "my-backend-development",
|
|
Metadata: &BackendMetadata{
|
|
Name: "my-backend-development",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
upgrades, err := CheckUpgradesAgainst(context.Background(), galleries, systemState, installed)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(upgrades).To(HaveKey("my-backend-development"))
|
|
Expect(upgrades).NotTo(HaveKey("my-backend"))
|
|
})
|
|
|
|
It("ignores synthetic alias rows whose key differs from Metadata.Name", func() {
|
|
// ListSystemBackends emits an extra row keyed by the alias name
|
|
// that re-uses the chosen concrete's metadata pointer. Pre-fix
|
|
// this row caused a duplicate gallery lookup in single-node
|
|
// (harmless by accident) and a phantom upgrade in distributed
|
|
// mode (real bug — the wire-reconstructed row carries
|
|
// Metadata.Name = alias and resolves against an unrelated entry).
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{Name: "my-alias"},
|
|
URI: filepath.Join(tempDir, "stable-meta"),
|
|
Version: "2.0.0",
|
|
},
|
|
{
|
|
Metadata: Metadata{Name: "my-backend-development"},
|
|
URI: filepath.Join(tempDir, "dev"),
|
|
Version: "2.0.0",
|
|
},
|
|
})
|
|
|
|
devMeta := &BackendMetadata{
|
|
Name: "my-backend-development",
|
|
Version: "1.0.0",
|
|
Alias: "my-alias",
|
|
}
|
|
installed := SystemBackends{
|
|
"my-backend-development": SystemBackend{
|
|
Name: "my-backend-development",
|
|
Metadata: devMeta,
|
|
},
|
|
// Synthetic alias row: key != Metadata.Name.
|
|
"my-alias": SystemBackend{
|
|
Name: "my-alias",
|
|
Metadata: devMeta,
|
|
},
|
|
}
|
|
|
|
upgrades, err := CheckUpgradesAgainst(context.Background(), galleries, systemState, installed)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(upgrades).To(HaveKey("my-backend-development"))
|
|
Expect(upgrades).NotTo(HaveKey("my-alias"))
|
|
})
|
|
})
|
|
|
|
Describe("UpgradeBackend", func() {
|
|
It("should replace backend directory and update metadata", func() {
|
|
// Install v1
|
|
installBackendWithVersion("my-backend", "1.0.0", "#!/bin/sh\necho v1")
|
|
|
|
// Create a source directory with v2 content
|
|
srcDir := filepath.Join(tempDir, "v2-source")
|
|
Expect(os.MkdirAll(srcDir, 0750)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(srcDir, "run.sh"), []byte("#!/bin/sh\necho v2"), 0755)).To(Succeed())
|
|
|
|
// Gallery points to the v2 source dir
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{
|
|
Name: "my-backend",
|
|
},
|
|
URI: srcDir,
|
|
Version: "2.0.0",
|
|
},
|
|
})
|
|
|
|
ml := model.NewModelLoader(systemState)
|
|
err := UpgradeBackend(context.Background(), systemState, ml, galleries, "my-backend", nil)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Verify run.sh was updated
|
|
content, err := os.ReadFile(filepath.Join(backendsPath, "my-backend", "run.sh"))
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(string(content)).To(Equal("#!/bin/sh\necho v2"))
|
|
|
|
// Verify metadata was updated
|
|
metaData, err := os.ReadFile(filepath.Join(backendsPath, "my-backend", "metadata.json"))
|
|
Expect(err).NotTo(HaveOccurred())
|
|
var meta BackendMetadata
|
|
Expect(json.Unmarshal(metaData, &meta)).To(Succeed())
|
|
Expect(meta.Version).To(Equal("2.0.0"))
|
|
Expect(meta.Name).To(Equal("my-backend"))
|
|
})
|
|
|
|
It("should restore backup on failure", func() {
|
|
// Install v1
|
|
installBackendWithVersion("my-backend", "1.0.0", "#!/bin/sh\necho v1")
|
|
|
|
// Gallery points to a nonexistent path (no run.sh will be found)
|
|
nonExistentDir := filepath.Join(tempDir, "does-not-exist")
|
|
writeGalleryYAML([]GalleryBackend{
|
|
{
|
|
Metadata: Metadata{
|
|
Name: "my-backend",
|
|
},
|
|
URI: nonExistentDir,
|
|
Version: "2.0.0",
|
|
},
|
|
})
|
|
|
|
ml := model.NewModelLoader(systemState)
|
|
err := UpgradeBackend(context.Background(), systemState, ml, galleries, "my-backend", nil)
|
|
Expect(err).To(HaveOccurred())
|
|
|
|
// Verify v1 is still intact
|
|
content, err := os.ReadFile(filepath.Join(backendsPath, "my-backend", "run.sh"))
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(string(content)).To(Equal("#!/bin/sh\necho v1"))
|
|
|
|
// Verify metadata still says v1
|
|
metaData, err := os.ReadFile(filepath.Join(backendsPath, "my-backend", "metadata.json"))
|
|
Expect(err).NotTo(HaveOccurred())
|
|
var meta BackendMetadata
|
|
Expect(json.Unmarshal(metaData, &meta)).To(Succeed())
|
|
Expect(meta.Version).To(Equal("1.0.0"))
|
|
})
|
|
})
|
|
})
|