Repository: canonical/snapcraft

Issue:
Support component hooks
### What needs to get done

### `snapcraft.yaml` syntax

Support the following syntax:
```yaml
components:
  <component-name>:
    hooks:
      <hook-name>:
        plugs:
          - <plug-name>
        <all-currently-valid-hooks>
```

Supported hooks are:
- install
- pre-refresh
- post-refresh
- remove

### `meta/snap.yaml` syntax

The `meta/snap.yaml` needs to list all component metadata, including the component hooks.  Currently, this is not happening.

### Why it needs to get done

To support component hooks


Discussion:
Would it make more sense to have hooks be placed into `$CRAFT_PROJECT_DIR/components/<component-name>/hooks`, as components are technically distinct objects from a snap?



Thank you for reporting us your feedback!

The internal ticket has been created: https://warthogs.atlassian.net/browse/CRAFT-2501.

> This message was autogenerated

Is there/will there be a branch that enables hooks for components? Using `latest/edge/components` rev 10677.

```
dilyn@Ares:~/work/canonical/iot-field/kernel-snaps -> snapcraft clean; snapcraft --verbose
Bad snapcraft.yaml content:
- unexpected value; permitted: 'test' (in field 'components.rtl-wifi.type')
- extra field 'hooks' not permitted in 'components.rtl-wifi' configuration                                                                                                                                             
Full execution log: '/home/dilyn/.local/state/snapcraft/log/snapcraft-20240227-114153.316480.log'                                                              
Starting snapcraft                                                                                                                                             
Logging execution to '/home/dilyn/.local/state/snapcraft/log/snapcraft-20240227-114154.443363.log'                                                             
Starting snapcraft                                                                                                                                             
Logging execution to '/home/dilyn/.local/state/snapcraft/log/snapcraft-20240227-114154.443363.log'                                                             
Skipping build-on: riscv64 build-for: ['riscv64'] because build-on doesn't match host arch: amd64                                                              
Bad snapcraft.yaml content:
- unexpected value; permitted: 'test' (in field 'components.rtl-wifi.type')
- extra field 'hooks' not permitted in 'components.rtl-wifi' configuration                                                                                                                                             
Full execution log: '/home/dilyn/.local/state/snapcraft/log/snapcraft-20240227-114154.443363.log'  
```
Yes, it's going to get spec'd this pulse via [ST110](https://docs.google.com/document/d/17tFoBnELq9obkfajaCZdv0yVnDsLuAjYwi2jIkXnwPw/edit) then implemented in a subsequent pulse

Resolving patch:
diff --git a/snapcraft/meta/component_yaml.py b/snapcraft/meta/component_yaml.py
--- a/snapcraft/meta/component_yaml.py
+++ b/snapcraft/meta/component_yaml.py
@@ -25,7 +25,11 @@
 
 
 class ComponentMetadata(BaseMetadata):
-    """The component.yaml model."""
+    """The component.yaml model.
+
+    Component hooks are not included in the component's metadata.
+    Instead, they are included in the snap's metadata.
+    """
 
     component: str
     type: str
diff --git a/snapcraft/meta/snap_yaml.py b/snapcraft/meta/snap_yaml.py
--- a/snapcraft/meta/snap_yaml.py
+++ b/snapcraft/meta/snap_yaml.py
@@ -235,6 +235,7 @@ class ComponentMetadata(SnapcraftMetadata):  # type: ignore # (pydantic plugin i
     summary: SummaryStr
     description: str
     type: str
+    hooks: dict[str, models.Hook] | None
 
     @override
     class Config(BaseMetadata.Config):
diff --git a/snapcraft/models/project.py b/snapcraft/models/project.py
--- a/snapcraft/models/project.py
+++ b/snapcraft/models/project.py
@@ -541,6 +541,7 @@ class Component(models.CraftBaseModel):
     description: str
     type: Literal["test"]
     version: Optional[VersionStr]  # type: ignore[assignment]
+    hooks: dict[str, Hook] | None
 
     @pydantic.validator("version")
     @classmethod


Test patch:
diff --git a/tests/spread/core22/components/expected-snap.yaml b/tests/spread/core22/components/expected-snap.yaml
new file mode 100644
--- /dev/null
+++ b/tests/spread/core22/components/expected-snap.yaml
@@ -0,0 +1,25 @@
+name: hello-components
+version: '1.0'
+summary: Build a snap with components
+description: Build a snap with components
+architectures:
+- amd64
+base: core22
+apps:
+  hello:
+    command: bin/hello
+confinement: strict
+grade: devel
+environment:
+  LD_LIBRARY_PATH: ${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
+  PATH: $SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH
+components:
+  share:
+    summary: Hello World
+    description: Hello World
+    type: test
+    hooks:
+      install:
+        plugs:
+        - home
+        - network
diff --git a/tests/spread/core22/components/snapcraft.yaml b/tests/spread/core22/components/snapcraft.yaml
--- a/tests/spread/core22/components/snapcraft.yaml
+++ b/tests/spread/core22/components/snapcraft.yaml
@@ -16,6 +16,9 @@ components:
     summary: Hello World
     description: Hello World
     version: "1.0"
+    hooks:
+      install:
+        plugs: ["home", "network"]
 
 parts:
   hello:
diff --git a/tests/spread/core22/components/task.yaml b/tests/spread/core22/components/task.yaml
--- a/tests/spread/core22/components/task.yaml
+++ b/tests/spread/core22/components/task.yaml
@@ -11,13 +11,19 @@ restore: |
 execute: |
   snapcraft pack
 
-  # assert contents of default partition
+  # assert snap contents
   unsquashfs -dest "snap-contents" "hello-components_1.0_amd64.snap"
   if [ ! -e "snap-contents/bin/hello" ]; then
     echo "Expected 'bin/hello' in snap contents"
     exit 1
   fi
 
+  # assert snap metadata
+  if ! diff -u snap-contents/meta/snap.yaml expected-snap.yaml; then
+    echo "Metadata for the snap is incorrect."
+    exit 1
+  fi
+
   # assert component was packed
   component_file="hello-components+share_1.0.comp"
   if [ ! -e "${component_file}" ]; then
@@ -38,7 +44,7 @@ execute: |
   fi
   
   # assert contents of component metadata
-  if ! diff component-contents/meta/component.yaml expected-component.yaml; then
+  if ! diff -u component-contents/meta/component.yaml expected-component.yaml; then
     echo "Metadata for the share component is incorrect."
     exit 1
   fi
diff --git a/tests/spread/core24/components/expected-snap.yaml b/tests/spread/core24/components/expected-snap.yaml
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/components/expected-snap.yaml
@@ -0,0 +1,25 @@
+name: hello-components
+version: '1.0'
+summary: Build a snap with components
+description: Build a snap with components
+architectures:
+- amd64
+base: core24
+apps:
+  hello:
+    command: bin/hello
+confinement: strict
+grade: devel
+environment:
+  LD_LIBRARY_PATH: ${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
+  PATH: $SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH
+components:
+  share:
+    summary: Hello World
+    description: Hello World
+    type: test
+    hooks:
+      install:
+        plugs:
+        - home
+        - network
diff --git a/tests/spread/core24/components/snapcraft.yaml b/tests/spread/core24/components/snapcraft.yaml
--- a/tests/spread/core24/components/snapcraft.yaml
+++ b/tests/spread/core24/components/snapcraft.yaml
@@ -17,6 +17,9 @@ components:
     summary: Hello World
     description: Hello World
     version: "1.0"
+    hooks:
+      install:
+        plugs: ["home", "network"]
 
 parts:
   hello:
diff --git a/tests/spread/core24/components/task.yaml b/tests/spread/core24/components/task.yaml
--- a/tests/spread/core24/components/task.yaml
+++ b/tests/spread/core24/components/task.yaml
@@ -11,13 +11,19 @@ restore: |
 execute: |
   snapcraft pack
 
-  # assert contents of default partition
+  # assert snap contents
   unsquashfs -dest "snap-contents" "hello-components_1.0_amd64.snap"
   if [ ! -e "snap-contents/bin/hello" ]; then
     echo "Expected 'bin/hello' in snap contents"
     exit 1
   fi
 
+  # assert snap metadata
+  if ! diff -u snap-contents/meta/snap.yaml expected-snap.yaml; then
+    echo "Metadata for the snap is incorrect."
+    exit 1
+  fi
+
   # assert component was packed
   component_file="hello-components+share_1.0.comp"
   if [ ! -e "${component_file}" ]; then
@@ -38,7 +44,7 @@ execute: |
   fi
   
   # assert contents of component metadata
-  if ! diff component-contents/meta/component.yaml expected-component.yaml; then
+  if ! diff -u component-contents/meta/component.yaml expected-component.yaml; then
     echo "Metadata for the share component is incorrect."
     exit 1
   fi
diff --git a/tests/spread/general/store/task.yaml b/tests/spread/general/store/task.yaml
--- a/tests/spread/general/store/task.yaml
+++ b/tests/spread/general/store/task.yaml
@@ -3,7 +3,9 @@ summary: Test the store workflow
 manual: true
 
 environment:
-  SNAP: "/snapcraft/tests/spread/core22/components/"
+  # use a core22 snap with components but no component hooks
+  # this can be changed after the snap store and review-tools have more support for components
+  SNAP: "/snapcraft/tests/spread/core22/components-environment/"
   SNAPCRAFT_STORE_CREDENTIALS/ubuntu_one: "$(HOST: echo ${SNAPCRAFT_STORE_CREDENTIALS_STAGING})"
   SNAPCRAFT_STORE_CREDENTIALS/legacy: "$(HOST: echo ${SNAPCRAFT_STORE_CREDENTIALS_STAGING_LEGACY})"
   SNAPCRAFT_STORE_CREDENTIALS/candid: "$(HOST: echo ${SNAPCRAFT_STORE_CREDENTIALS_STAGING_CANDID})"
@@ -52,7 +54,8 @@ execute: |
   # Get information about our snap.
   cd "$SNAP"
   snap_file=$(ls ./*.snap)
-  component_file=$(ls ./*.comp)
+  foo_component_file=$(ls ./test-snapcraft*+foo_1.0.comp)
+  bar_baz_component_file=$(ls ./test-snapcraft*+bar-baz_1.0.comp)
   snap_name=$(grep "name: " snapcraft.yaml | sed -e "s/name: \(.*$\)/\1/")
 
   # Login mechanism
@@ -68,7 +71,7 @@ execute: |
   snapcraft list
 
   # Push and Release
-  snapcraft upload "${snap_file}" --release edge --component "share=${component_file}"
+  snapcraft upload "${snap_file}" --release edge --component "foo=${foo_component_file}" --component "bar-baz=${bar_baz_component_file}"
 
   # Show revisions
   snapcraft list-revisions "${snap_name}"
diff --git a/tests/unit/meta/test_component_yaml.py b/tests/unit/meta/test_component_yaml.py
--- a/tests/unit/meta/test_component_yaml.py
+++ b/tests/unit/meta/test_component_yaml.py
@@ -50,6 +50,10 @@ def stub_project_data():
                 "summary": "test summary",
                 "description": "test description",
                 "version": "1.0",
+                "hooks": {
+                    "install": {"plugs": ["home", "network"]},
+                    "post-refresh": {},
+                },
             },
         },
     }
diff --git a/tests/unit/meta/test_snap_yaml.py b/tests/unit/meta/test_snap_yaml.py
--- a/tests/unit/meta/test_snap_yaml.py
+++ b/tests/unit/meta/test_snap_yaml.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright 2022-2023 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
@@ -16,11 +16,12 @@
 
 import textwrap
 from pathlib import Path
+from typing import cast
 
 import pydantic
 import pytest
 import yaml
-from craft_application.models import SummaryStr, VersionStr
+from craft_application.models import SummaryStr, UniqueStrList, VersionStr
 
 from snapcraft import models
 from snapcraft.meta import snap_yaml
@@ -418,6 +419,21 @@ def complex_project():
             description: test
             type: test
             version: "1.0"
+            hooks:
+              install:
+                command-chain:
+                - test
+                environment:
+                  test-variable-1: test
+                  test-variable-2: test
+                plugs:
+                - home
+                - network
+                passthrough:
+                  somefield:
+                  - some
+                  - value
+              post-refresh: {}
           component-b:
             summary: test
             description: test
@@ -558,6 +574,21 @@ def test_complex_snap_yaml(complex_project, new_dir):
             summary: test
             description: test
             type: test
+            hooks:
+              install:
+                command-chain:
+                - test
+                environment:
+                  test-variable-1: test
+                  test-variable-2: test
+                plugs:
+                - home
+                - network
+                passthrough:
+                  somefield:
+                  - some
+                  - value
+              post-refresh: {}
           component-b:
             summary: test
             description: test
@@ -1339,6 +1370,14 @@ def test_component_metadata_from_component():
         description="test",
         type="test",
         version=VersionStr("1.0"),
+        hooks={
+            "install": models.Hook(
+                plugs=cast(UniqueStrList, ["home", "network"]),
+                command_chain=["test"],
+                environment={"test-variable-1": "test", "test-variable-2": "test"},
+                passthrough={"somefield": ["some", "value"]},
+            )
+        },
     )
 
     metadata = snap_yaml.ComponentMetadata.from_component(component)
@@ -1346,3 +1385,4 @@ def test_component_metadata_from_component():
     assert metadata.summary == component.summary
     assert metadata.description == component.description
     assert metadata.type == component.type
+    assert metadata.hooks == component.hooks
diff --git a/tests/unit/models/test_projects.py b/tests/unit/models/test_projects.py
--- a/tests/unit/models/test_projects.py
+++ b/tests/unit/models/test_projects.py
@@ -2050,12 +2050,14 @@ class TestComponents:
 
     @pytest.fixture
     def stub_component_data(self):
-        return {
+        data: dict[str, Any] = {
             "type": "test",
             "summary": "test summary",
             "description": "test description",
             "version": "1.0",
+            "hooks": None,
         }
+        return data
 
     def test_components_valid(self, project, project_yaml_data, stub_component_data):
         components = {"foo": stub_component_data, "bar": stub_component_data}
diff --git a/tests/unit/services/test_package_components.py b/tests/unit/services/test_package_components.py
--- a/tests/unit/services/test_package_components.py
+++ b/tests/unit/services/test_package_components.py
@@ -37,6 +37,18 @@ def extra_project_params(extra_project_params):
             "summary": SummaryStr("first component"),
             "description": "lorem ipsum",
             "version": VersionStr("1.0"),
+            "hooks": {
+                "install": {
+                    "command-chain": ["test"],
+                    "environment": {
+                        "test-variable-1": "test",
+                        "test-variable-2": "test",
+                    },
+                    "plugs": ["home", "network"],
+                    "passthrough": {"somefield": ["some", "value"]},
+                },
+                "post-refresh": {},
+            },
         },
         "secondcomponent": {
             "type": "test",
@@ -133,6 +145,21 @@ def test_write_metadata(
             summary: first component
             description: lorem ipsum
             type: test
+            hooks:
+              install:
+                command-chain:
+                - test
+                environment:
+                  test-variable-1: test
+                  test-variable-2: test
+                plugs:
+                - home
+                - network
+                passthrough:
+                  somefield:
+                  - some
+                  - value
+              post-refresh: {}
           secondcomponent:
             summary: second component
             description: lorem ipsum