Files
LocalAI/core/services/addons/gateway/apps.go
T
Ettore Di Giacinto 8011e54ddd fix(addons): answer reads while the owner runs an upgrade
An install or upgrade holds the owner's state lock through the backup,
the commit and the start of the new generation. The apps list, the host
access overview and the installed list took the same lock, so they
waited for the whole operation and the frontends timed out after 65 s.
The Add-ons page then had no list to show.

Reads now work on a snapshot of the journal that every journal write
publishes, and never wait for an operation. The apps list reports the
running operation of each add-on (kind, phase, since), and the owner
records the stage of an upgrade through the start of the candidate. A
second upgrade of an add-on that is changing is refused with a conflict.

A frontend waits eight seconds for a quick read of the owner. When the
owner does not answer, the apps list returns the last list it got for
the same person, marked stale for ten minutes. Without such a list the
route answers a typed owner_busy or owner_unavailable 503, and the pins
route no longer drops pins it could not check.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-04 17:41:18 +00:00

219 lines
6.1 KiB
Go

// SPDX-License-Identifier: MIT
package gateway
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"slices"
"strconv"
"sync"
"time"
"github.com/mudler/LocalAI/core/services/addons/apps"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// appsCall marks a frontend's request for the apps list. Like an artifact
// lookup it is a read, never a typed dispatch.
type appsCall struct{}
const appsListAction = "apps-list"
// Apps returns the apps the actor may see, from the owner. The owner derives
// the actor's visibility and administrator view from its own state; the
// frontend only carries the authenticated identity.
func (g *Gateway) Apps(ctx context.Context, a contract.Actor) (apps.List, error) {
if e := g.revalidate(ctx, a); e != nil {
return apps.List{}, e
}
if g.Forward != nil {
list, e := g.Forward.apps(ctx, a)
if e == nil {
g.appsCache.put(a, list)
return list, nil
}
// The owner is busy or unreachable: a page is better served with the
// last list this frontend got for the same person, marked stale, than
// with an error it could mistake for "nothing is installed". A denial
// is never covered by the cache.
if StatusFor(e) == http.StatusServiceUnavailable && ctx.Err() == nil {
if old, ok := g.appsCache.get(a); ok {
var timeout *OwnerTimeout
old.list.Stale, old.list.StaleReason = true, "owner_unavailable"
if errors.As(e, &timeout) {
old.list.StaleReason = "owner_busy"
}
old.list.StaleSince = old.at.UTC().Format(time.RFC3339)
return old.list, nil
}
}
return apps.List{}, e
}
return g.appsLocal(ctx, a)
}
// AppsStaleTTL is how long a frontend keeps the last apps list of a person to
// answer with while the owner does not.
const AppsStaleTTL = 10 * time.Minute
const appsCacheMax = 256
type cachedApps struct {
list apps.List
at time.Time
}
// appsCache holds the last good list per person. It is a per-process
// convenience for the busy owner, never a source of truth: it is only read
// when the owner cannot answer, and it expires.
type appsCache struct {
mu sync.Mutex
entries map[string]cachedApps
now func() time.Time
}
func cacheKey(a contract.Actor) string { return a.Mode + "\x00" + a.UserID }
func (c *appsCache) clock() time.Time {
if c.now != nil {
return c.now()
}
return time.Now()
}
func (c *appsCache) put(a contract.Actor, list apps.List) {
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
c.entries = map[string]cachedApps{}
}
if _, ok := c.entries[cacheKey(a)]; !ok && len(c.entries) >= appsCacheMax {
oldest, when := "", time.Time{}
for k, v := range c.entries {
if oldest == "" || v.at.Before(when) {
oldest, when = k, v.at
}
}
delete(c.entries, oldest)
}
list.Stale, list.StaleReason, list.StaleSince = false, "", ""
c.entries[cacheKey(a)] = cachedApps{list: list, at: c.clock()}
}
func (c *appsCache) get(a contract.Actor) (cachedApps, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[cacheKey(a)]
if !ok || c.clock().Sub(e.at) > AppsStaleTTL {
delete(c.entries, cacheKey(a))
return cachedApps{}, false
}
// The apps slice is shared with later readers: hand out a copy of it.
e.list.Apps = slices.Clone(e.list.Apps)
return e, true
}
func (g *Gateway) appsLocal(ctx context.Context, a contract.Actor) (apps.List, error) {
if g.Manager == nil {
return apps.List{}, ErrUnavailable
}
list, e := g.Manager.Apps(ctx, a)
if e != nil {
return apps.List{}, e
}
return apps.List{Apps: list}, nil
}
// apps forwards the list request to the owner.
func (c *Carrier) apps(ctx context.Context, a contract.Actor) (apps.List, error) {
var raw [16]byte
if _, e := rand.Read(raw[:]); e != nil {
return apps.List{}, ErrUnavailable
}
request := contract.Request{OperationID: "l" + hex.EncodeToString(raw[:]), Action: appsListAction}
resp, e := c.send(ctx, a, wireRequest{Request: request, Apps: &appsCall{}})
if e != nil {
return apps.List{}, e
}
defer func() { _ = resp.Body.Close() }()
var wire appsWire
if e = decode(io.LimitReader(resp.Body, 2<<20), &wire); e != nil {
return apps.List{}, ErrUnavailable
}
return wire.list(), nil
}
// appsWire is the apps list between two replicas. The resource mapping of an
// app is not part of the public apps API, so it travels beside the list.
type appsWire struct {
apps.List
Resources map[string]*contract.ResourceTools `json:"resources,omitempty"`
}
func newAppsWire(list apps.List) appsWire {
w := appsWire{List: list}
for _, a := range list.Apps {
if a.Resources != nil {
if w.Resources == nil {
w.Resources = map[string]*contract.ResourceTools{}
}
w.Resources[a.ID] = a.Resources
}
}
return w
}
func (w appsWire) list() apps.List {
for i := range w.Apps {
w.Apps[i].Resources = w.Resources[w.Apps[i].ID]
}
return w.List
}
// icon serves the verified icon of an app the actor may see. The bytes come
// from the owner's immutable package, are checked against the digest the
// receipt recorded at install and are never served from an addon process.
func (g *Gateway) icon(ctx context.Context, a contract.Actor, r RelayRequest, w http.ResponseWriter) error {
if g.Manager == nil {
return ErrUnavailable
}
receipt, e := g.Manager.AppIcon(ctx, a, r.Request.AddonID, r.Request.Generation)
if e != nil {
return e
}
icon := receipt.Icon
root, e := os.OpenRoot(receipt.Directory)
if e != nil {
return ErrUnavailable
}
defer func() { _ = root.Close() }()
f, e := root.Open(icon.Path)
if e != nil {
return ErrUnavailable
}
defer func() { _ = f.Close() }()
b, e := io.ReadAll(io.LimitReader(f, contract.MaxIconBytes+1))
if e != nil || int64(len(b)) != icon.Size || icon.Size > contract.MaxIconBytes || fmt.Sprintf("sha256:%x", sha256.Sum256(b)) != icon.Digest {
// The package changed after it was verified. Never serve it.
return ErrUnavailable
}
if e = g.revalidate(ctx, a); e != nil {
return e
}
h := w.Header()
h.Set("Content-Type", icon.MediaType)
h.Set("Content-Length", strconv.Itoa(len(b)))
h.Set("Cache-Control", "private, no-store")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
_, e = w.Write(b)
return e
}