Skip to content

Commit

Permalink
feat: pack components
Browse files Browse the repository at this point in the history
Signed-off-by: Callahan Kovacs <callahan.kovacs@canonical.com>
  • Loading branch information
mr-cal committed Jan 5, 2024
1 parent a0ef479 commit 2ac347c
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 3 deletions.
32 changes: 32 additions & 0 deletions snapcraft/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,38 @@ def _pack(command: List[Union[str, Path]]) -> str:
return filename


def pack_component(
directory: Path, output_dir: Path, compression: Optional[str] = None
) -> str:
"""Pack a directory containing component data.
Calls `snap pack <options> <component-dir> <output-dir>`.
Requires snapd to be installed from the `latest/edge` channel.
:param directory: Directory to pack.
:param compression: Compression type to use, None for default.
:param output_dir: Directory to output component to.
:returns: The filename of the packed component.
:raises SnapcraftError: If the component cannot be packed.
"""
command: List[Union[str, Path]] = ["snap", "pack"]
if compression:
command.extend(["--compression", compression])
command.extend([directory, output_dir])

try:
return _pack(command)
except errors.SnapcraftError as err:
err.resolution = (
"Packing components is experimental and requires `snapd` "
"to be installed from the `latest/edge` channel."
)
raise


def pack_snap(
directory: Path,
*,
Expand Down
41 changes: 41 additions & 0 deletions snapcraft/parts/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,47 @@ def _run_lifecycle_and_pack( # noqa PLR0913
)
emit.progress(f"Created snap package {snap_filename}", permanent=True)

if project.components:
_pack_components(lifecycle, project, parsed_args)


def _pack_components(
lifecycle: PartsLifecycle,
project: Project,
parsed_args: "argparse.Namespace",
):
"""Pack components.
`--output` can be used to set the output directory, the name of the snap, or both.
If `output` is a directory, output components in the output directory.
If `output` is a filename, output components in the parent directory.
If `output` is not provided, output components in the cwd.
:param lifecycle: The part lifecycle.
:param project: The snapcraft project.
:param parsed_args: Snapcraft's argument namespace.
"""
emit.progress("Creating component packages...")

if parsed_args.output:
output = Path(parsed_args.output)
if output.is_dir():
output_dir = output.resolve()
else:
output_dir = output.parent.resolve()
else:
output_dir = Path.cwd()

for component in project.get_component_names():
filename = pack.pack_component(
directory=lifecycle.get_prime_dir_for_component(component),
compression=project.compression,
output_dir=output_dir,
)
emit.verbose(f"Packed component {component!r} to {filename!r}.")
emit.progress("Created component packages", permanent=True)


def _generate_metadata(
*,
Expand Down
19 changes: 16 additions & 3 deletions tests/spread/core22/components/simple/task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ summary: Build a snap with components
restore: |
snapcraft clean
rm -f ./*.snap
snap revert snapd
prepare: |
# `snap pack` for components is only supported in the `latest/edge` channel
snap refresh snapd --channel=latest/edge
execute: |
cd "./snap"
snapcraft pack --destructive-mode
# assert contents of default partition
Expand All @@ -15,14 +20,22 @@ execute: |
exit 1
fi
# assert component was packed
component_file="snap-with-components+man-pages_1.0.comp"
if [[ ! -e "${component_file}" ]]; then
echo "Expected component to exist but is does not exist."
exit 1
fi
# assert contents of component/man-pages
if [[ ! -d "prime/component/man-pages/man1" ]]; then
unsquashfs "$component_file"
if [[ ! -d "squashfs-root/man1" ]]; then
echo "Expected directory to exist but is does not exist."
exit 1
fi
# assert contents of component metadata
if ! diff prime/component/man-pages/meta/component.yaml expected-man-pages-component.yaml; then
if ! diff squashfs-root/meta/component.yaml expected-man-pages-component.yaml; then
echo "Metadata for the man-pages component is incorrect."
exit 1
fi
98 changes: 98 additions & 0 deletions tests/unit/parts/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2066,3 +2066,101 @@ def test_lifecycle_write_component_metadata(
component_prime_dir=new_dir / "prime/component/bar-baz",
),
]


@pytest.mark.usefixtures("enable_partitions_feature", "project_vars")
@pytest.mark.parametrize("step", ["pack", "snap"])
def test_lifecycle_pack_components(
step, snapcraft_yaml, new_dir, mocker, stub_component_data
):
"""Pack components as part of the lifecycle."""
yaml_data = snapcraft_yaml(base="core22", components=stub_component_data)
project = Project.unmarshal(snapcraft_yaml(**yaml_data))
mocker.patch("snapcraft.parts.PartsLifecycle.run")
mocker.patch("snapcraft.meta.component_yaml.write")
mocker.patch("snapcraft.pack.pack_snap")
mock_pack = mocker.patch("snapcraft.pack.pack_component")

parsed_args = argparse.Namespace(
debug=False,
destructive_mode=True,
use_lxd=False,
enable_manifest=True,
ua_token=None,
parts=[],
manifest_image_information=None,
output=None,
)

parts_lifecycle._run_command(
step,
project=project,
parse_info={},
assets_dir=Path(),
start_time=datetime.now(),
parallel_build_count=8,
parsed_args=parsed_args,
)

assert mock_pack.mock_calls == [
call(
directory=new_dir / "prime/component/foo",
compression="xz",
output_dir=new_dir,
),
call(
directory=new_dir / "prime/component/bar-baz",
compression="xz",
output_dir=new_dir,
),
]


@pytest.mark.usefixtures("enable_partitions_feature", "project_vars")
@pytest.mark.parametrize("step", ["pack", "snap"])
@pytest.mark.parametrize("output", ["dir", "dir/file"])
def test_lifecycle_pack_components_with_output(
output, step, snapcraft_yaml, new_dir, mocker, stub_component_data
):
"""Pack components when `--output` is passed with a directory or filename."""
Path(new_dir / "dir").mkdir()
yaml_data = snapcraft_yaml(base="core22", components=stub_component_data)
project = Project.unmarshal(snapcraft_yaml(**yaml_data))
mocker.patch("snapcraft.parts.PartsLifecycle.run")
mocker.patch("snapcraft.meta.component_yaml.write")
mocker.patch("snapcraft.pack.pack_snap")
mock_pack = mocker.patch("snapcraft.pack.pack_component")

parsed_args = argparse.Namespace(
debug=False,
destructive_mode=True,
use_lxd=False,
enable_manifest=True,
ua_token=None,
parts=[],
manifest_image_information=None,
output=output,
)

parts_lifecycle._run_command(
step,
project=project,
parse_info={},
assets_dir=Path(),
start_time=datetime.now(),
parallel_build_count=8,
parsed_args=parsed_args,
)

assert mock_pack.mock_calls == [
call(
directory=new_dir / "prime/component/foo",
compression="xz",
output_dir=new_dir / "dir",
),
call(
directory=new_dir / "prime/component/bar-baz",
compression="xz",
output_dir=new_dir / "dir",
),
]
54 changes: 54 additions & 0 deletions tests/unit/test_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,57 @@ def test_pack_snap_error(mocker, new_dir, fake_process):
"""app description field 'command' contains illegal "pack-error foo=bar" """
"""(legal: '^[A-Za-z0-9/. _#:$-]*$')"""
)


def test_pack_component(fake_process, new_dir):
fake_process.register_subprocess(
["snap", "pack", str(new_dir / "in"), str(new_dir / "out")],
stdout="built: my-snap+component_1.0.comp",
)

name = pack.pack_component(
directory=new_dir / "in",
output_dir=new_dir / "out",
)

assert name == "my-snap+component_1.0.comp"


def test_pack_component_with_compression(fake_process, new_dir):
fake_process.register_subprocess(
[
"snap",
"pack",
"--compression",
"test",
str(new_dir / "in"),
str(new_dir / "out"),
],
stdout="built: my-snap+component_1.0.comp",
)

name = pack.pack_component(
directory=new_dir / "in",
output_dir=new_dir / "out",
compression="test",
)

assert name == "my-snap+component_1.0.comp"


def test_pack_component_error(fake_process, new_dir):
fake_process.register_subprocess(
["snap", "pack", str(new_dir / "in"), str(new_dir / "out")],
returncode=1,
)

with pytest.raises(errors.SnapcraftError) as raised:
pack.pack_component(
directory=new_dir / "in",
output_dir=new_dir / "out",
)

assert raised.value.resolution == (
"Packing components is experimental and requires `snapd` "
"to be installed from the `latest/edge` channel."
)

0 comments on commit 2ac347c

Please sign in to comment.