mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-26 16:14:57 -04:00
* feat(oci): pull ORAS artifacts into a directory Galleries published to an OCI registry arrive as an ORAS artifact whose layers carry their tree path in org.opencontainers.image.title. Nothing in pkg/oci could read a non-image manifest, so add PullArtifact: it lays the layers out under a destination directory and returns the manifest digest so callers can pin and verify signatures on it. A registry is remote input and this writes files, so the manifest is validated before any byte is fetched. A title that is absolute, escapes the destination or is missing is refused, an artifact of an unexpected artifactType is refused, and both the layer count and the total size are capped with caller-settable limits. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * feat(gallery): fetch and verify galleries published as OCI artifacts A gallery whose URL is oci://host/repo:tag is now pulled as an ORAS artifact and its index.yaml read from the unpacked tree, alongside the existing HTTP and file sources. Mirrors, the per-candidate timeout, the failure cooldown and the last known good copy work the same for both schemes, so a gallery can mirror an OCI primary with an HTTP fallback. When the gallery declares a verification policy the artifact reference is resolved to its manifest digest, the publisher signature is checked against that digest, and only then is the same digest pulled. Nothing unverified is written to disk or parsed. With strict integrity mode on, an OCI gallery without a verification policy is refused instead of warned about, which is what the flag already does for backend installs. The pull lands in a staging directory that is renamed into the cache only once the whole tree is on disk and the index reads back as an index, so a failed or interrupted pull leaves nothing a later fetch would serve. A fetch that finds a fresh cache entry does not contact the registry. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * feat(gallery): resolve entry urls relative to the gallery root A gallery published as a self-contained tree, which is what an OCI gallery is, names its base configs by their place in that tree. Such a url reached the HTTP client verbatim and no entry of the gallery could be installed. A scheme-less entry url now resolves against the root the index came from: the unpacked artifact for an oci:// gallery, and the directory of the index URL otherwise. The relative path may not climb out of that root, reusing the guard the artifact puller already applies to layer titles, and an entry that does is dropped from the listing rather than failing the whole gallery. An entry that names its own scheme is untouched, so .ref indirection and local github: resolution behave as before. Reading an entry of an OCI gallery also needs the unpacked artifact directory as its trusted root, since the cache is deliberately a sibling of the models directory the downloader otherwise confines a file read to. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * fix(oci): restrict artifact file permissions Create artifact files with owner-only permissions to satisfy G302. Document the existing path validation for the G304 scanner finding. Check the permissions of downloaded layers in the artifact test. Assisted-by: Codex:gpt-6 gosec * fix(oci): confine artifact writes to their root Use root-relative filesystem operations to reject escapes through existing directory symlinks. Retain owner-only permissions and add a symlink escape regression test, without suppressing the path traversal finding. Assisted-by: Codex:gpt-6 --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
669 lines
22 KiB
Go
669 lines
22 KiB
Go
package gallery
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/lithammer/fuzzysearch/fuzzy"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/pkg/downloader"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
"github.com/mudler/LocalAI/pkg/utils"
|
|
"github.com/mudler/LocalAI/pkg/vram"
|
|
"github.com/mudler/LocalAI/pkg/xsync"
|
|
"github.com/mudler/xlog"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// validateGalleryConfigURL guards the gallery config fetch against SSRF. A
|
|
// gallery config URL can be attacker-controlled (e.g. POST /models/apply with
|
|
// an empty id fetches it directly), so a plain http(s) URL must not be allowed
|
|
// to reach private, loopback, link-local or cloud-metadata addresses. Other
|
|
// schemes (huggingface://, github:, oci://, ollama://, file://) resolve to
|
|
// fixed public services or local files and are not a network-SSRF vector, so
|
|
// they are left untouched.
|
|
// See https://github.com/mudler/LocalAI/issues/10665
|
|
func validateGalleryConfigURL(rawURL string) error {
|
|
lower := strings.ToLower(strings.TrimSpace(rawURL))
|
|
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
|
return utils.ValidateExternalURL(rawURL)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) {
|
|
var config T
|
|
if err := validateGalleryConfigURL(url); err != nil {
|
|
xlog.Error("refusing to fetch gallery config", "error", err, "url", url)
|
|
return config, err
|
|
}
|
|
uri := downloader.URI(url)
|
|
err := uri.ReadWithCallback(galleryConfigReadRoot(url, basePath), func(url string, d []byte) error {
|
|
return yaml.Unmarshal(d, &config)
|
|
})
|
|
if err != nil {
|
|
xlog.Error("failed to get gallery config for url", "error", err, "url", url)
|
|
return config, err
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func GetGalleryConfigFromURLWithContext[T any](ctx context.Context, url string, basePath string) (T, error) {
|
|
var config T
|
|
if err := validateGalleryConfigURL(url); err != nil {
|
|
xlog.Error("refusing to fetch gallery config", "error", err, "url", url)
|
|
return config, err
|
|
}
|
|
uri := downloader.URI(url)
|
|
err := uri.ReadWithAuthorizationAndCallback(ctx, galleryConfigReadRoot(url, basePath), "", func(url string, d []byte) error {
|
|
return yaml.Unmarshal(d, &config)
|
|
})
|
|
if err != nil {
|
|
xlog.Error("failed to get gallery config for url", "error", err, "url", url)
|
|
return config, err
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func ReadConfigFile[T any](filePath string) (*T, error) {
|
|
// Read the YAML file
|
|
yamlFile, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read YAML file: %v", err)
|
|
}
|
|
|
|
// Unmarshal YAML data into a Config struct
|
|
var config T
|
|
err = yaml.Unmarshal(yamlFile, &config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal YAML: %v", err)
|
|
}
|
|
|
|
return &config, nil
|
|
}
|
|
|
|
type GalleryElement interface {
|
|
SetGallery(gallery config.Gallery)
|
|
SetInstalled(installed bool)
|
|
GetName() string
|
|
GetDescription() string
|
|
GetTags() []string
|
|
GetInstalled() bool
|
|
GetLicense() string
|
|
GetGallery() config.Gallery
|
|
}
|
|
|
|
type GalleryElements[T GalleryElement] []T
|
|
|
|
func (gm GalleryElements[T]) Search(term string) GalleryElements[T] {
|
|
var filteredModels GalleryElements[T]
|
|
term = strings.ToLower(term)
|
|
for _, m := range gm {
|
|
if fuzzy.Match(term, strings.ToLower(m.GetName())) ||
|
|
fuzzy.Match(term, strings.ToLower(m.GetGallery().Name)) ||
|
|
strings.Contains(strings.ToLower(m.GetName()), term) ||
|
|
strings.Contains(strings.ToLower(m.GetDescription()), term) ||
|
|
strings.Contains(strings.ToLower(m.GetGallery().Name), term) ||
|
|
strings.Contains(strings.ToLower(strings.Join(m.GetTags(), ",")), term) {
|
|
filteredModels = append(filteredModels, m)
|
|
}
|
|
}
|
|
|
|
return filteredModels
|
|
}
|
|
|
|
// FilterGalleryModelsByUsecase returns models whose known_usecases include all
|
|
// the bits set in usecase. For example, passing FLAG_CHAT matches any model
|
|
// with the chat usecase; passing FLAG_CHAT|FLAG_VISION matches only models
|
|
// that have both.
|
|
func FilterGalleryModelsByUsecase(models GalleryElements[*GalleryModel], usecase config.ModelConfigUsecase) GalleryElements[*GalleryModel] {
|
|
var filtered GalleryElements[*GalleryModel]
|
|
for _, m := range models {
|
|
u := m.GetKnownUsecases()
|
|
if u != nil && (*u&usecase) == usecase {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// FilterGalleryModelsByMultimodal returns models whose known_usecases span two
|
|
// or more orthogonal modality groups (e.g. chat+vision, tts+transcript).
|
|
func FilterGalleryModelsByMultimodal(models GalleryElements[*GalleryModel]) GalleryElements[*GalleryModel] {
|
|
var filtered GalleryElements[*GalleryModel]
|
|
for _, m := range models {
|
|
u := m.GetKnownUsecases()
|
|
if u != nil && config.IsMultimodal(*u) {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (gm GalleryElements[T]) FilterByTag(tag string) GalleryElements[T] {
|
|
var filtered GalleryElements[T]
|
|
for _, m := range gm {
|
|
for _, t := range m.GetTags() {
|
|
if strings.EqualFold(t, tag) {
|
|
filtered = append(filtered, m)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (gm GalleryElements[T]) SortByName(sortOrder string) GalleryElements[T] {
|
|
slices.SortFunc(gm, func(a, b T) int {
|
|
r := strings.Compare(strings.ToLower(a.GetName()), strings.ToLower(b.GetName()))
|
|
if sortOrder == "desc" {
|
|
return -r
|
|
}
|
|
return r
|
|
})
|
|
return gm
|
|
}
|
|
|
|
func (gm GalleryElements[T]) SortByRepository(sortOrder string) GalleryElements[T] {
|
|
slices.SortFunc(gm, func(a, b T) int {
|
|
r := strings.Compare(strings.ToLower(a.GetGallery().Name), strings.ToLower(b.GetGallery().Name))
|
|
if sortOrder == "desc" {
|
|
return -r
|
|
}
|
|
return r
|
|
})
|
|
return gm
|
|
}
|
|
|
|
func (gm GalleryElements[T]) SortByLicense(sortOrder string) GalleryElements[T] {
|
|
slices.SortFunc(gm, func(a, b T) int {
|
|
licenseA := a.GetLicense()
|
|
licenseB := b.GetLicense()
|
|
var r int
|
|
if licenseA == "" && licenseB != "" {
|
|
r = 1
|
|
} else if licenseA != "" && licenseB == "" {
|
|
r = -1
|
|
} else {
|
|
r = strings.Compare(strings.ToLower(licenseA), strings.ToLower(licenseB))
|
|
}
|
|
if sortOrder == "desc" {
|
|
return -r
|
|
}
|
|
return r
|
|
})
|
|
return gm
|
|
}
|
|
|
|
func (gm GalleryElements[T]) SortByInstalled(sortOrder string) GalleryElements[T] {
|
|
slices.SortFunc(gm, func(a, b T) int {
|
|
var r int
|
|
// Sort by installed status: installed items first (true > false)
|
|
if a.GetInstalled() != b.GetInstalled() {
|
|
if a.GetInstalled() {
|
|
r = -1
|
|
} else {
|
|
r = 1
|
|
}
|
|
} else {
|
|
r = strings.Compare(strings.ToLower(a.GetName()), strings.ToLower(b.GetName()))
|
|
}
|
|
if sortOrder == "desc" {
|
|
return -r
|
|
}
|
|
return r
|
|
})
|
|
return gm
|
|
}
|
|
|
|
func (gm GalleryElements[T]) FindByName(name string) T {
|
|
for _, m := range gm {
|
|
if strings.EqualFold(m.GetName(), name) {
|
|
return m
|
|
}
|
|
}
|
|
var zero T
|
|
return zero
|
|
}
|
|
|
|
func (gm GalleryElements[T]) Paginate(pageNum int, itemsNum int) GalleryElements[T] {
|
|
start := (pageNum - 1) * itemsNum
|
|
end := start + itemsNum
|
|
if start > len(gm) {
|
|
start = len(gm)
|
|
}
|
|
if end > len(gm) {
|
|
end = len(gm)
|
|
}
|
|
return gm[start:end]
|
|
}
|
|
|
|
func FindGalleryElement[T GalleryElement](models []T, name string) T {
|
|
var model T
|
|
name = strings.ReplaceAll(name, string(os.PathSeparator), "__")
|
|
|
|
if !strings.Contains(name, "@") {
|
|
for _, m := range models {
|
|
if strings.EqualFold(strings.ToLower(m.GetName()), strings.ToLower(name)) {
|
|
model = m
|
|
break
|
|
}
|
|
}
|
|
|
|
} else {
|
|
for _, m := range models {
|
|
if strings.EqualFold(strings.ToLower(name), strings.ToLower(fmt.Sprintf("%s@%s", m.GetGallery().Name, m.GetName()))) {
|
|
model = m
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return model
|
|
}
|
|
|
|
// List available models
|
|
// Models galleries are a list of yaml files that are hosted on a remote server (for example github).
|
|
// Each yaml file contains a list of models that can be downloaded and optionally overrides to define a new model setting.
|
|
func AvailableGalleryModels(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryModel], error) {
|
|
var models []*GalleryModel
|
|
|
|
// Get models from galleries
|
|
for _, gallery := range galleries {
|
|
galleryModels, err := getGalleryElements(gallery, systemState.Model.ModelsPath, systemState.RequireBackendIntegrity, func(model *GalleryModel) bool {
|
|
if _, err := os.Stat(filepath.Join(systemState.Model.ModelsPath, fmt.Sprintf("%s.yaml", model.GetName()))); err == nil {
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Resolve model URLs locally (for local galleries) and collect unique
|
|
// URLs that need fetching for backend resolution.
|
|
uniqueURLs := map[string]struct{}{}
|
|
usable := make([]*GalleryModel, 0, len(galleryModels))
|
|
for _, m := range galleryModels {
|
|
if m.URL != "" {
|
|
m.URL = resolveModelURLLocally(m.URL, gallery.URL)
|
|
// The gallery carried on the entry is the one the index was
|
|
// really read from, with a .ref indirection already followed,
|
|
// so it is the root an entry path is relative to.
|
|
resolved, err := resolveGalleryEntryURL(m.URL, m.GetGallery(), systemState.Model.ModelsPath)
|
|
if err != nil {
|
|
// One unusable entry must not cost the user the rest of
|
|
// the gallery, so it is dropped and named rather than
|
|
// failing the listing. It is left out entirely because an
|
|
// entry whose url does not resolve cannot be installed,
|
|
// and offering it would only fail later and further away.
|
|
xlog.Error("dropping a gallery entry whose url does not resolve",
|
|
"gallery", gallery.Name, "model", m.Name, "url", m.URL, "error", err)
|
|
continue
|
|
}
|
|
m.URL = resolved
|
|
}
|
|
usable = append(usable, m)
|
|
if m.Backend == "" && m.URL != "" {
|
|
uniqueURLs[m.URL] = struct{}{}
|
|
}
|
|
}
|
|
galleryModels = usable
|
|
|
|
// Pre-warm cache with parallel fetches to avoid sequential HTTP
|
|
// requests on cold start (~50 unique gallery config files).
|
|
if len(uniqueURLs) > 0 {
|
|
urls := make([]string, 0, len(uniqueURLs))
|
|
for u := range uniqueURLs {
|
|
urls = append(urls, u)
|
|
}
|
|
prefetchModelConfigs(urls, systemState.Model.ModelsPath)
|
|
}
|
|
|
|
// Resolve backends from warm cache.
|
|
for _, m := range galleryModels {
|
|
if m.Backend == "" {
|
|
m.Backend = resolveBackend(m, systemState.Model.ModelsPath)
|
|
}
|
|
}
|
|
|
|
models = append(models, galleryModels...)
|
|
}
|
|
|
|
return models, nil
|
|
}
|
|
|
|
var (
|
|
availableModelsMu sync.RWMutex
|
|
availableModelsCache GalleryElements[*GalleryModel]
|
|
// Whether a load has happened, tracked apart from the slice itself. A
|
|
// gallery that legitimately holds nothing caches as an empty (often nil)
|
|
// slice, and testing the slice for nil read that as "never loaded": every
|
|
// call then took the blocking path and bumped the generation, which is the
|
|
// same cache-defeating loop the refresh interval exists to stop.
|
|
availableModelsLoaded bool
|
|
refreshing atomic.Bool
|
|
galleryGeneration atomic.Uint64
|
|
lastRefreshUnixNano atomic.Int64
|
|
)
|
|
|
|
// How often the cached model list may be refreshed from upstream.
|
|
//
|
|
// This is a floor on refresh frequency, not a TTL: the cache is served
|
|
// regardless, and this only decides how often a background re-fetch is worth
|
|
// starting. It matters far more than it looks, because a refresh bumps
|
|
// galleryGeneration, and that invalidates every VRAM estimate cache in
|
|
// pkg/vram. Refreshing on every call therefore kept those caches permanently
|
|
// cold: the gallery listing is one request but the UI asks for one VRAM
|
|
// estimate per row, so a single page view triggered dozens of refreshes and
|
|
// every estimate paid full price for a remote probe it had already made.
|
|
//
|
|
// A package variable rather than a constant so tests can drive refreshes
|
|
// without waiting.
|
|
var GalleryRefreshInterval = 5 * time.Minute
|
|
|
|
// GalleryGeneration returns a counter that increments each time the gallery
|
|
// model list is refreshed from upstream. VRAM estimation caches use this to
|
|
// invalidate entries when the gallery data changes.
|
|
func GalleryGeneration() uint64 { return galleryGeneration.Load() }
|
|
|
|
// ResetGalleryModelCache drops the cached model list, once any background
|
|
// refresh already in flight has finished writing to it.
|
|
//
|
|
// It exists for tests. The cache is a package global keyed by nothing, which is
|
|
// right for a process serving one gallery configuration and wrong for a suite
|
|
// where each spec stands up its own: a refresh one spec triggered can land in
|
|
// the middle of the next and answer it with the previous spec's entries, so
|
|
// whichever assertion happens to straddle it fails at random.
|
|
//
|
|
// Waiting for the in-flight refresh rather than only clearing is the point. The
|
|
// refresh publishes its result after this call would otherwise have returned,
|
|
// so clearing without waiting just narrows the window.
|
|
func ResetGalleryModelCache() {
|
|
for refreshing.Load() {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
availableModelsMu.Lock()
|
|
availableModelsCache = nil
|
|
availableModelsLoaded = false
|
|
availableModelsMu.Unlock()
|
|
// Also clear the refresh stamp, or a suite that reset the cache would find
|
|
// the next refresh throttled by the previous spec's clock.
|
|
lastRefreshUnixNano.Store(0)
|
|
}
|
|
|
|
// AvailableGalleryModelsCached returns gallery models from an in-memory cache.
|
|
// Local-only fields (installed status) are refreshed on every call. A background
|
|
// goroutine is triggered to re-fetch the full model list (including network
|
|
// calls) so subsequent requests pick up changes without blocking the caller.
|
|
// The first call with an empty cache blocks until the initial load completes.
|
|
func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryModel], error) {
|
|
availableModelsMu.RLock()
|
|
cached := availableModelsCache
|
|
loaded := availableModelsLoaded
|
|
availableModelsMu.RUnlock()
|
|
|
|
if loaded {
|
|
// Refresh installed status under write lock to avoid races with
|
|
// concurrent readers and the background refresh goroutine.
|
|
availableModelsMu.Lock()
|
|
for _, m := range cached {
|
|
_, err := os.Stat(filepath.Join(systemState.Model.ModelsPath, fmt.Sprintf("%s.yaml", m.GetName())))
|
|
m.SetInstalled(err == nil)
|
|
}
|
|
availableModelsMu.Unlock()
|
|
// Trigger a background refresh if one is not already running.
|
|
triggerGalleryRefresh(galleries, systemState)
|
|
return cached, nil
|
|
}
|
|
|
|
// No cache yet — must do a blocking load.
|
|
models, err := AvailableGalleryModels(galleries, systemState)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
availableModelsMu.Lock()
|
|
availableModelsCache = models
|
|
availableModelsLoaded = true
|
|
galleryGeneration.Add(1)
|
|
availableModelsMu.Unlock()
|
|
lastRefreshUnixNano.Store(time.Now().UnixNano())
|
|
|
|
return models, nil
|
|
}
|
|
|
|
// triggerGalleryRefresh starts a background goroutine that refreshes the
|
|
// gallery model cache. Only one refresh runs at a time; concurrent calls
|
|
// are no-ops.
|
|
func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.SystemState) {
|
|
if GalleryRefreshInterval > 0 {
|
|
last := lastRefreshUnixNano.Load()
|
|
if last != 0 && time.Since(time.Unix(0, last)) < GalleryRefreshInterval {
|
|
return
|
|
}
|
|
}
|
|
if !refreshing.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
// Stamped before the fetch rather than after, so a slow upstream cannot
|
|
// let a queue of callers each start their own refresh behind this one.
|
|
lastRefreshUnixNano.Store(time.Now().UnixNano())
|
|
go func() {
|
|
defer refreshing.Store(false)
|
|
models, err := AvailableGalleryModels(galleries, systemState)
|
|
if err != nil {
|
|
xlog.Error("background gallery refresh failed", "error", err)
|
|
return
|
|
}
|
|
availableModelsMu.Lock()
|
|
changed := !sameModelSet(availableModelsCache, models)
|
|
availableModelsCache = models
|
|
availableModelsLoaded = true
|
|
// Only a real change invalidates the VRAM caches. An unchanged gallery
|
|
// re-fetched on schedule must not throw away work that is still valid,
|
|
// which is the difference between an estimate costing nothing and
|
|
// costing a network round trip.
|
|
if changed {
|
|
galleryGeneration.Add(1)
|
|
}
|
|
availableModelsMu.Unlock()
|
|
if changed {
|
|
vram.InvalidatePersistentCache()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// sameModelSet reports whether two model lists describe the same gallery, for
|
|
// the purpose of deciding whether derived caches are still valid. Names and
|
|
// order are enough: a change to an entry's files or size arrives with a new
|
|
// gallery index, and comparing every field on every entry would cost more than
|
|
// the caches save.
|
|
func sameModelSet(a, b GalleryElements[*GalleryModel]) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i].GetName() != b[i].GetName() {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// List available backends
|
|
func AvailableBackends(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
|
|
return availableBackendsWithFilter(galleries, systemState, func(backend *GalleryBackend) bool {
|
|
return backend.IsCompatibleWith(systemState)
|
|
})
|
|
}
|
|
|
|
// AvailableBackendsUnfiltered returns all available backends without filtering by system capability.
|
|
func AvailableBackendsUnfiltered(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
|
|
return availableBackendsWithFilter(galleries, systemState, nil)
|
|
}
|
|
|
|
// AvailableBackendsForCapabilities lists backends runnable on the local system
|
|
// OR on any remote host reporting one of the supplied capabilities.
|
|
//
|
|
// In a distributed deployment the host serving this listing (the controller)
|
|
// is usually a GPU-less pod while the GPUs live on worker nodes. Filtering
|
|
// only against the controller hid every GPU-only meta backend from admins even
|
|
// though installing it by name on a worker worked fine, so compatibility is
|
|
// evaluated as a union over the cluster. An empty capabilities slice reproduces
|
|
// AvailableBackends exactly, keeping single-node behavior untouched.
|
|
func AvailableBackendsForCapabilities(galleries []config.Gallery, systemState *system.SystemState, capabilities []string) (GalleryElements[*GalleryBackend], error) {
|
|
if len(capabilities) == 0 {
|
|
return AvailableBackends(galleries, systemState)
|
|
}
|
|
|
|
// Each remote capability is evaluated through a state pinned to that exact
|
|
// capability, so the controller's own detection (and any forced capability
|
|
// on the controller image) cannot leak into the worker's verdict. Backend
|
|
// paths still come from the controller's state because that is where the
|
|
// gallery metadata is read from.
|
|
nodeStates := make([]*system.SystemState, 0, len(capabilities))
|
|
for _, capability := range capabilities {
|
|
nodeStates = append(nodeStates, system.NewCapabilityState(capability,
|
|
system.WithBackendPath(systemState.Backend.BackendsPath)))
|
|
}
|
|
|
|
return availableBackendsWithFilter(galleries, systemState, func(backend *GalleryBackend) bool {
|
|
if backend.IsCompatibleWith(systemState) {
|
|
return true
|
|
}
|
|
for _, nodeState := range nodeStates {
|
|
if backend.IsCompatibleWith(nodeState) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
// availableBackendsWithFilter lists available backends, keeping only those
|
|
// accepted by compatible. A nil compatible keeps everything.
|
|
func availableBackendsWithFilter(galleries []config.Gallery, systemState *system.SystemState, compatible func(*GalleryBackend) bool) (GalleryElements[*GalleryBackend], error) {
|
|
var backends []*GalleryBackend
|
|
|
|
systemBackends, err := ListSystemBackends(systemState)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get backends from galleries
|
|
for _, gallery := range galleries {
|
|
galleryBackends, err := getGalleryElements(gallery, systemState.Backend.BackendsPath, systemState.RequireBackendIntegrity, func(backend *GalleryBackend) bool {
|
|
return systemBackends.Exists(backend.GetName())
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if compatible == nil {
|
|
backends = append(backends, galleryBackends...)
|
|
continue
|
|
}
|
|
|
|
for _, backend := range galleryBackends {
|
|
if compatible(backend) {
|
|
backends = append(backends, backend)
|
|
}
|
|
}
|
|
}
|
|
|
|
return backends, nil
|
|
}
|
|
|
|
func findGalleryURLFromReferenceURL(url string, basePath string) (string, error) {
|
|
var refFile string
|
|
uri := downloader.URI(url)
|
|
err := uri.ReadWithCallback(basePath, func(url string, d []byte) error {
|
|
refFile = string(d)
|
|
if len(refFile) == 0 {
|
|
return fmt.Errorf("invalid reference file at url %s: %s", url, d)
|
|
}
|
|
cutPoint := strings.LastIndex(url, "/")
|
|
refFile = url[:cutPoint+1] + refFile
|
|
return nil
|
|
})
|
|
return refFile, err
|
|
}
|
|
|
|
type galleryCacheEntry struct {
|
|
yamlEntry []byte
|
|
lastUpdated time.Time
|
|
}
|
|
|
|
func (entry galleryCacheEntry) hasExpired() bool {
|
|
return entry.lastUpdated.Before(time.Now().Add(-1 * time.Hour))
|
|
}
|
|
|
|
var galleryCache = xsync.NewSyncedMap[string, galleryCacheEntry]()
|
|
|
|
func getGalleryElements[T GalleryElement](gallery config.Gallery, basePath string, requireIntegrity bool, isInstalledCallback func(T) bool) ([]T, error) {
|
|
var models []T = []T{}
|
|
|
|
if strings.HasSuffix(gallery.URL, ".ref") {
|
|
var err error
|
|
gallery.URL, err = findGalleryURLFromReferenceURL(gallery.URL, basePath)
|
|
if err != nil {
|
|
return models, err
|
|
}
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("%s-%s", gallery.Name, gallery.URL)
|
|
if galleryCache.Exists(cacheKey) {
|
|
entry := galleryCache.Get(cacheKey)
|
|
// refresh if last updated is more than 1 hour ago
|
|
if !entry.hasExpired() {
|
|
err := yaml.Unmarshal(entry.yamlEntry, &models)
|
|
if err != nil {
|
|
return models, err
|
|
}
|
|
} else {
|
|
galleryCache.Delete(cacheKey)
|
|
}
|
|
}
|
|
|
|
if len(models) == 0 {
|
|
// The cache key stays the gallery's identity rather than the URL that
|
|
// answered: a mirror serves the same index, so a mirror-served fetch
|
|
// must populate the entry the primary would have filled.
|
|
body, servedBy, err := fetchGalleryIndex(context.Background(), gallery, basePath, requireIntegrity)
|
|
if err != nil {
|
|
return models, fmt.Errorf("failed to read gallery elements: %w", err)
|
|
}
|
|
if servedBy != gallery.URL {
|
|
// A mirror's URL, or the path of the last known good copy on disk
|
|
// when nothing was reachable at all — either way, not the primary.
|
|
xlog.Info("gallery served by a fallback source", "gallery", gallery.Name, "source", servedBy)
|
|
}
|
|
galleryCache.Set(cacheKey, galleryCacheEntry{
|
|
yamlEntry: body,
|
|
lastUpdated: time.Now(),
|
|
})
|
|
if err := yaml.Unmarshal(body, &models); err != nil {
|
|
if yamlErr, ok := err.(*yaml.TypeError); ok {
|
|
xlog.Debug("YAML errors", "errors", strings.Join(yamlErr.Errors, "\n"), "models", models)
|
|
}
|
|
return models, fmt.Errorf("failed to read gallery elements: %w", err)
|
|
}
|
|
}
|
|
|
|
// Add gallery to models
|
|
for _, model := range models {
|
|
model.SetGallery(gallery)
|
|
model.SetInstalled(isInstalledCallback(model))
|
|
}
|
|
return models, nil
|
|
}
|