Repository: canonical/snapcraft

Issue:
Package snap components
### What needs to get done

Each component’s prime directory and its metadata file should be packaged with `snap pack`.

The packaged components should be placed alongside the snap package.


The final message of snapcraft should include the packaged components.  For example:
```
Snapped hello_world_1.0_amd64.snap and components `hello-world+foo_1.0.comp`, `hello-world+bar_1.0.comp`, and `hello-world+baz_1.0.comp`.
```

### Why it needs to get done

To support components in snapcraft


Discussion:

Resolving patch:
diff --git a/snapcraft/pack.py b/snapcraft/pack.py
--- a/snapcraft/pack.py
+++ b/snapcraft/pack.py
@@ -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
@@ -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,
     *,
@@ -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
@@ -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,
+    )
diff --git a/snapcraft/parts/lifecycle.py b/snapcraft/parts/lifecycle.py
--- a/snapcraft/parts/lifecycle.py
+++ b/snapcraft/parts/lifecycle.py
@@ -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(
     *,


Test patch:
diff --git a/tests/spread/core22/components/file-migration/task.yaml b/tests/spread/core22/components/file-migration/task.yaml
--- a/tests/spread/core22/components/file-migration/task.yaml
+++ b/tests/spread/core22/components/file-migration/task.yaml
@@ -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
diff --git a/tests/spread/core22/components/simple/task.yaml b/tests/spread/core22/components/simple/task.yaml
--- a/tests/spread/core22/components/simple/task.yaml
+++ b/tests/spread/core22/components/simple/task.yaml
@@ -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
@@ -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
diff --git a/tests/unit/parts/test_lifecycle.py b/tests/unit/parts/test_lifecycle.py
--- a/tests/unit/parts/test_lifecycle.py
+++ b/tests/unit/parts/test_lifecycle.py
@@ -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",
+        ),
+    ]
diff --git a/tests/unit/test_pack.py b/tests/unit/test_pack.py
--- a/tests/unit/test_pack.py
+++ b/tests/unit/test_pack.py
@@ -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."
+    )