diff --git a/rockcraft/layers.py b/rockcraft/layers.py index 952e50874..d3a4f85c2 100644 --- a/rockcraft/layers.py +++ b/rockcraft/layers.py @@ -20,11 +20,13 @@ import tarfile from collections import defaultdict from pathlib import Path +from tempfile import TemporaryDirectory from craft_cli import emit from craft_parts.executor.collisions import paths_collide from craft_parts.overlays import overlays from craft_parts.permissions import Permissions +from craft_parts.utils import process from rockcraft import errors @@ -45,14 +47,56 @@ def archive_layer( candidates = _gather_layer_paths(new_layer_dir, base_layer_dir) layer_paths = _merge_layer_paths(candidates) - with tarfile.open(temp_tar_file, mode="w") as tar_file: - # Iterate on sorted keys, so that the directories are always listed before - # any files that they contain (otherwise tools like Docker might choke on - # the layer tarball). - for arcname in sorted(layer_paths): - filepath = layer_paths[arcname] - emit.debug(f"Adding to layer: {filepath} as '{arcname}'") - tar_file.add(filepath, arcname=arcname, recursive=False) + layer_contents: list[Path] = [] + transforms: list[str] = [] + with TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + # Just create an empty tar file if there's nothing to put in the layer + # GNU tar simply refuses to create an actual empty tar file though, so just use + # python's tarfile + if not layer_paths: + with tarfile.open(temp_tar_file, "w"): + pass + return + + # # Walk in reverse to avoid encountering not-yet created dirs + # for arcname in sorted(layer_paths, reverse=True): + # filepath = layer_paths[arcname] + # emit.debug(f"Adding to layer: {filepath} as {arcname!r}") + + # # Construct a new file at `arcname` with the same contents as `filepath`. + # # This emulates the `arcname` parameter of `tarfile.open()`, which is not + # # present in GNU tar. + # new_path = tmppath / arcname + # layer_contents.append(new_path.relative_to(tmppath)) + # if new_path.is_dir() and new_path.exists(): + # continue + # new_path.parent.mkdir(parents=True, exist_ok=True) + # filepath.rename(new_path) + + for arcname, oldname in layer_paths.items(): + oldname = str(oldname).removeprefix("/") + transforms.extend(["--transform", f"s|{str(oldname)}|{arcname}|"]) + + # GNU tar is being used instead of Python's `tarfile` as it does not support + # special file attributes like xattrs. + tar_command: list[str | Path] = [ + "tar", + "-cf", + temp_tar_file.resolve(), + # Don't descend automatically into directories + "--no-recursion", + # Preserve all those fancy attributes + "--acls", + "--xattrs", + "--selinux", + *transforms, + # Tarball sorted files, so that the directories are always listed before + # any files that they contain (otherwise tools like Docker might choke on + # the layer tarball). + *sorted(str(p) for p in layer_paths.values()), + ] + process.run(tar_command, cwd=tmppath, check=True) def prune_prime_files(prime_dir: Path, files: set[str], base_layer_dir: Path) -> None: diff --git a/tests/unit/test_oci.py b/tests/unit/test_oci.py index b73596d22..1aa0f62f1 100644 --- a/tests/unit/test_oci.py +++ b/tests/unit/test_oci.py @@ -17,7 +17,6 @@ import hashlib import json import os -import tarfile from pathlib import Path from typing import NamedTuple from unittest.mock import ANY, call, mock_open, patch @@ -284,30 +283,34 @@ def test_add_layer(self, mocker, mock_run, new_dir): Path("layer_dir").mkdir() Path("layer_dir/foo.txt").touch() pid = os.getpid() - - spy_add = mocker.spy(tarfile.TarFile, "add") - + mock_proc_run = mocker.patch("craft_parts.utils.process.run") image.add_layer("tag", Path("layer_dir")) - # The `Tarfile.add()` on the directory ends up calling the method multiple - # times (due to the recursion), but we're mainly interested that the first - # call was to add `layer_dir`. - assert spy_add.mock_calls[0] == call( - ANY, Path("layer_dir/foo.txt"), arcname="foo.txt", recursive=False - ) + expected_tar_file = new_dir / f"c/.temp_layer.{pid}.tar" + expected_tar_cmd = [ + "tar", + "-cf", + expected_tar_file, + "--no-recursion", + "--acls", + "--xattrs", + "--selinux", + Path("foo.txt"), + ] + mock_proc_run.assert_called_once_with(expected_tar_cmd, cwd=ANY, check=True) expected_cmd = [ "umoci", "raw", "add-layer", "--image", str(new_dir / "c/a:b"), - str(new_dir / f"c/.temp_layer.{pid}.tar"), + str(expected_tar_file), "--tag", "tag", ] - assert mock_run.mock_calls == [ - call([*expected_cmd, "--history.created_by", " ".join(expected_cmd)]) - ] + mock_run.assert_called_once_with( + [*expected_cmd, "--history.created_by", " ".join(expected_cmd)] + ) def test_add_new_user( self,