fix(oci): stage image downloads in a configured directory, not in /tmp (#12280)

* fix(oci): stage image downloads in a configured directory, not in /tmp

Problem:
- the image tar and its compressed layers staged in os.TempDir()
- /tmp is commonly a RAM-backed tmpfs far smaller than a backend image
- big installs failed with a full /tmp or silently ate RAM

Change:
- staging path resolved like the other storage paths
  (LOCALAI_DOWNLOAD_STAGING_PATH, default ${basepath}/downloading),
  threaded to the extractor as a download option
- every download works in its own subdirectory holding its tar and
  layers: nothing shared between concurrent downloads, one removal
  cleans a download up, a crash leaves one self-contained orphan
- callers that pass no staging directory keep the OS temp behavior

Assisted-by: Claude:claude-fable-5
Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>

* fix(oci): handle staging cleanup errors

Log staging cleanup failures to satisfy errcheck. Test extraction with an
unavailable OS temp directory and verify that staging is removed.

Assisted-by: Codex:GPT-6
Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>

---------

Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
This commit is contained in:
Plamen K. Kosseff authored and GitHub committed 2026-10-05 01:27:45 +02:00
1 parent 4d681b7f6d
commit 104c2fb440
9 files changed
+78 -8

No files matched your search

+2
View File
@@ -42,6 +42,7 @@ type RunCMD struct {
BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends used for inferencing" group:"backends"`
BackendsSystemPath string `env:"LOCALAI_BACKENDS_SYSTEM_PATH,BACKEND_SYSTEM_PATH" type:"path" default:"/var/lib/local-ai/backends" help:"Path containing system backends used for inferencing" group:"backends"`
ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"`
DownloadStagingPath string `env:"LOCALAI_DOWNLOAD_STAGING_PATH,DOWNLOAD_STAGING_PATH" type:"path" default:"${basepath}/downloading" help:"Path where in-flight downloads are staged before extraction" group:"storage"`
ArtifactDownloadConcurrency int `env:"LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY" help:"How many files of a model artifact to download at once. 1 (the default) downloads sequentially. Raising it helps artifacts split into many files on a fast link, at the cost of more concurrent load on the models volume" group:"storage" default:"1"`
GeneratedContentPath string `env:"LOCALAI_GENERATED_CONTENT_PATH,GENERATED_CONTENT_PATH" type:"path" default:"${generatedcontentpath}" help:"Location for generated content (e.g. images, audio, videos)" group:"storage"`
UploadPath string `env:"LOCALAI_UPLOAD_PATH,UPLOAD_PATH" type:"path" default:"${uploadpath}" help:"Path to store uploads from files api" group:"storage"`
@@ -280,6 +281,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
system.WithBackendSystemPath(r.BackendsSystemPath),
system.WithModelPath(r.ModelsPath),
system.WithBackendPath(r.BackendsPath),
system.WithStagingPath(r.DownloadStagingPath),
system.WithBackendImagesReleaseTag(r.BackendImagesReleaseTag),
system.WithBackendImagesBranchTag(r.BackendImagesBranchTag),
system.WithBackendDevSuffix(r.BackendDevSuffix),
+1
View File
@@ -339,6 +339,7 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
if optsErr != nil {
return fmt.Errorf("backend %q: %w", config.Name, optsErr)
}
downloadOpts = append(downloadOpts, downloader.WithStagingDir(systemState.StagingPath))
// PreferDevelopmentBackends installs the development image as the primary URI,
// keeping the released image reachable as the first fallback — instead of only
+1
View File
@@ -283,6 +283,7 @@ func UpgradeBackend(ctx context.Context, systemState *system.SystemState, modelL
if err != nil {
return fmt.Errorf("upgrade %q: %w", backendName, err)
}
downloadOpts = append(downloadOpts, downloader.WithStagingDir(systemState.StagingPath))
backendPath := filepath.Join(systemState.Backend.BackendsPath, backendName)
tmpPath := backendPath + ".upgrade-tmp"
+1
View File
@@ -28,6 +28,7 @@ model, see [Model Configuration]({{% relref "advanced/model-configuration" %}}).
| Parameter | Default | Description | Environment Variable |
|-----------|---------|-------------|----------------------|
| `--models-path` | `BASEPATH/models` | Path containing models used for inferencing | `$LOCALAI_MODELS_PATH`, `$MODELS_PATH` |
| `--download-staging-path` | `BASEPATH/downloading` | Path where in-flight downloads are staged before extraction | `$LOCALAI_DOWNLOAD_STAGING_PATH`, `$DOWNLOAD_STAGING_PATH` |
| `--data-path` | `BASEPATH/data` | Path for persistent data (collectiondb, agent state, tasks, jobs). Separates mutable data from configuration | `$LOCALAI_DATA_PATH` |
| `--generated-content-path` | `TMPDIR/localai-UID/generated/content` | Location for assets generated by backends (e.g. stablediffusion, images, audio, videos). Defaults under the OS temp dir (`$TMPDIR`, falling back to `/tmp`), scoped to the current user's UID so accounts sharing a host never collide. | `$LOCALAI_GENERATED_CONTENT_PATH`, `$GENERATED_CONTENT_PATH` |
| `--upload-path` | `TMPDIR/localai-UID/upload` | Path to store uploads from files API. Defaults under the OS temp dir (`$TMPDIR`, falling back to `/tmp`), scoped to the current user's UID. | `$LOCALAI_UPLOAD_PATH`, `$UPLOAD_PATH` |
+7 -2
View File
@@ -71,6 +71,7 @@ type downloadOptions struct {
verifier ImageVerifier
bearerToken string
transferProgress TransferProgressSink
stagingDir string
}
// DownloadOption configures DownloadFileWithContext / DownloadFile.
@@ -93,6 +94,10 @@ func WithBearerToken(token string) DownloadOption {
return func(o *downloadOptions) { o.bearerToken = token }
}
func WithStagingDir(dir string) DownloadOption {
return func(o *downloadOptions) { o.stagingDir = dir }
}
// WithTransferProgress attaches a sink for raw HTTP download byte progress.
func WithTransferProgress(sink TransferProgressSink) DownloadOption {
return func(o *downloadOptions) { o.transferProgress = sink }
@@ -627,7 +632,7 @@ func (uri URI) DownloadFileWithContext(ctx context.Context, filePath, sha string
return fmt.Errorf("failed to open tarball: %s", err.Error())
}
return oci.ExtractOCIImage(ctx, img, url, filePath, downloadStatus)
return oci.ExtractOCIImage(ctx, img, url, filePath, dopts.stagingDir, downloadStatus)
}
url = URI(url).OCIReference()
@@ -653,7 +658,7 @@ func (uri URI) DownloadFileWithContext(ctx context.Context, filePath, sha string
xlog.Info("Image signature verified", "ref", pinned)
}
return oci.ExtractOCIImage(ctx, img, url, filePath, downloadStatus)
return oci.ExtractOCIImage(ctx, img, url, filePath, dopts.stagingDir, downloadStatus)
}
// Check for cancellation before starting
+26
View File
@@ -117,6 +117,32 @@ func buildChainedLinkTar() []byte {
return buf.Bytes()
}
var _ = Describe("OCI download staging", func() {
It("uses the configured directory without OS temp space and cleans up", func() {
root := GinkgoT().TempDir()
staging := filepath.Join(root, "staging")
destination := filepath.Join(root, "destination")
Expect(os.Mkdir(destination, 0755)).To(Succeed())
layer := buildLayer(tar.Header{
Name: "payload", Mode: 0644,
PAXRecords: map[string]string{"content": "downloaded bytes"},
})
image, err := mutate.AppendLayers(empty.Image, layer)
Expect(err).NotTo(HaveOccurred())
GinkgoT().Setenv("TMPDIR", filepath.Join(root, "missing-temp"))
GinkgoT().Setenv("TMP", filepath.Join(root, "missing-temp"))
GinkgoT().Setenv("TEMP", filepath.Join(root, "missing-temp"))
Expect(ExtractOCIImage(context.Background(), image, "test/image", destination, staging, nil)).To(Succeed())
content, err := os.ReadFile(filepath.Join(destination, "payload"))
Expect(err).NotTo(HaveOccurred())
Expect(string(content)).To(Equal("downloaded bytes"))
entries, err := os.ReadDir(staging)
Expect(err).NotTo(HaveOccurred())
Expect(entries).To(BeEmpty())
})
})
var _ = Describe("Tar extraction fallback for link-less filesystems", func() {
It("downloads a layered image once and preserves whiteouts before copying links", func() {
base := buildLayer(
+30 -5
View File
@@ -28,6 +28,7 @@ import (
"github.com/google/go-containerregistry/pkg/v1/tarball"
"github.com/mudler/LocalAI/pkg/credentials"
"github.com/mudler/LocalAI/pkg/xio"
"github.com/mudler/xlog"
)
// ref: https://github.com/mudler/luet/blob/master/pkg/helpers/docker/docker.go#L117
@@ -323,10 +324,34 @@ func (pw *progressWriter) Write(p []byte) (int, error) {
return n, nil
}
// ExtractOCIImage will extract a given targetImage into a given targetDestination
func ExtractOCIImage(ctx context.Context, img v1.Image, imageRef string, targetDestination string, downloadStatus func(string, string, string, float64)) error {
// Create a temporary tar file
tmpTarFile, err := os.CreateTemp("", "localai-oci-*.tar")
// ExtractOCIImage will extract a given targetImage into a given
// targetDestination. A non-empty stagingDir holds the in-flight image
// tar and layers (the caller picks a directory whose filesystem can
// hold them); empty falls back to the OS temp directory, which on many
// systems is a RAM-backed tmpfs far smaller than a backend image.
func ExtractOCIImage(ctx context.Context, img v1.Image, imageRef string, targetDestination, stagingDir string, downloadStatus func(string, string, string, float64)) error {
// Every download works in its own directory: the image tar and the
// layer staging below both live there, nothing is shared between
// concurrent downloads, and one removal cleans a download up.
var downloadDir string
var err error
if stagingDir != "" {
if err := os.MkdirAll(stagingDir, 0755); err != nil {
return fmt.Errorf("failed to create staging directory: %v", err)
}
downloadDir, err = os.MkdirTemp(stagingDir, "localai-oci-*")
} else {
downloadDir, err = os.MkdirTemp("", "localai-oci-*")
}
if err != nil {
return fmt.Errorf("failed to create download directory: %v", err)
}
defer func() {
if err := os.RemoveAll(downloadDir); err != nil {
xlog.Warn("Failed to remove OCI download directory", "path", downloadDir, "error", err)
}
}()
tmpTarFile, err := os.CreateTemp(downloadDir, "image-*.tar")
if err != nil {
return fmt.Errorf("failed to create temporary tar file: %v", err)
}
@@ -504,7 +529,7 @@ func DownloadOCIImageTar(ctx context.Context, img v1.Image, imageRef string, tar
}
// Create a temporary directory to store the compressed layers
tmpDir, err := os.MkdirTemp("", "localai-oci-layers-*")
tmpDir, err := os.MkdirTemp(filepath.Dir(tarFilePath), "localai-oci-layers-*")
if err != nil {
return fmt.Errorf("failed to create temporary directory: %v", err)
}
+1 -1
View File
@@ -32,7 +32,7 @@ var _ = Describe("OCI", func() {
Expect(err).NotTo(HaveOccurred())
defer os.RemoveAll(dir)
err = ExtractOCIImage(context.TODO(), img, imageName, dir, nil)
err = ExtractOCIImage(context.TODO(), img, imageName, dir, "", nil)
Expect(err).NotTo(HaveOccurred())
})
})
+9
View File
@@ -19,6 +19,9 @@ type SystemState struct {
Backend Backend
Model Model
VRAM uint64
// StagingPath holds in-flight downloads before extraction; empty
// falls back to the OS temp directory.
StagingPath string
systemCapabilities string
@@ -53,6 +56,12 @@ func WithBackendSystemPath(path string) SystemStateOptions {
}
}
func WithStagingPath(path string) SystemStateOptions {
return func(s *SystemState) {
s.StagingPath = path
}
}
func WithModelPath(path string) SystemStateOptions {
return func(s *SystemState) {
s.Model.ModelsPath = path