mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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
This commit is contained in:
20 files changed
+356
-14
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"keepOwnNamed": "{{name}}의 일괄 허용을 내 허용으로 유지",
|
||||
"loadError": "호스트 접근을 불러오지 못했습니다",
|
||||
"loading": "이 애드온이 요청하는 내용을 읽는 중...",
|
||||
"loadingSlow": "아직 진행 중입니다. 권한을 보여 주기 전에 패키지를 내려받고 서명을 확인하고 있습니다. 큰 애드온은 시간이 걸릴 수 있습니다.",
|
||||
"loadingPage": "호스트 접근을 불러오는 중",
|
||||
"locked": "배포 설정이 호스트 접근 변경을 꺼 두었습니다. 이 페이지는 읽을 수만 있습니다.",
|
||||
"lockedNoAuth": "로그인이 꺼져 있어 이 서버에 접근할 수 있는 누구나 이를 바꿀 수 있습니다. 관리자가 정책 파일에서 설정합니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"keepOwnNamed": "将 {{name}} 的统一授权保留为我的授权",
|
||||
"loadError": "主机访问未能加载",
|
||||
"loading": "正在读取此插件的请求...",
|
||||
"loadingSlow": "仍在进行。正在下载软件包并检查其签名,然后才能显示权限。较大的插件可能需要一些时间。",
|
||||
"loadingPage": "正在加载主机访问",
|
||||
"locked": "部署已关闭对主机访问的修改。你可以查看此页面,但不能修改。",
|
||||
"lockedNoAuth": "登录已关闭,任何能访问此服务器的人都可能修改这些设置;它们由管理员在策略文件中设定。",
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function ApproveUpgradeDialog({ addon, sourceId, api, actions, on
|
||||
)}
|
||||
>
|
||||
<p>{t('hostAccess.approve.body', { name: addon.title, current: addon.version })}</p>
|
||||
{host.status === 'loading' && <p className="ad-quiet" role="status">{t('hostAccess.loading')}</p>}
|
||||
{host.status === 'loading' && <p className="ad-quiet" role="status" data-testid="approve-loading">{t(host.slow ? 'hostAccess.loadingSlow' : 'hostAccess.loading')}</p>}
|
||||
{host.status === 'error' && (
|
||||
<Callout tone="error" role="alert" testId="approve-preview-error" actions={<Button size="sm" icon="refresh" onClick={host.retry}>{t('catalog.retry')}</Button>}>
|
||||
{describeError(host.error, t)}
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
|
||||
{t(skillInstallNoticeKey(c), { skill: c.skill, name: c.name })}
|
||||
</Callout>
|
||||
))}
|
||||
{host.status === 'loading' && <p className="ad-quiet" role="status" data-testid="hostaccess-loading">{t('hostAccess.loading')}</p>}
|
||||
{host.status === 'loading' && <p className="ad-quiet" role="status" data-testid="hostaccess-loading">{t(host.slow ? 'hostAccess.loadingSlow' : 'hostAccess.loading')}</p>}
|
||||
{host.status === 'error' && (
|
||||
<Callout tone="warn" testId="hostaccess-error" actions={<Button size="sm" icon="refresh" onClick={host.retry}>{t('catalog.retry')}</Button>}>
|
||||
{t('hostAccess.previewFailed')}
|
||||
|
||||
+11
-3
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user