mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-07 04:53:24 -05:00
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
219 lines
6.1 KiB
Go
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
|
|
}
|