From d6e050e0e36a8703399815cc848cc6a0a7ca3d84 Mon Sep 17 00:00:00 2001 From: Fernando Bravo <39527354+fernando79513@users.noreply.github.com> Date: Fri, 8 Mar 2024 16:31:57 +0100 Subject: [PATCH] Fix dkms build validation in mantic (BugFix) (#1039) * Moved dkms_build_validation to python script * Refactored dkms test * added dkms python test to sru.pxu * Finished dkms validation implementation and added tests * Replaced prints for logs * Fixed some of the PR comments * Decomposition of main branches * Fixed bad command and a typing * Added context for error lines * # We will capture stdout and stderr in stdout * Fixed tests for run command * Small issues fixed on get_context_lines --- providers/sru/bin/dkms_build_validation | 67 ------ providers/sru/bin/dkms_build_validation.py | 186 +++++++++++++++ .../sru/tests/test_dkms_build_validation.py | 218 ++++++++++++++++++ providers/sru/units/sru.pxu | 2 +- 4 files changed, 405 insertions(+), 68 deletions(-) delete mode 100755 providers/sru/bin/dkms_build_validation create mode 100755 providers/sru/bin/dkms_build_validation.py create mode 100644 providers/sru/tests/test_dkms_build_validation.py diff --git a/providers/sru/bin/dkms_build_validation b/providers/sru/bin/dkms_build_validation deleted file mode 100755 index 24440640c..000000000 --- a/providers/sru/bin/dkms_build_validation +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/bash -# Copyright 2017 Canonical Ltd. -# Written by: -# Taihsiang Ho (tai271828) -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3, -# as published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -ubuntu_release=`lsb_release -r | cut -d ':' -f 2 | xargs` - -if [ $ubuntu_release = '22.04' ]; then - kernel_ver_min=`dkms status | awk -F ', ' {'print $2'} | sort -V | uniq | head -1` - kernel_ver_max=`dkms status | awk -F ', ' {'print $2'} | sort -V | uniq | tail -1` -else - kernel_ver_min=`dkms status | awk -F ', ' {'print $3'} | sort -V | uniq | head -1` - kernel_ver_max=`dkms status | awk -F ', ' {'print $3'} | sort -V | uniq | tail -1` -fi -kernel_ver_current=`uname -r` - -number_dkms_min=`dkms status | grep $kernel_ver_min | grep installed | wc -l` -number_dkms_max=`dkms status | grep $kernel_ver_max | grep installed | wc -l` - -scan_log="/var/log/apt/term.log" - -# kernel_ver_max should be the same as kernel_ver_current -if [ "$kernel_ver_current" != "$kernel_ver_max" ]; then - echo "Current using kernel version does not match the latest built DKMS module." - echo "Your running kernel: $kernel_ver_current" - echo "Latest DKMS module built on kernel: $kernel_ver_max" - echo "Maybe the target DKMS was not built," - echo "or you are not running the latest available kernel." - echo - echo "=== DKMS status ===" - dkms status - exit 1 -fi - -# compare the number of dkms modules of min and max kernels -if [ "$number_dkms_min" -ne "$number_dkms_max" ]; then - echo "$number_dkms_min modules for $kernel_ver_min" - echo "$number_dkms_max modules for $kernel_ver_max" - echo "DKMS module number is inconsistent. Some modules may not be built." - echo - echo "=== DKMS status ===" - dkms status -fi - -# scan the APT log during system update -error_message="Bad return status for module build on kernel: $kernel_ver_current" -error_in_log=`grep "$error_message" $scan_log | wc -l` -if [ "$error_in_log" -gt 0 ]; then - echo "Found dkms build error messages in $scan_log" - echo - echo "=== build log ===" - grep "$error_message" $scan_log -A 5 -B 5 - exit 1 -fi - diff --git a/providers/sru/bin/dkms_build_validation.py b/providers/sru/bin/dkms_build_validation.py new file mode 100755 index 000000000..a1cf4f68e --- /dev/null +++ b/providers/sru/bin/dkms_build_validation.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# +# Copyright 2017-2024 Canonical Ltd. +# Written by: +# Taihsiang Ho (tai271828) +# Fernando Bravo +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from collections import Counter +import logging +from packaging import version +import re +import subprocess +import sys +import textwrap +from typing import Dict, List, Set + +logger = logging.getLogger("dkms_build_validation") + + +def run_command(command: List[str]) -> str: + """Run a shell command and return its output""" + try: + result = subprocess.check_output( + command, + stderr=subprocess.STDOUT, # We capture stdout and stderr in stdout + universal_newlines=True, + ) + return result.strip() + except subprocess.CalledProcessError as e: + raise SystemExit( + "Command '{0}' failed with exit code {1}:\n{2}".format( + e.cmd, e.returncode, e.stdout + ) + ) + + +def parse_version(ver: str) -> version.Version: + """Parse the version string and return a version object""" + match = re.match(r"(\d+\.\d+\.\d+(-\d+)?)", ver) + if match: + parsed_version = version.parse(match.group(1)) + else: + raise SystemExit("Invalid version string: {0}".format(ver)) + return parsed_version + + +def parse_dkms_status(dkms_status: str, ubuntu_release: str) -> List[Dict]: + """Parse the output of 'dkms status', the result is a list of dictionaries + that contain the kernel version parsed the status for each one. + """ + kernel_info = [] + for line in dkms_status.splitlines(): + details, status = line.split(": ") + if version.parse(ubuntu_release) >= version.parse("22.04"): + kernel_ver = details.split(", ")[1] + else: + kernel_ver = details.split(", ")[2] + kernel_info.append({"version": kernel_ver, "status": status}) + + sorted_kernel_info = sorted( + kernel_info, key=lambda x: parse_version(x["version"]) + ) + return sorted_kernel_info + + +def check_kernel_version( + kernel_ver_current: str, sorted_kernel_info: List[Dict], dkms_status: str +) -> int: + kernel_ver_max = sorted_kernel_info[-1]["version"] + if kernel_ver_max != kernel_ver_current: + msg = textwrap.dedent( + """ + Current kernel version does not match the latest built DKMS module. + Your running kernel: {kernel_ver_current} + Latest DKMS module built on kernel: {kernel_ver_max} + Maybe the target DKMS was not built, + or you are not running the latest available kernel. + """.format( + kernel_ver_current=kernel_ver_current, + kernel_ver_max=kernel_ver_max, + ) + ) + logger.error(msg) + logger.error("=== DKMS status ===\n{0}".format(dkms_status)) + return 1 + return 0 + + +def check_dkms_module_count(sorted_kernel_info: List[Dict], dkms_status: str): + kernel_ver_max = sorted_kernel_info[-1]["version"] + kernel_ver_min = sorted_kernel_info[0]["version"] + + version_count = Counter([item["version"] for item in sorted_kernel_info]) + number_dkms_min = version_count[kernel_ver_min] + number_dkms_max = version_count[kernel_ver_max] + number_dkms_min = version_count[kernel_ver_min] + number_dkms_max = version_count[kernel_ver_max] + + if number_dkms_min != number_dkms_max: + msg = textwrap.dedent( + """ + {number_dkms_min} modules for {kernel_ver_min} + {number_dkms_max} modules for {kernel_ver_max} + DKMS module number is inconsistent. Some modules may not be built. + """.format( + number_dkms_min=number_dkms_min, + kernel_ver_min=kernel_ver_min, + number_dkms_max=number_dkms_max, + kernel_ver_max=kernel_ver_max, + ) + ) + logger.warning(msg) + logger.warning("=== DKMS status ===\n{0}".format(dkms_status)) + return 1 + return 0 + + +def get_context_lines(log: List[str], line_numbers: Set[int]) -> List[str]: + # Create a set with the indexes of the lines to be printed + context_lines = set() + context = 5 + n_lines = len(log) + for i in line_numbers: + min_numbers = max(0, i - context) + max_numbers = min(n_lines, i + context + 1) + for j in range(min_numbers, max_numbers): + context_lines.add(j) + return [log[i] for i in sorted(context_lines)] + + +def has_dkms_build_errors(kernel_ver_current: str) -> int: + log_path = "/var/log/apt/term.log" + err_msg = "Bad return status for module build on kernel: {}".format( + kernel_ver_current + ) + with open(log_path, "r") as f: + log = f.readlines() + err_line_numbers = {i for i, line in enumerate(log) if err_msg in line} + if err_line_numbers: + logger.error( + "Found dkms build error messages in {}".format(log_path) + ) + logger.error("\n=== build log ===") + err_with_context = get_context_lines(log, err_line_numbers) + logger.error("".join(err_with_context)) + return 1 + return 0 + + +def main(): + # Get the kernel version and DKMS status + ubuntu_release = run_command(["lsb_release", "-r"]).split()[-1] + dkms_status = run_command(["dkms", "status"]) + + # Parse and sort the DKMS status and sort the kernel versions + sorted_kernel_info = parse_dkms_status(dkms_status, ubuntu_release) + + # kernel_ver_max should be the same as kernel_ver_current + kernel_ver_current = run_command(["uname", "-r"]) + if check_kernel_version( + kernel_ver_current, sorted_kernel_info, dkms_status + ): + return 1 + + # Count the occurernces of the latest and the oldest kernel version and + # compare the number of DKMS modules for min and max kernel versions + check_dkms_module_count(sorted_kernel_info, dkms_status) + + # Scan the APT log for errors during system update + return has_dkms_build_errors(kernel_ver_current) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/providers/sru/tests/test_dkms_build_validation.py b/providers/sru/tests/test_dkms_build_validation.py new file mode 100644 index 000000000..ecb5a20a1 --- /dev/null +++ b/providers/sru/tests/test_dkms_build_validation.py @@ -0,0 +1,218 @@ +from packaging.version import Version +import unittest +from unittest.mock import patch, mock_open +import subprocess + +from dkms_build_validation import ( + run_command, + parse_dkms_status, + parse_version, + check_kernel_version, + check_dkms_module_count, + get_context_lines, + has_dkms_build_errors, + main, +) + + +class TestDKMSValidation(unittest.TestCase): + + # Example output of `dkms status` + dkms_status = ( + "fwts/24.01.00, 6.5.0-17-generic, x86_64: installed\n" + "fwts/24.01.00, 6.5.0-15-generic, x86_64: installed" + ) + + sorted_kernel_info = [ + {"version": "6.5.0-15-generic", "status": "installed"}, + {"version": "6.5.0-17-generic", "status": "installed"}, + ] + + @patch("dkms_build_validation.subprocess.check_output") + def test_run_command(self, mock_check_output): + mock_check_output.return_value = "output" + result = run_command(["lsb_release", "-r"]) + self.assertEqual(result, "output") + mock_check_output.assert_called_once_with( + ["lsb_release", "-r"], + stderr=subprocess.STDOUT, + universal_newlines=True, + ) + + @patch("subprocess.check_output") + def test_run_command_exception(self, mock_check_output): + # Simulate a CalledProcessError exception + mock_check_output.side_effect = subprocess.CalledProcessError( + 1, ["test_command"] + ) + + # run_command will raise an exception + with self.assertRaises(SystemExit): + run_command(["test_command"]) + + def test_parse_dkms_status(self): + ubuntu_release = "22.04" + kernel_info = parse_dkms_status(self.dkms_status, ubuntu_release) + # Assuming you have a specific expected output for kernel_info + expected_kernel_info = [ + {"version": "6.5.0-15-generic", "status": "installed"}, + {"version": "6.5.0-17-generic", "status": "installed"}, + ] + self.assertEqual(kernel_info, expected_kernel_info) + + def test_parse_dkms_status_old(self): + old_dkms_status = ( + "fwts, 24.01.00, 6.5.0-17-generic, x86_64: installed\n" + "fwts, 24.01.00, 6.5.0-15-generic, x86_64: installed" + ) + ubuntu_release = "18.04" + sorted_kernel_info = parse_dkms_status(old_dkms_status, ubuntu_release) + # Assuming you have a specific expected output for kernel_info + expected_kernel_info = [ + {"version": "6.5.0-15-generic", "status": "installed"}, + {"version": "6.5.0-17-generic", "status": "installed"}, + ] + self.assertEqual(sorted_kernel_info, expected_kernel_info) + + # Test the old format with a newer Ubuntu release + ubuntu_release = "22.04" + sorted_kernel_info = parse_dkms_status(old_dkms_status, ubuntu_release) + self.assertNotEqual(sorted_kernel_info, expected_kernel_info) + + def test_parse_version(self): + # Test with a valid version string + self.assertEqual( + parse_version("6.5.0-18-generic"), Version("6.5.0.post18") + ) + # Test with a shorter valid version string + self.assertEqual(parse_version("6.5.0"), Version("6.5.0")) + + # Test with an different version string + self.assertNotEqual( + parse_version("6.5.0-20-generic"), Version("6.5.0.post18") + ) + + # Test with an invalid version string + with self.assertRaises(SystemExit): + parse_version("Wrong version string") + + def test_check_kernel_version(self): + # Test with a kernel version that matches the latest one + self.assertEqual( + check_kernel_version( + "6.5.0-17-generic", self.sorted_kernel_info, self.dkms_status + ), + 0, + ) + + # Test with a kernel version that doesn't match the latest one + self.assertEqual( + check_kernel_version( + "6.5.0-18-generic", self.sorted_kernel_info, self.dkms_status + ), + 1, + ) + + def test_check_dkms_module_count(self): + # Test with the same number of modules + self.assertEqual( + check_dkms_module_count(self.sorted_kernel_info, self.dkms_status), + 0, + ) + + # Test with a different number of modules + bad_kernel_info = self.sorted_kernel_info + [ + {"version": "6.5.0-17-generic", "status": "installed"} + ] + self.assertEqual( + check_dkms_module_count(bad_kernel_info, self.dkms_status), + 1, + ) + + def test_get_context_lines_center(self): + log = ["L{}".format(i) for i in range(0, 20)] + line_idx = {10, 11} + expected_output = ["L{}".format(i) for i in range(5, 17)] + self.assertEqual(get_context_lines(log, line_idx), expected_output) + + def test_get_context_lines_edges(self): + log = ["L{}".format(i) for i in range(0, 20)] + line_idx = {0, 18} + expected_output = [ + "L0", + "L1", + "L2", + "L3", + "L4", + "L5", + "L13", + "L14", + "L15", + "L16", + "L17", + "L18", + "L19", + ] + self.assertEqual(get_context_lines(log, line_idx), expected_output) + + def test_has_dkms_build_errors(self): + kernel_ver_current = "6.5.0-17-generic" + + # Test with a log file that doesn't contain any errors + data = "Some log message\nSome log message\nSome log message\n" + with patch("builtins.open", mock_open(read_data=data)): + self.assertEqual(has_dkms_build_errors(kernel_ver_current), False) + + # Test with a log file that contains errors + data = ( + "Some log message\n" + "Bad return status for module build on kernel: 6.5.0-17-generic\n" + "Some log message\n" + ) + with patch("builtins.open", mock_open(read_data=data)): + self.assertEqual(has_dkms_build_errors(kernel_ver_current), True) + + @patch("dkms_build_validation.run_command") + @patch("dkms_build_validation.parse_dkms_status") + @patch("dkms_build_validation.check_kernel_version") + @patch("dkms_build_validation.check_dkms_module_count") + @patch("dkms_build_validation.has_dkms_build_errors") + def test_main( + self, mock_err, mock_count, mock_ver, mock_parse, mock_run_command + ): + mock_run_command.return_value = "output" + mock_parse.return_value = [] + mock_ver.return_value = 0 + mock_count.return_value = 0 + mock_err.return_value = 0 + self.assertEqual(main(), 0) + + @patch("dkms_build_validation.run_command") + @patch("dkms_build_validation.parse_dkms_status") + @patch("dkms_build_validation.check_kernel_version") + @patch("dkms_build_validation.check_dkms_module_count") + @patch("dkms_build_validation.has_dkms_build_errors") + def test_main_different_kernel_version( + self, mock_err, mock_count, mock_ver, mock_parse, mock_run_command + ): + mock_run_command.return_value = "output" + mock_parse.return_value = [] + mock_ver.return_value = 1 + mock_count.return_value = 0 + mock_err.return_value = 0 + self.assertEqual(main(), 1) + + @patch("dkms_build_validation.run_command") + @patch("dkms_build_validation.parse_dkms_status") + @patch("dkms_build_validation.check_kernel_version") + @patch("dkms_build_validation.check_dkms_module_count") + @patch("dkms_build_validation.has_dkms_build_errors") + def test_main_with_dkms_build_errors( + self, mock_err, mock_count, mock_ver, mock_parse, mock_run_command + ): + mock_run_command.return_value = "output" + mock_parse.return_value = [] + mock_ver.return_value = 0 + mock_count.return_value = 0 + mock_err.return_value = 1 + self.assertEqual(main(), 1) diff --git a/providers/sru/units/sru.pxu b/providers/sru/units/sru.pxu index 6b2bc35b7..85c666ca6 100644 --- a/providers/sru/units/sru.pxu +++ b/providers/sru/units/sru.pxu @@ -7,7 +7,7 @@ category_id: com.canonical.plainbox::miscellanea id: miscellanea/dkms_build_validation requires: package.name == 'dkms' command: - dkms_build_validation + dkms_build_validation.py _summary: Validate the build status of DKMS modules, automatically _description: Firstly, check the built number of DKMS modules.