← Back to team overview

curtin-dev team mailing list archive

[Merge] ~dbungert/curtin:kernel-lowest-layer into curtin:master

 

Dan Bungert has proposed merging ~dbungert/curtin:kernel-lowest-layer into curtin:master.

Commit message:
do not squash

add distro capabilities for package removal, kernels especially, and have curthooks use that

Requested reviews:
  curtin developers (curtin-dev)

For more details, see:
https://code.launchpad.net/~dbungert/curtin/+git/curtin/+merge/466708
-- 
Your team curtin developers is requested to review the proposed merge of ~dbungert/curtin:kernel-lowest-layer into curtin:master.
diff --git a/curtin/commands/curthooks.py b/curtin/commands/curthooks.py
index b278703..5a7a126 100644
--- a/curtin/commands/curthooks.py
+++ b/curtin/commands/curthooks.py
@@ -375,55 +375,69 @@ def install_kernel(cfg, target):
 
     kernel_cfg = config.fromdict(config.KernelConfig, kernel_cfg_d)
 
-    mapping = copy.deepcopy(KERNEL_MAPPING)
-    config.merge_config(mapping, kernel_cfg.mapping)
-
-    # Machines using flash-kernel may need additional dependencies installed
-    # before running. Run those checks in the ephemeral environment so the
-    # target only has required packages installed.  See LP: #1640519
-    fk_packages = get_flash_kernel_pkgs()
-    if fk_packages:
-        distro.install_packages(fk_packages.split(), target=target)
-
-    if kernel_cfg.package:
-        install(kernel_cfg.package)
+    if not kernel_cfg.install:
+        if kernel_cfg.remove_existing:
+            distro.purge_all_kernels(target=target)
+        LOG.debug(
+            "Not installing any kernel since kernel: {install: false} "
+            "was specified"
+        )
         return
 
-    # uname[2] is kernel name (ie: 3.16.0-7-generic)
-    # version gets X.Y.Z, flavor gets anything after second '-'.
-    kernel = os.uname()[2]
-    codename, _ = util.subp(['lsb_release', '--codename', '--short'],
-                            capture=True, target=target)
-    codename = codename.strip()
-    version, abi, flavor = kernel.split('-', 2)
+    with contextlib.ExitStack() as exitstack:
+        if kernel_cfg.remove_existing:
+            exitstack.enter_context(distro.purge_kernels(target=target))
 
-    try:
-        map_suffix = mapping[codename][version]
-    except KeyError:
-        LOG.warn("Couldn't detect kernel package to install for %s."
-                 % kernel)
-        if kernel_cfg.fallback_package is not None:
-            install(kernel_cfg.fallback_package)
-        return
+        mapping = copy.deepcopy(KERNEL_MAPPING)
+        config.merge_config(mapping, kernel_cfg.mapping)
 
-    package = "linux-{flavor}{map_suffix}".format(
-        flavor=flavor, map_suffix=map_suffix)
+        # Machines using flash-kernel may need additional dependencies
+        # installed before running. Run those checks in the ephemeral
+        # environment so the target only has required packages installed.
+        # See LP: #1640519
+        fk_packages = get_flash_kernel_pkgs()
+        if fk_packages:
+            distro.install_packages(fk_packages.split(), target=target)
 
-    if distro.has_pkg_available(package, target):
-        if distro.has_pkg_installed(package, target):
-            LOG.debug("Kernel package '%s' already installed", package)
-        else:
-            LOG.debug("installing kernel package '%s'", package)
-            install(package)
-    else:
-        if kernel_cfg.fallback_package is not None:
-            LOG.info("Kernel package '%s' not available.  "
-                     "Installing fallback package '%s'.",
-                     package, kernel_cfg.fallback_package)
-            install(kernel_cfg.fallback_package)
+        if kernel_cfg.package:
+            install(kernel_cfg.package)
+            return
+
+        # uname[2] is kernel name (ie: 3.16.0-7-generic)
+        # version gets X.Y.Z, flavor gets anything after second '-'.
+        kernel = os.uname()[2]
+        codename, _ = util.subp(['lsb_release', '--codename', '--short'],
+                                capture=True, target=target)
+        codename = codename.strip()
+        version, abi, flavor = kernel.split('-', 2)
+
+        try:
+            map_suffix = mapping[codename][version]
+        except KeyError:
+            LOG.warn("Couldn't detect kernel package to install for %s."
+                     % kernel)
+            if kernel_cfg.fallback_package is not None:
+                install(kernel_cfg.fallback_package)
+            return
+
+        package = "linux-{flavor}{map_suffix}".format(
+            flavor=flavor, map_suffix=map_suffix)
+
+        if distro.has_pkg_available(package, target):
+            if distro.has_pkg_installed(package, target):
+                LOG.debug("Kernel package '%s' already installed", package)
+            else:
+                LOG.debug("installing kernel package '%s'", package)
+                install(package)
         else:
-            LOG.warn("Kernel package '%s' not available and no fallback."
-                     " System may not boot.", package)
+            if kernel_cfg.fallback_package is not None:
+                LOG.info("Kernel package '%s' not available.  "
+                         "Installing fallback package '%s'.",
+                         package, kernel_cfg.fallback_package)
+                install(kernel_cfg.fallback_package)
+            else:
+                LOG.warn("Kernel package '%s' not available and no fallback."
+                         " System may not boot.", package)
 
 
 def uefi_remove_old_loaders(grubcfg: config.GrubConfig, target: str):
diff --git a/curtin/config.py b/curtin/config.py
index 2dff329..e9f578d 100644
--- a/curtin/config.py
+++ b/curtin/config.py
@@ -159,6 +159,8 @@ class KernelConfig:
     package: typing.Optional[str] = None
     fallback_package: str = "linux-generic"
     mapping: dict = attr.Factory(dict)
+    install: bool = attr.ib(default=True, converter=value_as_boolean)
+    remove_existing: bool = attr.ib(default=False, converter=value_as_boolean)
 
 
 class SerializationError(Exception):
diff --git a/curtin/distro.py b/curtin/distro.py
index 4664320..973eb35 100644
--- a/curtin/distro.py
+++ b/curtin/distro.py
@@ -1,5 +1,6 @@
 # This file is part of curtin. See LICENSE file for copyright and license info.
 from collections import namedtuple
+from contextlib import contextmanager
 import os
 import re
 import textwrap
@@ -477,6 +478,105 @@ def install_packages(pkglist, osfamily=None, opts=None, target=None, env=None,
                        assume_downloaded=assume_downloaded)
 
 
+def grep_status_list_kernels(target=None):
+    target = target_path(target)
+    cmd = [
+        "grep-status",
+        "--whole-pkg",
+        "-FProvides",
+        "linux-image",
+        "--and",
+        "-FStatus",
+        "installed",
+        "--show-field=Package",
+        "--no-field-names",
+    ]
+    out, _ = subp(cmd, capture=True, target=target, rcs=(0, 1))
+    return out.splitlines()
+
+
+def list_kernels(osfamily=None, target=None):
+    if not osfamily:
+        osfamily = get_osfamily(target=target)
+
+    distro_cfg = {
+        DISTROS.debian: grep_status_list_kernels
+    }
+
+    list_kernels_cmd = distro_cfg.get(osfamily)
+    if list_kernels_cmd is None:
+        raise ValueError('No package purge command for distro: %s' %
+                         osfamily)
+
+    return list_kernels_cmd(target=target)
+
+
+@contextmanager
+def purge_kernels(osfamily=None, target=None):
+    before = set(list_kernels(osfamily=osfamily, target=target))
+    yield
+
+    if not bool(before):
+        LOG.debug('No kernels to remove - no kernels preinstalled')
+        return
+
+    # the second part of purge_kernels is intended to be run after any kernels
+    # we're installing have been installed.  We expect the list of kernels to
+    # grow as a result.  So if the set has not changed, that means that the
+    # kernel we asked to install was installed already, so we shouldn't be
+    # removing one.  This should work fine for a single kernel being
+    # preinstalled, but will fail to remove in the case of 2 kernels
+    # preinstalled but only one of them is intended.
+    after = set(list_kernels(osfamily=osfamily, target=target))
+    if not bool(after - before):
+        LOG.debug(
+            'No kernels to remove - kernel to install was already installed'
+        )
+        return
+
+    purge_packages(list(before), target=target)
+
+
+def purge_all_kernels(osfamily=None, target=None):
+    kernels = list_kernels(osfamily=osfamily, target=target)
+    if not bool(kernels):
+        LOG.debug('No kernels to remove - no kernels preinstalled')
+        return
+
+    purge_packages(kernels, target=target)
+
+
+def purge_packages(pkglist, osfamily=None, opts=None, target=None, env=None):
+    if isinstance(pkglist, str):
+        pkglist = [pkglist]
+
+    LOG.debug('Removing packages %s', pkglist)
+
+    if not osfamily:
+        osfamily = get_osfamily(target=target)
+
+    distro_cfg = {
+        DISTROS.debian: {'function': run_apt_command,
+                         'subcommands': ('purge', 'autoremove')},
+    }
+
+    purge_cmd = distro_cfg.get(osfamily)
+    if not purge_cmd:
+        raise ValueError('No package purge command for distro: %s' %
+                         osfamily)
+
+    for mode in distro_cfg[osfamily]['subcommands']:
+        ret = distro_cfg[osfamily]['function'](
+            mode,
+            args=pkglist,
+            opts=opts,
+            target=target,
+            env=env,
+            assume_downloaded=True,
+        )
+    return ret
+
+
 def has_pkg_available(pkg, target=None, osfamily=None):
     if not osfamily:
         osfamily = get_osfamily(target=target)
diff --git a/doc/topics/config.rst b/doc/topics/config.rst
index 5671200..d511f94 100644
--- a/doc/topics/config.rst
+++ b/doc/topics/config.rst
@@ -434,14 +434,37 @@ Default mapping for Releases to package names is as follows::
 
 Specify the exact package to install in the target OS.
 
-**Example**::
+**install**: *<boolean>*
+
+Defaults to True.  If False, no kernel install is attempted.
+
+**remove_existing**: *<boolean>*
+
+Supported on Debian and Ubuntu OSes.  Defaults to False.  If True, known
+kernels in .deb packages are removed from the target system (packages which
+``Provides: linux-image``), followed by an ``apt-get autoremove``.  If no
+kernel is being installed, this also implies the removal of the
+``linux-firmware`` package.
+
+**Examples**::
 
   kernel:
     fallback-package: linux-image-generic
     package: linux-image-generic-lts-xenial
     mapping:
       - xenial:
-        - 4.4.0: -my-custom-kernel    
+        - 4.4.0: -my-custom-kernel
+
+  # install this kernel if not yet installed,
+  # and remove other kernels if present
+  kernel:
+    package: linux-image-generic-hwe-24.04
+    remove_existing: true
+
+  # install nothing and remove existing kernels
+  kernel:
+    install: false
+    remove_existing: true
 
 
 kexec
diff --git a/tests/unittests/test_curthooks.py b/tests/unittests/test_curthooks.py
index b5666e2..6819f7a 100644
--- a/tests/unittests/test_curthooks.py
+++ b/tests/unittests/test_curthooks.py
@@ -6,6 +6,7 @@ import textwrap
 from typing import Optional
 
 import attr
+from parameterized import parameterized
 
 from curtin.commands import curthooks
 from curtin.commands.block_meta import extract_storage_ordered_dict
@@ -58,12 +59,20 @@ class TestCurthooksInstallKernel(CiTestCase):
         ccc = 'curtin.commands.curthooks'
         self.add_patch('curtin.distro.has_pkg_available', 'mock_haspkg')
         self.add_patch('curtin.distro.install_packages', 'mock_instpkg')
+        self.add_patch('curtin.distro.purge_packages', 'mock_purgepkg')
+        self.add_patch(
+            'curtin.distro.grep_status_list_kernels', 'mock_list_kernels',
+        )
+        self.add_patch(
+            'curtin.distro.os_release', return_value={"ID": "ubuntu"}
+        )
         self.add_patch(ccc + '.os.uname', 'mock_uname')
         self.add_patch(ccc + '.util.subp', 'mock_subp')
         self.add_patch(
             ccc + '.get_flash_kernel_pkgs',
             'mock_get_flash_kernel_pkgs')
 
+        self.mock_get_flash_kernel_pkgs.return_value = None
         self.fk_env = {'FK_FORCE': 'yes', 'FK_FORCE_CONTAINER': 'yes'}
         # Tests don't actually install anything so we just need a name
         self.target = self.tmp_dir()
@@ -85,7 +94,6 @@ class TestCurthooksInstallKernel(CiTestCase):
     def test__installs_kernel_package(self):
         kernel_package = "mock-linux-kernel"
         kernel_cfg = {'kernel': {'package': kernel_package}}
-        self.mock_get_flash_kernel_pkgs.return_value = None
         with patch.dict(os.environ, clear=True):
             curthooks.install_kernel(kernel_cfg, self.target)
 
@@ -125,13 +133,93 @@ class TestCurthooksInstallKernel(CiTestCase):
                 ["linux-flavor-lts-dapper"],
                 target=self.target, env=self.fk_env)
 
-    def test__installs_kernel_null(self):
-        kernel_cfg = {'kernel': None}
+    @parameterized.expand((
+        [{'kernel': None}],
+        [{'kernel': {'install': 'false'}}],
+    ))
+    def test__not_installs_kernel(self, kernel_cfg):
         with patch.dict(os.environ, clear=True):
             curthooks.install_kernel(kernel_cfg, self.target)
 
             self.mock_instpkg.assert_not_called()
 
+    def test__removes_and_installs_kernel(self):
+        to_install_kernel_package = "mock-linux-kernel"
+        to_remove_kernel_package = "mock-to-remove"
+        kernel_cfg = {
+            'kernel': {
+                'package': to_install_kernel_package,
+                'remove_existing': 'true',
+            }
+        }
+        self.mock_subp.return_value = ("warty", "")
+        self.mock_uname.return_value = (None, None, "1.2.3-4-flavor")
+        self.mock_list_kernels.side_effect = [
+            [to_remove_kernel_package],
+            [to_install_kernel_package, to_remove_kernel_package],
+        ]
+
+        with patch.dict(os.environ, clear=True):
+            curthooks.install_kernel(kernel_cfg, self.target)
+
+            self.mock_instpkg.assert_called_with(
+                [to_install_kernel_package],
+                target=self.target,
+                env=self.fk_env,
+            )
+            self.mock_purgepkg.assert_called_with(
+                [to_remove_kernel_package], target=self.target
+            )
+
+    def test__installs_kernel_nothing_to_remove(self):
+        to_install_kernel_package = "mock-linux-kernel"
+        kernel_cfg = {
+            'kernel': {
+                'package': to_install_kernel_package,
+                'remove_existing': 'true',
+            }
+        }
+        self.mock_subp.return_value = ("warty", "")
+        self.mock_uname.return_value = (None, None, "1.2.3-4-flavor")
+        self.mock_list_kernels.return_value = []
+
+        with patch.dict(os.environ, clear=True):
+            curthooks.install_kernel(kernel_cfg, self.target)
+
+            self.mock_instpkg.assert_called_with(
+                [to_install_kernel_package],
+                target=self.target,
+                env=self.fk_env,
+            )
+            self.mock_purgepkg.assert_not_called()
+
+    def test__target_already_has_kernel(self):
+        to_install_kernel_package = "mock-linux-kernel"
+        kernel_cfg = {
+            'kernel': {
+                'package': to_install_kernel_package,
+                'remove_existing': 'true',
+            }
+        }
+        self.mock_subp.return_value = ("warty", "")
+        self.mock_uname.return_value = (None, None, "1.2.3-4-flavor")
+        self.mock_list_kernels.return_value = ["mock-kernel-1.2.3-4-generic"]
+
+        with patch.dict(os.environ, clear=True):
+            curthooks.install_kernel(kernel_cfg, self.target)
+
+            # the mapping from kernel to install and what list_kernls returns
+            # is not straightforward, so we ask apt to install the package and
+            # apt shouldn't have to do very much
+            self.mock_instpkg.assert_called_with(
+                [to_install_kernel_package],
+                target=self.target,
+                env=self.fk_env,
+            )
+            # but because nothing actually gets installed, there is nothing to
+            # remove
+            self.mock_purgepkg.assert_not_called()
+
 
 class TestEnableDisableUpdateInitramfs(CiTestCase):
 
diff --git a/tools/vmtest-system-setup b/tools/vmtest-system-setup
index 2f82464..482e431 100755
--- a/tools/vmtest-system-setup
+++ b/tools/vmtest-system-setup
@@ -15,6 +15,7 @@ DEPS=(
   build-essential
   cloud-image-utils
   cryptsetup
+  dctrl-tools
   git
   make
   net-tools

Follow ups