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

+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