diff --git a/core/application/application.go b/core/application/application.go
index 0e4bad5ecf..b46dd1b86a 100644
--- a/core/application/application.go
+++ b/core/application/application.go
@@ -1060,6 +1060,7 @@ func (a *Application) startAddons() error {
// The update check reads the libraries and the journal the owner
// holds; it applies nothing, and its cache is bounded.
m.EnableUpdates(updates.Config{TTL: c.UpdateCacheTTL, MinRefresh: c.UpdateRefreshInterval})
+ startUpdateRefresh(a.applicationConfig.Context, m.RefreshUpdates, addonUpdateRefreshEvery)
// The staging area of zip files waits empty at start: a file that was
// uploaded before a restart has to be uploaded again.
files := &uploads.Store{Root: filepath.Join(c.Root, "uploads")}
@@ -1118,6 +1119,35 @@ func (a *Application) startAddons() error {
// and an unreachable library must not keep LocalAI or the installed add-ons
// down. Until it ends the cache is empty, as it is after any failed refresh;
// an explicit refresh does the same work again.
+// addonUpdateRefreshEvery is how often the owner asks the libraries about the
+// installed add-ons in the background. The check is an observation; an upgrade
+// decides again when it runs.
+const addonUpdateRefreshEvery = 30 * time.Minute
+
+// startUpdateRefresh checks the installed add-ons shortly after start and then
+// on every interval, until ctx ends. Without it the answer of the update check
+// is empty after each restart and the page shows "not known" until an
+// administrator asks.
+func startUpdateRefresh(ctx context.Context, refresh func(context.Context) int, every time.Duration) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ go func() {
+ // Let the startup read of the libraries finish first.
+ timer := time.NewTimer(10 * time.Second)
+ defer timer.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-timer.C:
+ }
+ refresh(ctx)
+ timer.Reset(every)
+ }
+ }()
+}
+
func startLibraryRefresh(ctx context.Context, refresh func(context.Context) error) {
go func() {
if err := refresh(ctx); err != nil && ctx.Err() == nil {
diff --git a/core/http/react-ui/e2e/addons-hostgrants.spec.js b/core/http/react-ui/e2e/addons-hostgrants.spec.js
index 3a9a6afce3..48a95441d1 100644
--- a/core/http/react-ui/e2e/addons-hostgrants.spec.js
+++ b/core/http/react-ui/e2e/addons-hostgrants.spec.js
@@ -55,8 +55,8 @@ function overview(over = {}) {
// The stand-in for the host-grants routes. `state.overview` and `state.preview`
// are what the owner answers; every request is logged.
async function mockHostGrants(page, appsState, options = {}) {
- const state = { overview: options.overview || overview(), preview: options.preview || PREVIEW, log: [], affects: options.affects || [], failInstall: options.failInstall || null }
- await page.route(/\/api\/addons\/host-grants(\/.*)?(\?.*)?$/, route => {
+ const state = { overview: options.overview || overview(), preview: options.preview || PREVIEW, log: [], affects: options.affects || [], failInstall: options.failInstall || null, previewDelayMs: options.previewDelayMs || 0 }
+ await page.route(/\/api\/addons\/host-grants(\/.*)?(\?.*)?$/, async route => {
const req = route.request()
const url = new URL(req.url())
const path = url.pathname.replace('/api/addons/host-grants', '') || '/'
@@ -64,7 +64,11 @@ async function mockHostGrants(page, appsState, options = {}) {
state.log.push({ method: req.method(), path, query: url.search, body, confirm: req.headers()['x-localai-confirm'] })
const json = (payload, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(payload) })
if (req.method() === 'GET' && path === '/') return json(state.overview)
- if (path === '/preview') return json(state.preview)
+ if (path === '/preview') {
+ // A slow owner: it downloads and verifies the package before it answers.
+ if (state.previewDelayMs) await new Promise(r => setTimeout(r, state.previewDelayMs))
+ return json(state.preview)
+ }
if (path === '/install') {
if (state.failInstall) return json({ error: { type: state.failInstall, message: state.failInstall } }, 409)
if (body.action === 'upgrade') {
@@ -151,6 +155,22 @@ test.describe('Host access: install dialog', () => {
expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0)
})
+ test('says what it waits for when the owner is slow, then shows the permissions, with no sideways scroll', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 800 })
+ await open(page, { installed: [], releases: [release('notes', { name: 'Notes' })], hostgrants: { previewDelayMs: 4500 } })
+ await page.goto('/app/addons?tab=browse')
+ await page.locator('.cat-item', { hasText: 'Notes' }).getByRole('button', { name: /^Install / }).click()
+ const dialog = page.getByTestId('install-dialog')
+ const loading = dialog.getByTestId('hostaccess-loading')
+ await expect(loading).toHaveText('Reading what this add-on asks for...')
+ // The install stays closed while the owner has not confirmed anything.
+ await expect(dialog.getByTestId('install-confirm')).toBeDisabled()
+ await expect(loading).toContainText('downloaded and its signature checked', { timeout: 6000 })
+ expect(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)).toBe(false)
+ await expect(dialog.getByTestId('install-host-access')).toBeVisible({ timeout: 8000 })
+ await expect(loading).toHaveCount(0)
+ })
+
test('says so when the add-on comes from another library than the official one', async ({ page }) => {
await open(page, { installed: [], releases: [release('notes', { name: 'Notes' })], hostgrants: { preview: { ...PREVIEW, official: false } } })
await page.goto('/app/addons?tab=browse')
diff --git a/core/http/react-ui/public/locales/de/addons.json b/core/http/react-ui/public/locales/de/addons.json
index 33a8eba1c2..82ffaf69e2 100644
--- a/core/http/react-ui/public/locales/de/addons.json
+++ b/core/http/react-ui/public/locales/de/addons.json
@@ -864,6 +864,7 @@
"keepOwnNamed": "Die pauschalen Freigaben von {{name}} als eigene behalten",
"loadError": "Der Host-Zugriff wurde nicht geladen",
"loading": "Lese, was dieses Add-on anfordert...",
+ "loadingSlow": "Läuft noch. Das Paket wird heruntergeladen und seine Signatur geprüft, bevor die Berechtigungen angezeigt werden können. Ein großes Add-on kann etwas dauern.",
"loadingPage": "Host-Zugriff wird geladen",
"locked": "Das Deployment hat Änderungen am Host-Zugriff abgeschaltet. Du kannst diese Seite lesen, aber nicht ändern.",
"lockedNoAuth": "Die Anmeldung ist ausgeschaltet, daher könnte jeder, der diesen Server erreicht, dies ändern; der Administrator legt es in der Richtliniendatei fest.",
diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json
index 460c75326d..9ee4adad44 100644
--- a/core/http/react-ui/public/locales/en/addons.json
+++ b/core/http/react-ui/public/locales/en/addons.json
@@ -864,6 +864,7 @@
"keepOwnNamed": "Keep the blanket grants of {{name}} as my own",
"loadError": "Host access did not load",
"loading": "Reading what this add-on asks for...",
+ "loadingSlow": "Still working. The package is being downloaded and its signature checked before the permissions can be shown. A large add-on can take a while.",
"loadingPage": "Loading host access",
"locked": "The deployment turned off changes to host access. You can read this page but not change it.",
"lockedNoAuth": "Sign-in is off, so anyone who can reach this server could change these; they are set by the administrator in the policy file.",
diff --git a/core/http/react-ui/public/locales/es/addons.json b/core/http/react-ui/public/locales/es/addons.json
index f940a8ab0a..81ff4e11c5 100644
--- a/core/http/react-ui/public/locales/es/addons.json
+++ b/core/http/react-ui/public/locales/es/addons.json
@@ -864,6 +864,7 @@
"keepOwnNamed": "Conservar como míos los permisos generales de {{name}}",
"loadError": "El acceso al host no se cargó",
"loading": "Leyendo lo que pide este complemento...",
+ "loadingSlow": "Sigue en curso. Se está descargando el paquete y comprobando su firma antes de poder mostrar los permisos. Un complemento grande puede tardar un poco.",
"loadingPage": "Cargando el acceso al host",
"locked": "El despliegue desactivó los cambios en el acceso al host. Puedes leer esta página, pero no cambiarla.",
"lockedNoAuth": "El inicio de sesión está desactivado, así que cualquiera que llegue a este servidor podría cambiar esto; el administrador lo define en el archivo de políticas.",
diff --git a/core/http/react-ui/public/locales/fr/addons.json b/core/http/react-ui/public/locales/fr/addons.json
index 992614ee89..9027ba95cf 100644
--- a/core/http/react-ui/public/locales/fr/addons.json
+++ b/core/http/react-ui/public/locales/fr/addons.json
@@ -868,6 +868,7 @@
"keepOwnNamed": "Garder comme les miennes les autorisations générales de {{name}}",
"loadError": "L'accès à l'hôte ne s'est pas chargé",
"loading": "Lecture de ce que demande ce module...",
+ "loadingSlow": "Toujours en cours. Le paquet est téléchargé et sa signature vérifiée avant l'affichage des autorisations. Un grand module peut prendre un moment.",
"loadingPage": "Chargement de l'accès à l'hôte",
"locked": "Le déploiement a désactivé les changements d'accès à l'hôte. Vous pouvez lire cette page, pas la modifier.",
"lockedNoAuth": "La connexion est désactivée, donc toute personne qui atteint ce serveur pourrait modifier ceci ; l'administrateur le définit dans le fichier de politique.",
diff --git a/core/http/react-ui/public/locales/id/addons.json b/core/http/react-ui/public/locales/id/addons.json
index 28816dcaa3..3d7ce3e314 100644
--- a/core/http/react-ui/public/locales/id/addons.json
+++ b/core/http/react-ui/public/locales/id/addons.json
@@ -864,6 +864,7 @@
"keepOwnNamed": "Jadikan izin umum {{name}} sebagai milik saya",
"loadError": "Akses host tidak termuat",
"loading": "Membaca apa yang diminta add-on ini...",
+ "loadingSlow": "Masih berjalan. Paket sedang diunduh dan tanda tangannya diperiksa sebelum izin dapat ditampilkan. Add-on yang besar dapat memakan waktu.",
"loadingPage": "Memuat akses host",
"locked": "Deployment mematikan perubahan akses host. Anda dapat membaca halaman ini tetapi tidak mengubahnya.",
"lockedNoAuth": "Login dimatikan, sehingga siapa pun yang dapat menjangkau server ini dapat mengubahnya; administrator mengaturnya di berkas kebijakan.",
diff --git a/core/http/react-ui/public/locales/it/addons.json b/core/http/react-ui/public/locales/it/addons.json
index 37b7b1159c..95932308b0 100644
--- a/core/http/react-ui/public/locales/it/addons.json
+++ b/core/http/react-ui/public/locales/it/addons.json
@@ -864,6 +864,7 @@
"keepOwnNamed": "Tieni come miei i permessi generali di {{name}}",
"loadError": "L'accesso all'host non si è caricato",
"loading": "Lettura di ciò che questo add-on richiede...",
+ "loadingSlow": "Ancora in corso. Il pacchetto viene scaricato e la sua firma verificata prima di mostrare i permessi. Un add-on grande può richiedere un po' di tempo.",
"loadingPage": "Caricamento dell'accesso all'host",
"locked": "Il deployment ha disattivato le modifiche all'accesso all'host. Puoi leggere questa pagina ma non modificarla.",
"lockedNoAuth": "L'accesso è disattivato, quindi chiunque raggiunga questo server potrebbe modificarlo; l'amministratore lo imposta nel file di policy.",
diff --git a/core/http/react-ui/public/locales/ko/addons.json b/core/http/react-ui/public/locales/ko/addons.json
index de1c5573e7..fa7d3354f1 100644
--- a/core/http/react-ui/public/locales/ko/addons.json
+++ b/core/http/react-ui/public/locales/ko/addons.json
@@ -864,6 +864,7 @@
"keepOwnNamed": "{{name}}의 일괄 허용을 내 허용으로 유지",
"loadError": "호스트 접근을 불러오지 못했습니다",
"loading": "이 애드온이 요청하는 내용을 읽는 중...",
+ "loadingSlow": "아직 진행 중입니다. 권한을 보여 주기 전에 패키지를 내려받고 서명을 확인하고 있습니다. 큰 애드온은 시간이 걸릴 수 있습니다.",
"loadingPage": "호스트 접근을 불러오는 중",
"locked": "배포 설정이 호스트 접근 변경을 꺼 두었습니다. 이 페이지는 읽을 수만 있습니다.",
"lockedNoAuth": "로그인이 꺼져 있어 이 서버에 접근할 수 있는 누구나 이를 바꿀 수 있습니다. 관리자가 정책 파일에서 설정합니다.",
diff --git a/core/http/react-ui/public/locales/pt-BR/addons.json b/core/http/react-ui/public/locales/pt-BR/addons.json
index 221ffe3588..eaf7e744f7 100644
--- a/core/http/react-ui/public/locales/pt-BR/addons.json
+++ b/core/http/react-ui/public/locales/pt-BR/addons.json
@@ -864,6 +864,7 @@
"keepOwnNamed": "Manter como minhas as permissões gerais de {{name}}",
"loadError": "O acesso ao host não carregou",
"loading": "Lendo o que este add-on pede...",
+ "loadingSlow": "Ainda em andamento. O pacote está sendo baixado e sua assinatura verificada antes de mostrar as permissões. Um add-on grande pode demorar um pouco.",
"loadingPage": "Carregando o acesso ao host",
"locked": "A implantação desativou mudanças no acesso ao host. Você pode ler esta página, mas não alterá-la.",
"lockedNoAuth": "O login está desativado, então qualquer pessoa que alcance este servidor poderia alterar isto; o administrador define no arquivo de política.",
diff --git a/core/http/react-ui/public/locales/zh-CN/addons.json b/core/http/react-ui/public/locales/zh-CN/addons.json
index b134bebc88..0a823ab91b 100644
--- a/core/http/react-ui/public/locales/zh-CN/addons.json
+++ b/core/http/react-ui/public/locales/zh-CN/addons.json
@@ -864,6 +864,7 @@
"keepOwnNamed": "将 {{name}} 的统一授权保留为我的授权",
"loadError": "主机访问未能加载",
"loading": "正在读取此插件的请求...",
+ "loadingSlow": "仍在进行。正在下载软件包并检查其签名,然后才能显示权限。较大的插件可能需要一些时间。",
"loadingPage": "正在加载主机访问",
"locked": "部署已关闭对主机访问的修改。你可以查看此页面,但不能修改。",
"lockedNoAuth": "登录已关闭,任何能访问此服务器的人都可能修改这些设置;它们由管理员在策略文件中设定。",
diff --git a/core/http/react-ui/src/components/addons/ApproveUpgradeDialog.jsx b/core/http/react-ui/src/components/addons/ApproveUpgradeDialog.jsx
index c76acec774..e054523b0b 100644
--- a/core/http/react-ui/src/components/addons/ApproveUpgradeDialog.jsx
+++ b/core/http/react-ui/src/components/addons/ApproveUpgradeDialog.jsx
@@ -58,7 +58,7 @@ export default function ApproveUpgradeDialog({ addon, sourceId, api, actions, on
)}
>
{t('hostAccess.approve.body', { name: addon.title, current: addon.version })}
- {host.status === 'loading' && {t('hostAccess.loading')}
}
+ {host.status === 'loading' && {t(host.slow ? 'hostAccess.loadingSlow' : 'hostAccess.loading')}
}
{host.status === 'error' && (
{t('catalog.retry')}}>
{describeError(host.error, t)}
diff --git a/core/http/react-ui/src/components/addons/InstallDialog.jsx b/core/http/react-ui/src/components/addons/InstallDialog.jsx
index 85b396fd0b..a810da0b7a 100644
--- a/core/http/react-ui/src/components/addons/InstallDialog.jsx
+++ b/core/http/react-ui/src/components/addons/InstallDialog.jsx
@@ -146,7 +146,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
{t(skillInstallNoticeKey(c), { skill: c.skill, name: c.name })}
))}
- {host.status === 'loading' && {t('hostAccess.loading')}
}
+ {host.status === 'loading' && {t(host.slow ? 'hostAccess.loadingSlow' : 'hostAccess.loading')}
}
{host.status === 'error' && (
{t('catalog.retry')}}>
{t('hostAccess.previewFailed')}
diff --git a/core/http/react-ui/src/components/addons/useHostPreview.js b/core/http/react-ui/src/components/addons/useHostPreview.js
index 2a56dbe2f7..60e4ff5630 100644
--- a/core/http/react-ui/src/components/addons/useHostPreview.js
+++ b/core/http/react-ui/src/components/addons/useHostPreview.js
@@ -5,14 +5,20 @@ import { initialTicks, normalizePreview } from './hostGrantsState'
// Asks the owner what a library entry would need, from the package it
// verifies itself (the library's own listing is not trusted for this). It
// reads and grants nothing. Returns { status: 'loading'|'ready'|'error',
-// preview, error, ticked, setTick }.
+// slow, preview, error, ticked, setTick }. `slow` turns true when the owner
+// has not answered after SLOW_AFTER_MS: it is downloading and verifying the
+// package, which a page should say instead of a bare spinner.
+export const SLOW_AFTER_MS = 3000
+
export function useHostPreview(entry, { enabled = true, api = hostGrantsApi } = {}) {
const key = entry ? `${entry.sourceId}/${entry.id}/${entry.version}` : ''
const [state, setState] = useState({ key: '', status: 'loading', preview: null, error: null, ticked: [] })
const [attempt, setAttempt] = useState(0)
+ const [slowKey, setSlowKey] = useState('')
useEffect(() => {
if (!enabled || !entry) return undefined
const ctl = new AbortController()
+ const timer = setTimeout(() => setSlowKey(`${key}#${attempt}`), SLOW_AFTER_MS)
api.preview({ sourceId: entry.sourceId, addonId: entry.id, version: entry.version }, ctl.signal)
.then(raw => {
const preview = normalizePreview(raw)
@@ -22,11 +28,13 @@ export function useHostPreview(entry, { enabled = true, api = hostGrantsApi } =
if (error && error.name === 'AbortError') return
setState({ key, status: 'error', preview: null, error, ticked: [] })
})
- return () => ctl.abort()
+ return () => { clearTimeout(timer); ctl.abort() }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, enabled, attempt, api])
const mine = state.key === key
const setTick = (name, on) => setState(s => ({ ...s, ticked: on ? [...new Set([...s.ticked, name])] : s.ticked.filter(n => n !== name) }))
const retry = () => { setState({ key: '', status: 'loading', preview: null, error: null, ticked: [] }); setAttempt(n => n + 1) }
- return mine ? { ...state, setTick, retry } : { status: 'loading', preview: null, error: null, ticked: [], setTick, retry }
+ const loading = !mine || state.status === 'loading'
+ const slow = loading && slowKey === `${key}#${attempt}`
+ return mine ? { ...state, slow, setTick, retry } : { status: 'loading', slow, preview: null, error: null, ticked: [], setTick, retry }
}
diff --git a/core/services/addons/hostaccess.go b/core/services/addons/hostaccess.go
index f012c0f3b4..b13ce7e378 100644
--- a/core/services/addons/hostaccess.go
+++ b/core/services/addons/hostaccess.go
@@ -3,8 +3,11 @@ 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"
@@ -119,14 +122,41 @@ type HostAccessRelease struct {
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.
+// 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.opMu.Lock()
- defer m.opMu.Unlock()
m.mu.Lock()
if e := m.check(ctx); e != nil {
m.mu.Unlock()
@@ -136,12 +166,110 @@ func (m *Manager) PreviewRelease(ctx context.Context, sourceID, addonID, version
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
@@ -151,7 +279,7 @@ func (m *Manager) PreviewRelease(ctx context.Context, sourceID, addonID, version
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}
+ Official: OfficialReceipt(rc)}
if u := rc.Manifest.Capabilities.UI; u != nil && u.Title != nil && u.Title.Default() != "" {
out.Title = u.Title.Default()
}
diff --git a/core/services/addons/hostaccess_preview_test.go b/core/services/addons/hostaccess_preview_test.go
new file mode 100644
index 0000000000..d8894b6167
--- /dev/null
+++ b/core/services/addons/hostaccess_preview_test.go
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: MIT
+package addons
+
+import (
+ "context"
+ "github.com/google/go-containerregistry/pkg/registry"
+ "github.com/mudler/LocalAI/core/services/addons/catalog"
+ "github.com/mudler/LocalAI/core/services/addons/updates"
+ "net/http/httptest"
+ "strings"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("the host access preview of a release", func() {
+ It("downloads and verifies the package once, so the next read of the same release is immediate", func() {
+ rig := newUpdateRig(false)
+ rig.lib.archiveDelay = 500 * time.Millisecond
+
+ start := time.Now()
+ first, e := rig.m.PreviewRelease(ctx, "test", "sample", "1.2.3")
+ firstTook := time.Since(start)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(firstTook).To(BeNumerically(">=", 500*time.Millisecond))
+
+ start = time.Now()
+ second, e := rig.m.PreviewRelease(ctx, "test", "sample", "1.2.3")
+ secondTook := time.Since(start)
+ Expect(e).NotTo(HaveOccurred())
+ GinkgoWriter.Printf("preview: first %s, second %s\n", firstTook, secondTook)
+ Expect(second).To(Equal(first))
+ Expect(secondTook).To(BeNumerically("<", 100*time.Millisecond))
+ })
+
+ It("reads again when the library lists another package for the same version", func() {
+ rig := newUpdateRig(false)
+ first, e := rig.m.PreviewRelease(ctx, "test", "sample", "1.2.3")
+ Expect(e).NotTo(HaveOccurred())
+ rig.lib.publish("1.2.3", "", "other-bytes", catalog.Requirements{})
+ rig.lib.refresh()
+ second, e := rig.m.PreviewRelease(ctx, "test", "sample", "1.2.3")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(second.Digest).NotTo(Equal(first.Digest))
+ })
+
+ It("does not wait behind a read of the same release that is in flight", func() {
+ rig := newUpdateRig(false)
+ rig.lib.archiveDelay = 400 * time.Millisecond
+ done := make(chan time.Duration, 2)
+ for i := 0; i < 2; i++ {
+ go func() {
+ defer GinkgoRecover()
+ start := time.Now()
+ _, e := rig.m.PreviewRelease(ctx, "test", "sample", "1.2.3")
+ Expect(e).NotTo(HaveOccurred())
+ done <- time.Since(start)
+ }()
+ }
+ a, b := <-done, <-done
+ GinkgoWriter.Printf("two concurrent previews: %s and %s\n", a, b)
+ Expect(max(a, b)).To(BeNumerically("<", 700*time.Millisecond))
+ })
+})
+
+var _ = Describe("the update status after a restart", func() {
+ It("is a recent check once the owner has refreshed in the background, not unknown", func() {
+ r := newUpdateRig(false)
+ reg := httptest.NewServer(registry.New())
+ DeferCleanup(reg.Close)
+ artifact := "oci://" + strings.TrimPrefix(reg.URL, "http://") + "/local-ai-addons:sample-development"
+ current := pushDevelopment(artifact, "0.0.0-dev.aaaaaaaaaaaa", "dev-1")
+ r.m.installer.Lookup = func(context.Context, string, string) (string, error) { return current, nil }
+ fresh := func() {
+ r.m.EnableUpdates(updates.Config{Now: func() time.Time { return *r.clk }, MinRefresh: time.Second, TTL: time.Hour})
+ }
+ fresh()
+ r.lib.mu.Lock()
+ r.lib.releases = append(r.lib.releases, catalog.Release{ID: "sample", Name: "Sample", Version: "0.0.0-development", Channel: "development",
+ Artifact: artifact, Mutable: true, OS: "linux", Arch: "amd64"})
+ r.lib.mu.Unlock()
+ r.lib.refresh()
+ _, e := r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "install", AddonID: "sample", Action: "install", SourceID: "test", Version: "0.0.0-development"})
+ Expect(e).NotTo(HaveOccurred())
+
+ // A restart empties the cache of the check. A moving tag has no
+ // resolved digest then, and the page read unknown on every visit.
+ fresh()
+ before, e := r.m.UpdateStatus(ctx, actor, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(before.State).To(Equal(updates.Unknown))
+
+ Expect(r.m.RefreshUpdates(ctx)).To(Equal(1))
+ after, e := r.m.UpdateStatus(ctx, actor, "sample")
+ Expect(e).NotTo(HaveOccurred())
+ Expect(after.State).To(Equal(updates.UpToDate))
+ Expect(after.CheckedAt.IsZero()).To(BeFalse())
+ })
+})
diff --git a/core/services/addons/manager.go b/core/services/addons/manager.go
index ae8095f162..8107427a3a 100644
--- a/core/services/addons/manager.go
+++ b/core/services/addons/manager.go
@@ -169,6 +169,8 @@ type Manager struct {
autoFailures map[string]string
// heldUpgrades holds, per add-on, an upgrade that waits for approval.
heldUpgrades map[string]heldUpgrade
+ // previews keeps the verified answer of the host access preview per release.
+ previews previewCache
// skillSync keeps the chats' skill layer in step with the installed
// packages. Nil until the chats' files exist (see SetSkillSync).
skillSync SkillSync
diff --git a/core/services/addons/updates.go b/core/services/addons/updates.go
index 6a6e274d05..ec3985b149 100644
--- a/core/services/addons/updates.go
+++ b/core/services/addons/updates.go
@@ -4,6 +4,7 @@ package addons
import (
"context"
"errors"
+ "sort"
"github.com/mudler/LocalAI/pkg/addons/zipformat"
"time"
@@ -103,6 +104,39 @@ func (m *Manager) checkPins(ctx context.Context, cmd LifecycleCommand, st Status
return catalog.Release{}, ErrCheckFailed
}
+// RefreshUpdates asks the libraries about every installed add-on that has one,
+// so the answer the pages read is a recent check and not an empty cache. It is
+// the owner's own background work: it reads libraries and applies nothing. It
+// returns the number of add-ons checked. An add-on whose check fails keeps a
+// check-failed status with its time, never a missing one.
+func (m *Manager) RefreshUpdates(ctx context.Context) int {
+ if m.updates == nil {
+ return 0
+ }
+ m.mu.Lock()
+ var ids []string
+ for id, st := range m.journal.Addons {
+ if !st.Installed {
+ continue
+ }
+ if receipt, ok := currentReceipt(st); ok && receipt.SourceID != zipformat.SourceID {
+ ids = append(ids, id)
+ }
+ }
+ m.mu.Unlock()
+ sort.Strings(ids)
+ n := 0
+ for _, id := range ids {
+ if ctx.Err() != nil {
+ break
+ }
+ if _, e := m.updates.Check(ctx, id, true); e == nil {
+ n++
+ }
+ }
+ return n
+}
+
// visibleTo reports whether the actor may read update status of an add-on: an
// administrator for any installed add-on, anyone else only for an enabled one.
// Hiding is the same denial for an add-on that does not exist.
diff --git a/core/services/addons/updates_test.go b/core/services/addons/updates_test.go
index 36a9278a76..2a9aaae85a 100644
--- a/core/services/addons/updates_test.go
+++ b/core/services/addons/updates_test.go
@@ -45,6 +45,8 @@ type updateLibrary struct {
sources *catalog.Sources
// transform lets lifecycle specs publish real typed executable packages.
transform func(map[string]any) map[string][]byte
+ // archiveDelay slows every package download, as a large package on a slow link does.
+ archiveDelay time.Duration
}
func (l *updateLibrary) publish(version, channel, content string, requires catalog.Requirements) catalog.Release {
@@ -120,6 +122,7 @@ func newUpdateRig(withUpdates bool) *updateRig {
return
}
if data, ok := lib.archives[r.URL.Path]; ok {
+ time.Sleep(lib.archiveDelay)
_, _ = w.Write(data)
return
}
diff --git a/scripts/addons/suites.json b/scripts/addons/suites.json
index c9c61e273e..78ec0fcbae 100644
--- a/scripts/addons/suites.json
+++ b/scripts/addons/suites.json
@@ -1111,7 +1111,11 @@
"serves every other read from the snapshot too",
"refuses a second upgrade with a conflict instead of racing or waiting",
"shows the new generation and no operation once the upgrade ended",
- "still refuses a read after the owner closed"
+ "still refuses a read after the owner closed",
+ "downloads and verifies the package once, so the next read of the same release is immediate",
+ "reads again when the library lists another package for the same version",
+ "does not wait behind a read of the same release that is in flight",
+ "is a recent check once the owner has refreshed in the background, not unknown"
],
"report": ".cache/addons/logs/lifecycle.json"
},
@@ -3079,7 +3083,11 @@
"serves every other read from the snapshot too",
"refuses a second upgrade with a conflict instead of racing or waiting",
"shows the new generation and no operation once the upgrade ended",
- "still refuses a read after the owner closed"
+ "still refuses a read after the owner closed",
+ "downloads and verifies the package once, so the next read of the same release is immediate",
+ "reads again when the library lists another package for the same version",
+ "does not wait behind a read of the same release that is in flight",
+ "is a recent check once the owner has refreshed in the background, not unknown"
]
},
{