Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pack components #4506

Merged
merged 5 commits into from
Jan 9, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 66 additions & 16 deletions snapcraft/pack.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2022 Canonical Ltd.
# Copyright 2022,2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
Expand Down Expand Up @@ -90,6 +90,64 @@ def _get_filename(
return None


def _pack(command: List[Union[str, Path]]) -> str:
mr-cal marked this conversation as resolved.
Show resolved Hide resolved
"""Pack a directory with `snap pack` as a snap or component.

:param command: `snap pack` command to execute

:returns: The filename of the packed snap or component.

:raises SnapcraftError: If the directory cannot be packed.
"""
emit.debug(f"Pack command: {command}")

try:
proc = subprocess.run(
command, capture_output=True, check=True, universal_newlines=True
)
except subprocess.CalledProcessError as err:
msg = f"{err!s}"
mr-cal marked this conversation as resolved.
Show resolved Hide resolved
details = None
if err.stderr:
details = err.stderr.strip()
raise errors.SnapcraftError(msg, details=details) from err

filename = Path(str(proc.stdout).partition(":")[2].strip()).name
mr-cal marked this conversation as resolved.
Show resolved Hide resolved
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 All @@ -101,6 +159,8 @@ def pack_snap(
) -> str:
"""Pack snap contents with `snap pack`.

Calls `snap pack <options> <snap-dir> <output-dir>`.

`output` may either be a directory, a file path, or just a file name.
- directory: write snap to directory with default snap name
- file path: write snap to specified directory with specified snap name
Expand All @@ -115,13 +175,16 @@ def pack_snap(
:param name: Name of snap project.
:param version: Version of snap project.
:param target_arch: Target architecture the snap project is built to.

:returns: The filename of the packed snap.

:raises SnapcraftError: If the directory cannot be packed.
"""
emit.debug(f"pack_snap: output={output!r}, compression={compression!r}")

# TODO remove workaround once LP: #1950465 is fixed
_verify_snap(directory)

# create command formatted as `snap pack <options> <snap-dir> <output-dir>`
command: List[Union[str, Path]] = ["snap", "pack"]
output_file = _get_filename(output, name, version, target_arch)
if output_file is not None:
Expand All @@ -132,17 +195,4 @@ def pack_snap(
command.append(_get_directory(output))

emit.progress("Creating snap package...")
emit.debug(f"Pack command: {command}")
try:
proc = subprocess.run(
command, capture_output=True, check=True, universal_newlines=True
)
except subprocess.CalledProcessError as err:
msg = f"{err!s}"
details = None
if err.stderr:
details = err.stderr.strip()
raise errors.SnapcraftError(msg, details=details) from err

snap_filename = Path(str(proc.stdout).partition(":")[2].strip()).name
return snap_filename
return _pack(command)
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",
mr-cal marked this conversation as resolved.
Show resolved Hide resolved
):
"""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
5 changes: 5 additions & 0 deletions tests/spread/core22/components/file-migration/task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ summary: Test file migratation 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"
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."
)
Loading