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 }