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
This commit is contained in:
localai-org-maint-bot
2026-07-28 16:08:53 +00:00
parent 38ae3a24f9
commit 799cec14f1
3 changed files with 66 additions and 4 deletions

View File

@@ -191,6 +191,12 @@ The container exposes the following volumes:
| `/configuration` | Dynamic config files (api_keys.json, external_backends.json, runtime_settings.json) | `--localai-config-dir` | `$LOCALAI_CONFIG_DIR` |
| `/data` | Persistent data (collections, agent state, tasks, jobs) | `--data-path` | `$LOCALAI_DATA_PATH` |
Backend OCI images contain symbolic links. When `/backends` is stored on a
filesystem that cannot create links, such as some CIFS/SMB mounts, LocalAI
materializes each link as a regular file so installation can complete. This can
use more disk space than a local filesystem. Prefer a Docker or Podman named
volume for `/backends` when possible.
To persist models and data, mount volumes:
```bash

View File

@@ -87,6 +87,36 @@ func buildTar() []byte {
return buf.Bytes()
}
func buildChainedLinkTar() []byte {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
content := []byte("real library bytes")
Expect(tw.WriteHeader(&tar.Header{
Name: "lib/libcublas.so",
Typeflag: tar.TypeSymlink,
Linkname: "libcublas.so.12",
Mode: 0777,
})).To(Succeed())
Expect(tw.WriteHeader(&tar.Header{
Name: "lib/libcublas.so.12",
Typeflag: tar.TypeSymlink,
Linkname: "libcublas.so.12.8.5.5",
Mode: 0777,
})).To(Succeed())
Expect(tw.WriteHeader(&tar.Header{
Name: "lib/libcublas.so.12.8.5.5",
Typeflag: tar.TypeReg,
Mode: 0644,
Size: int64(len(content)),
})).To(Succeed())
_, err := tw.Write(content)
Expect(err).NotTo(HaveOccurred())
Expect(tw.Close()).To(Succeed())
return buf.Bytes()
}
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(
@@ -185,5 +215,17 @@ var _ = Describe("Tar extraction fallback for link-less filesystems", func() {
Expect(err).NotTo(HaveOccurred())
Expect(string(data)).To(Equal("real library bytes"))
})
It("materialises chained symlinks regardless of archive order", func() {
origSymlink := symlink
symlink = func(string, string) error { return syscall.ENOTSUP }
DeferCleanup(func() { symlink = origSymlink })
dir := GinkgoT().TempDir()
Expect(extractTarCopyingLinks(bytes.NewReader(buildChainedLinkTar()), dir)).To(Succeed())
Expect(os.ReadFile(filepath.Join(dir, "lib", "libcublas.so"))).To(Equal([]byte("real library bytes")))
Expect(os.ReadFile(filepath.Join(dir, "lib", "libcublas.so.12"))).To(Equal([]byte("real library bytes")))
})
})
})

View File

@@ -611,11 +611,25 @@ func extractTarCopyingLinks(r io.Reader, targetDestination string) error {
}
}
// Second pass: materialise links that the filesystem could not represent.
for _, link := range pending {
if err := copyFilePreservingMode(link.targetPath, link.path); err != nil {
return fmt.Errorf("failed to copy link target %s -> %s: %w", link.targetPath, link.path, err)
// Materialise links in dependency order. Soname links are often chained
// (libfoo.so -> libfoo.so.1 -> libfoo.so.1.2), and archives do not guarantee
// that the nearest-to-file link appears first.
for len(pending) > 0 {
var unresolved []pendingLink
for _, link := range pending {
if err := copyFilePreservingMode(link.targetPath, link.path); err != nil {
if errors.Is(err, os.ErrNotExist) {
unresolved = append(unresolved, link)
continue
}
return fmt.Errorf("failed to copy link target %s -> %s: %w", link.targetPath, link.path, err)
}
}
if len(unresolved) == len(pending) {
link := unresolved[0]
return fmt.Errorf("failed to resolve copied link target %s -> %s", link.targetPath, link.path)
}
pending = unresolved
}
return nil
}