← Back to team overview

curtin-dev team mailing list archive

[Merge] ~dbungert/curtin:series-fixes into curtin:release/23.1

 

Dan Bungert has proposed merging ~dbungert/curtin:series-fixes into curtin:release/23.1.

Commit message:
Merge fixes to 23.1 release branch

git cherry-pick -x 33411a7..07ec70c3                                                                      
git cherry-pick -x 9aed2e3^..f0556cc                                                                      
git cherry-pick -x 0131c2dd^..3969bca                                                                     
git cherry-pick -x fb70e570..a9d2efd3                                                                     
git cherry-pick -x 1f7e8591                                                                               
git cherry-pick -x d7cbc39e^..0fbdf389                                                                    


Requested reviews:
  curtin developers (curtin-dev)

For more details, see:
https://code.launchpad.net/~dbungert/curtin/+git/curtin/+merge/443612
-- 
Your team curtin developers is requested to review the proposed merge of ~dbungert/curtin:series-fixes into curtin:release/23.1.
diff --git a/Makefile b/Makefile
index c04c4cf..5e8822a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,5 @@
 TOP := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
 CWD := $(shell pwd)
-PYTHON2 ?= python2
 PYTHON3 ?= python3
 COVERAGE ?= 1
 DEFAULT_COVERAGEOPTS = --with-coverage --cover-erase --cover-branches --cover-package=curtin --cover-inclusive 
@@ -16,11 +15,11 @@ target_dirs ?= curtin tests tools
 build:
 
 bin/curtin: curtin/pack.py tools/write-curtin
-	$(PYTHON) tools/write-curtin bin/curtin
+	$(PYTHON3) tools/write-curtin bin/curtin
 
 check: unittest
 
-style-check: pep8 pyflakes pyflakes3
+style-check: pep8 pyflakes3
 
 coverage: coverageopts ?= $(DEFAULT_COVERAGEOPTS)
 coverage: unittest
@@ -28,26 +27,15 @@ coverage: unittest
 pep8:
 	@$(CWD)/tools/run-pep8
 
-pyflakes:
-	$(PYTHON2) -m pyflakes $(target_dirs)
-
-pyflakes3:
+pyflakes pyflakes3:
 	$(PYTHON3) -m pyflakes $(target_dirs)
 
-pylint:
-	$(PYTHON2) -m pylint $(pylintopts) $(target_dirs)
-
-pylint3:
+pylint pylint3:
 	$(PYTHON3) -m pylint $(pylintopts) $(target_dirs)
 
-unittest2:
-	$(PYTHON2) -m nose $(coverageopts) $(noseopts) tests/unittests
-
-unittest3:
+unittest unittest3:
 	$(PYTHON3) -m nose $(coverageopts) $(noseopts) tests/unittests
 
-unittest: unittest2 unittest3
-
 schema-validate:
 	@$(CWD)/tools/schema-validate-storage
 
@@ -55,7 +43,7 @@ docs: check-doc-deps
 	make -C doc html
 
 check-doc-deps:
-	@which sphinx-build && $(PYTHON) -c 'import sphinx_rtd_theme' || \
+	@which sphinx-build && $(PYTHON3) -c 'import sphinx_rtd_theme' || \
 		{ echo "Missing doc dependencies. Install with:"; \
 		  pkgs="python3-sphinx-rtd-theme python3-sphinx"; \
 		  echo sudo apt-get install -qy $$pkgs ; exit 1; }
diff --git a/curtin/block/__init__.py b/curtin/block/__init__.py
index 1f9bb60..b6802fb 100644
--- a/curtin/block/__init__.py
+++ b/curtin/block/__init__.py
@@ -13,6 +13,7 @@ from curtin.block import lvm
 from curtin.block import multipath
 from curtin.log import LOG
 from curtin.udev import udevadm_settle, udevadm_info
+from curtin.util import NotExclusiveError
 from curtin import storage_config
 
 
@@ -733,6 +734,7 @@ def get_root_device(dev, paths=None):
     LOG.debug('Searching for filesystem on %s containing one of: %s',
               dev, paths)
     partitions = get_pardevs_on_blockdevs(dev)
+    LOG.debug('Known partitions %s', list(partitions.keys()))
     target = None
     tmp_mount = tempfile.mkdtemp()
     for i in partitions:
@@ -1040,9 +1042,13 @@ def check_dos_signature(device):
     # this signature must be at 0x1fe
     # https://en.wikipedia.org/wiki/Master_boot_record#Sector_layout
     devname = dev_path(path_to_kname(device))
-    return (is_block_device(devname) and util.file_size(devname) >= 0x200 and
-            (util.load_file(devname, decode=False, read_len=2, offset=0x1fe) ==
-             b'\x55\xAA'))
+    if not is_block_device(devname):
+        return False
+    file_size = util.file_size(devname)
+    if file_size < 0x200:
+        return False
+    signature = util.load_file(devname, decode=False, read_len=2, offset=0x1fe)
+    return signature == b'\x55\xAA'
 
 
 def check_efi_signature(device):
@@ -1083,9 +1089,20 @@ def is_extended_partition(device):
     # within the first 4 partitions and will have a valid dos signature,
     # because the format of the extended partition matches that of a real mbr
     (parent_dev, part_number) = get_blockdev_for_partition(device)
-    return (get_part_table_type(parent_dev) in ['dos', 'msdos'] and
-            part_number is not None and int(part_number) <= 4 and
-            check_dos_signature(device))
+    if (get_part_table_type(parent_dev) in ['dos', 'msdos'] and
+            part_number is not None and int(part_number) <= 4):
+        try:
+            return check_dos_signature(device)
+        except OSError as ose:
+            # Some older series have the extended partition block device but
+            # return ENXIO when attempting to read it.  Make a best guess from
+            # the parent_dev.
+            if ose.errno == errno.ENXIO:
+                return check_dos_signature(parent_dev)
+            else:
+                raise
+    else:
+        return False
 
 
 def is_zfs_member(device):
@@ -1159,7 +1176,7 @@ def exclusive_open(path, exclusive=True):
             # python2 leaves fd open if there os.fdopen fails
             if fd_needs_closing and sys.version_info.major == 2:
                 os.close(fd)
-    except OSError:
+    except OSError as exc:
         LOG.error("Failed to exclusively open path: %s", path)
         holders = get_holders(path)
         LOG.error('Device holders with exclusive access: %s', holders)
@@ -1167,7 +1184,10 @@ def exclusive_open(path, exclusive=True):
         LOG.error('Device mounts: %s', mount_points)
         fusers = util.fuser_mount(path)
         LOG.error('Possible users of %s:\n%s', path, fusers)
-        raise
+        if exclusive and exc.errno == errno.EBUSY:
+            raise NotExclusiveError from exc
+        else:
+            raise
 
 
 def wipe_file(path, reader=None, buflen=4 * 1024 * 1024, exclusive=True):
@@ -1233,8 +1253,9 @@ def quick_zero(path, partitions=True, exclusive=True):
         quick_zero(pt, partitions=False)
 
     LOG.debug("wiping 1M on %s at offsets %s", path, offsets)
-    return zero_file_at_offsets(path, offsets, buflen=buflen, count=count,
-                                exclusive=exclusive)
+    util.not_exclusive_retry(
+            zero_file_at_offsets,
+            path, offsets, buflen=buflen, count=count, exclusive=exclusive)
 
 
 def zero_file_at_offsets(path, offsets, buflen=1024, count=1024, strict=False,
diff --git a/curtin/commands/apt_config.py b/curtin/commands/apt_config.py
index e02327a..0b8066b 100644
--- a/curtin/commands/apt_config.py
+++ b/curtin/commands/apt_config.py
@@ -601,14 +601,8 @@ def apply_apt_proxy_config(cfg, proxy_fname, config_fname):
         LOG.debug("write apt proxy info to %s", proxy_fname)
         util.write_file(proxy_fname, '\n'.join(proxies) + '\n')
     elif os.path.isfile(proxy_fname):
-        # When $ curtin apt-config is called with no proxy set, it makes
-        # sense to remove the proxy file (if present). Having said that,
-        # this code is also called automatically at the curthooks stage with an
-        # empty configuration. Since the installation of external packages and
-        # execution of unattended-upgrades (which happen after executing the
-        # curthooks) need to use the proxy if specified, we must not let the
-        # curthooks remove the proxy file.
-        pass
+        util.del_file(proxy_fname)
+        LOG.debug("no apt proxy configured, removed %s", proxy_fname)
 
     if cfg.get('conf', None):
         LOG.debug("write apt config info to %s", config_fname)
@@ -638,12 +632,9 @@ def apply_apt_preferences(cfg, pref_fname):
 
     prefs = cfg.get("preferences")
     if not prefs:
-        # When $ curtin apt-config is called with no preferences set, it makes
-        # sense to remove the preferences file (if present). Having said that,
-        # this code is also called automatically at the curthooks stage with an
-        # empty configuration. Since the installation of packages (which
-        # happens after executing the curthooks) needs to honor the preferences
-        # set, we must not let the curthooks remove the preferences file.
+        if os.path.isfile(pref_fname):
+            util.del_file(pref_fname)
+            LOG.debug("no apt preferences configured, removed %s", pref_fname)
         return
     prefs_as_strings = [preference_to_str(pref) for pref in prefs]
     LOG.debug("write apt preferences info to %s.", pref_fname)
diff --git a/curtin/commands/block_meta.py b/curtin/commands/block_meta.py
index 7988f3a..b00defe 100644
--- a/curtin/commands/block_meta.py
+++ b/curtin/commands/block_meta.py
@@ -1,7 +1,7 @@
 # This file is part of curtin. See LICENSE file for copyright and license info.
 
 from collections import OrderedDict, namedtuple
-from curtin import (block, config, paths, util)
+from curtin import (block, compat, config, paths, util)
 from curtin.block import schemas
 from curtin.block import (bcache, clear_holders, dasd, iscsi, lvm, mdadm, mkfs,
                           multipath, zfs)
@@ -159,13 +159,17 @@ def write_image_to_disk(source, dev):
                     '--', source['uri'], devnode])
     util.subp(['partprobe', devnode])
 
-    udevadm_trigger([devnode])
-    try:
-        lvm.activate_volgroups()
-    except util.ProcessExecutionError:
-        # partial vg may not come up due to missing members, that's OK
-        pass
-    udevadm_settle()
+    for i in range(3):
+        # For images that contain block devices of interest to device mapper, a
+        # single round can sometimes not be enough to discover the mapped
+        # devices.  So let's have an ugly retry loop.
+        udevadm_trigger([devnode])
+        try:
+            lvm.activate_volgroups()
+        except util.ProcessExecutionError:
+            # partial vg may not come up due to missing members, that's OK
+            pass
+        udevadm_settle()
 
     # Images from MAAS have well-known/required paths present
     # on the rootfs partition.  Use these values to select the
@@ -649,7 +653,6 @@ DEVS = set()
 def image_handler(info, storage_config, context):
     path = info['path']
     size = int(util.human2bytes(info['size']))
-    sector_size = str(int(util.human2bytes(info.get('sector_size', 512))))
     if info.get('preserve', False):
         actual_size = os.stat(path).st_size
         if size != actual_size:
@@ -666,10 +669,14 @@ def image_handler(info, storage_config, context):
             if os.path.exists(path):
                 os.unlink(path)
             raise
+
+    cmd = ['losetup', '--show', '--find', path]
+    sector_size = int(util.human2bytes(info.get('sector_size', 512)))
+    if sector_size != 512:
+        compat.supports_large_sectors(fatal=True)
+        cmd.extend(('--sector-size', str(sector_size)))
     try:
-        dev = util.subp([
-            'losetup', '--show', '--sector-size', sector_size, '--find', path],
-            capture=True)[0].strip()
+        dev = util.subp(cmd, capture=True)[0].strip()
     except BaseException:
         if os.path.exists(path) and not info.get('preserve'):
             os.unlink(path)
@@ -854,17 +861,28 @@ def calc_dm_partition_info(partition_kname):
 
 
 def calc_partition_info(partition_kname, logical_block_size_bytes):
+    p_size_sec = 0
+    p_start_sec = 0
     if partition_kname.startswith('dm-'):
         p_start, p_size = calc_dm_partition_info(partition_kname)
     else:
         pdir = block.sys_block_path(partition_kname)
         p_size = int(util.load_file(os.path.join(pdir, "size")))
         p_start = int(util.load_file(os.path.join(pdir, "start")))
+        if p_size == 0 or p_start == 0:
+            # if sysfs reported a 0, let's try sfdisk
+            sfdisk_info = block.sfdisk_info(partition_kname)
+            part_path = block.kname_to_path(partition_kname)
+            part_info = block.get_partition_sfdisk_info(part_path, sfdisk_info)
+            p_size_sec = part_info['size']
+            p_start_sec = part_info['start']
 
     # NB: sys/block/X/{size,start} and dmsetup output are both always
     # in 512b sectors
-    p_size_sec = p_size * 512 // logical_block_size_bytes
-    p_start_sec = p_start * 512 // logical_block_size_bytes
+    if p_size_sec == 0:
+        p_size_sec = p_size * 512 // logical_block_size_bytes
+    if p_start_sec == 0:
+        p_start_sec = p_start * 512 // logical_block_size_bytes
 
     LOG.debug("calc_partition_info: %s size_sectors=%s start_sectors=%s",
               partition_kname, p_size_sec, p_start_sec)
diff --git a/curtin/commands/block_meta_v2.py b/curtin/commands/block_meta_v2.py
index 79dfd39..570da83 100644
--- a/curtin/commands/block_meta_v2.py
+++ b/curtin/commands/block_meta_v2.py
@@ -8,7 +8,7 @@ from typing import (
 
 import attr
 
-from curtin import (block, util)
+from curtin import (block, compat, util)
 from curtin.commands.block_meta import (
     _get_volume_fstype,
     disk_handler as disk_handler_v1,
@@ -26,6 +26,23 @@ from curtin.storage_config import (
 from curtin.udev import udevadm_settle
 
 
+def to_utf8_hex_notation(string: str) -> str:
+    ''' Convert a string into a valid ASCII string where all characters outside
+    the alphanumerical range (according to bytes.isalnum()) are translated to
+    their corresponding \\x notation. E.g.:
+        to_utf8_hex_notation("hello") => "hello"
+        to_utf8_hex_notation("réservée") => "r\\xc3\\xa9serv\\xc3\\xa9e"
+        to_utf8_hex_notation("sp ace") => "sp\\x20ace"
+    '''
+    result = ''
+    for c in bytearray(string, 'utf-8'):
+        if bytes([c]).isalnum():
+            result += bytes([c]).decode()
+        else:
+            result += f'\\x{c:02x}'
+    return result
+
+
 @attr.s(auto_attribs=True)
 class PartTableEntry:
     # The order listed here matches the order sfdisk represents these fields
@@ -49,7 +66,11 @@ class PartTableEntry:
             if v is not None:
                 r += ' {}={}'.format(a, v)
         if self.name is not None:
-            r += ' name="{}"'.format(self.name)
+            # Partition names are basically free-text fields. Injecting some
+            # characters such as '"', '\' and '\n' will result in lots of
+            # trouble.  Fortunately, sfdisk supports \x notation, so we can
+            # rely on it.
+            r += ' name="{}"'.format(to_utf8_hex_notation(self.name))
         if self.attrs:
             r += ' attrs="{}"'.format(' '.join(self.attrs))
         if self.bootable:
@@ -150,9 +171,10 @@ class SFDiskPartTable:
     def apply(self, device):
         sfdisk_script = self.render()
         LOG.debug("sfdisk input:\n---\n%s\n---\n", sfdisk_script)
-        util.subp(
-            ['sfdisk', '--no-tell-kernel', '--no-reread', device],
-            data=sfdisk_script.encode('ascii'))
+        cmd = ['sfdisk', '--no-reread', device]
+        if compat.supports_sfdisk_no_tell_kernel():
+            cmd.append('--no-tell-kernel')
+        util.subp(cmd, data=sfdisk_script.encode('ascii'))
         util.subp(['partprobe', device])
         # sfdisk and partprobe (as invoked here) use ioctls to inform the
         # kernel that the partition table has changed so it can add and remove
@@ -248,12 +270,24 @@ class DOSPartTable(SFDiskPartTable):
     label = 'dos'
     _extended = None
 
+    @staticmethod
+    def is_logical(action) -> bool:
+        flag = action.get('flag', None)
+        if flag == 'logical':
+            return True
+        # In some scenarios, a swap partition can be in the extended
+        # partition. When it does, the flag is set to 'swap'.
+        # In some other scenarios, a bootable partition can also be in the
+        # extended partition. This is not a supported use-case but is
+        # yet another scenario where flag is not set to 'logical'.
+        return action.get('number', 0) > 4
+
     def add(self, action):
         flag = action.get('flag', None)
         start = action.get('offset', None)
         if start is not None:
             start = self.bytes2sectors(start)
-        if flag == 'logical':
+        if self.is_logical(action):
             if self._extended is None:
                 raise Exception("logical partition without extended partition")
             prev = None
diff --git a/curtin/commands/install_grub.py b/curtin/commands/install_grub.py
index 38bf71a..03b4670 100644
--- a/curtin/commands/install_grub.py
+++ b/curtin/commands/install_grub.py
@@ -26,12 +26,13 @@ CMD_ARGUMENTS = (
 GRUB_MULTI_INSTALL = '/usr/lib/grub/grub-multi-install'
 
 
-def get_grub_package_name(target_arch, uefi, rhel_ver=None):
+def get_grub_package_name(target_arch, uefi, rhel_ver=None, osfamily=None):
     """Determine the correct grub distro package name.
 
     :param: target_arch: string specifying the target system architecture
     :param: uefi: boolean indicating if system is booted via UEFI or not
     :param: rhel_ver: string specifying the major Redhat version in use.
+    :param: osfamily: string specifying the target os family
     :returns: tuple of strings, grub package name and grub target name
     """
     if target_arch is None:
@@ -43,43 +44,64 @@ def get_grub_package_name(target_arch, uefi, rhel_ver=None):
     if 'ppc64' in target_arch:
         return ('grub-ieee1275', 'powerpc-ieee1275')
     if uefi:
-        if target_arch == 'amd64':
-            grub_name = 'grub-efi-%s' % target_arch
-            grub_target = "x86_64-efi"
-        elif target_arch == 'x86_64':
-            # centos 7+, no centos6 support
-            # grub2-efi-x64 installs a signed grub bootloader
-            grub_name = "grub2-efi-x64"
-            grub_target = "x86_64-efi"
-        elif target_arch == 'aarch64':
-            # centos 7+, no centos6 support
-            # grub2-efi-aa64 installs a signed grub bootloader
-            grub_name = "grub2-efi-aa64"
-            grub_target = "arm64-efi"
-        elif target_arch == 'arm64':
-            grub_name = 'grub-efi-%s' % target_arch
-            grub_target = "arm64-efi"
-        elif target_arch == 'i386':
-            grub_name = 'grub-efi-ia32'
-            grub_target = 'i386-efi'
-        elif target_arch == 'riscv64':
-            grub_name = 'grub-efi-riscv64'
-            grub_target = 'riscv64-efi'
+        if osfamily == distro.DISTROS.redhat:
+            if target_arch == 'x86_64':
+                # centos 7+, no centos6 support
+                # grub2-efi-x64 installs a signed grub bootloader
+                grub_name = "grub2-efi-x64"
+                grub_target = "x86_64-efi"
+            elif target_arch == 'aarch64':
+                # centos 7+, no centos6 support
+                # grub2-efi-aa64 installs a signed grub bootloader
+                grub_name = "grub2-efi-aa64"
+                grub_target = "arm64-efi"
+            else:
+                raise ValueError('Unsupported RHEL version: %s', rhel_ver)
+        elif osfamily == distro.DISTROS.suse:
+            if target_arch == 'x86_64':
+                grub_target = "x86_64-efi"
+            elif target_arch == 'aarch64':
+                grub_target = "arm64-efi"
+            else:
+                raise ValueError('Unsupported SUSE arch: %s', target_arch)
+            grub_name = 'grub2-%s' % grub_target
         else:
-            raise ValueError('Unsupported UEFI arch: %s' % target_arch)
+            if target_arch == 'amd64':
+                grub_name = 'grub-efi-%s' % target_arch
+                grub_target = "x86_64-efi"
+            elif target_arch == 'arm64':
+                grub_name = 'grub-efi-%s' % target_arch
+                grub_target = "arm64-efi"
+            elif target_arch == 'i386':
+                grub_name = 'grub-efi-ia32'
+                grub_target = 'i386-efi'
+            elif target_arch == 'riscv64':
+                grub_name = 'grub-efi-riscv64'
+                grub_target = 'riscv64-efi'
+            else:
+                raise ValueError('Unsupported UEFI arch %s for OS %s' %
+                                 (target_arch, osfamily))
     else:
         grub_target = 'i386-pc'
-        if target_arch in ['i386', 'amd64']:
-            grub_name = 'grub-pc'
-        elif target_arch == 'x86_64':
-            if rhel_ver == '6':
-                grub_name = 'grub'
-            elif rhel_ver in ['7', '8', '9']:
-                grub_name = 'grub2-pc'
+        if osfamily == distro.DISTROS.redhat:
+            if target_arch == 'x86_64':
+                if rhel_ver == '6':
+                    grub_name = 'grub'
+                elif rhel_ver in ['7', '8', '9']:
+                    grub_name = 'grub2-pc'
+                else:
+                    raise ValueError('Unsupported RHEL version: %s', rhel_ver)
+            elif target_arch == 'i386':
+                grub_name = 'grub-pc'
             else:
-                raise ValueError('Unsupported RHEL version: %s', rhel_ver)
+                raise ValueError('Unsupported RHEL arch: %s', target_arch)
+        elif osfamily == distro.DISTROS.suse:
+            grub_name = 'grub2-i386-pc'
+        elif target_arch in ['i386', 'amd64']:
+            grub_name = 'grub-pc'
         else:
-            raise ValueError('Unsupported arch: %s' % target_arch)
+            raise ValueError('Unsupported arch %s for OS %s' %
+                             (target_arch, osfamily))
 
     return (grub_name, grub_target)
 
@@ -397,7 +419,9 @@ def install_grub(devices, target, uefi=None, grubcfg=None):
                 if distroinfo.family == distro.DISTROS.redhat else None)
 
     check_target_arch_machine(target, arch=target_arch, uefi=uefi)
-    grub_name, grub_target = get_grub_package_name(target_arch, uefi, rhel_ver)
+    grub_name, grub_target = get_grub_package_name(target_arch, uefi,
+                                                   rhel_ver=rhel_ver,
+                                                   osfamily=distroinfo.family)
     grub_conf = get_grub_config_file(target, distroinfo.family)
     new_params = get_carryover_params(distroinfo)
     prepare_grub_dir(target, grub_conf)
diff --git a/curtin/compat.py b/curtin/compat.py
new file mode 100644
index 0000000..c20b650
--- /dev/null
+++ b/curtin/compat.py
@@ -0,0 +1,38 @@
+# This file is part of curtin. See LICENSE file for copyright and license info.
+
+import re
+
+from curtin import util
+
+
+def _get_util_linux_ver():
+    line = util.subp(['losetup', '--version'], capture=True)[0].strip()
+    m = re.fullmatch(r'losetup from util-linux ([\d.]+)', line)
+    if m is None:
+        return None
+    return m.group(1)
+
+
+def _check_util_linux_ver(ver, label='', fatal=False):
+    ul_ver = _get_util_linux_ver()
+    result = ul_ver is not None and ul_ver >= ver
+    if not result and fatal:
+        raise RuntimeError(
+            'this system lacks the required {} support'.format(label))
+    return result
+
+
+def supports_large_sectors(fatal=False):
+    # Known requirements:
+    # * Kernel 4.14+
+    #   * Minimum supported things have a higher kernel, so skip that check
+    # * util-linux 2.30+
+    #   * xenial has 2.27.1, bionic has 2.31.1
+    # However, see also this, which suggests using 2.37.1:
+    # https://lore.kernel.org/lkml/20210615084259.yj5pmyjonfqcg7lg@xxxxxxxxxxx/
+    return _check_util_linux_ver('2.37.1', 'large sector', fatal)
+
+
+def supports_sfdisk_no_tell_kernel(fatal=False):
+    # Needs util-linux 2.29+
+    return _check_util_linux_ver('2.29', 'sfdisk', fatal)
diff --git a/curtin/udev.py b/curtin/udev.py
index fa4f940..c43e7a5 100644
--- a/curtin/udev.py
+++ b/curtin/udev.py
@@ -131,11 +131,25 @@ def udevadm_info(path=None):
 
 def udev_all_block_device_properties():
     import pyudev
-    props = []
+    devices_props = []
     c = pyudev.Context()
     for device in c.list_devices(subsystem='block'):
-        props.append(dict(device.properties))
-    return props
+        # When dereferencing device[prop], pyudev calls bytes.decode(), which
+        # can fail if the value is invalid utf-8. We don't want a single
+        # invalid value to completely prevent probing. So we iterate
+        # over each value manually and ignore those which are invalid.  We know
+        # that PARTNAME is subject to failures when accents and other special
+        # characters are used in a GPT partition name.
+        # See LP: 2017862
+        props = {}
+        for prop in device.properties:
+            try:
+                props[prop] = device.properties[prop]
+            except UnicodeDecodeError:
+                LOG.warning('ignoring property %s because it is not valid'
+                            ' utf-8', prop)
+        devices_props.append(props)
+    return devices_props
 
 
 # vi: ts=4 expandtab syntax=python
diff --git a/curtin/util.py b/curtin/util.py
index aaa6008..75d7545 100644
--- a/curtin/util.py
+++ b/curtin/util.py
@@ -2,7 +2,7 @@
 
 import argparse
 import collections
-from contextlib import contextmanager
+from contextlib import contextmanager, suppress
 import errno
 import json
 import os
@@ -62,6 +62,11 @@ _DNS_REDIRECT_IP = None
 BASIC_MATCHER = re.compile(r'\$\{([A-Za-z0-9_.]+)\}|\$([A-Za-z0-9_.]+)')
 
 
+class NotExclusiveError(OSError):
+    ''' Exception to raise when an exclusive open (i.e, O_EXCL) fails with
+    EBUSY '''
+
+
 def _subp(args, data=None, rcs=None, env=None, capture=False,
           combine_capture=False, shell=False, logstring=False,
           decode="replace", target=None, cwd=None, log_captured=False,
@@ -1323,4 +1328,11 @@ def uses_systemd():
 
     return _USES_SYSTEMD
 
+
+def not_exclusive_retry(fun, *args, **kwargs):
+    with suppress(NotExclusiveError):
+        return fun(*args, **kwargs)
+    time.sleep(1)
+    return fun(*args, **kwargs)
+
 # vi: ts=4 expandtab syntax=python
diff --git a/test-requirements.txt b/test-requirements.txt
index 1970d03..0d93c02 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -1,3 +1,6 @@
+pip
+wheel
+virtualenv
 jsonschema
 mock
 nose
diff --git a/tests/integration/test_block_meta.py b/tests/integration/test_block_meta.py
index b25d2ce..a2368e8 100644
--- a/tests/integration/test_block_meta.py
+++ b/tests/integration/test_block_meta.py
@@ -9,10 +9,10 @@ from parameterized import parameterized
 import re
 import sys
 from typing import Optional
+from unittest import skipIf
 import yaml
 
-from curtin import block, log, udev, util
-
+from curtin import block, compat, distro, log, udev, util
 from curtin.commands.block_meta import _get_volume_fstype
 from curtin.commands.block_meta_v2 import ONE_MIB_BYTES
 
@@ -26,11 +26,10 @@ class IntegrationTestCase(CiTestCase):
 
 @contextlib.contextmanager
 def loop_dev(image, sector_size=512):
-    dev = util.subp([
-        'losetup',
-        '--show', '--find', '--sector-size', str(sector_size),
-        image,
-        ], capture=True, decode='ignore')[0].strip()
+    cmd = ['losetup', '--show', '--find', image]
+    if sector_size != 512:
+        cmd.extend(('--sector-size', str(sector_size)))
+    dev = util.subp(cmd, capture=True, decode='ignore')[0].strip()
     util.subp(['partprobe', dev])
     try:
         udev.udevadm_trigger([dev])
@@ -117,7 +116,7 @@ def summarize_partitions(dev):
     parts = []
     ptable_json = util.subp(['sfdisk', '-J', dev], capture=True)[0]
     ptable = json.loads(ptable_json)['partitiontable']
-    sectorsize = ptable['sectorsize']
+    sectorsize = ptable.get('sectorsize', 512)
     assert dev == ptable['device']
     sysfs_data = block.sysfs_partition_data(dev)
     for part in ptable['partitions']:
@@ -168,8 +167,7 @@ class StorageConfigBuilder:
 
     def add_image(self, *, path, size, create=False, **kw):
         if create:
-            with open(path, "wb") as f:
-                f.write(b"\0" * int(util.human2bytes(size)))
+            util.subp(['truncate', '-s', str(size), path])
         action = self._add(type='image', path=path, size=size, **kw)
         self.cur_image = action['id']
         return action
@@ -311,15 +309,19 @@ class TestBlockMeta(IntegrationTestCase):
     def test_default_offsets_msdos_v2(self):
         self._test_default_offsets('msdos', 2)
 
+    @skipIf(not compat.supports_large_sectors(), 'test is for large sectors')
     def test_default_offsets_gpt_v1_4k(self):
         self._test_default_offsets('gpt', 1, 4096)
 
+    @skipIf(not compat.supports_large_sectors(), 'test is for large sectors')
     def test_default_offsets_msdos_v1_4k(self):
         self._test_default_offsets('msdos', 1, 4096)
 
+    @skipIf(not compat.supports_large_sectors(), 'test is for large sectors')
     def test_default_offsets_gpt_v2_4k(self):
         self._test_default_offsets('gpt', 2, 4096)
 
+    @skipIf(not compat.supports_large_sectors(), 'test is for large sectors')
     def test_default_offsets_msdos_v2_4k(self):
         self._test_default_offsets('msdos', 2, 4096)
 
@@ -424,8 +426,17 @@ class TestBlockMeta(IntegrationTestCase):
                     PartData(number=6, offset=13 << 20, size=10 << 20),
                 ])
 
-            p1kname = block.partition_kname(block.path_to_kname(dev), 1)
-            self.assertTrue(block.is_extended_partition('/dev/' + p1kname))
+            if distro.lsb_release()['release'] >= '20.04':
+                p1kname = block.partition_kname(block.path_to_kname(dev), 1)
+                self.assertTrue(block.is_extended_partition('/dev/' + p1kname))
+            else:
+                # on Bionic and earlier, the block device for the extended
+                # partition is not functional, so attempting to verify it is
+                # expected to fail.  So just read the value directly from the
+                # expected signature location.
+                signature = util.load_file(dev, decode=False,
+                                           read_len=2, offset=0x1001fe)
+                self.assertEqual(b'\x55\xAA', signature)
 
     def test_logical_v1(self):
         self._test_logical(1)
@@ -716,6 +727,8 @@ class TestBlockMeta(IntegrationTestCase):
                     PartData(number=6, offset=13 << 20, size=20 << 20),
                 ])
 
+    @skipIf(distro.lsb_release()['release'] < '20.04',
+            'old lsblk will not list info about extended partitions')
     def test_resize_extended(self):
         img = self.tmp_path('image.img')
         config = StorageConfigBuilder(version=2)
@@ -865,6 +878,8 @@ class TestBlockMeta(IntegrationTestCase):
             self.assertEqual(139 << 20, _get_filesystem_size(dev, p2))
             self.assertEqual(50 << 20, _get_filesystem_size(dev, p3))
 
+    @skipIf(distro.lsb_release()['release'] < '20.04',
+            'old lsblk will not list info about extended partitions')
     def test_mix_of_operations_msdos(self):
         # a test that keeps, creates, resizes, and deletes a partition
         # including handling of extended/logical
@@ -1084,9 +1099,13 @@ class TestBlockMeta(IntegrationTestCase):
             actual_name = sfdisk_info['partitions'][0]['name']
         self.assertEqual(name, actual_name)
 
-    def test_gpt_name_persistent(self):
+    @parameterized.expand([
+        ('random', CiTestCase.random_string(),),
+        # "écrasé" means "overwritten"
+        ('unicode', "'name' must not be écrasé/덮어쓴!"),
+    ])
+    def test_gpt_name_persistent(self, title, name):
         self.img = self.tmp_path('image.img')
-        name = self.random_string()
         config = StorageConfigBuilder(version=2)
         config.add_image(path=self.img, size='20M', ptable='gpt')
         p1 = config.add_part(number=1, offset=1 << 20, size=18 << 20,
@@ -1124,6 +1143,8 @@ class TestBlockMeta(IntegrationTestCase):
             actual_attrs = set(attrs_str.split(' '))
         self.assertEqual(set(attrs), actual_attrs)
 
+    @skipIf(distro.lsb_release()['release'] < '18.04',
+            'old sfdisk no attr support')
     def test_gpt_set_multi_attr(self):
         self.img = self.tmp_path('image.img')
         config = StorageConfigBuilder(version=2)
@@ -1171,12 +1192,14 @@ class TestBlockMeta(IntegrationTestCase):
         config = StorageConfigBuilder(version=2)
         config.add_image(path=self.img, create=True, size='20M', ptable='gpt',
                          preserve=True)
+        # Set first-lba, and also a stub partition to keep older sfdisk happy.
         script = '''\
 label: gpt
-first-lba: 34'''.encode()
+first-lba: 34
+1MiB 1MiB L'''.encode()
         with loop_dev(self.img) as dev:
             cmd = ['sfdisk', dev]
-            util.subp(cmd, data=script)
+            util.subp(cmd, data=script, capture=True)
 
         config.add_part(number=1, offset=1 << 20, size=1 << 20)
         self.run_bm(config.render())
@@ -1193,9 +1216,11 @@ first-lba: 34'''.encode()
         config = StorageConfigBuilder(version=2)
         config.add_image(path=self.img, create=True, size='20M', ptable='gpt',
                          preserve=True)
+        # Set last-lba, and also a stub partition to keep older sfdisk happy.
         script = '''\
 label: gpt
-last-lba: 10240'''.encode()
+last-lba: 10240
+1MiB 1MiB L'''.encode()
         with loop_dev(self.img) as dev:
             cmd = ['sfdisk', dev]
             util.subp(cmd, data=script)
@@ -1210,6 +1235,8 @@ last-lba: 10240'''.encode()
             # default is disk size in sectors - 17 KiB
             self.assertEqual(10240, sfdisk_info['lastlba'])
 
+    @skipIf(distro.lsb_release()['release'] < '18.04',
+            'old sfdisk has no table-length support')
     def test_gpt_table_length_persistent(self):
         self.img = self.tmp_path('image.img')
         config = StorageConfigBuilder(version=2)
diff --git a/tests/unittests/helpers.py b/tests/unittests/helpers.py
index 819e2c5..ffb2775 100644
--- a/tests/unittests/helpers.py
+++ b/tests/unittests/helpers.py
@@ -85,6 +85,7 @@ class CiTestCase(TestCase):
             handler = logging.StreamHandler(self.logs)
             handler.setFormatter(formatter)
             self.old_handlers = self.logger.handlers
+            self.logger.setLevel(logging.DEBUG)
             self.logger.handlers = [handler]
 
         if self.allowed_subp is True:
@@ -152,8 +153,8 @@ class CiTestCase(TestCase):
         return os.path.normpath(
             os.path.abspath(os.path.sep.join((_dir, path))))
 
-    @classmethod
-    def random_string(cls, length=8):
+    @staticmethod
+    def random_string(length=8):
         """ return a random lowercase string with default length of 8"""
         return ''.join(
             random.choice(string.ascii_lowercase) for _ in range(length))
diff --git a/tests/unittests/test_block_lvm.py b/tests/unittests/test_block_lvm.py
index fc6130a..315281d 100644
--- a/tests/unittests/test_block_lvm.py
+++ b/tests/unittests/test_block_lvm.py
@@ -76,12 +76,12 @@ class TestBlockLvm(CiTestCase):
     @mock.patch('curtin.block.lvm.distro')
     def test_lvm_scan(self, mock_distro, mock_util, mock_lvmetad):
         """check that lvm_scan formats commands correctly for each release"""
-        cmds = [['pvscan'], ['vgscan']]
         for (count, (codename, lvmetad_status, use_cache)) in enumerate(
                 [('precise', False, False),
                  ('trusty', False, False),
                  ('xenial', False, False), ('xenial', True, True),
                  (None, True, True), (None, False, False)]):
+            cmds = [['pvscan'], ['vgscan']]
             mock_distro.lsb_release.return_value = {'codename': codename}
             mock_lvmetad.return_value = lvmetad_status
             lvm.lvm_scan()
@@ -92,7 +92,7 @@ class TestBlockLvm(CiTestCase):
 
             calls = [mock.call(cmd, capture=True) for cmd in expected]
             self.assertEqual(len(expected), len(mock_util.subp.call_args_list))
-            mock_util.subp.has_calls(calls)
+            mock_util.subp.assert_has_calls(calls)
             mock_util.subp.reset_mock()
 
     @mock.patch('curtin.block.lvm.lvmetad_running')
@@ -112,7 +112,7 @@ class TestBlockLvm(CiTestCase):
         expected = [cmd + cmd_filter for cmd in cmds]
         calls = [mock.call(cmd, capture=True) for cmd in expected]
         self.assertEqual(len(expected), len(mock_util.subp.call_args_list))
-        mock_util.subp.has_calls(calls)
+        mock_util.subp.assert_has_calls(calls)
 
 
 class TestBlockLvmMultipathFilter(CiTestCase):
diff --git a/tests/unittests/test_commands_block_meta.py b/tests/unittests/test_commands_block_meta.py
index 82530ff..5599886 100644
--- a/tests/unittests/test_commands_block_meta.py
+++ b/tests/unittests/test_commands_block_meta.py
@@ -25,6 +25,34 @@ def random_uuid():
 empty_context = block_meta.BlockMetaContext({})
 
 
+class TestToUTF8HexNotation(CiTestCase):
+    def test_alpha(self):
+        self.assertEqual(
+                block_meta_v2.to_utf8_hex_notation("HelloWorld"), "HelloWorld")
+
+    def test_alphanum(self):
+        self.assertEqual(
+                block_meta_v2.to_utf8_hex_notation("Hello1234"), "Hello1234")
+
+    def test_alnum_space(self):
+        self.assertEqual(
+                block_meta_v2.to_utf8_hex_notation("Hello 1234"),
+                "Hello\\x201234")
+
+    def test_with_accent(self):
+        # '\xe9'.isalpha(), which is equivalent to 'é'.isalpha(), is True
+        # because 'é' is considered a letter according to the unicode standard.
+        # b'\xe9'.isalpha(), on the other hand, is False.
+        self.assertEqual(
+                block_meta_v2.to_utf8_hex_notation("réservée"),
+                "r\\xc3\\xa9serv\\xc3\\xa9e")
+
+    def test_hangul(self):
+        self.assertEqual(
+                block_meta_v2.to_utf8_hex_notation("리눅스"),
+                '\\xeb\\xa6\\xac\\xeb\\x88\\x85\\xec\\x8a\\xa4')
+
+
 class TestGetPathToStorageVolume(CiTestCase):
 
     def setUp(self):
@@ -3050,14 +3078,20 @@ label: gpt
         table.add(dict(number=1, offset=1 << 20, size=9 << 20, flag='boot',
                        partition_name=name))
         type_id = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"
+        to_hex = block_meta_v2.to_utf8_hex_notation
         expected = f'''\
 label: gpt
 
-1:  start=2048 size=18432 type={type_id} name="{name}"'''
+1:  start=2048 size=18432 type={type_id} name="{to_hex(name)}"'''
         self.assertEqual(expected, table.render())
 
-    def test_gpt_name_spaces(self):
-        name = self.random_string() + " " + self.random_string()
+    def test_gpt_name_free_text(self):
+        name = 'my "분할" réservée'
+        expected_name = ''.join([
+            'my',
+            '\\x20\\x22\\xeb\\xb6\\x84\\xed\\x95\\xa0\\x22',
+            '\\x20r\\xc3\\xa9serv\\xc3\\xa9e',
+        ])
         table = block_meta_v2.GPTPartTable(512)
         table.add(dict(number=1, offset=1 << 20, size=9 << 20, flag='boot',
                        partition_name=name))
@@ -3065,7 +3099,7 @@ label: gpt
         expected = f'''\
 label: gpt
 
-1:  start=2048 size=18432 type={type_id} name="{name}"'''
+1:  start=2048 size=18432 type={type_id} name="{expected_name}"'''
         self.assertEqual(expected, table.render())
 
     def test_gpt_attrs_none(self):
@@ -3255,10 +3289,25 @@ label: dos
                 number=1, start=2, size=3, type='04', bootable=False,
                 uuid=None, name=None, attrs=None)
         pte.preserve({'uuid': uuid, 'name': name, 'attrs': attrs})
+        to_hex = block_meta_v2.to_utf8_hex_notation
         expected = f'1:  start=2 size=3 type=04 uuid={uuid} ' + \
-            f'name="{name}" attrs="{attrs}"'
+            f'name="{to_hex(name)}" attrs="{attrs}"'
         self.assertEqual(expected, pte.render())
 
+    def test_v2_dos_is_logical(self):
+        action = {"flag": "logical"}
+        self.assertTrue(block_meta_v2.DOSPartTable.is_logical(action))
+        action = {"flag": "logical", "number": 5}
+        self.assertTrue(block_meta_v2.DOSPartTable.is_logical(action))
+        action = {"flag": "swap", "number": 2}
+        self.assertFalse(block_meta_v2.DOSPartTable.is_logical(action))
+        action = {"flag": "swap", "number": 5}
+        self.assertTrue(block_meta_v2.DOSPartTable.is_logical(action))
+        action = {"flag": "boot", "number": 5}
+        self.assertTrue(block_meta_v2.DOSPartTable.is_logical(action))
+        action = {"flag": "swap"}
+        self.assertFalse(block_meta_v2.DOSPartTable.is_logical(action))
+
 
 class TestPartitionNeedsResize(CiTestCase):
 
diff --git a/tests/unittests/test_commands_install_grub.py b/tests/unittests/test_commands_install_grub.py
index ab52505..00004d7 100644
--- a/tests/unittests/test_commands_install_grub.py
+++ b/tests/unittests/test_commands_install_grub.py
@@ -16,120 +16,191 @@ class TestGetGrubPackageName(CiTestCase):
         target_arch = 'ppc64le'
         uefi = False
         rhel_ver = None
+        osfamily = distro.DISTROS.debian
         self.assertEqual(
             ('grub-ieee1275', 'powerpc-ieee1275'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_uefi_debian_amd64(self):
         target_arch = 'amd64'
         uefi = True
         rhel_ver = None
+        osfamily = distro.DISTROS.debian
         self.assertEqual(
             ('grub-efi-amd64', 'x86_64-efi'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_uefi_rhel7_amd64(self):
         target_arch = 'x86_64'
         uefi = True
         rhel_ver = '7'
+        osfamily = distro.DISTROS.redhat
         self.assertEqual(
             ('grub2-efi-x64', 'x86_64-efi'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_uefi_rhel8_amd64(self):
         target_arch = 'x86_64'
         uefi = True
         rhel_ver = '8'
+        osfamily = distro.DISTROS.redhat
         self.assertEqual(
             ('grub2-efi-x64', 'x86_64-efi'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
+
+    def test_uefi_suse_amd64(self):
+        target_arch = 'x86_64'
+        uefi = True
+        rhel_ver = None
+        osfamily = distro.DISTROS.suse
+        self.assertEqual(
+            ('grub2-x86_64-efi', 'x86_64-efi'),
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_uefi_rhel7_arm64(self):
         target_arch = 'aarch64'
         uefi = True
         rhel_ver = '7'
+        osfamily = distro.DISTROS.redhat
         self.assertEqual(
             ('grub2-efi-aa64', 'arm64-efi'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_uefi_rhel8_arm64(self):
         target_arch = 'aarch64'
         uefi = True
         rhel_ver = '8'
+        osfamily = distro.DISTROS.redhat
         self.assertEqual(
             ('grub2-efi-aa64', 'arm64-efi'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_uefi_debian_arm64(self):
         target_arch = 'arm64'
         uefi = True
         rhel_ver = None
+        osfamily = distro.DISTROS.debian
         self.assertEqual(
             ('grub-efi-arm64', 'arm64-efi'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
+
+    def test_uefi_suse_arm64(self):
+        target_arch = 'aarch64'
+        uefi = True
+        rhel_ver = None
+        osfamily = distro.DISTROS.suse
+        self.assertEqual(
+            ('grub2-arm64-efi', 'arm64-efi'),
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_uefi_debian_i386(self):
         target_arch = 'i386'
         uefi = True
         rhel_ver = None
+        osfamily = distro.DISTROS.debian
         self.assertEqual(
             ('grub-efi-ia32', 'i386-efi'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_debian_amd64(self):
         target_arch = 'amd64'
         uefi = False
         rhel_ver = None
+        osfamily = distro.DISTROS.debian
         self.assertEqual(
             ('grub-pc', 'i386-pc'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_rhel6_amd64(self):
         target_arch = 'x86_64'
         uefi = False
         rhel_ver = '6'
+        osfamily = distro.DISTROS.redhat
         self.assertEqual(
             ('grub', 'i386-pc'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_rhel7_amd64(self):
         target_arch = 'x86_64'
         uefi = False
         rhel_ver = '7'
+        osfamily = distro.DISTROS.redhat
         self.assertEqual(
             ('grub2-pc', 'i386-pc'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_rhel8_amd64(self):
         target_arch = 'x86_64'
         uefi = False
         rhel_ver = '8'
+        osfamily = distro.DISTROS.redhat
         self.assertEqual(
             ('grub2-pc', 'i386-pc'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
+
+    def test_suse_amd64(self):
+        target_arch = 'x86_64'
+        uefi = False
+        rhel_ver = None
+        osfamily = distro.DISTROS.suse
+        self.assertEqual(
+            ('grub2-i386-pc', 'i386-pc'),
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_debian_i386(self):
         target_arch = 'i386'
         uefi = False
         rhel_ver = None
+        osfamily = distro.DISTROS.debian
         self.assertEqual(
             ('grub-pc', 'i386-pc'),
-            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver))
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
+
+    def test_suse_i386(self):
+        target_arch = 'i386'
+        uefi = False
+        rhel_ver = None
+        osfamily = distro.DISTROS.suse
+        self.assertEqual(
+            ('grub2-i386-pc', 'i386-pc'),
+            install_grub.get_grub_package_name(target_arch, uefi, rhel_ver,
+                                               osfamily))
 
     def test_invalid_rhel_version(self):
+        osfamily = distro.DISTROS.redhat
         with self.assertRaises(ValueError):
             install_grub.get_grub_package_name('x86_64', uefi=False,
-                                               rhel_ver='5')
+                                               rhel_ver='5', osfamily=osfamily)
 
     def test_invalid_arch(self):
+        osfamily = distro.DISTROS.debian
         with self.assertRaises(ValueError):
             install_grub.get_grub_package_name(self.random_string(),
-                                               uefi=False, rhel_ver=None)
+                                               uefi=False, rhel_ver=None,
+                                               osfamily=osfamily)
 
     def test_invalid_arch_uefi(self):
+        osfamily = distro.DISTROS.debian
         with self.assertRaises(ValueError):
             install_grub.get_grub_package_name(self.random_string(),
-                                               uefi=True, rhel_ver=None)
+                                               uefi=True, rhel_ver=None,
+                                               osfamily=osfamily)
 
 
 class TestGetGrubConfigFile(CiTestCase):
@@ -1095,7 +1166,8 @@ class TestInstallGrub(CiTestCase):
         self.m_distro_get_distroinfo.assert_called_with(target=self.target)
         self.m_distro_get_architecture.assert_called_with(target=self.target)
         self.assertEqual(0, self.m_distro_rpm_get_dist_id.call_count)
-        self.m_get_grub_package_name.assert_called_with('amd64', uefi, None)
+        self.m_get_grub_package_name.assert_called_with('amd64', uefi, None,
+                                                        'debian')
         self.m_get_grub_config_file.assert_called_with(self.target,
                                                        self.distroinfo.family)
         self.m_get_carryover_params.assert_called_with(self.distroinfo)
@@ -1136,7 +1208,8 @@ class TestInstallGrub(CiTestCase):
         self.m_distro_get_distroinfo.assert_called_with(target=self.target)
         self.m_distro_get_architecture.assert_called_with(target=self.target)
         self.assertEqual(0, self.m_distro_rpm_get_dist_id.call_count)
-        self.m_get_grub_package_name.assert_called_with('amd64', uefi, None)
+        self.m_get_grub_package_name.assert_called_with('amd64', uefi, None,
+                                                        'debian')
         self.m_get_grub_config_file.assert_called_with(self.target,
                                                        self.distroinfo.family)
         self.m_get_carryover_params.assert_called_with(self.distroinfo)
@@ -1178,7 +1251,8 @@ class TestInstallGrub(CiTestCase):
         self.m_distro_get_distroinfo.assert_called_with(target=self.target)
         self.m_distro_get_architecture.assert_called_with(target=self.target)
         self.assertEqual(0, self.m_distro_rpm_get_dist_id.call_count)
-        self.m_get_grub_package_name.assert_called_with('amd64', uefi, None)
+        self.m_get_grub_package_name.assert_called_with('amd64', uefi, None,
+                                                        'debian')
         self.m_get_grub_config_file.assert_called_with(self.target,
                                                        self.distroinfo.family)
         self.m_get_carryover_params.assert_called_with(self.distroinfo)
diff --git a/tests/unittests/test_compat.py b/tests/unittests/test_compat.py
new file mode 100644
index 0000000..0ba5002
--- /dev/null
+++ b/tests/unittests/test_compat.py
@@ -0,0 +1,34 @@
+# This file is part of curtin. See LICENSE file for copyright and license info.
+
+from unittest import mock
+
+from curtin import compat
+from .helpers import CiTestCase
+
+
+class TestUtilLinuxVer(CiTestCase):
+    @mock.patch('curtin.util.subp')
+    def test_ul_ver(self, m_subp):
+        m_subp.return_value = ('losetup from util-linux 2.31.1', '')
+        self.assertEqual('2.31.1', compat._get_util_linux_ver())
+
+    @mock.patch('curtin.util.subp')
+    def test_ul_malformed(self, m_subp):
+        m_subp.return_value = ('losetup from util-linux asdf', '')
+        self.assertEqual(None, compat._get_util_linux_ver())
+
+    @mock.patch('curtin.compat._get_util_linux_ver')
+    def test_verpass(self, m_gulv):
+        m_gulv.return_value = '1.23.4'
+        self.assertTrue(compat._check_util_linux_ver('1.20'))
+
+    @mock.patch('curtin.compat._get_util_linux_ver')
+    def test_verfail(self, m_gulv):
+        m_gulv.return_value = '1.23.4'
+        self.assertFalse(compat._check_util_linux_ver('1.24'))
+
+    @mock.patch('curtin.compat._get_util_linux_ver')
+    def test_verfatal(self, m_gulv):
+        m_gulv.return_value = '1.23.4'
+        with self.assertRaisesRegex(RuntimeError, '.*my feature.*'):
+            compat._check_util_linux_ver('1.24', 'my feature', fatal=True)
diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py
index a111ea1..6a0c951 100644
--- a/tests/unittests/test_util.py
+++ b/tests/unittests/test_util.py
@@ -1158,4 +1158,43 @@ class TestSanitizeSource(CiTestCase):
         self.assertEqual(expected, result)
 
 
+class TestNotExclusiveRetry(CiTestCase):
+    @mock.patch('curtin.util.time.sleep')
+    def test_not_exclusive_retry_success(self, sleep):
+        f = mock.Mock(return_value='success')
+
+        self.assertEqual(util.not_exclusive_retry(f, 1, 2, 3), 'success')
+        sleep.assert_not_called()
+
+    @mock.patch('curtin.util.time.sleep')
+    def test_not_exclusive_retry_failed(self, sleep):
+        f = mock.Mock(side_effect=OSError)
+
+        with self.assertRaises(OSError):
+            util.not_exclusive_retry(f, 1, 2, 3)
+        sleep.assert_not_called()
+
+    @mock.patch('curtin.util.time.sleep')
+    def test_not_exclusive_retry_not_exclusive_once_then_success(self, sleep):
+        f = mock.Mock(side_effect=[util.NotExclusiveError, 'success'])
+
+        self.assertEqual(util.not_exclusive_retry(f, 1, 2, 3), 'success')
+        sleep.assert_called_once()
+
+    @mock.patch('curtin.util.time.sleep')
+    def test_not_exclusive_retry_not_exclusive_twice(self, sleep):
+        f = mock.Mock(side_effect=[util.NotExclusiveError] * 2)
+
+        with self.assertRaises(util.NotExclusiveError):
+            util.not_exclusive_retry(f, 1, 2, 3)
+        sleep.assert_called_once()
+
+    @mock.patch('curtin.util.time.sleep')
+    def test_not_exclusive_retry_not_exclusive_once_then_error(self, sleep):
+        f = mock.Mock(side_effect=[util.NotExclusiveError, OSError])
+
+        with self.assertRaises(OSError):
+            util.not_exclusive_retry(f, 1, 2, 3)
+        sleep.assert_called_once()
+
 # vi: ts=4 expandtab syntax=python
diff --git a/tools/vmtest-create-static-images b/tools/vmtest-create-static-images
index d236608..3c20ef3 100755
--- a/tools/vmtest-create-static-images
+++ b/tools/vmtest-create-static-images
@@ -34,7 +34,8 @@ trap cleanup EXIT
         mkpart primary 0% 25% \
         mkpart primary 25% 100% \
         set 2 lvm on
-    sudo udevadm trigger --settle "${loopdev}"
+    sudo udevadm trigger "${loopdev}"
+    sudo udevadm settle
 
     # create LVM volumes
     sudo pvcreate "${loopdev}"p2
diff --git a/tools/vmtest-system-setup b/tools/vmtest-system-setup
index 4596df5..c34e9aa 100755
--- a/tools/vmtest-system-setup
+++ b/tools/vmtest-system-setup
@@ -11,27 +11,37 @@ case "$(uname -m)" in
   s390x) qemu="qemu-system-s390x";;
 esac
 
-get_python_apt() {
-    [[ "$1" < "21.04" ]] && echo python-apt
-    [[ "$1" > "16.04" ]] && echo python3-apt
-}
-
 DEPS=(
+  build-essential
   cloud-image-utils
+  git
   make
   net-tools
+  pep8
   python3
+  python3-apt
   python3-attr
+  python3-coverage
   python3-jsonschema
   python3-nose
+  python3-oauthlib
+  python3-pip
+  python3-pyflakes
+  python3-pytest
+  python3-pyudev
   python3-simplestreams
+  python3-wheel
   python3-yaml
-  $(get_python_apt "$(lsb_release -sr)")
+  lvm2
+  ntfs-3g
   ovmf
+  parted
   simplestreams
   $qemu
   ubuntu-cloudimage-keyring
   tgt
+  tox
+  wget
 )
 
 apt_get() {

Follow ups