mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-03 19:14:56 -05:00
* fix(workspace): coordinate shared filesystem blobs Sync filesystem publication and isolate materialized tool files from immutable blob inodes. Reject symlink paths within the blob tree. Opt shared PostgreSQL participants into revision-lock coordination for ingest, reference publication, and purge checks and deletion. Preserve legacy ingest order when the explicit option is disabled. Verify process publication and interruption, injected failures, and shared-schema PostgreSQL races across independent connection pools. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(workspace): bound shared filesystem blob cleanup Honor immediate caller cancellation and give delayed PostgreSQL cleanup an independent 30-second budget for revision-lock acquisition and reference checks. Preserve blobs when cleanup cannot acquire the lock. Cover cancellation, delayed cleanup, and pool release. Convert publication tests to Ginkgo/Gomega while retaining independent-process child bootstrapping. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(workspace): opt into shared filesystem blobs Allow Files participants to share immutable blobs without S3. Require PostgreSQL metadata and reject invalid explicit roots at startup. Keep default storage and model staging unchanged. Document the volume, permissions and coordinated backend-switching contract. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(workspace): clean up failed shared startup Cancel the owned startup context and close the auth pool when explicit shared Files startup fails. Preserve the caller context and legacy startup behavior. Cover early validation and late Files failure with PostgreSQL-backed cleanup assertions. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(workspace): select blob storage automatically Use configured S3 for every workspace participant, including owners without distributed services. Otherwise retain DataPath/chats/blobs through the direct filesystem adapter, without another path setting. Derive PostgreSQL publication and purge coordination from the blob backend so non-distributed owners also protect shared filesystem bytes. Keep shared mount and metadata identity an explicit operator contract. Remove the opt-in probe and document storage selection without migration. Verify automatic selection with red/green regressions, S3 constructor spies, focused startup tests and blob race tests. Exercise shared files and PostgreSQL coordination against an owned PostgreSQL 16 fixture. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(workspace): clean up configured S3 startup failures Own the startup child context for configured S3 as well as PostgreSQL metadata. Cancel background work, shut down opened resources and close the auth pool on fatal initialization errors without canceling the caller context or changing legacy JSON/SQLite without S3. Regress missing-bucket failures using temporary SQLite and no-auth owners through the production cleanup helper. Verify pool closure, owned-context cancellation and resource shutdown. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(workspace): resolve filesystem CI diagnostics Mark best-effort cleanup errors explicitly and inspect the config type without copying its atomic fields. Document why the validated blob paths do not permit file inclusion. Keep publication, synchronization and PostgreSQL coordination unchanged. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
235 lines
8.3 KiB
Go
235 lines
8.3 KiB
Go
package application
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
|
|
"github.com/mudler/xlog"
|
|
|
|
"github.com/mudler/LocalAI/core/services/addons"
|
|
"github.com/mudler/LocalAI/core/services/addons/packages"
|
|
"github.com/mudler/LocalAI/core/services/storage"
|
|
"github.com/mudler/LocalAI/core/services/studio/builtinskills"
|
|
"github.com/mudler/LocalAI/core/services/studio/files"
|
|
"github.com/mudler/LocalAI/core/services/studio/textindex"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace/blob"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace/gormstore"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace/jsonstore"
|
|
)
|
|
|
|
// studioMaxFileSize limits one file of a workspace.
|
|
const studioMaxFileSize int64 = 256 << 20
|
|
|
|
// studioUploadLimit is the largest file an upload of the Files page can
|
|
// send: the workspace limit, or the operator's --upload-limit when that is
|
|
// set and lower. Without it the general 15 MB limit does not apply here.
|
|
func (a *Application) studioUploadLimit() int64 {
|
|
limit := studioMaxFileSize
|
|
if cfg := a.ApplicationConfig(); cfg.UploadLimitSet() && int64(cfg.UploadLimitMB)<<20 < limit {
|
|
limit = int64(cfg.UploadLimitMB) << 20
|
|
}
|
|
return limit
|
|
}
|
|
|
|
// StudioFiles returns the files service of the chats. It is never nil: when
|
|
// files are off, it says why.
|
|
func (a *Application) StudioFiles() *files.Service {
|
|
if a.studioFiles == nil {
|
|
return files.Disabled("Files are not set up on this server.")
|
|
}
|
|
return a.studioFiles
|
|
}
|
|
|
|
// initStudioFiles builds the workspace of the chats.
|
|
//
|
|
// The choice of the index is explicit. With a database (the auth database: a
|
|
// SQLite file or PostgreSQL) the tree lives there, in the GORM store. With no
|
|
// database it is a folder under the data path with a JSON index, in the JSON
|
|
// store; a build without cgo and without any build tag has it too. The bytes
|
|
// live in configured S3 for every role, otherwise at DataPath/chats/blobs.
|
|
// Moving files between backends is not supported automatically.
|
|
func (a *Application) initStudioFiles() (result error) {
|
|
return a.initStudioFilesWithS3(func(ctx context.Context, cfg storage.S3Config) (storage.ObjectStore, error) {
|
|
return storage.NewS3Store(ctx, cfg)
|
|
})
|
|
}
|
|
|
|
func (a *Application) initStudioFilesWithS3(newS3 func(context.Context, storage.S3Config) (storage.ObjectStore, error)) (result error) {
|
|
objects, err := a.workspaceObjects(newS3)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
strict := objects != nil || (a.authDB != nil && a.authDB.Dialector.Name() == "postgres")
|
|
defer func() {
|
|
if result != nil && a.studioFilesTemp != "" {
|
|
_ = os.RemoveAll(a.studioFilesTemp)
|
|
a.studioFilesTemp = ""
|
|
}
|
|
}()
|
|
dp := a.applicationConfig.DataPath
|
|
if dp == "" && (a.authDB == nil || objects == nil) {
|
|
a.studioFiles = files.Disabled("There is no data path, so files are off. Start LocalAI with a data path to keep files.")
|
|
return nil
|
|
}
|
|
var tmpCache string
|
|
if dp == "" { // a cluster without a data path: the blob cache is a temporary folder
|
|
dir, err := os.MkdirTemp("", "localai-files-*")
|
|
if err != nil {
|
|
if strict {
|
|
return fmt.Errorf("workspace scratch: %w", err)
|
|
}
|
|
a.studioFiles = files.Disabled("Files are off: there is no folder for their cache.")
|
|
return
|
|
}
|
|
a.studioFilesTemp, tmpCache = dir, dir
|
|
}
|
|
root := filepath.Join(dp, "chats")
|
|
if dp == "" {
|
|
root = tmpCache
|
|
}
|
|
|
|
var blobs blob.Store
|
|
if objects != nil {
|
|
blobs = blob.NewObjectStore(objects, filepath.Join(root, "blobcache"))
|
|
} else {
|
|
blobs, err = blob.NewFS(filepath.Join(root, "blobs"))
|
|
}
|
|
if err != nil {
|
|
if strict {
|
|
return fmt.Errorf("workspace blob store: %w", err)
|
|
}
|
|
xlog.Warn("Files disabled: cannot open the blob store", "error", err)
|
|
a.studioFiles = files.Disabled("Files are off: their storage could not be opened.")
|
|
return
|
|
}
|
|
ws, closer, err := a.openWorkspaceIndex(root, blobs)
|
|
if err != nil {
|
|
if strict {
|
|
return fmt.Errorf("workspace index: %w", err)
|
|
}
|
|
xlog.Warn("Files disabled: cannot start the workspace", "error", err)
|
|
a.studioFiles = files.Disabled("Files are off: the workspace could not be started.")
|
|
return
|
|
}
|
|
f := files.New(ws, files.WithBlobs(blobs), files.WithMaxFileSize(a.studioUploadLimit()))
|
|
if err := f.EnsureShared(context.Background()); err != nil {
|
|
xlog.Warn("Files disabled: cannot create the shared folders", "error", err)
|
|
a.studioFiles = files.Disabled("Files are off: the workspace could not be started.")
|
|
if closer != nil {
|
|
_ = closer.Close()
|
|
}
|
|
if strict {
|
|
return fmt.Errorf("workspace folders: %w", err)
|
|
}
|
|
return
|
|
}
|
|
// The office skills ship with the server. A copy that fails leaves the
|
|
// chats as they are: the skills are help, not a need.
|
|
if err := f.SyncBuiltin(context.Background(), builtinskills.FS()); err != nil {
|
|
xlog.Warn("Could not write the built-in skills", "error", err)
|
|
}
|
|
a.studioFiles, a.studioFilesIndex = f, closer
|
|
a.initStudioTextIndex(f, root)
|
|
a.syncAddonSkills(f)
|
|
return nil
|
|
}
|
|
|
|
// initStudioTextIndex builds the text index of the documents in the files and
|
|
// starts its indexer. The text lives with the workspace index: in the auth
|
|
// database when there is one, otherwise in a folder under root, never among
|
|
// the users' files. In a cluster each pass of the indexer runs under an
|
|
// advisory lock, so one frontend reads the files and every frontend searches
|
|
// the shared text. A chat worker searches it but never runs the indexer.
|
|
func (a *Application) initStudioTextIndex(f *files.Service, root string) {
|
|
var st textindex.Store
|
|
var err error
|
|
if a.authDB != nil {
|
|
st, err = textindex.NewGORM(a.authDB)
|
|
} else {
|
|
st, err = textindex.NewJSON(filepath.Join(root, "textindex"))
|
|
}
|
|
if err != nil {
|
|
xlog.Warn("File text index disabled: cannot open its store; documents are found by name only", "error", err)
|
|
return
|
|
}
|
|
cfg := a.applicationConfig
|
|
ix := textindex.New(textindex.Options{
|
|
Store: st, Workspace: f.Store(), Admin: workspace.Actor{Principal: files.AdminPrincipal}, DB: a.authDB,
|
|
Enabled: func() bool { return !cfg.DisableChatsIndexFiles },
|
|
MaxTextBytes: cfg.EffectiveChatsIndexMaxTextBytes,
|
|
})
|
|
f.SetTextIndex(ix)
|
|
a.studioTextIndex = ix
|
|
if cfg.Distributed.ChatWorker {
|
|
return
|
|
}
|
|
go ix.Run(a.backgroundContext())
|
|
}
|
|
|
|
// syncAddonSkills lets the add-on owner keep the add-on skill layer in step
|
|
// with the installed packages: it copies the skills of every installed add-on
|
|
// now and after each install, upgrade and uninstall, and removes the folders of
|
|
// add-ons that are gone. It does nothing on a frontend: only the owner holds
|
|
// the verified packages, and the layer is in the workspace store every
|
|
// frontend reads.
|
|
func (a *Application) syncAddonSkills(f *files.Service) {
|
|
m := a.addonManager
|
|
if m == nil {
|
|
return
|
|
}
|
|
ctx := a.applicationConfig.Context
|
|
m.SetSkillSync(ctx, addonSkillSync(f))
|
|
installed, err := m.InstalledSkillIDs(ctx)
|
|
if err != nil {
|
|
return // not the owner
|
|
}
|
|
present, err := f.AddonSkillIDs(ctx)
|
|
if err != nil {
|
|
xlog.Warn("Could not list the add-on skills", "error", err)
|
|
return
|
|
}
|
|
for _, id := range present {
|
|
if !slices.Contains(installed, id) {
|
|
if err := f.RemoveAddonSkills(ctx, id); err != nil {
|
|
xlog.Warn("Could not remove the skills of an add-on", "addon", id, "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// addonSkillSync copies the skills of a package into the add-on layer, each
|
|
// file checked against the receipt, or removes them.
|
|
func addonSkillSync(f *files.Service) addons.SkillSync {
|
|
return func(ctx context.Context, id string, r *packages.PackageReceipt) error {
|
|
if r == nil || len(r.Skills) == 0 {
|
|
return f.RemoveAddonSkills(ctx, id)
|
|
}
|
|
fsys, err := packages.SkillFS(*r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.SyncAddonSkills(ctx, id, fsys)
|
|
}
|
|
}
|
|
|
|
// openWorkspaceIndex opens the index of the workspace: the GORM store on the
|
|
// auth database when there is one, and the JSON store in a folder under root
|
|
// otherwise. The returned closer is nil for the shared database, which the
|
|
// application closes itself.
|
|
func (a *Application) openWorkspaceIndex(root string, blobs blob.Store) (workspace.Store, io.Closer, error) {
|
|
if a.authDB != nil {
|
|
ws, err := gormstore.New(a.authDB, blobs, gormstore.Options{Admin: files.AdminPrincipal, MaxFileSize: studioMaxFileSize})
|
|
return ws, nil, err
|
|
}
|
|
ws, err := jsonstore.New(filepath.Join(root, "workspace"), blobs, jsonstore.Options{Admin: files.AdminPrincipal, MaxFileSize: studioMaxFileSize})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return ws, ws, nil
|
|
}
|