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" ] }, {