Files
LocalAI/core/services/addons/hostaccess.go
T
Ettore Di Giacinto a0e32049d4 fix(addons): stage a release once for the permissions preview and keep update checks warm
The install dialog and the upgrade approval ask the owner for the
permissions of a release. The owner answers from the verified manifest of
the staged package, so every read downloaded the package again, checked
its signature, and held the operation lock the whole time. A second read
(a retry, the upgrade approval, a reopened dialog) paid the full cost
again and waited behind the first.

Keep the verified answer per release, keyed by the digest the library
pins, for ten minutes, and let concurrent reads share one download. The
operation lock is held for the stage only. A moving tag has no fixed
bytes and is still read every time. The installed flag is read on every
call, and the nonce still binds the digest, so nothing is shown as
allowed that the owner has not confirmed.

The dialogs now say what they wait for after three seconds instead of a
bare "Reading what this add-on asks for...".

The update check cache is empty after every start, so a moving channel
read as "not known" on each visit until an administrator asked. The owner
now checks the installed add-ons in the background shortly after start
and every 30 minutes.

Measured with a package that takes 500 ms to download: the second read
went from 510 ms to 13 microseconds, and two concurrent reads from 410
and 820 ms to 404 ms each.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-05 09:11:56 +00:00

289 lines
8.9 KiB
Go

// SPDX-License-Identifier: MIT
package addons
import (
"context"
"errors"
"slices"
"sort"
"sync"
"time"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/packages"
addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
"github.com/mudler/LocalAI/pkg/addons/zipformat"
)
// OfficialReceipt reports whether a package came from the official add-on
// library that LocalAI ships and carries the official signature. The source ID
// alone is not enough, since an administrator names libraries freely.
func OfficialReceipt(r packages.PackageReceipt) bool {
return r.SourceID == catalog.OfficialLibraryID && r.Verification == "sha256+sigstore" && catalog.IsOfficialAddonURL(r.SourceURL)
}
// HostAccessInstalled is an installed add-on as the host access page needs it.
type HostAccessInstalled struct {
AddonID, Title, Version, State, Identity, SourceID string
Generation uint64
// OperationID is the lifecycle operation that completed the installed
// generation.
OperationID string
Official bool
Required, Optional []string
// NewOptional are optional operations that the installed version asks for,
// that the generation before did not, and that nothing grants yet.
NewOptional []string
// HeldVersion and HeldDelta describe an upgrade that waits for approval.
HeldVersion string
HeldDelta []string
}
type heldUpgrade struct {
version string
delta []string
}
func (m *Manager) holdUpgrade(id, version string, delta []string) {
m.autoMu.Lock()
defer m.autoMu.Unlock()
if m.heldUpgrades == nil {
m.heldUpgrades = map[string]heldUpgrade{}
}
m.heldUpgrades[id] = heldUpgrade{version: version, delta: slices.Clone(delta)}
}
func declared(r packages.PackageReceipt) (required, optional []string) {
if h := r.Manifest.Capabilities.HostOperations; h != nil {
return slices.Clone(h.Required), slices.Clone(h.Optional)
}
return nil, nil
}
// HostAccessInstalled lists the installed add-ons with the host operations
// their verified manifests ask for. It never reads a grant: the caller asks
// the Authority.
func (m *Manager) HostAccessInstalled(ctx context.Context) ([]HostAccessInstalled, error) {
v, e := m.readStateLive(ctx)
if e != nil {
return nil, e
}
var out []HostAccessInstalled
for id, st := range v.addons {
r, ok := currentReceipt(st)
if !ok || !st.Installed {
continue
}
in := HostAccessInstalled{AddonID: id, Title: id, Version: r.Manifest.Version, Identity: st.Identity, SourceID: r.SourceID,
Generation: st.Generation, Official: OfficialReceipt(r), State: addonruntime.StateStopped}
if u := r.Manifest.Capabilities.UI; u != nil && u.Title != nil && u.Title.Default() != "" {
in.Title = u.Title.Default()
}
if st.DesiredEnabled && m.runtime != nil {
in.State = m.runtime.State(id, st.Generation)
}
if m.admit(r) != nil {
in.State = "failed"
}
in.Required, in.Optional = declared(r)
for _, g := range st.Generations {
if g.Number+1 == st.Generation {
pr, po := declared(g.Receipt)
for _, op := range in.Optional {
if !slices.Contains(pr, op) && !slices.Contains(po, op) {
if ok, _ := m.hostAuthority().Allows(id, op); !ok {
in.NewOptional = append(in.NewOptional, op)
}
}
}
}
}
for oid, op := range v.operations {
if op.Command.AddonID == id && op.Status.Phase == string(lifecycleCompleted) && op.Status.Generation == st.Generation &&
(op.Command.Action == "install" || op.Command.Action == "upgrade") && (in.OperationID == "" || oid < in.OperationID) {
in.OperationID = oid
}
}
m.autoMu.Lock()
if h, ok := m.heldUpgrades[id]; ok && h.version != in.Version {
in.HeldVersion, in.HeldDelta = h.version, slices.Clone(h.delta)
}
m.autoMu.Unlock()
out = append(out, in)
}
sort.Slice(out, func(i, j int) bool { return out[i].AddonID < out[j].AddonID })
return out, nil
}
// HostAccessRelease is what the verified, staged package of a release asks for.
type HostAccessRelease struct {
AddonID, Title, Version, Digest string
Official, Installed bool
Required, Optional []string
}
// previewKeep is how long the verified answer for one release is reused. The
// key holds the digest the library pins, so a changed package is a new key;
// the time only bounds how long an answer outlives a withdrawn release.
const previewKeep = 10 * time.Minute
// previewMax bounds the answers kept.
const previewMax = 32
type previewKey struct{ source, addon, version, digest, artifact string }
type previewEntry struct {
rel HostAccessRelease
at time.Time
done chan struct{}
err error
}
// previewCache keeps the answer of the staged, verified package of a release,
// and lets concurrent reads of one release share a single download. It holds
// only what the verified manifest says; whether the add-on is installed is
// read again on every call.
type previewCache struct {
mu sync.Mutex
entries map[previewKey]*previewEntry
}
// PreviewRelease resolves and stages a release, reads its verified manifest
// and discards the stage. It changes no state of the add-ons. A release the
// library pins by digest is staged once: the digest fixes the bytes, so the
// verified answer holds for the same key, and a second read (a retry, the
// upgrade approval, another administrator) does not download it again.
func (m *Manager) PreviewRelease(ctx context.Context, sourceID, addonID, version string) (HostAccessRelease, error) {
if m.installer == nil {
return HostAccessRelease{}, ErrForbidden
}
m.mu.Lock()
if e := m.check(ctx); e != nil {
m.mu.Unlock()
return HostAccessRelease{}, e
}
installed := m.journal.Addons[addonID].Installed
m.mu.Unlock()
if sourceID == zipformat.SourceID {
// A local file: the version field names the staged file.
m.opMu.Lock()
defer m.opMu.Unlock()
return m.previewFile(ctx, version)
}
r, e := m.resolveRelease(ctx, LifecycleCommand{SourceID: sourceID, AddonID: addonID, Version: version})
if e != nil {
return HostAccessRelease{}, e
}
var out HostAccessRelease
if r.Mutable || r.Digest == "" {
// A moving tag names no fixed bytes: always read it.
out, e = m.stagePreview(ctx, r)
} else {
out, e = m.previews.get(ctx, previewKey{sourceID, addonID, version, r.Digest, r.Artifact}, func() (HostAccessRelease, error) { return m.stagePreview(ctx, r) })
}
if e != nil {
return HostAccessRelease{}, e
}
out.Installed = installed
return out, nil
}
func (c *previewCache) get(ctx context.Context, k previewKey, load func() (HostAccessRelease, error)) (HostAccessRelease, error) {
for {
c.mu.Lock()
if c.entries == nil {
c.entries = map[previewKey]*previewEntry{}
}
e, ok := c.entries[k]
if ok {
select {
case <-e.done:
if e.err == nil && time.Since(e.at) < previewKeep {
c.mu.Unlock()
return e.rel, nil
}
delete(c.entries, k)
ok = false
default:
}
}
if !ok {
e = &previewEntry{done: make(chan struct{})}
c.entries[k] = e
c.prune()
c.mu.Unlock()
e.rel, e.err = load()
e.at = time.Now()
c.mu.Lock()
if e.err != nil && c.entries[k] == e {
delete(c.entries, k)
}
c.mu.Unlock()
close(e.done)
return e.rel, e.err
}
c.mu.Unlock()
select {
case <-e.done:
if e.err == nil {
return e.rel, nil
}
// The reader ahead failed, maybe because its caller left: try on our own.
if ctx.Err() != nil {
return HostAccessRelease{}, ctx.Err()
}
if !errors.Is(e.err, context.Canceled) && !errors.Is(e.err, context.DeadlineExceeded) {
return HostAccessRelease{}, e.err
}
case <-ctx.Done():
return HostAccessRelease{}, ctx.Err()
}
}
}
// prune drops the oldest finished answers over the bound. The caller holds mu.
func (c *previewCache) prune() {
for len(c.entries) > previewMax {
var oldest previewKey
var at time.Time
found := false
for k, e := range c.entries {
select {
case <-e.done:
default:
continue
}
if !found || e.at.Before(at) {
oldest, at, found = k, e.at, true
}
}
if !found {
return
}
delete(c.entries, oldest)
}
}
// stagePreview downloads and verifies the package of a release, reads its
// manifest and discards the stage. It holds the operation lock for the stage
// only, so the stage folder is not cleaned under it.
func (m *Manager) stagePreview(ctx context.Context, r catalog.Release) (HostAccessRelease, error) {
m.opMu.Lock()
defer m.opMu.Unlock()
staged, e := m.installer.Stage(ctx, r, nil)
if e != nil {
return HostAccessRelease{}, e
}
rc := staged.Receipt()
if e = m.installer.Discard(staged); e != nil {
return HostAccessRelease{}, e
}
out := HostAccessRelease{AddonID: rc.Manifest.ID, Title: rc.Manifest.ID, Version: rc.Manifest.Version, Digest: rc.Digest,
Official: OfficialReceipt(rc)}
if u := rc.Manifest.Capabilities.UI; u != nil && u.Title != nil && u.Title.Default() != "" {
out.Title = u.Title.Default()
}
out.Required, out.Optional = declared(rc)
return out, nil
}