From 799cec14f1a0c699a67b73d0553f3ee1eacd7e55 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:08:53 +0000 Subject: [PATCH] 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 --- docs/content/getting-started/containers.md | 6 ++++ pkg/oci/extract_internal_test.go | 42 ++++++++++++++++++++++ pkg/oci/image.go | 22 +++++++++--- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/docs/content/getting-started/containers.md b/docs/content/getting-started/containers.md index eb3d80f16..3af645c3e 100644 --- a/docs/content/getting-started/containers.md +++ b/docs/content/getting-started/containers.md @@ -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 diff --git a/pkg/oci/extract_internal_test.go b/pkg/oci/extract_internal_test.go index 8a56e8c17..b9b99ac11 100644 --- a/pkg/oci/extract_internal_test.go +++ b/pkg/oci/extract_internal_test.go @@ -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"))) + }) }) }) diff --git a/pkg/oci/image.go b/pkg/oci/image.go index 52656a28f..8e1546647 100644 --- a/pkg/oci/image.go +++ b/pkg/oci/image.go @@ -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 }