From 245930608be356e219e5a70bff4684e52eb9f058 Mon Sep 17 00:00:00 2001 From: Kristian Amlie Date: Mon, 14 Mar 2022 14:32:19 +0100 Subject: [PATCH] MEN-5219: Integrate with `grub.d` framework. This means that `grub-install` and `update-grub` no longer risk bricking the device, but will produce boot scripts with Mender support integrated. It also means that the standard GRUB menu will be available. It is supported on x86_64 platforms where `grub.d` is available, and can be turned on and off with `MENDER_GRUB_D_INTEGRATION`. The default is to use it if available. Devices that did not previously use `grub.d` integration won't be upgraded correctly with it turned on, so it is advised to set `MENDER_GRUB_D_INTEGRATION=n` if you are upgrading existing devices. Changelog: Commit Signed-off-by: Kristian Amlie --- Dockerfile | 4 +- configs/mender_grub_config | 13 +- mender-convert-modify | 16 ++- modules/grub.sh | 97 ++++++++++++--- modules/probe.sh | 11 ++ scripts/test/mender-convert-qemu | 2 +- scripts/test/test-utils.sh | 1 - tests/conftest.py | 4 +- tests/mender-image-tests | 2 +- tests/test_grub_integration.py | 199 +++++++++++++++++++++++++++++++ 10 files changed, 323 insertions(+), 26 deletions(-) create mode 100644 tests/test_grub_integration.py diff --git a/Dockerfile b/Dockerfile index b54cc9b..6e77706 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,7 +53,9 @@ RUN apt-get update && env DEBIAN_FRONTEND=noninteractive apt-get install -y \ # manipulate binary and hex xxd \ # JSON power tool - jq + jq \ +# GRUB command line tools, primarily grub-probe + grub-common COPY --from=build /root/pxz/pxz /usr/bin/pxz diff --git a/configs/mender_grub_config b/configs/mender_grub_config index 69382ca..c14c513 100644 --- a/configs/mender_grub_config +++ b/configs/mender_grub_config @@ -1,8 +1,17 @@ -# Use U-boot -> GRUB -> EFI boot-loader integration +# Use UEFI -> GRUB boot-loader integration +# +# On ARM, U-boot is used as UEFI (EFI) provider # # Only disable this if you know what you are doing MENDER_GRUB_EFI_INTEGRATION=y +# Integrate with /etc/grub.d boot scripts. +# +# This has no effect unless MENDER_GRUB_EFI_INTEGRATION is enabled. Default is +# auto, in which case it will be detected. This is only supported for x86_64 +# platforms at the moment. +MENDER_GRUB_D_INTEGRATION=auto + # Version of GRUB to use. Note that there needs to be a precompiled version # available at the MENDER_STORAGE_URL download source. GRUB_VERSION=2.04 @@ -17,7 +26,7 @@ GRUB_VERSION=2.04 MENDER_GRUB_KERNEL_BOOT_ARGS="" # grub-mender-grubenv is the Mender integration for the GRUB bootloader -MENDER_GRUBENV_VERSION="1a7db967495bbe8be53b7a69dcb42822f39d9a74" +MENDER_GRUBENV_VERSION="b06a8e2cf13776b5cfc896fa8068006dd9992ebb" MENDER_GRUBENV_URL="${MENDER_GITHUB_ORG}/grub-mender-grubenv/archive/${MENDER_GRUBENV_VERSION}.tar.gz" # Name of the storage device containing root filesystem partitions in GRUB diff --git a/mender-convert-modify b/mender-convert-modify index f083d3c..f6ad9b1 100755 --- a/mender-convert-modify +++ b/mender-convert-modify @@ -142,12 +142,24 @@ if [ "${MENDER_GRUB_EFI_INTEGRATION}" == "y" ]; then if has_grub_efi "work/boot"; then # No need to install Grub, use the one already present, and only install - # our grub.cfg - grub_install_with_grub_efi_present + # our tools. + log_info "GRUB EFI bootloader already present, not installing one." + grub_install_grub_editenv_binary else + log_info "Installing GRUB EFI bootloader..." grub_install_mender_grub fi + if [ "$MENDER_GRUB_D_INTEGRATION" = y ] || ( [ "$MENDER_GRUB_D_INTEGRATION" = auto ] && supports_grub_d "work/rootfs" ); then + log_info "Generating grub config using update-grub..." + grub_create_grub_config + grub_install_grub_d_config + else + log_info "Generating the mender-grub config..." + grub_create_grub_config + grub_install_standalone_grub_config + fi + fi run_and_log_cmd "sudo mkdir -p work/rootfs/data/mender" diff --git a/modules/grub.sh b/modules/grub.sh index f662445..a19b659 100644 --- a/modules/grub.sh +++ b/modules/grub.sh @@ -45,7 +45,12 @@ EOF mender_kernel_root_base=${MENDER_STORAGE_DEVICE_BASE} EOF fi +} +# grub_install_standalone_grub_config +# +# +function grub_install_standalone_grub_config() { if [ -n "${MENDER_GRUB_KERNEL_BOOT_ARGS}" ]; then cat <<- EOF > work/grub-mender-grubenv-${MENDER_GRUBENV_VERSION}/11_bootargs_grub.cfg set bootargs="${MENDER_GRUB_KERNEL_BOOT_ARGS}" @@ -55,10 +60,83 @@ EOF ( cd work/grub-mender-grubenv-${MENDER_GRUBENV_VERSION} run_and_log_cmd "make 2>&1" - run_and_log_cmd "sudo make DESTDIR=../ BOOT_DIR=boot install-boot-files" - run_and_log_cmd "sudo make DESTDIR=../rootfs install-tools" + run_and_log_cmd "sudo make DESTDIR=$PWD/../ BOOT_DIR=boot install-standalone-boot-files" + run_and_log_cmd "sudo make DESTDIR=$PWD/../rootfs install-tools" + ) + +} + +# grub_install_grub_d_config +# +# +function grub_install_grub_d_config() { + if [ -n "${MENDER_GRUB_KERNEL_BOOT_ARGS}" ]; then + log_warn "MENDER_GRUB_KERNEL_BOOT_ARGS is ignored when MENDER_GRUB_D_INTEGRATION is enabled. Set it in the GRUB configuration instead." + fi + + # When using grub.d integration, /boot/efi must point to the boot partition, + # and /boot/grub must point to grub-mender-grubenv on the boot partition. + if [ ! -d work/rootfs/boot/efi ]; then + run_and_log_cmd "sudo mkdir work/rootfs/boot/efi" + fi + run_and_log_cmd "sudo mkdir work/boot/grub-mender-grubenv" + run_and_log_cmd "sudo mv work/rootfs/boot/grub/* work/boot/grub-mender-grubenv/" + run_and_log_cmd "sudo rmdir work/rootfs/boot/grub" + run_and_log_cmd "sudo ln -s efi/grub-mender-grubenv work/rootfs/boot/grub" + + ( + cd work/grub-mender-grubenv-${MENDER_GRUBENV_VERSION} + run_and_log_cmd "make 2>&1" + run_and_log_cmd "sudo make DESTDIR=$PWD/../ BOOT_DIR=boot install-boot-env" + run_and_log_cmd "sudo make DESTDIR=$PWD/../rootfs install-grub.d-boot-scripts" + run_and_log_cmd "sudo make DESTDIR=$PWD/../rootfs install-tools" + # We need this for running the scripts once. + run_and_log_cmd "sudo make DESTDIR=$PWD/../rootfs install-offline-files" ) + # Mender-convert usually runs in a container. It's difficult to launch + # additional containers from within an existing one, but we need to run + # `update-grub` on a simulated device using some sort of container. Use good + # old `chroot`, which doesn't provide perfect containment, but it is good + # enough for our purposes, and doesn't require special containment + # capabilities. This will not work for foreign architectures, but we could + # probably use something like qemu-aarch64-static to get around that. + run_and_log_cmd "sudo mount work/boot work/rootfs/boot/efi -o bind" + run_and_log_cmd "sudo mount /dev work/rootfs/dev -o bind,ro" + run_and_log_cmd "sudo mount /proc work/rootfs/proc -o bind,ro" + run_and_log_cmd "sudo mount /sys work/rootfs/sys -o bind,ro" + + local ret=0 + + # Use `--no-nvram`, since we cannot update firmware memory in an offline + # build. Instead, use `--removable`, which creates entries that automate + # booting if you put the image into a new device, which you almost certainly + # will after using mender-convert. + run_and_log_cmd_noexit "sudo chroot work/rootfs grub-install --removable --no-nvram" || ret=$? + if [ $ret -eq 0 ]; then + run_and_log_cmd_noexit "sudo chroot work/rootfs grub-install --no-nvram" || ret=$? + fi + + if [ $ret -eq 0 ]; then + run_and_log_cmd_noexit "sudo chroot work/rootfs update-grub" || ret=$? + fi + + # Very important that these are unmounted, otherwise Docker may start to + # remove files inside them while tearing down the container. You can guess + # how I found that out... We run without the logger because otherwise the + # message from the previous command, which is the important one, is lost. + sudo umount -l work/rootfs/boot/efi || true + sudo umount -l work/rootfs/dev || true + sudo umount -l work/rootfs/proc || true + sudo umount -l work/rootfs/sys || true + + [ $ret -ne 0 ] && exit $ret + + ( + cd work/grub-mender-grubenv-${MENDER_GRUBENV_VERSION} + # Should be removed after running. + run_and_log_cmd "sudo make DESTDIR=$PWD/../rootfs uninstall-offline-files" + ) } # grub_install_grub_editenv_binary @@ -74,18 +152,6 @@ function grub_install_grub_editenv_binary() { } -# grub_install_with_shim_present -# -# Keep the existing boot shim, and bootloader, and only install the mender-grub -# config -function grub_install_with_grub_efi_present() { - - grub_create_grub_config - - grub_install_grub_editenv_binary - -} - # grub_install_mender_grub # # Install mender-grub on the converted boot partition @@ -98,9 +164,6 @@ function grub_install_mender_grub() { run_and_log_cmd "sudo ln -s ${initrd_imagetype} work/rootfs/boot/initrd" fi - log_info "Generating the mender-grub config..." - grub_create_grub_config - # Remove conflicting boot files. These files do not necessarily effect the # functionality, but lets get rid of them to avoid confusion. # diff --git a/modules/probe.sh b/modules/probe.sh index 5e3517f..0491fad 100644 --- a/modules/probe.sh +++ b/modules/probe.sh @@ -353,3 +353,14 @@ is_efi_compatible_kernel() { has_grub_efi() { find "${1}" -type f -name 'grub*.efi' -print0 | grep -qz grub } + +supports_grub_d() { + test -d "$1"/etc/grub.d || return 1 + + # Because we are executing programs inside a chroot in the image, we cannot + # currently convert non-native architectures to use grub.d integration. See + # relevant section on chroot inside grub_install_grub_d_config. + [ "$(probe_arch)" = "$(uname -m)" ] || return 1 + + return 0 +} diff --git a/scripts/test/mender-convert-qemu b/scripts/test/mender-convert-qemu index 80a9b08..7e8cd6c 100755 --- a/scripts/test/mender-convert-qemu +++ b/scripts/test/mender-convert-qemu @@ -17,7 +17,7 @@ fi qemu-system-x86_64 \ -enable-kvm \ -nographic \ - -m 256 \ + -m 512 \ -net user,hostfwd=tcp::8822-:22 \ -net nic,macaddr=52:54:00$(od -txC -An -N3 /dev/urandom|tr \ :) \ -bios ${ovmf_file} \ diff --git a/scripts/test/test-utils.sh b/scripts/test/test-utils.sh index 5e83c26..8e23583 100644 --- a/scripts/test/test-utils.sh +++ b/scripts/test/test-utils.sh @@ -140,7 +140,6 @@ run_tests() { --sdimg-location="${MENDER_CONVERT_DIR}/deploy" \ --ssh-priv-key="./ssh-priv-key/key" \ --qemu-wrapper="../scripts/test/mender-convert-qemu" \ - mender-image-tests \ ${pytest_extra_args} exitcode=$? diff --git a/tests/conftest.py b/tests/conftest.py index 5344b09..c00bfff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -# Copyright 2021 Northern.tech AS +# Copyright 2022 Northern.tech AS # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -22,3 +22,5 @@ sys.path.append( # Load the parser for our custom option flags pytest_plugins = "utils.parseropts.parseropts" + +from utils.fixtures import * diff --git a/tests/mender-image-tests b/tests/mender-image-tests index ee1266a..8af131b 160000 --- a/tests/mender-image-tests +++ b/tests/mender-image-tests @@ -1 +1 @@ -Subproject commit ee1266afe8ec58efce9b4223e2489ac023b0582f +Subproject commit 8af131b9eface1555d2141d513c0dd6860c7815f diff --git a/tests/test_grub_integration.py b/tests/test_grub_integration.py new file mode 100644 index 0000000..4eaaa56 --- /dev/null +++ b/tests/test_grub_integration.py @@ -0,0 +1,199 @@ +#!/usr/bin/python +# Copyright 2022 Northern.tech AS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import re +import os +import subprocess + +from utils.common import ( + extract_partition, + get_no_sftp, +) + + +@pytest.fixture(scope="function") +def cleanup_boot_scripts(request, connection): + """Take a backup of the various grub.cfg files and restore them after the + test. This is recommended for tests that call `grub-install` and/or + `update-grub`, so that other tests can also run them from a pristine + state.""" + + connection.run( + "cp $(find /boot/efi/EFI/ -name grub.cfg -not -path '*/EFI/BOOT/*') /data/grub-efi.cfg" + ) + connection.run("cp /boot/grub/grub.cfg /data/grub-main.cfg") + connection.run("cp /boot/grub-mender-grubenv.cfg /data/grub-mender-grubenv.cfg") + + def cleanup(): + connection.run( + "mv /data/grub-efi.cfg $(find /boot/efi/EFI/ -name grub.cfg -not -path '*/EFI/BOOT/*')" + ) + connection.run("mv /data/grub-main.cfg /boot/grub/grub.cfg") + connection.run("mv /data/grub-mender-grubenv.cfg /boot/grub-mender-grubenv.cfg") + + request.addfinalizer(cleanup) + + +def check_all_root_occurrences_valid(grub_cfg): + found_expected = False + inside_10_header = False + # One of the functions we define and use. + expected = "mender_check_and_restore_env" + with open(grub_cfg) as fd: + lineno = 0 + for line in fd.readlines(): + lineno += 1 + if re.match(r'^\s*root="\$\{mender_grub_storage_device\}', line): + continue + + if line.strip() == "### BEGIN /etc/grub.d/00_header ###": + # We allow root references inside the 00_header, because they + # are overriden by Mender later. + inside_10_header = True + elif line.strip() == "### END /etc/grub.d/00_header ###": + inside_10_header = False + if not inside_10_header and re.match(r"^\s*(set +)?root=", line): + pytest.fail( + "Found unexpected occurrence of `root=` in grub boot script\n" + "%d:%s" % (lineno, line) + ) + + if line.find(expected) >= 0: + found_expected = True + + assert found_expected, "Expected content (%s) not found" % expected + + +@pytest.mark.usefixtures("setup_board", "cleanup_boot_scripts") +class TestGrubIntegration: + @pytest.mark.min_mender_version("1.0.0") + def test_no_root_occurrences(self, connection, latest_part_image): + """Test that the generated grub scripts do not contain any occurrences of + `root=` except for known instances that we control. This is + important because Mender needs to keep tight control of when this + variable is set, in order to boot from, and mount, the correct root + partition.""" + + # First, check that the offline generated scripts don't have any. + extract_partition(latest_part_image, 1) + try: + subprocess.check_call( + ["mcopy", "-i", "img1.fs", "::/grub-mender-grubenv/grub.cfg", "."] + ) + check_all_root_occurrences_valid("grub.cfg") + finally: + os.remove("img1.fs") + os.remove("grub.cfg") + + extract_partition(latest_part_image, 2) + try: + subprocess.check_call( + [ + "debugfs", + "-R", + "dump -p /boot/grub-mender-grubenv.cfg grub-mender-grubenv.cfg", + "img2.fs", + ] + ) + check_all_root_occurrences_valid("grub-mender-grubenv.cfg") + finally: + os.remove("img2.fs") + os.remove("grub-mender-grubenv.cfg") + + # Then, check that the runtime generated scripts don't have any. + get_no_sftp("/boot/grub/grub.cfg", connection) + try: + check_all_root_occurrences_valid("grub.cfg") + finally: + os.remove("grub.cfg") + + get_no_sftp("/boot/grub-mender-grubenv.cfg", connection) + try: + check_all_root_occurrences_valid("grub-mender-grubenv.cfg") + finally: + os.remove("grub-mender-grubenv.cfg") + + # Check again after running `update-grub`. + connection.run("grub-install && update-grub") + get_no_sftp("/boot/grub/grub.cfg", connection) + try: + check_all_root_occurrences_valid("grub.cfg") + finally: + os.remove("grub.cfg") + + get_no_sftp("/boot/grub-mender-grubenv.cfg", connection) + try: + check_all_root_occurrences_valid("grub-mender-grubenv.cfg") + finally: + os.remove("grub-mender-grubenv.cfg") + + @pytest.mark.min_mender_version("1.0.0") + def test_offline_and_runtime_boot_scripts_identical(self, connection): + # Update scripts at runtime. + connection.run("grub-install && update-grub") + + # Take advantage of the copies already made by cleanup_boot_scripts + # fixture above, and use the copies in /data. + + # Take into account some known, but harmless differences. The "hd0,gpt1" + # style location is missing from the offline generated grub-efi.cfg + # file, but it is harmless because the filesystem UUID is being used + # instead. + connection.run( + r"sed -Ee 's/ *hd[0-9]+,gpt[0-9]+//' " + "$(find /boot/efi/EFI/ -name grub.cfg -not -path '*/EFI/BOOT/*') " + "> /data/new-grub-efi-modified.cfg" + ) + try: + connection.run("diff -u /data/grub-efi.cfg /data/new-grub-efi-modified.cfg") + finally: + connection.run("rm -f /data/new-grub-efi-modified.cfg") + + # Another few differences we work around in the main grub files: + # * `--hint` parameters are not generated in offline copy. + # * `root` variable is not set in offline copy. + # * `fwsetup` is added somewhat randomly depending on availability both + # on build host and device. + try: + connection.run("cp /data/grub-main.cfg /data/old-grub-modified.cfg") + connection.run("cp /boot/grub/grub.cfg /data/new-grub-modified.cfg") + connection.run( + r"sed -i -En -e '/\bsearch\b/{s/ --hint[^ ]*//g;}' " + "-e \"/^set root='hd0,gpt1'$/d\" " + r"-e '\,### BEGIN /etc/grub.d/30_uefi-firmware ###,{p; n; :loop; \,### END /etc/grub.d/30_uefi-firmware ###,b end; n; b loop; :end;}' " + "-e p " + "/data/old-grub-modified.cfg /data/new-grub-modified.cfg" + ) + connection.run("diff -u /data/old-grub-modified.cfg /data/new-grub-modified.cfg") + finally: + connection.run("rm -f /data/old-grub-modified.cfg /data/new-grub-modified.cfg") + + # Same differences as in previous check. + try: + connection.run("cp /data/grub-mender-grubenv.cfg /data/old-grub-mender-grubenv-modified.cfg") + connection.run("cp /boot/grub-mender-grubenv.cfg /data/new-grub-mender-grubenv-modified.cfg") + connection.run( + r"sed -i -En -e '/\bsearch\b/{s/ --hint[^ ]*//g;}' " + "-e \"/^set root='hd0,gpt1'$/d\" " + r"-e '\,### BEGIN /etc/grub.d/30_uefi-firmware ###,{p; n; :loop; \,### END /etc/grub.d/30_uefi-firmware ###,b end; n; b loop; :end;}' " + "-e p " + "/data/old-grub-mender-grubenv-modified.cfg /data/new-grub-mender-grubenv-modified.cfg" + ) + connection.run( + "diff -u /data/old-grub-mender-grubenv-modified.cfg /data/new-grub-mender-grubenv-modified.cfg" + ) + finally: + connection.run("rm -f /data/old-grub-mender-grubenv-modified.cfg /data/new-grub-mender-grubenv-modified.cfg")