Repository: canonical/snapcraft

Issue:
`package-repositories` fail with the core24 base due to missing packages
### Bug Description

We have a  LP recipe for a snap with `base: core24` that's failing due to the following:

```
gpg: WARNING: unsafe permissions on homedir '/tmp/tmpvro98o2s'
gpg: error running '/usr/bin/dirmngr': probably not installed
gpg: failed to start dirmngr '/usr/bin/dirmngr': Configuration error
gpg: can't connect to the dirmngr: Configuration error
gpg: keyserver receive failed: No dirmngr
```

The build log is here:
https://launchpadlibrarian.net/724229364/buildlog_snap_ubuntu_noble_arm64_pi-24_BUILDING.txt.gz

And the recipe is here:
https://github.com/snapcore/pi-gadget/blob/24/snapcraft.yaml

It seems to happen when we use the following

```
package-repositories:
  - type: apt
    ppa: snappy-dev/image
``` 

We have tried to add the missing packages `gnupg` and `dirmngr` - but snapcraft fails before installing packages. It seems to have had special handling for core22 [here](https://github.com/canonical/snapcraft/blob/be36b4dc0c98fbbff10e38fc8aef23fc39a0e51f/snapcraft/parts/parts.py#L242)

### To Reproduce

1. Create a snap recipe on LP using the latest edge snapcraft.
2. Use a snapcraft.yaml that contains both of these configs options
    * `base: core24`
    *  `package-repositories:` with `type: apt`


### Environment

Snapcraft on LP.

### snapcraft.yaml

```shell
name: pi
version: 24-1
summary: Raspberry Pi gadget
description: |
  Support files for booting Raspberry Pi.
  This gadget snap supports the Raspberry Pi 2B, 3B, 3A+, 3B+, 4B, Compute
  Module 3, and the Compute Module 3+ universally.
type: gadget
base: core24
build-base: devel
assumes: [kernel-assets]
platforms:
  rpi:
    build-on: arm64
    build-for: arm64
  rpi-amd64:
    build-on: amd64
    build-for: arm64

package-repositories:
  - type: apt
    ppa: snappy-dev/image

confinement: strict
grade: devel
parts:
  gadget:
    plugin: nil
    source: .
    override-build: |
      set -x
      
      # we only support arm64 for 24 for pi
      BUILD_ARCH_TRIPLET=aarch64-linux-gnu
      BUILD_SERIES=noble
      BUILD_ARCH="$(dpkg-architecture -t $BUILD_ARCH_TRIPLET -q DEB_HOST_ARCH)"
      
      # but only use the cross-compiling sources when actually cross compiling
      # as otherwise its just safer to use the build environment - it offers
      # much more flexibility.
      if [ "$CRAFT_ARCH_TRIPLET_BUILD_ON" = "x86_64-linux-gnu" ] || [ -z "$CRAFT_ARCH_TRIPLET_BUILD_ON" ]; then
        sed -i "/^deb/s/\bSERIES/$BUILD_SERIES/" ./helpers/cross-sources.list
        sed -i "/^deb/s/\bARCH\b/$BUILD_ARCH/" ./helpers/cross-sources.list
        OPTIONAL_ARGS="SERIES_HOST=\"$BUILD_SERIES\" SOURCES_HOST=\"./helpers/cross-sources.list\" SOURCES_D_HOST=\"./helpers\""
      fi
      
      make -C $CRAFT_PART_SRC core \
        DESTDIR=${CRAFT_PART_INSTALL} \
        SERIES="$BUILD_SERIES" \
        ARCH="$BUILD_ARCH" \
        ${OPTIONAL_ARGS:-}
    prime:
      - boot-assets/*
      - piboot.conf
    build-packages:
      - lsb-release
      - dpkg-dev
      - make
      - gnupg
      - dirmngr

slots:
  bcm-gpio-0:
    interface: gpio
    number: 0
  bcm-gpio-1:
    interface: gpio
    number: 1
  bcm-gpio-2:
    interface: gpio
    number: 2
  bcm-gpio-3:
    interface: gpio
    number: 3
  bcm-gpio-4:
    interface: gpio
    number: 4
  bcm-gpio-5:
    interface: gpio
    number: 5
  bcm-gpio-6:
    interface: gpio
    number: 6
  bcm-gpio-7:
    interface: gpio
    number: 7
  bcm-gpio-8:
    interface: gpio
    number: 8
  bcm-gpio-9:
    interface: gpio
    number: 9
  bcm-gpio-10:
    interface: gpio
    number: 10
  bcm-gpio-11:
    interface: gpio
    number: 11
  bcm-gpio-12:
    interface: gpio
    number: 12
  bcm-gpio-13:
    interface: gpio
    number: 13
  bcm-gpio-14:
    interface: gpio
    number: 14
  bcm-gpio-15:
    interface: gpio
    number: 15
  bcm-gpio-16:
    interface: gpio
    number: 16
  bcm-gpio-17:
    interface: gpio
    number: 17
  bcm-gpio-18:
    interface: gpio
    number: 18
  bcm-gpio-19:
    interface: gpio
    number: 19
  bcm-gpio-20:
    interface: gpio
    number: 20
  bcm-gpio-21:
    interface: gpio
    number: 21
  bcm-gpio-22:
    interface: gpio
    number: 22
  bcm-gpio-23:
    interface: gpio
    number: 23
  bcm-gpio-24:
    interface: gpio
    number: 24
  bcm-gpio-25:
    interface: gpio
    number: 25
  bcm-gpio-26:
    interface: gpio
    number: 26
  bcm-gpio-27:
    interface: gpio
    number: 27
  i2c-0:
    interface: i2c
    path: /dev/i2c-0
  i2c-1:
    interface: i2c
    path: /dev/i2c-1
  i2c-2:
    interface: i2c
    path: /dev/i2c-2
  i2c-3:
    interface: i2c
    path: /dev/i2c-3
  i2c-4:
    interface: i2c
    path: /dev/i2c-4
  i2c-5:
    interface: i2c
    path: /dev/i2c-5
  i2c-6:
    interface: i2c
    path: /dev/i2c-6
  bt-serial:
    interface: serial-port
    path: /dev/ttyAMA0
  serial0:
    interface: serial-port
    path: /dev/ttyS0
  serial1:
    interface: serial-port
    path: /dev/ttyS1
  serial2:
    interface: serial-port
    path: /dev/ttyS2
  serial3:
    interface: serial-port
    path: /dev/ttyS3
  serial4:
    interface: serial-port
    path: /dev/ttyS4
  serial5:
    interface: serial-port
    path: /dev/ttyS5
  serial6:
    interface: serial-port
    path: /dev/ttyS6
  serial7:
    interface: serial-port
    path: /dev/ttyS7
  serial8:
    interface: serial-port
    path: /dev/ttyS8
  serial9:
    interface: serial-port
    path: /dev/ttyS9
  spidev0:
    interface: spi
    path: /dev/spidev0.0
  spidev1:
    interface: spi
    path: /dev/spidev0.1
  spidev1-0:
    interface: spi
    path: /dev/spidev1.0
  spidev1-1:
    interface: spi
    path: /dev/spidev1.1
  spidev2-0:
    interface: spi
    path: /dev/spidev2.0
  spidev2-1:
    interface: spi
    path: /dev/spidev2.1
  spidev3-0:
    interface: spi
    path: /dev/spidev3.0
  spidev3-1:
    interface: spi
    path: /dev/spidev3.1
  spidev4-0:
    interface: spi
    path: /dev/spidev4.0
  spidev4-1:
    interface: spi
    path: /dev/spidev4.1
  spidev5-0:
    interface: spi
    path: /dev/spidev5.0
  spidev5-1:
    interface: spi
    path: /dev/spidev5.1
  spidev6-0:
    interface: spi
    path: /dev/spidev6.0
  spidev6-1:
    interface: spi
    path: /dev/spidev6.1
  pwm0:
    interface: pwm
    chip-number: 0
    channel: 0
  pwm1:
    interface: pwm
    chip-number: 0
    channel: 1
```


### Relevant log output

```shell
https://launchpadlibrarian.net/725096872/buildlog_snap_ubuntu_noble_arm64_pi-24_BUILDING.txt.gz
```


### Additional context

_No response_


Discussion:

Thank you for reporting us your feedback!

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

> This message was autogenerated


Resolving patch:
diff --git a/snapcraft/services/lifecycle.py b/snapcraft/services/lifecycle.py
--- a/snapcraft/services/lifecycle.py
+++ b/snapcraft/services/lifecycle.py
@@ -25,6 +25,7 @@
 from craft_application import AppMetadata, LifecycleService, ServiceFactory
 from craft_application.models import BuildInfo
 from craft_parts import ProjectInfo, StepInfo
+from craft_parts.packages import Repository as Repo
 from overrides import overrides
 
 from snapcraft import __version__, errors, models, os_release, parts, utils
@@ -60,6 +61,14 @@ def __init__(  # noqa: PLR0913 (too many arguments)
     def setup(self) -> None:
         project = cast(models.Project, self._project)
 
+        if project.package_repositories:
+            # Note: we unfortunately need to handle missing gpg/dirmngr binaries
+            # ourselves here, as this situation happens in Launchpad (where
+            # builds are executed destructively).
+            required_packages = ["gpg", "dirmngr"]
+            if any(p for p in required_packages if not Repo.is_package_installed(p)):
+                Repo.install_packages(required_packages, refresh_package_cache=False)
+
         # Have the lifecycle install the base snap, and look into it when
         # determining the package cutoff.
         self._manager_kwargs.update(


Test patch:
diff --git a/tests/spread/core24/package-repositories/task.yaml b/tests/spread/core24/package-repositories/task.yaml
--- a/tests/spread/core24/package-repositories/task.yaml
+++ b/tests/spread/core24/package-repositories/task.yaml
@@ -10,7 +10,14 @@ environment:
   SNAP/test_foreign_armhf: test-foreign-armhf
   SNAP/test_foreign_i386: test-foreign-i386
 
+prepare: |
+  # Remove the currently installed "gpg" and "dirmngr" packages to ensure that
+  # Snapcraft itself is installing them when necessary.
+  sudo dpkg --remove --force-depends gpg dirmngr
+
 restore: |
+  sudo apt install -y gpg dirmngr
+
   cd "$SNAP"
   rm -f ./*.snap
   snapcraft clean
diff --git a/tests/unit/services/test_lifecycle.py b/tests/unit/services/test_lifecycle.py
--- a/tests/unit/services/test_lifecycle.py
+++ b/tests/unit/services/test_lifecycle.py
@@ -23,6 +23,7 @@
 
 import pytest
 import pytest_subprocess
+from craft_parts.packages import Repository
 
 from snapcraft import __version__, models, os_release, utils
 
@@ -198,3 +199,24 @@ def test_lifecycle_prime_dirs(lifecycle_service):
     lifecycle_service.setup()
 
     assert lifecycle_service.prime_dirs == {None: lifecycle_service._work_dir / "prime"}
+
+
+@pytest.fixture()
+def package_repositories_params(extra_project_params):
+    """Add package-repositories configuration to the default project."""
+    extra_project_params["package_repositories"] = [{"type": "apt", "ppa": "test/ppa"}]
+
+
+@pytest.mark.usefixtures("package_repositories_params", "default_project")
+def test_lifecycle_installs_gpg_dirmngr(lifecycle_service, mocker):
+    mock_is_installed = mocker.patch.object(
+        Repository, "is_package_installed", return_value=False
+    )
+    mock_install = mocker.patch.object(Repository, "install_packages")
+
+    lifecycle_service.setup()
+
+    assert mock_is_installed.called
+    mock_install.assert_called_once_with(
+        ["gpg", "dirmngr"], refresh_package_cache=False
+    )