Repository: canonical/charmcraft

Issue:
Charmcraft 3.x not including symlinked directories in resulting charm
### Bug Description

Building classical charms with version 3 is not adding the submodules like charmhelpers in the hooks folder.

So running a charm build with version 3 is breaking saying that it's not possible to find the module charmhelpers

### To Reproduce

install charmcraft latest/edge

build a classic charm like [charm-nrpe](https://github.com/canonical/charm-nrpe) and deploy.

### Environment

Ubuntu 22.04 LTS

### charmcraft.yaml

```shell
type: charm

parts:
  charm:
    plugin: dump
    source: .
    prime:
      - actions/*
      - files/*
      - hooks/*
      - hooks/charmhelpers/*
      - templates/*
      - actions.yaml
      - config.yaml
      - copyright
      - LICENSE
      - metadata.yaml
      - README.md
bases:
    - build-on:
        - name: ubuntu
          channel: "20.04"
          architectures: ["amd64"]
      run-on:
        - name: ubuntu
          channel: "22.04"
          architectures:
              - amd64
        - name: ubuntu
          channel: "20.04"
          architectures:
              - amd64
        - name: ubuntu
          channel: "18.04"
          architectures:
              - amd64
```


### Relevant log output

```shell
unit-nrpe-0: 12:44:22 INFO juju.worker.meterstatus skipped "meter-status-changed" hook (missing)
unit-nrpe-0: 12:44:22 WARNING unit.nrpe/0.install Traceback (most recent call last):
unit-nrpe-0: 12:44:22 WARNING unit.nrpe/0.install   File "/var/lib/juju/agents/unit-nrpe-0/charm/hooks/install", line 4, in <module>
unit-nrpe-0: 12:44:22 WARNING unit.nrpe/0.install     import services
unit-nrpe-0: 12:44:22 WARNING unit.nrpe/0.install   File "/var/lib/juju/agents/unit-nrpe-0/charm/hooks/services.py", line 11, in <module>
unit-nrpe-0: 12:44:22 WARNING unit.nrpe/0.install     from charmhelpers.core import hookenv
unit-nrpe-0: 12:44:22 WARNING unit.nrpe/0.install ModuleNotFoundError: No module named 'charmhelpers'
unit-nrpe-0: 12:44:22 ERROR juju.worker.uniter.operation hook "install" (via explicit, bespoke hook script) failed: exit status 1
unit-nrpe-0: 12:44:22 INFO juju.worker.uniter awaiting error resolution for "install" hook
unit-nrpe-0: 12:45:49 INFO juju.worker.uniter awaiting error resolution for "install" hook
unit-nrpe-0: 12:48:43 INFO juju.worker.uniter awaiting error resolution for "install" hook
```


Discussion:

Patch:
diff --git a/charmcraft/utils/file.py b/charmcraft/utils/file.py
index cb4d5be3..a88c7393 100644
--- a/charmcraft/utils/file.py
+++ b/charmcraft/utils/file.py
@@ -63,8 +63,9 @@ def build_zip(zip_path: PathOrString, prime_dir: PathOrString) -> None:
     """
     zip_path = pathlib.Path(zip_path).resolve()
     prime_dir = pathlib.Path(prime_dir).resolve()
-    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as file:
-        for file_path in prime_dir.rglob("*"):
-            if not file_path.is_file():
-                continue
-            file.write(file_path, file_path.relative_to(prime_dir))
+    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
+        # Using os.walk() because Path.walk() is only added in 3.12
+        for dir_path_str, _, filenames in os.walk(prime_dir, followlinks=True):
+            for filename in filenames:
+                file_path = pathlib.Path(dir_path_str, filename)
+                zip_file.write(file_path, file_path.relative_to(prime_dir))


Test patch:
diff --git a/tests/unit/utils/test_file.py b/tests/unit/utils/test_file.py
index e13c2de0..04efda2c 100644
--- a/tests/unit/utils/test_file.py
+++ b/tests/unit/utils/test_file.py
@@ -105,41 +105,46 @@ def test_zipbuild_simple(tmp_path):
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
-def test_zipbuild_symlink_simple(tmp_path):
+def test_zipbuild_symlinks(tmp_path: pathlib.Path):
     """Symlinks are supported."""
     build_dir = tmp_path / "somedir"
     build_dir.mkdir()
 
-    testfile1 = build_dir / "real.txt"
-    testfile1.write_bytes(b"123\x00456")
-    testfile2 = build_dir / "link.txt"
-    testfile2.symlink_to(testfile1)
+    outside_dir = tmp_path / "another_dir"
+    outside_dir.mkdir()
+    outside_file = outside_dir / "some_file"
+    outside_file.write_bytes(b"123\x00456")
 
-    zip_filepath = tmp_path / "testresult.zip"
-    build_zip(zip_filepath, build_dir)
+    internal_dir = build_dir / "subdirectory"
+    internal_dir.mkdir()
+    real_file = internal_dir / "real.txt"
+    real_file.write_bytes(b"123\x00456")
 
-    zf = zipfile.ZipFile(zip_filepath)
-    assert sorted(x.filename for x in zf.infolist()) == ["link.txt", "real.txt"]
-    assert zf.read("real.txt") == b"123\x00456"
-    assert zf.read("link.txt") == b"123\x00456"
+    internal_file_link = build_dir / "link.txt"
+    internal_file_link.symlink_to(real_file)
 
+    internal_dir_link = build_dir / "link_dir"
+    internal_dir_link.symlink_to(internal_dir)
 
-@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
-def test_zipbuild_symlink_outside(tmp_path):
-    """No matter where the symlink points to."""
-    # outside the build dir
-    testfile1 = tmp_path / "real.txt"
-    testfile1.write_bytes(b"123\x00456")
+    external_file_link = build_dir / "external_link.txt"
+    external_file_link.symlink_to(outside_file)
 
-    # inside the build dir
-    build_dir = tmp_path / "somedir"
-    build_dir.mkdir()
-    testfile2 = build_dir / "link.txt"
-    testfile2.symlink_to(testfile1)
+    external_dir_link = build_dir / "external_link_dir"
+    external_dir_link.symlink_to(outside_dir)
 
     zip_filepath = tmp_path / "testresult.zip"
     build_zip(zip_filepath, build_dir)
 
     zf = zipfile.ZipFile(zip_filepath)
-    assert sorted(x.filename for x in zf.infolist()) == ["link.txt"]
-    assert zf.read("link.txt") == b"123\x00456"
+
+    expected_files = [
+        "external_link.txt",
+        "external_link_dir/some_file",
+        "link.txt",
+        "link_dir/real.txt",
+        "subdirectory/real.txt",
+    ]
+
+    assert sorted(x.filename for x in zf.infolist()) == expected_files
+    for file_name in expected_files:
+        assert zf.read(file_name) == b"123\x00456"