feat(addons): list installed add-ons and preview releases for host access

The host access page needs the verified requests of each installed
add-on, the operation that completed its generation, optional operations
an upgrade added, and an upgrade that waits for approval. It also needs
to read a release's manifest without installing it. Add both to the
manager. An automatic upgrade that is held for approval is remembered.

Assisted-by: Claude:claude-sonnet-5-5 go
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 22:06:43 +00:00
1 parent ef26fa19f7
commit 0dae0a2609
3 files changed
+165

No files matched your search

+4
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"sort"
"time"
@@ -80,6 +81,9 @@ func (m *Manager) AutoUpgrade(ctx context.Context) []AutoUpgradeResult {
if r.Err != nil {
var refusal *HostRequirementsRefusal
r.HeldForApproval = errors.As(r.Err, &refusal) && !refusal.Blocked()
if r.HeldForApproval {
m.holdUpgrade(id, st.CandidateVersion, append(slices.Clone(refusal.Summary.MissingRequired), refusal.Summary.MissingOptional...))
}
m.rememberAutoFailure(id, st.CandidateDigest)
xlog.Error("Failed to auto-upgrade add-on", "addon", id, "from", st.InstalledVersion, "to", st.CandidateVersion, "error", r.Err)
} else {
+159
View File
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: MIT
package addons
import (
"context"
"slices"
"sort"
"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"
)
// 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) {
m.mu.Lock()
defer m.mu.Unlock()
if e := m.check(ctx); e != nil {
return nil, e
}
if m.uncertain || m.runtimeRecoveryRequired {
return nil, ErrRecoveryRequired
}
var out []HostAccessInstalled
for id, st := range m.journal.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 m.journal.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
}
// PreviewRelease resolves and stages a release, reads its verified manifest
// and discards the stage. It changes no state of the add-ons.
func (m *Manager) PreviewRelease(ctx context.Context, sourceID, addonID, version string) (HostAccessRelease, error) {
if m.installer == nil {
return HostAccessRelease{}, ErrForbidden
}
m.opMu.Lock()
defer m.opMu.Unlock()
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()
r, e := m.resolveRelease(ctx, LifecycleCommand{SourceID: sourceID, AddonID: addonID, Version: version})
if e != nil {
return HostAccessRelease{}, e
}
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), Installed: installed}
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
}
+2
View File
@@ -153,6 +153,8 @@ type Manager struct {
// automatic upgrade that failed. In memory only: a restart tries again.
autoMu sync.Mutex
autoFailures map[string]string
// heldUpgrades holds, per add-on, an upgrade that waits for approval.
heldUpgrades map[string]heldUpgrade
// skillSync keeps the chats' skill layer in step with the installed
// packages. Nil until the chats' files exist (see SetSkillSync).
skillSync SkillSync