Skip to content

Commit 68a0460

Browse files
fix(oci): install backends on filesystems without symlinks (#11166)
* fix(backends): fall back to copying links when the filesystem rejects symlinks (#10890) Backend installation extracts the OCI image tar via containerd's archive.Apply, which calls os.Symlink directly. On filesystems that do not support symlinks (notably CIFS/SMB mounts, commonly used to back the /backends volume) the syscall fails with "operation not supported" and the whole install aborts, leaving an empty backend directory. The CUDA llama.cpp image trips this on the libcublas.so -> libcublas.so.12.x symlink. When archive.Apply fails with a link-unsupported error, reset the staging directory and re-extract with a pure-Go walker that still attempts real symlinks/hardlinks first and degrades to copying the link target's contents in place when the filesystem rejects them. mutate.Extract already flattened the layers, so the tar carries no whiteouts to interpret. Link copies are deferred to a second pass so forward references resolve. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:opus-4.8 [Claude Code] * fix(oci): check deferred Close in copyFilePreservingMode (errcheck) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:opus-4.8 [Claude Code] * fix(oci): reject path-traversal tar entries in the link-copy fallback safeJoin sanitized "../.." entries by clamping them under root instead of rejecting them, so a malicious entry was silently redirected rather than refused. Join without the leading-slash trick and reject any entry whose cleaned path resolves outside root; absolute link targets are still mapped under root (image-root relative) rather than escaping. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:opus-4.8 [Claude Code] * fix(oci): silence gosec on the validated link-copy file ops Use hdr.FileInfo().Mode() instead of converting the int64 tar mode to os.FileMode (removes two G115 overflow findings), and annotate the tar extraction file operations with justified #nosec comments: every path is validated by safeJoin against the extraction root before use (G304/G305). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:opus-4.8 [Claude Code] * fix(oci): build extraction image from downloaded layers Avoid appending downloaded layers to the original remote-backed image, which duplicates the layer stack and reopens the source during extraction. Building from an empty image preserves the flattened whiteout semantics while keeping extraction local. Assisted-by: Codex:gpt-5 * fix(oci): materialize chained links in dependency order Retry deferred link copies until their targets exist so soname chains work on filesystems without symlink support. Document that copied links can increase backend storage usage on CIFS and SMB mounts. Assisted-by: Codex:gpt-5 --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
1 parent ef724a3 commit 68a0460

3 files changed

Lines changed: 483 additions & 3 deletions

File tree

docs/content/getting-started/containers.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,12 @@ must be exactly `/models`, `/backends`, `/configuration`, and `/data`. In
202202
UnRAID and other container-template UIs, create one path mapping for each row
203203
in the table above.
204204

205+
Backend OCI images contain symbolic links. When `/backends` is stored on a
206+
filesystem that cannot create links, such as some CIFS/SMB mounts, LocalAI
207+
materializes each link as a regular file so installation can complete. This can
208+
use more disk space than a local filesystem. Prefer a Docker or Podman named
209+
volume for `/backends` when possible.
210+
205211
To use bind mounts:
206212

207213
```bash

pkg/oci/extract_internal_test.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package oci
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"compress/gzip"
7+
"context"
8+
"errors"
9+
"io"
10+
"os"
11+
"path/filepath"
12+
"syscall"
13+
14+
v1 "github.com/google/go-containerregistry/pkg/v1"
15+
"github.com/google/go-containerregistry/pkg/v1/empty"
16+
"github.com/google/go-containerregistry/pkg/v1/mutate"
17+
"github.com/google/go-containerregistry/pkg/v1/tarball"
18+
. "github.com/onsi/ginkgo/v2"
19+
. "github.com/onsi/gomega"
20+
)
21+
22+
type compressedOnlyLayer struct {
23+
v1.Layer
24+
digest v1.Hash
25+
}
26+
27+
func (l compressedOnlyLayer) Uncompressed() (io.ReadCloser, error) {
28+
return nil, errors.New("downloaded layer reopened from source")
29+
}
30+
31+
func (l compressedOnlyLayer) Digest() (v1.Hash, error) { return l.digest, nil }
32+
33+
func buildLayer(entries ...tar.Header) v1.Layer {
34+
var buf bytes.Buffer
35+
zw := gzip.NewWriter(&buf)
36+
tw := tar.NewWriter(zw)
37+
for _, header := range entries {
38+
content := []byte(header.PAXRecords["content"])
39+
header.PAXRecords = nil
40+
header.Size = int64(len(content))
41+
Expect(tw.WriteHeader(&header)).To(Succeed())
42+
if len(content) != 0 {
43+
_, err := tw.Write(content)
44+
Expect(err).NotTo(HaveOccurred())
45+
}
46+
}
47+
Expect(tw.Close()).To(Succeed())
48+
Expect(zw.Close()).To(Succeed())
49+
layer, err := tarball.LayerFromReader(bytes.NewReader(buf.Bytes()))
50+
Expect(err).NotTo(HaveOccurred())
51+
digest, _, err := v1.SHA256(bytes.NewReader([]byte{byte(len(entries))}))
52+
Expect(err).NotTo(HaveOccurred())
53+
return compressedOnlyLayer{Layer: layer, digest: digest}
54+
}
55+
56+
// buildTar assembles an in-memory tar carrying a directory, a regular file and
57+
// a relative symlink pointing at that file, mirroring the layout of a backend
58+
// image (e.g. libcublas.so -> libcublas.so.12).
59+
func buildTar() []byte {
60+
var buf bytes.Buffer
61+
tw := tar.NewWriter(&buf)
62+
63+
Expect(tw.WriteHeader(&tar.Header{
64+
Name: "lib/",
65+
Typeflag: tar.TypeDir,
66+
Mode: 0755,
67+
})).To(Succeed())
68+
69+
content := []byte("real library bytes")
70+
Expect(tw.WriteHeader(&tar.Header{
71+
Name: "lib/libcublas.so.12",
72+
Typeflag: tar.TypeReg,
73+
Mode: 0644,
74+
Size: int64(len(content)),
75+
})).To(Succeed())
76+
_, err := tw.Write(content)
77+
Expect(err).NotTo(HaveOccurred())
78+
79+
Expect(tw.WriteHeader(&tar.Header{
80+
Name: "lib/libcublas.so",
81+
Typeflag: tar.TypeSymlink,
82+
Linkname: "libcublas.so.12",
83+
Mode: 0777,
84+
})).To(Succeed())
85+
86+
Expect(tw.Close()).To(Succeed())
87+
return buf.Bytes()
88+
}
89+
90+
func buildChainedLinkTar() []byte {
91+
var buf bytes.Buffer
92+
tw := tar.NewWriter(&buf)
93+
94+
content := []byte("real library bytes")
95+
Expect(tw.WriteHeader(&tar.Header{
96+
Name: "lib/libcublas.so",
97+
Typeflag: tar.TypeSymlink,
98+
Linkname: "libcublas.so.12",
99+
Mode: 0777,
100+
})).To(Succeed())
101+
Expect(tw.WriteHeader(&tar.Header{
102+
Name: "lib/libcublas.so.12",
103+
Typeflag: tar.TypeSymlink,
104+
Linkname: "libcublas.so.12.8.5.5",
105+
Mode: 0777,
106+
})).To(Succeed())
107+
Expect(tw.WriteHeader(&tar.Header{
108+
Name: "lib/libcublas.so.12.8.5.5",
109+
Typeflag: tar.TypeReg,
110+
Mode: 0644,
111+
Size: int64(len(content)),
112+
})).To(Succeed())
113+
_, err := tw.Write(content)
114+
Expect(err).NotTo(HaveOccurred())
115+
116+
Expect(tw.Close()).To(Succeed())
117+
return buf.Bytes()
118+
}
119+
120+
var _ = Describe("Tar extraction fallback for link-less filesystems", func() {
121+
It("downloads a layered image once and preserves whiteouts before copying links", func() {
122+
base := buildLayer(
123+
tar.Header{Name: "lib/removed.so", Mode: 0644, PAXRecords: map[string]string{"content": "removed"}},
124+
tar.Header{Name: "lib/libcublas.so.12", Mode: 0644, PAXRecords: map[string]string{"content": "old library"}},
125+
)
126+
top := buildLayer(
127+
tar.Header{Name: "lib/.wh.removed.so", Mode: 0644},
128+
tar.Header{Name: "lib/libcublas.so.12", Mode: 0644, PAXRecords: map[string]string{"content": "new library"}},
129+
tar.Header{Name: "lib/libcublas.so", Typeflag: tar.TypeSymlink, Linkname: "libcublas.so.12", Mode: 0777},
130+
)
131+
image, err := mutate.AppendLayers(empty.Image, base, top)
132+
Expect(err).NotTo(HaveOccurred())
133+
134+
tmp := GinkgoT().TempDir()
135+
tarPath := filepath.Join(tmp, "rootfs.tar")
136+
Expect(DownloadOCIImageTar(context.Background(), image, "test/image", tarPath, nil)).To(Succeed())
137+
138+
originalSymlink := symlink
139+
symlink = func(string, string) error { return syscall.ENOTSUP }
140+
DeferCleanup(func() { symlink = originalSymlink })
141+
142+
destination := filepath.Join(tmp, "destination")
143+
Expect(os.Mkdir(destination, 0755)).To(Succeed())
144+
Expect(ExtractOCIImageFromTar(context.Background(), tarPath, "test/image", destination, nil)).To(Succeed())
145+
Expect(filepath.Join(destination, "lib", "removed.so")).NotTo(BeAnExistingFile())
146+
Expect(os.ReadFile(filepath.Join(destination, "lib", "libcublas.so.12"))).To(Equal([]byte("new library")))
147+
Expect(os.ReadFile(filepath.Join(destination, "lib", "libcublas.so"))).To(Equal([]byte("new library")))
148+
})
149+
150+
Describe("isLinkUnsupportedError", func() {
151+
It("recognises filesystem link-unsupported errors", func() {
152+
Expect(isLinkUnsupportedError(syscall.ENOTSUP)).To(BeTrue())
153+
Expect(isLinkUnsupportedError(syscall.EOPNOTSUPP)).To(BeTrue())
154+
Expect(isLinkUnsupportedError(syscall.EPERM)).To(BeTrue())
155+
Expect(isLinkUnsupportedError(&os.LinkError{
156+
Op: "symlink",
157+
Old: "libcublas.so.12",
158+
New: "/backends/lib/libcublas.so",
159+
Err: syscall.ENOTSUP,
160+
})).To(BeTrue())
161+
})
162+
163+
It("does not misclassify unrelated errors", func() {
164+
Expect(isLinkUnsupportedError(os.ErrNotExist)).To(BeFalse())
165+
Expect(isLinkUnsupportedError(syscall.ENOSPC)).To(BeFalse())
166+
})
167+
})
168+
169+
Describe("safeJoin", func() {
170+
It("keeps entries inside the root", func() {
171+
root := "/tmp/extract-root"
172+
p, err := safeJoin(root, "lib/libcublas.so")
173+
Expect(err).NotTo(HaveOccurred())
174+
Expect(p).To(Equal(filepath.Join(root, "lib/libcublas.so")))
175+
})
176+
177+
It("rejects path traversal entries", func() {
178+
_, err := safeJoin("/tmp/extract-root", "../../etc/passwd")
179+
Expect(err).To(HaveOccurred())
180+
})
181+
})
182+
183+
Describe("extractTarCopyingLinks", func() {
184+
It("preserves symlinks when the filesystem supports them", func() {
185+
dir := GinkgoT().TempDir()
186+
Expect(extractTarCopyingLinks(bytes.NewReader(buildTar()), dir)).To(Succeed())
187+
188+
linkPath := filepath.Join(dir, "lib", "libcublas.so")
189+
fi, err := os.Lstat(linkPath)
190+
Expect(err).NotTo(HaveOccurred())
191+
Expect(fi.Mode() & os.ModeSymlink).NotTo(BeZero())
192+
193+
data, err := os.ReadFile(linkPath)
194+
Expect(err).NotTo(HaveOccurred())
195+
Expect(string(data)).To(Equal("real library bytes"))
196+
})
197+
198+
It("copies the target when symlink creation is unsupported", func() {
199+
// Simulate a CIFS/SMB mount: symlink() reports ENOTSUP.
200+
origSymlink := symlink
201+
symlink = func(string, string) error { return syscall.ENOTSUP }
202+
DeferCleanup(func() { symlink = origSymlink })
203+
204+
dir := GinkgoT().TempDir()
205+
Expect(extractTarCopyingLinks(bytes.NewReader(buildTar()), dir)).To(Succeed())
206+
207+
linkPath := filepath.Join(dir, "lib", "libcublas.so")
208+
fi, err := os.Lstat(linkPath)
209+
Expect(err).NotTo(HaveOccurred())
210+
// The entry must now be a real, regular file (a copy), not a symlink.
211+
Expect(fi.Mode() & os.ModeSymlink).To(BeZero())
212+
Expect(fi.Mode().IsRegular()).To(BeTrue())
213+
214+
data, err := os.ReadFile(linkPath)
215+
Expect(err).NotTo(HaveOccurred())
216+
Expect(string(data)).To(Equal("real library bytes"))
217+
})
218+
219+
It("materialises chained symlinks regardless of archive order", func() {
220+
origSymlink := symlink
221+
symlink = func(string, string) error { return syscall.ENOTSUP }
222+
DeferCleanup(func() { symlink = origSymlink })
223+
224+
dir := GinkgoT().TempDir()
225+
Expect(extractTarCopyingLinks(bytes.NewReader(buildChainedLinkTar()), dir)).To(Succeed())
226+
227+
Expect(os.ReadFile(filepath.Join(dir, "lib", "libcublas.so"))).To(Equal([]byte("real library bytes")))
228+
Expect(os.ReadFile(filepath.Join(dir, "lib", "libcublas.so.12"))).To(Equal([]byte("real library bytes")))
229+
})
230+
})
231+
})

0 commit comments

Comments
 (0)