Repository: conan-io/conan

Issue:
[bug] Bugs in `runtime_deployer`
### Describe the bug

Keeping the bug report info from #16527.

The `runtime_deployer` from #15382 is a very good addition to Conan, but the implementation currently has some defects:
- Only `*.so` files (e.g. `libz.so`) are copied, but not `*.so.*` (e.g. `libz.so.1` and `libz.so.1.3.1`).
- `shutil.copy2(..., follow_symlinks=symlinks)` should be `shutil.copy2(..., follow_symlinks=not symlinks)`.

### How to reproduce it

_No response_


Discussion:
Another issue is that runtime_deploy does not keep any tree structure.
For example, the openssl package has lib/engines-3/capi.so.
Deployers should keep these trees intact.

I considered trying full_deploy instead and post-processing its output to merge all the packages together into one single tree, something like `mv full_deploy/host/*/*/*/*/lib*/* lib64/` `mv full_deploy/host/*/*/*/*/bin/* bin/`

But there's also mystery with full_deploy, it doesn't seem to deploy the runtime libs:
```
$ ls full_deploy/host/*/*/*/*/lib/
full_deploy/host/benchmark/1.8.4/Release/x86_64/lib/:
full_deploy/host/bzip2/1.0.8/Release/x86_64/lib/:
full_deploy/host/fmt/10.2.1/Release/x86_64/lib/:
full_deploy/host/gtest/1.14.0/Release/x86_64/lib/:
full_deploy/host/libcurl/8.8.0/Release/x86_64/lib/:
full_deploy/host/libpq/15.5/Release/x86_64/lib/:
full_deploy/host/ocilib/4.7.4/Release/x86_64/lib/:
full_deploy/host/openssl/3.2.2/Release/x86_64/lib/:
cmake
full_deploy/host/oracle-instant-client/19.5.0/Release/x86_64/lib/:
full_deploy/host/pcre2/10.42/Release/x86_64/lib/:
full_deploy/host/poco/1.13.3/Release/x86_64/lib/:
full_deploy/host/pugixml/1.14/Release/x86_64/lib/:
full_deploy/host/spdlog/1.14.1/Release/x86_64/lib/:
full_deploy/host/xerces-c/3.2.5/Release/x86_64/lib/:
full_deploy/host/zlib/1.3.1/Release/x86_64/lib/:
```

This might be the same bug.
If we look at poco's package tree for example, it contains lib files all in this form:
```
libPocoCrypto.so -> libPocoCrypto.so.103
libPocoCrypto.so.103
```
So, all regular files are `*.so.*` and all symlinks are `*.so`
Maybe the deployer takes `*.so` *BUT* ignores symlinks ?




Patch:
diff --git a/conan/internal/deploy.py b/conan/internal/deploy.py
index 1c28895469b..f7fc534bc29 100644
--- a/conan/internal/deploy.py
+++ b/conan/internal/deploy.py
@@ -102,6 +102,7 @@ def runtime_deploy(graph, output_folder):
     Deploy all the shared libraries and the executables of the dependencies in a flat directory.
 
     It preserves symlinks in case the configuration tools.deployer:symlinks is True.
+    It preserves the directory structure when having subfolders
     """
     conanfile = graph.root.conanfile
     output = ConanOutput(scope="runtime_deploy")
@@ -136,7 +137,7 @@ def runtime_deploy(graph, output_folder):
 
 def _flatten_directory(dep, src_dir, output_dir, symlinks, extension_filter=None):
     """
-    Copy all the files from the source directory in a flat output directory.
+    Copy all the files from the source directory in a flat output directory, respecting subfolders.
     An optional string, named extension_filter, can be set to copy only the files with
     the listed extensions.
     """
@@ -144,16 +145,20 @@ def _flatten_directory(dep, src_dir, output_dir, symlinks, extension_filter=None
 
     output = ConanOutput(scope="runtime_deploy")
     for src_dirpath, _, src_filenames in os.walk(src_dir, followlinks=symlinks):
+        rel_path = os.path.relpath(src_dirpath, src_dir)
         for src_filename in src_filenames:
             if extension_filter and not any(fnmatch.fnmatch(src_filename, f'*{ext}') for ext in extension_filter):
                 continue
 
             src_filepath = os.path.join(src_dirpath, src_filename)
-            dest_filepath = os.path.join(output_dir, src_filename)
+            dest_filepath = os.path.join(output_dir, rel_path, src_filename)
 
             if not symlinks and os.path.islink(src_filepath):
                 continue
 
+            if not os.path.exists(os.path.dirname(dest_filepath)):
+                os.makedirs(os.path.dirname(dest_filepath))
+
             if os.path.exists(dest_filepath):
                 if filecmp.cmp(src_filepath, dest_filepath):  # Be efficient, do not copy
                     output.verbose(f"{dest_filepath} exists with same contents, skipping copy")


Test patch:
diff --git a/test/functional/command/test_install_deploy.py b/test/functional/command/test_install_deploy.py
index faaeb98cf7b..87ce8c84171 100644
--- a/test/functional/command/test_install_deploy.py
+++ b/test/functional/command/test_install_deploy.py
@@ -508,8 +508,9 @@ def package_info(self):
         c.run("install --requires=pkga/1.0 --requires=pkgb/1.0 --deployer=runtime_deploy "
               "--deployer-folder=myruntime -vvv")
 
-        expected = sorted(["pkga.so", "pkgb.so", "pkga.dll"])
-        assert sorted(os.listdir(os.path.join(c.current_folder, "myruntime"))) == expected
+        assert sorted(os.listdir(os.path.join(c.current_folder, "myruntime"))) == sorted(["bin", "lib"])
+        assert sorted(os.listdir(os.path.join(c.current_folder, "myruntime", "lib"))) == sorted(['pkga.so', 'pkgb.so'])
+        assert sorted(os.listdir(os.path.join(c.current_folder, "myruntime", "bin"))) == sorted(['pkga.dll'])
 
 @pytest.mark.parametrize("symlink, expected",
                          [(True, ["libfoo.so.0.1.0", "libfoo.so.0", "libfoo.so"]),
@@ -553,6 +554,68 @@ def package(self):
         assert not os.path.islink(lib)
 
 
+def test_runtime_deploy_subfolder():
+    """ The deployer runtime_deploy should preserve subfolder structure when deploying shared libraries
+    """
+    c = TestClient()
+    conanfile = textwrap.dedent("""
+           from conan import ConanFile
+           from conan.tools.files import copy
+           import os
+           class Pkg(ConanFile):
+               package_type = "shared-library"
+               def package(self):
+                   copy(self, "*.so*", src=self.build_folder, dst=self.package_folder, keep_path=True)
+           """)
+    c.save({"foo/conanfile.py": conanfile,
+            "foo/lib/libfoo.so": "",
+            "foo/lib/subfolder/libbar.so": "",
+            "foo/lib/subfolder/subsubfolder/libqux.so": "",})
+    c.run("export-pkg foo/ --name=foo --version=0.1.0")
+    c.run(f"install --requires=foo/0.1.0 --deployer=runtime_deploy --deployer-folder=output")
+
+    assert sorted(os.listdir(os.path.join(c.current_folder, "output"))) == ["libfoo.so", "subfolder"]
+    assert sorted(os.listdir(os.path.join(c.current_folder, "output", "subfolder"))) == ["libbar.so", "subsubfolder"]
+    assert sorted(os.listdir(os.path.join(c.current_folder, "output", "subfolder", "subsubfolder"))) == ["libqux.so"]
+
+
+def test_runtime_deploy_subfolder_symlink():
+    """ The deployer runtime_deploy should preserve subfolder structure when deploying shared
+        libraries with symlinks
+    """
+    c = TestClient()
+    conanfile = textwrap.dedent("""
+           from conan import ConanFile
+           from conan.tools.files import copy, chdir, mkdir
+           import os
+           class Pkg(ConanFile):
+               package_type = "shared-library"
+               def package(self):
+                   copy(self, "*.so*", src=self.build_folder, dst=self.package_folder)
+                   with chdir(self, os.path.join(self.package_folder, "lib")):
+                        os.symlink(src="subfolder/libfoo.so.1.0", dst="libfoo.so.1")
+                        os.symlink(src="libfoo.so.1", dst="libfoo.so")
+           """)
+    c.save({"foo/conanfile.py": conanfile,
+            "foo/lib/subfolder/libfoo.so.1.0": "",})
+    c.run("export-pkg foo/ --name=foo --version=0.1.0")
+    c.run(f"install --requires=foo/0.1.0 --deployer=runtime_deploy --deployer-folder=output -c:a tools.deployer:symlinks=True")
+
+    assert os.listdir(os.path.join(c.current_folder, "output", "subfolder")) == ["libfoo.so.1.0"]
+    # INFO: This test requires in Windows to have symlinks enabled, otherwise it will fail
+    if platform.system() != "Windows":
+        assert sorted(os.listdir(os.path.join(c.current_folder, "output"))) == ["libfoo.so", "libfoo.so.1" , "subfolder"]
+        link_so_0 = os.path.join(c.current_folder, "output", "libfoo.so.1")
+        link_so = os.path.join(c.current_folder, "output", "libfoo.so")
+        lib = os.path.join(c.current_folder, "output", "subfolder", "libfoo.so.1.0")
+        assert os.path.islink(link_so_0)
+        assert os.path.islink(link_so)
+        assert not os.path.isabs(os.readlink(link_so_0))
+        assert not os.path.isabs(os.readlink(os.path.join(link_so)))
+        assert os.path.realpath(link_so) == os.path.realpath(link_so_0)
+        assert os.path.realpath(link_so_0) == os.path.realpath(lib)
+
+
 def test_deployer_errors():
     c = TestClient()
     c.save({"conanfile.txt": "",