Skip to content

Commit

Permalink
feat: pack components (#4506)
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 authored Jan 9, 2024
1 parent d20a073 commit 71fa126
Show file tree
Hide file tree
Showing 6 changed files with 313 additions and 40 deletions.
109 changes: 86 additions & 23 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,78 @@ def _get_filename(
return None


def _pack(
directory: Path,
output_dir: Path,
output_file: Optional[str],
compression: Optional[str],
) -> str:
"""Pack a directory with `snap pack` as a snap or component.
:param directory: Directory to pack.
:param output_dir: Directory to output the artefact to.
:param output_file: Name of the artefact.
:param compression: Compression type to use, None for default.
:returns: The filename of the packed snap or component.
:raises SnapcraftError: If the directory cannot be packed.
"""
command: List[Union[str, Path]] = ["snap", "pack"]

if output_file:
command.extend(["--filename", output_file])
if compression:
command.extend(["--compression", compression])
command.extend([directory, output_dir])

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 = str(err)
details = None
if err.stderr:
details = err.stderr.strip()
raise errors.SnapcraftError(msg, details=details) from err

return Path(str(proc.stdout).partition(":")[2].strip()).name


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.
"""
try:
return _pack(
directory=directory,
output_dir=output_dir,
output_file=None,
compression=compression,
)
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 +173,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,34 +189,23 @@ 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_dir = _get_directory(output)
output_file = _get_filename(output, name, version, target_arch)
if output_file is not None:
command.extend(["--filename", output_file])
if compression is not None:
command.extend(["--compression", compression])
command.append(directory)
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(
directory=directory,
output_dir=output_dir,
output_file=output_file,
compression=compression,
)
38 changes: 38 additions & 0 deletions snapcraft/parts/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,44 @@ 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.output)


def _pack_components(
lifecycle: PartsLifecycle, project: Project, output: Optional[str]
) -> None:
"""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 output: Output filepath of snap.
"""
emit.progress("Creating component packages...")

if output:
if Path(output).is_dir():
output_dir = Path(output).resolve()
else:
output_dir = Path(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
33 changes: 20 additions & 13 deletions tests/spread/core22/components/file-migration/task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,46 @@ summary: Test file migratation with components

restore: |
snapcraft clean
rm -f ./*.snap
rm -rf ./*.snap ./*.comp
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 file1-new and file2-new are in the default partition
if [[ ! -e "prime/default/file1-new" ]] || \
[[ ! -e "prime/default/file2-new" ]]; then
# assert file1-new and file2-new are in the snap
unsquashfs -dest "snap-contents" "snap-with-components_1.0_amd64.snap"
if [[ ! -e "snap-contents/file1-new" ]] || \
[[ ! -e "snap-contents/file2-new" ]]; then
echo "Expected file to exist but is does not exist."
exit 1
fi
# assert other files do not exist in the default partition
if [[ -e "prime/default/file3" ]] || \
[[ -e "prime/default/file4" ]] || \
[[ -e "prime/default/file5" ]]; then
# assert other files do not exist in the snap
if [[ -e "snap-contents/file3" ]] || \
[[ -e "snap-contents/file4" ]] || \
[[ -e "snap-contents/file5" ]]; then
echo "Expected file not to exist but is does exist."
exit 1
fi
# assert file3 is in the component
if [[ ! -e "prime/component/bar-baz/file3" ]]; then
unsquashfs -dest "component-contents" "snap-with-components+bar-baz_1.0.comp"
if [[ ! -e "component-contents/file3" ]]; then
echo "Expected file to exist but is does not exist."
exit 1
fi
# assert other files do not exist in the component
if [[ -e "prime/component/bar-baz/file1-new" ]] || \
[[ -e "prime/component/bar-baz/file2-new" ]] || \
[[ -e "prime/component/bar-baz/file4" ]] || \
[[ -e "prime/component/bar-baz/file5" ]]; then
if [[ -e "component-contents/file1-new" ]] || \
[[ -e "component-contents/file2-new" ]] || \
[[ -e "component-contents/file4" ]] || \
[[ -e "component-contents/file5" ]]; then
echo "Expected file to exist but is does not exist."
exit 1
fi
21 changes: 17 additions & 4 deletions tests/spread/core22/components/simple/task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ summary: Build a snap with components

restore: |
snapcraft clean
rm -f ./*.snap
rm -f ./*.snap ./*.comp
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",
),
]
Loading

0 comments on commit 71fa126

Please sign in to comment.