Repository: canonical/snapcraft

Issue:
Support project hooks for core24 snaps
### What needs to get done

The `PackageService` for core24 snaps should support project hooks.  The behavior should be the same as core22.

The failures can be found by running `spread google:ubuntu-24.04-64:tests/spread/general/hooks/`.  See failing logs [here](https://paste.ubuntu.com/p/CjBwVKcwyR/).

### Why it needs to get done

To support building core24 snaps with craft-application


Discussion:

Thank you for reporting us your feedback!

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

> This message was autogenerated


Resolving patch:
diff --git a/snapcraft/services/package.py b/snapcraft/services/package.py
--- a/snapcraft/services/package.py
+++ b/snapcraft/services/package.py
@@ -29,6 +29,7 @@
 from snapcraft import errors, linters, models, pack, utils
 from snapcraft.linters import LinterStatus
 from snapcraft.meta import snap_yaml
+from snapcraft.parts.setup_assets import setup_assets
 from snapcraft.services import Lifecycle
 from snapcraft.utils import process_version
 
@@ -84,6 +85,23 @@ def pack(self, prime_dir: pathlib.Path, dest: pathlib.Path) -> list[pathlib.Path
             )
         ]
 
+    def _get_assets_dir(self) -> pathlib.Path:
+        """Return a snapcraft assets directory.
+
+        Asset directories can exist in:
+
+        - <PROJECT_ROOT>/snap
+        - <PROJECT_ROOT>/build-aux/snap
+        """
+        project_dir = self._services.lifecycle.project_info.project_dir
+        for asset_reldir in ("snap", "build-aux/snap"):
+            asset_dir = project_dir / asset_reldir
+            if asset_dir.exists():
+                return asset_dir
+
+        # This is for backwards compatibility with setup_assets(...)
+        return project_dir / "snap"
+
     @override
     def write_metadata(self, path: pathlib.Path) -> None:
         """Write the project metadata to metadata.yaml in the given directory.
@@ -105,9 +123,79 @@ def write_metadata(self, path: pathlib.Path) -> None:
 
             shutil.copy(self._snapcraft_yaml_path, snap_dir)
 
+        assets_dir = self._get_assets_dir()
+        setup_assets(
+            self._project,
+            assets_dir=assets_dir,
+            project_dir=self._services.lifecycle.project_info.project_dir,
+            prime_dir=path,
+            meta_directory_handler=meta_directory_handler,
+        )
+
     @property
     def metadata(self) -> snap_yaml.SnapMetadata:
         """Get the metadata model for this project."""
         return snap_yaml.get_metadata_from_project(
             self._project, self._services.lifecycle.prime_dir, arch=self._build_for
         )
+
+
+def _hardlink_or_copy(source: pathlib.Path, destination: pathlib.Path) -> bool:
+    """Try to hardlink and fallback to copy if it fails.
+
+    :param source: the source path.
+    :param destination: the destination path.
+    :returns: True if a hardlink was done or False for copy.
+    """
+    # Unlink the destination to avoid link failures
+    destination.unlink(missing_ok=True)
+
+    try:
+        destination.hardlink_to(source)
+    except OSError as os_error:
+        # Cross device link
+        if os_error.errno != 18:
+            raise
+        shutil.copy(source, destination)
+        return False
+
+    return True
+
+
+def meta_directory_handler(assets_dir: pathlib.Path, path: pathlib.Path):
+    """Handle hooks and gui assets from Snapcraft.
+
+    :param assets_dir: directory with project assets.
+    :param path: directory to write assets to.
+    """
+    meta_dir = path / "meta"
+    built_snap_hooks = path / "snap" / "hooks"
+    hooks_project_dir = assets_dir / "hooks"
+
+    hooks_meta_dir = meta_dir / "hooks"
+
+    if built_snap_hooks.is_dir():
+        hooks_meta_dir.mkdir(parents=True, exist_ok=True)
+        for hook in built_snap_hooks.iterdir():
+            meta_dir_hook = hooks_meta_dir / hook.name
+            # Remove to always refresh to the latest
+            meta_dir_hook.unlink(missing_ok=True)
+            meta_dir_hook.hardlink_to(hook)
+
+    # Overwrite any built hooks with project level ones
+    if hooks_project_dir.is_dir():
+        hooks_meta_dir.mkdir(parents=True, exist_ok=True)
+        for hook in hooks_project_dir.iterdir():
+            meta_dir_hook = hooks_meta_dir / hook.name
+
+            _hardlink_or_copy(hook, meta_dir_hook)
+
+    # Write any gui assets
+    gui_project_dir = assets_dir / "gui"
+    gui_meta_dir = meta_dir / "gui"
+    if gui_project_dir.is_dir():
+        gui_meta_dir.mkdir(parents=True, exist_ok=True)
+        for gui in gui_project_dir.iterdir():
+            meta_dir_gui = gui_meta_dir / gui.name
+
+            _hardlink_or_copy(gui, meta_dir_gui)


Test patch:
diff --git a/tests/spread/core24/hooks/aux/build-aux/snap/hooks/install b/tests/spread/core24/hooks/aux/build-aux/snap/hooks/install
new file mode 100755
--- /dev/null
+++ b/tests/spread/core24/hooks/aux/build-aux/snap/hooks/install
@@ -0,0 +1,3 @@
+#! /bin/bash
+
+echo "I am a project install hook!"
diff --git a/tests/spread/core24/hooks/aux/build-aux/snap/snapcraft.yaml b/tests/spread/core24/hooks/aux/build-aux/snap/snapcraft.yaml
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/hooks/aux/build-aux/snap/snapcraft.yaml
@@ -0,0 +1,18 @@
+name: hooks
+base: core24
+build-base: devel
+version: '1.0'
+summary: Test conflicting generated and project hooks.
+description: Project hooks in $PROJECT/snap/hooks/ should take priority over conflicting code generated hooks.
+grade: devel
+confinement: strict
+
+parts:
+  hooks:
+    source: src/
+    plugin: go
+    build-snaps: [go/1.20/stable]
+    organize:
+      bin/install: snap/hooks/install
+      bin/configure: snap/hooks/configure
+      
diff --git a/tests/spread/core24/hooks/aux/src/configure/configure.go b/tests/spread/core24/hooks/aux/src/configure/configure.go
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/hooks/aux/src/configure/configure.go
@@ -0,0 +1,28 @@
+// -*- Mode: Go; indent-tabs-mode: t -*-
+
+/*
+ * Copyright (C) 2022 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
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package main
+
+import (
+	"fmt"
+)
+
+func main() {
+	fmt.Println("I am a code generated configure hook!")
+}
diff --git a/tests/spread/core24/hooks/aux/src/go.mod b/tests/spread/core24/hooks/aux/src/go.mod
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/hooks/aux/src/go.mod
@@ -0,0 +1,3 @@
+module github.com/snapcore/snapcraft/tests/spread/general/hooks/generated-hooks
+
+go 1.20
diff --git a/tests/spread/core24/hooks/aux/src/install/install.go b/tests/spread/core24/hooks/aux/src/install/install.go
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/hooks/aux/src/install/install.go
@@ -0,0 +1,28 @@
+// -*- Mode: Go; indent-tabs-mode: t -*-
+
+/*
+ * Copyright (C) 2022 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
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package main
+
+import (
+	"fmt"
+)
+
+func main() {
+	fmt.Println("I am a code generated install hook!")
+}
diff --git a/tests/spread/core24/hooks/default/snap/hooks/install b/tests/spread/core24/hooks/default/snap/hooks/install
new file mode 100755
--- /dev/null
+++ b/tests/spread/core24/hooks/default/snap/hooks/install
@@ -0,0 +1,3 @@
+#! /bin/bash
+
+echo "I am a project install hook!"
diff --git a/tests/spread/core24/hooks/default/snap/snapcraft.yaml b/tests/spread/core24/hooks/default/snap/snapcraft.yaml
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/hooks/default/snap/snapcraft.yaml
@@ -0,0 +1,18 @@
+name: hooks
+base: core24
+build-base: devel
+version: '1.0'
+summary: Test conflicting generated and project hooks.
+description: Project hooks in $PROJECT/snap/hooks/ should take priority over conflicting code generated hooks.
+grade: devel
+confinement: strict
+
+parts:
+  hooks:
+    source: src/
+    plugin: go
+    build-snaps: [go/1.20/stable]
+    organize:
+      bin/install: snap/hooks/install
+      bin/configure: snap/hooks/configure
+      
diff --git a/tests/spread/core24/hooks/default/src/configure/configure.go b/tests/spread/core24/hooks/default/src/configure/configure.go
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/hooks/default/src/configure/configure.go
@@ -0,0 +1,28 @@
+// -*- Mode: Go; indent-tabs-mode: t -*-
+
+/*
+ * Copyright (C) 2022 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
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package main
+
+import (
+	"fmt"
+)
+
+func main() {
+	fmt.Println("I am a code generated configure hook!")
+}
diff --git a/tests/spread/core24/hooks/default/src/go.mod b/tests/spread/core24/hooks/default/src/go.mod
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/hooks/default/src/go.mod
@@ -0,0 +1,3 @@
+module github.com/snapcore/snapcraft/tests/spread/general/hooks/generated-hooks
+
+go 1.20
diff --git a/tests/spread/core24/hooks/default/src/install/install.go b/tests/spread/core24/hooks/default/src/install/install.go
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/hooks/default/src/install/install.go
@@ -0,0 +1,28 @@
+// -*- Mode: Go; indent-tabs-mode: t -*-
+
+/*
+ * Copyright (C) 2022 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
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package main
+
+import (
+	"fmt"
+)
+
+func main() {
+	fmt.Println("I am a code generated install hook!")
+}
diff --git a/tests/spread/core24/hooks/task.yaml b/tests/spread/core24/hooks/task.yaml
new file mode 100644
--- /dev/null
+++ b/tests/spread/core24/hooks/task.yaml
@@ -0,0 +1,37 @@
+summary: setup hooks
+
+environment:
+  SNAP/default: default
+  SNAP/build_aux: aux
+  
+restore: |
+  cd "${SNAP}"
+  
+  snapcraft clean --destructive-mode
+  rm -rf hooks_*.snap
+
+execute: |
+  cd "${SNAP}"
+  
+  snapcraft pack --destructive-mode
+
+  # verify hooks were staged
+   if [[ ! -f prime/meta/hooks/configure ]] && [[ ! -f prime/meta/hooks/install ]]; then
+      echo "no hook found in prime/meta/hooks/"
+      exit 1
+  fi
+
+  # Verify both built hooks are in prime/snap/hooks
+  prime/snap/hooks/configure | MATCH "I am a code generated configure hook!"
+  prime/snap/hooks/install | MATCH "I am a code generated install hook!"
+
+  # verify that the built configure hook was provisioned
+  prime/meta/hooks/configure | MATCH "I am a code generated configure hook!"
+
+  # verify that the install hook from the project is provisioned
+  # over the one that was built
+  prime/meta/hooks/install | MATCH "I am a project install hook"
+
+  # verify snap is installable
+  snap install hooks_1.0_*.snap --dangerous
+
diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py
--- a/tests/unit/services/test_package.py
+++ b/tests/unit/services/test_package.py
@@ -123,7 +123,8 @@ def test_write_metadata(
 
     package_service.write_metadata(new_dir)
 
-    assert (new_dir / "meta" / "snap.yaml").read_text() == dedent(
+    meta_dir = new_dir / "meta"
+    assert (meta_dir / "snap.yaml").read_text() == dedent(
         """\
         name: default
         version: '1.0'
@@ -176,3 +177,154 @@ def test_write_metadata_with_manifest(
     assert manifest.name == snap_yaml["name"]
     assert manifest.grade == snap_yaml["grade"]
     assert manifest.architectures == snap_yaml["architectures"]
+
+
+@pytest.fixture(params=["snap", "build-aux/snap"])
+def project_hooks_dir(new_dir, request):
+    hooks_dir = new_dir / request.param / "hooks"
+    hooks_dir.mkdir(parents=True)
+    yield hooks_dir
+
+
+def test_write_metadata_with_project_hooks(
+    package_service, default_factory, default_build_plan, new_dir, project_hooks_dir
+):
+    default_factory.set_kwargs(
+        "lifecycle",
+        work_dir=Path("work"),
+        cache_dir=new_dir,
+        build_plan=default_build_plan,
+    )
+    # Create some hooks
+    (project_hooks_dir / "configure").write_text("configure_hook")
+    (project_hooks_dir / "install").write_text("install_hook")
+
+    prime_dir = new_dir / "prime"
+    meta_dir = prime_dir / "meta"
+
+    package_service.write_metadata(prime_dir)
+
+    assert (meta_dir / "snap.yaml").read_text() == dedent(
+        """\
+        name: default
+        version: '1.0'
+        summary: default project
+        description: default project
+        license: MIT
+        architectures:
+        - amd64
+        base: core24
+        confinement: devmode
+        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
+    """
+    )
+
+    assert (meta_dir / "hooks").exists()
+    # Ensure the hook is the one we provided in the project
+    # and not a wrapped hook.
+    assert (meta_dir / "hooks" / "configure").exists()
+    assert (meta_dir / "hooks" / "configure").read_text() == "configure_hook"
+    assert (meta_dir / "hooks" / "install").exists()
+    assert (meta_dir / "hooks" / "install").read_text() == "install_hook"
+
+
+def test_write_metadata_with_built_hooks(
+    package_service,
+    default_factory,
+    default_build_plan,
+    new_dir,
+):
+    default_factory.set_kwargs(
+        "lifecycle",
+        work_dir=Path("work"),
+        cache_dir=new_dir,
+        build_plan=default_build_plan,
+    )
+    # Create some hooks
+    prime_dir = new_dir / "prime"
+    built_hooks_dir = prime_dir / "snap" / "hooks"
+    built_hooks_dir.mkdir(parents=True)
+    (built_hooks_dir / "configure").write_text("configure_hook")
+    (built_hooks_dir / "install").write_text("install_hook")
+
+    package_service.write_metadata(prime_dir)
+
+    meta_dir = prime_dir / "meta"
+    assert (meta_dir / "snap.yaml").read_text() == dedent(
+        """\
+        name: default
+        version: '1.0'
+        summary: default project
+        description: default project
+        license: MIT
+        architectures:
+        - amd64
+        base: core24
+        confinement: devmode
+        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
+    """
+    )
+
+    assert (meta_dir / "hooks").exists()
+    # Ensure the hook is the one we provided in the project
+    # and not a wrapped hook.
+    assert (meta_dir / "hooks" / "configure").exists()
+    assert (meta_dir / "hooks" / "configure").read_text() == "configure_hook"
+    assert (meta_dir / "hooks" / "install").exists()
+    assert (meta_dir / "hooks" / "install").read_text() == "install_hook"
+
+
+def test_write_metadata_with_project_gui(
+    package_service,
+    default_factory,
+    default_build_plan,
+    new_dir,
+):
+    default_factory.set_kwargs(
+        "lifecycle",
+        work_dir=Path("work"),
+        cache_dir=new_dir,
+        build_plan=default_build_plan,
+    )
+    # Create some gui
+    project_gui_dir = new_dir / "snap" / "gui"
+    project_gui_dir.mkdir(parents=True)
+    (project_gui_dir / "default.default.desktop").write_text("desktop_file")
+    (project_gui_dir / "icon.png").write_text("package_png_icon")
+
+    prime_dir = new_dir / "prime"
+    meta_dir = prime_dir / "meta"
+
+    package_service.write_metadata(prime_dir)
+
+    assert (meta_dir / "snap.yaml").read_text() == dedent(
+        """\
+        name: default
+        version: '1.0'
+        summary: default project
+        description: default project
+        license: MIT
+        architectures:
+        - amd64
+        base: core24
+        confinement: devmode
+        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
+    """
+    )
+
+    assert (meta_dir / "gui").exists()
+    # Ensure the hook is the one we provided in the project
+    # and not a wrapped hook.
+    assert (meta_dir / "gui" / "default.default.desktop").exists()
+    assert (meta_dir / "gui" / "default.default.desktop").read_text() == "desktop_file"
+    assert (meta_dir / "gui" / "icon.png").exists()
+    assert (meta_dir / "gui" / "icon.png").read_text() == "package_png_icon"